diff --git a/Cargo.lock b/Cargo.lock index 9db6107..c98cde4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -165,13 +165,17 @@ version = "1.5.0-alpha" dependencies = [ "audio-formats", "base64 0.22.1", + "bincode", + "bs58", "crc32fast", "flate2", + "freenet-adapter", "image", "keyring", "library", "log", "metadata", + "persona-state", "player", "quick-xml 0.37.5", "realfft", @@ -259,6 +263,21 @@ 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 = "bit-set" version = "0.8.0" @@ -301,6 +320,20 @@ dependencies = [ "wyz", ] +[[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" @@ -310,6 +343,15 @@ 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 = "block2" version = "0.6.2" @@ -568,6 +610,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" version = "1.0.5" @@ -584,6 +632,18 @@ dependencies = [ "memchr", ] +[[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 = "cookie" version = "0.18.1" @@ -693,6 +753,15 @@ 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 = "crc32fast" version = "1.5.0" @@ -746,6 +815,15 @@ dependencies = [ "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 = "cssparser" version = "0.36.0" @@ -785,6 +863,42 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[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.118", +] + [[package]] name = "darling" version = "0.23.0" @@ -873,6 +987,16 @@ dependencies = [ "thiserror 2.0.18", ] +[[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" @@ -909,8 +1033,19 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "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]] @@ -1046,6 +1181,31 @@ 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", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.16.0" @@ -1183,6 +1343,12 @@ dependencies = [ "log", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "field-offset" version = "0.3.6" @@ -1199,6 +1365,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flatbuffers" +version = "24.12.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f1baf0dbf96932ec9a3038d57900329c015b0bfb7b63d904f3bc27e2b02a096" +dependencies = [ + "bitflags 1.3.2", + "rustc_version", +] + [[package]] name = "flate2" version = "1.1.9" @@ -1272,6 +1448,60 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "freenet-adapter" +version = "1.5.0-alpha" +dependencies = [ + "bs58", + "freenet-stdlib", + "log", + "serde", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite", +] + +[[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.118", +] + +[[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", + "js-sys", + "semver", + "serde", + "serde-wasm-bindgen", + "serde_json", + "serde_with", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite", + "tracing", + "tracing-subscriber", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "funty" version = "2.0.0" @@ -1793,6 +2023,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.10.1" @@ -2021,7 +2260,7 @@ checksum = "76b0d7d4541def58a37bf8efc559683f21edce7c82f0d866c93ac21f7e098f93" dependencies = [ "attohttpc", "log", - "rand", + "rand 0.8.6", "url", "xmltree", ] @@ -2412,6 +2651,15 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "md-5" version = "0.10.6" @@ -2419,7 +2667,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", ] [[package]] @@ -2572,6 +2820,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-bigint" version = "0.4.8" @@ -3031,6 +3288,16 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "persona-state" +version = "1.5.0-alpha" +dependencies = [ + "bincode", + "blake3", + "ed25519-dalek", + "serde", +] + [[package]] name = "phf" version = "0.13.1" @@ -3090,6 +3357,16 @@ 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 = "pkg-config" version = "0.3.33" @@ -3348,8 +3625,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -3359,7 +3646,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -3371,6 +3668,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3661,7 +3967,7 @@ dependencies = [ "borsh", "bytes", "num-traits", - "rand", + "rand 0.8.6", "rkyv", "serde", "serde_json", @@ -3910,6 +4216,17 @@ dependencies = [ "typeid", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -4058,6 +4375,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.10.9" @@ -4065,8 +4393,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "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]] @@ -4075,6 +4412,15 @@ 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" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -4188,6 +4534,16 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4959,6 +5315,15 @@ dependencies = [ "syn 2.0.118", ] +[[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.53" @@ -5025,6 +5390,7 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "socket2", "tokio-macros", @@ -5062,6 +5428,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "489a59b6730eda1b0171fcfda8b121f4bee2b35cba8645ca35c5f7ba3eb736c1" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -5247,9 +5625,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -5257,6 +5647,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]] @@ -5297,6 +5717,23 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +dependencies = [ + "bytes", + "data-encoding", + "http 1.4.2", + "httparse", + "log", + "rand 0.9.5", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" @@ -5393,6 +5830,12 @@ dependencies = [ "url", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8-width" version = "0.1.9" @@ -5423,6 +5866,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "value-bag" version = "1.12.0" @@ -5510,6 +5959,8 @@ dependencies = [ "cfg-if", "once_cell", "rustversion", + "serde", + "serde_json", "wasm-bindgen-macro", "wasm-bindgen-shared", ] diff --git a/Cargo.toml b/Cargo.toml index 58973e3..21a6698 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,8 @@ members = [ "crates/metadata", "crates/library", "crates/audio-formats", + "crates/freenet-adapter", + "crates/persona-state", ] [workspace.package] diff --git a/crates/freenet-adapter/Cargo.toml b/crates/freenet-adapter/Cargo.toml new file mode 100644 index 0000000..197a910 --- /dev/null +++ b/crates/freenet-adapter/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "freenet-adapter" +version.workspace = true +edition.workspace = true + +# The narrow, versioned boundary around a LOCAL Freenet node +# (TODO-Feature-Docs/soulseek-social-chat-and-sharing/, supplement design +# §5.3), hardened by the F0–F7+F2b spike records in research/freenet-*/. +# +# Pins: freenet-stdlib =0.8.5 is what node 0.2.132 speaks (bincode wire); +# tokio-tungstenite must match the stdlib's minor or the stream type the +# WebApi takes will not unify. Bump BOTH together, against a tested node. + +[dependencies] +freenet-stdlib = { version = "=0.8.5", features = ["net"] } +tokio-tungstenite = "0.27.0" +tokio.workspace = true +serde.workspace = true +log.workspace = true +thiserror.workspace = true +bs58 = "0.5" diff --git a/crates/freenet-adapter/src/lib.rs b/crates/freenet-adapter/src/lib.rs new file mode 100644 index 0000000..7580e00 --- /dev/null +++ b/crates/freenet-adapter/src/lib.rs @@ -0,0 +1,789 @@ +//! The FreenetAdapter — SoulStream's ONLY door to a local Freenet node. +//! +//! Everything here follows the supplement design (§5.3) and the rules the +//! F0–F7+F2b spikes recorded (research/freenet-*/RUN-RECORD.md): +//! +//! - **Off by default, kill-switchable.** `enable()` is the only thing that +//! spawns work; `disable()` stops the supervisor, drops the connection, +//! and ends every retry. A missing/stopped/incompatible node must never +//! affect Soulseek or playback — nothing in here is on any audio path. +//! - **The privilege boundary is this crate.** A loopback Freenet API is +//! fully privileged and unauthenticated (F0): whatever can reach it owns +//! the node. The webview therefore gets STATUS ONLY through the Tauri +//! layer — never a node handle, never raw state bytes. +//! - **Detection is cheap and three-valued** (absent / occupied-not-api / +//! compatible), with its own short timeouts: a refused loopback connect +//! costs ~2 s on Windows (F0), so the TCP probe gets 600 ms. A TYPED host +//! error counts as compatible — local mode answers `ConnectedPeers` with +//! "not supported" (F0), so refusals prove the protocol. +//! - **One request in flight per connection.** The stdlib's `WebApi` +//! correlates responses FIFO; the session serializes ops and routes +//! unsolicited `UpdateNotification`s to a bounded event bus (lagging +//! subscribers lose oldest events, honestly, rather than growing queues). +//! - **Write rules from the spikes:** a node refuses the FIRST write to a +//! contract it never held ("auto-fetch triggered", F5) — `update` retries +//! over ~18 s and callers should prime with a GET (`get_then_update`); +//! sequence-bearing states must be read-and-bumped by the caller (F5); +//! a restarted node drops out of update propagation (F2) — services +//! reconcile by relaying their own state after reconnect. +//! - **Redacted diagnostics.** Logs carry ids, lengths and outcomes; never +//! state bytes, never key material. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex as StdMutex}; +use std::time::Duration; + +use freenet_stdlib::client_api::{ + ClientRequest, ContractRequest, ContractResponse, HostResponse, NodeQuery, WebApi, +}; +use freenet_stdlib::prelude::*; +use serde::Serialize; +use tokio::sync::{broadcast, mpsc, oneshot, watch}; +use tokio::time::timeout; + +pub const DEFAULT_WS_PORT: u16 = 7509; + +/// TCP probe budget — see the F0 note about Windows' 2 s refusal cost. +const DETECT_TCP_TIMEOUT: Duration = Duration::from_millis(600); +const DETECT_WS_TIMEOUT: Duration = Duration::from_secs(2); +const DETECT_QUERY_TIMEOUT: Duration = Duration::from_secs(5); +const OP_TIMEOUT: Duration = Duration::from_secs(120); +const PUT_TIMEOUT: Duration = Duration::from_secs(180); +/// "auto-fetch triggered" retry schedule (F5): 6 attempts, 3 s apart. +const AUTOFETCH_RETRIES: u32 = 6; +const AUTOFETCH_BACKOFF: Duration = Duration::from_secs(3); +/// Reconnect backoff while enabled but the node is missing/lost. +const SUPERVISOR_BACKOFF: Duration = Duration::from_secs(5); +const CMD_QUEUE: usize = 32; +const EVENT_QUEUE: usize = 64; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub enum AdapterStatus { + /// The kill switch position. Nothing runs, nothing retries. + Disabled, + Detecting, + /// Nothing listens on the port. NOT an error — Freenet is optional. + NodeAbsent, + /// Something listens but does not speak the client API. + NodeOccupied, + /// Speaks WebSocket but the protocol exchange failed undecipherably. + NodeIncompatible { detail: String }, + Connected, + /// Connection lost while enabled; the supervisor is retrying. + Lost { detail: String }, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub enum AdapterEvent { + Status(AdapterStatus), + /// A subscribed contract changed. Ids and lengths only — the webview + /// never receives replicated bytes through the event path. + ContractUpdate { instance: String, update_len: usize }, +} + +#[derive(Debug, thiserror::Error)] +pub enum AdapterError { + #[error("freenet layer is disabled")] + Disabled, + #[error("freenet node is not connected ({0:?})")] + NotConnected(AdapterStatus), + #[error("request timed out")] + Timeout, + #[error("adapter is shutting down")] + ShuttingDown, + #[error("node refused: {0}")] + Node(String), + #[error("{0}")] + Other(String), +} + +type Result = std::result::Result; + +/// What `contract_put`/`update` return so callers can address the instance +/// later without recomputing. +#[derive(Debug, Clone, Serialize)] +pub struct ContractIds { + pub instance: String, + pub code_hash: String, +} + +struct Cmd { + req: ClientRequest<'static>, + reply: oneshot::Sender>, + op_timeout: Duration, +} + +struct Inner { + ws_port: StdMutex, + status: StdMutex, + events: broadcast::Sender, + /// Sender into the LIVE session, if any. + session: StdMutex>>, + /// Kill switch: flipping to false ends the supervisor and its session. + enabled: watch::Sender, + /// Distinguishes supervisor generations so a stale task can't clobber + /// the status of a newer enable(). + generation: AtomicU64, +} + +#[derive(Clone)] +pub struct FreenetAdapter { + inner: Arc, +} + +impl Default for FreenetAdapter { + fn default() -> Self { + let (enabled, _) = watch::channel(false); + let (events, _) = broadcast::channel(EVENT_QUEUE); + Self { + inner: Arc::new(Inner { + ws_port: StdMutex::new(DEFAULT_WS_PORT), + status: StdMutex::new(AdapterStatus::Disabled), + events, + session: StdMutex::new(None), + enabled, + generation: AtomicU64::new(0), + }), + } + } +} + +/// Spawn on the caller's tokio runtime when there is one (async commands, +/// tests), else on a runtime the adapter owns. `enable()` is reached from +/// Tauri's event-loop thread — the setup hook, sync commands — where a bare +/// `tokio::spawn` is a non-unwinding panic that takes the whole process down +/// (0xc0000409, seen live 2026-09-02 on the first toggle of the switch). +fn spawn_anywhere(fut: F) +where + F: std::future::Future + Send + 'static, +{ + match tokio::runtime::Handle::try_current() { + Ok(handle) => { + handle.spawn(fut); + } + Err(_) => { + static RT: std::sync::OnceLock = std::sync::OnceLock::new(); + let rt = RT.get_or_init(|| { + tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .thread_name("freenet-adapter") + .enable_all() + .build() + .expect("freenet adapter runtime") + }); + rt.spawn(fut); + } + } +} + + +impl FreenetAdapter { + pub fn set_ws_port(&self, port: u16) { + *self.inner.ws_port.lock().unwrap() = port; + } + + pub fn ws_port(&self) -> u16 { + *self.inner.ws_port.lock().unwrap() + } + + pub fn status(&self) -> AdapterStatus { + self.inner.status.lock().unwrap().clone() + } + + pub fn is_enabled(&self) -> bool { + *self.inner.enabled.borrow() + } + + pub fn subscribe(&self) -> broadcast::Receiver { + self.inner.events.subscribe() + } + + fn set_status(&self, generation: u64, s: AdapterStatus) { + if self.inner.generation.load(Ordering::SeqCst) != generation { + return; // a newer enable()/disable() owns the status now + } + *self.inner.status.lock().unwrap() = s.clone(); + let _ = self.inner.events.send(AdapterEvent::Status(s)); + } + + /// Turn the layer on: spawns the supervisor, which detects, connects, + /// and reconnects with backoff until `disable()`. + pub fn enable(&self) { + if self.is_enabled() { + return; + } + let generation = self.inner.generation.fetch_add(1, Ordering::SeqCst) + 1; + self.inner.enabled.send_replace(true); + let this = self.clone(); + spawn_anywhere(async move { + this.supervisor(generation).await; + }); + log::info!("freenet adapter enabled (gen {generation})"); + } + + /// The kill switch. Ends the supervisor, drops the session, stops every + /// retry. Callers see `Disabled` errors immediately. + pub fn disable(&self) { + let generation = self.inner.generation.fetch_add(1, Ordering::SeqCst) + 1; + self.inner.enabled.send_replace(false); + *self.inner.session.lock().unwrap() = None; + *self.inner.status.lock().unwrap() = AdapterStatus::Disabled; + let _ = self.inner.events.send(AdapterEvent::Status(AdapterStatus::Disabled)); + log::info!("freenet adapter disabled (gen {generation})"); + } + + // ------------------------------------------------------------ detect + + /// Three-valued node detection. Usable while disabled (it opens its own + /// short-lived connection and touches no adapter state). + pub async fn detect(&self) -> AdapterStatus { + let port = self.ws_port(); + let addr = format!("127.0.0.1:{port}"); + match timeout(DETECT_TCP_TIMEOUT, tokio::net::TcpStream::connect(&addr)).await { + Err(_) | Ok(Err(_)) => return AdapterStatus::NodeAbsent, + Ok(Ok(stream)) => drop(stream), + } + let mut api = match timeout(DETECT_WS_TIMEOUT, connect_ws(port)).await { + Err(_) => return AdapterStatus::NodeOccupied, + Ok(Err(_)) => return AdapterStatus::NodeOccupied, + Ok(Ok(api)) => api, + }; + if api + .send(ClientRequest::NodeQueries(NodeQuery::ConnectedPeers)) + .await + .is_err() + { + return AdapterStatus::NodeIncompatible { detail: "send failed".into() }; + } + match timeout(DETECT_QUERY_TIMEOUT, api.recv()).await { + Ok(Ok(_)) => AdapterStatus::Connected, + // A decoded, typed refusal IS protocol proof (local mode answers + // ConnectedPeers with "not supported" — F0). + Ok(Err(e)) if e.to_string().contains("not supported") => AdapterStatus::Connected, + Ok(Err(e)) => AdapterStatus::NodeIncompatible { detail: truncate(&e.to_string(), 120) }, + Err(_) => AdapterStatus::NodeIncompatible { detail: "no reply to health query".into() }, + } + } + + // ------------------------------------------------------------ supervisor + + async fn supervisor(&self, generation: u64) { + let mut enabled_rx = self.inner.enabled.subscribe(); + loop { + if self.inner.generation.load(Ordering::SeqCst) != generation || !*enabled_rx.borrow() { + return; + } + self.set_status(generation, AdapterStatus::Detecting); + let detected = self.detect().await; + match detected { + AdapterStatus::Connected => {} + other => { + self.set_status(generation, other); + if wait_or_disabled(&mut enabled_rx, SUPERVISOR_BACKOFF).await { + return; + } + continue; + } + } + let port = self.ws_port(); + let api = match timeout(DETECT_WS_TIMEOUT, connect_ws(port)).await { + Ok(Ok(api)) => api, + _ => { + self.set_status(generation, AdapterStatus::Lost { detail: "connect failed".into() }); + if wait_or_disabled(&mut enabled_rx, SUPERVISOR_BACKOFF).await { + return; + } + continue; + } + }; + let (tx, rx) = mpsc::channel::(CMD_QUEUE); + *self.inner.session.lock().unwrap() = Some(tx); + self.set_status(generation, AdapterStatus::Connected); + log::info!("freenet adapter connected to 127.0.0.1:{port}"); + let reason = self.run_session(api, rx, &mut enabled_rx).await; + *self.inner.session.lock().unwrap() = None; + if self.inner.generation.load(Ordering::SeqCst) != generation || !*enabled_rx.borrow() { + return; + } + self.set_status(generation, AdapterStatus::Lost { detail: truncate(&reason, 120) }); + log::warn!("freenet session ended: {}", truncate(&reason, 120)); + if wait_or_disabled(&mut enabled_rx, SUPERVISOR_BACKOFF).await { + return; + } + } + } + + /// One connection's lifetime. STRICTLY one request in flight (the wire + /// correlates FIFO); notifications stream out between and during ops. + async fn run_session( + &self, + mut api: WebApi, + mut rx: mpsc::Receiver, + enabled_rx: &mut watch::Receiver, + ) -> String { + loop { + tokio::select! { + changed = enabled_rx.changed() => { + if changed.is_err() || !*enabled_rx.borrow() { + return "disabled".into(); + } + } + maybe_cmd = rx.recv() => { + let Some(cmd) = maybe_cmd else { return "handle dropped".into() }; + if api.send(cmd.req).await.is_err() { + let _ = cmd.reply.send(Err(AdapterError::Node("send failed".into()))); + return "send failed".into(); + } + // Await THIS op's response; unsolicited notifications + // arriving in between are forwarded, not misdelivered. + let deadline = tokio::time::Instant::now() + cmd.op_timeout; + loop { + let left = deadline.saturating_duration_since(tokio::time::Instant::now()); + if left.is_zero() { + let _ = cmd.reply.send(Err(AdapterError::Timeout)); + break; + } + match timeout(left, api.recv()).await { + Err(_) => { + let _ = cmd.reply.send(Err(AdapterError::Timeout)); + break; + } + Ok(Err(e)) => { + let _ = cmd.reply.send(Err(AdapterError::Node(e.to_string()))); + break; + } + Ok(Ok(HostResponse::ContractResponse( + ContractResponse::UpdateNotification { key, update }, + ))) => { + self.forward_notification(&key, &update); + continue; + } + Ok(Ok(resp)) => { + let _ = cmd.reply.send(Ok(resp)); + break; + } + } + } + } + streamed = api.recv() => { + // Idle traffic: subscription notifications, or the end + // of the connection. + match streamed { + Ok(HostResponse::ContractResponse( + ContractResponse::UpdateNotification { key, update }, + )) => self.forward_notification(&key, &update), + Ok(_) => { /* stray late response; correlation is FIFO, drop */ } + Err(e) => return e.to_string(), + } + } + } + } + } + + fn forward_notification(&self, key: &ContractKey, update: &UpdateData<'static>) { + let len = match update { + UpdateData::State(s) => s.as_ref().len(), + UpdateData::Delta(d) => d.as_ref().len(), + UpdateData::StateAndDelta { state, delta } => state.as_ref().len() + delta.as_ref().len(), + _ => 0, + }; + // Bounded broadcast: lagging receivers lose oldest events. Honest. + let _ = self.inner.events.send(AdapterEvent::ContractUpdate { + instance: key.id().encode(), + update_len: len, + }); + } + + // ------------------------------------------------------------ ops + + async fn request(&self, req: ClientRequest<'static>, op_timeout: Duration) -> Result { + if !self.is_enabled() { + return Err(AdapterError::Disabled); + } + let tx = { + let guard = self.inner.session.lock().unwrap(); + guard.clone() + }; + let Some(tx) = tx else { + return Err(AdapterError::NotConnected(self.status())); + }; + let (reply_tx, reply_rx) = oneshot::channel(); + tx.send(Cmd { req, reply: reply_tx, op_timeout }) + .await + .map_err(|_| AdapterError::ShuttingDown)?; + reply_rx.await.map_err(|_| AdapterError::ShuttingDown)? + } + + fn container(code: Vec, params: &[u8]) -> (ContractContainer, ContractKey) { + let params = Parameters::from(params.to_vec()).into_owned(); + let c = ContractContainer::Wasm(ContractWasmAPIVersion::V1(WrappedContract::new( + Arc::new(ContractCode::from(code)), + params, + ))); + let k = c.key(); + (c, k) + } + + /// Compute the ids a (code, params) pair addresses — no network. + pub fn contract_ids(code: Vec, params: &[u8]) -> ContractIds { + let (_, key) = Self::container(code, params); + ContractIds { + instance: key.id().encode(), + code_hash: key.encoded_code_hash(), + } + } + + pub async fn contract_put( + &self, + code: Vec, + params: &[u8], + state: Vec, + subscribe: bool, + ) -> Result { + let (container, key) = Self::container(code, params); + let ids = ContractIds { + instance: key.id().encode(), + code_hash: key.encoded_code_hash(), + }; + let resp = self + .request( + ContractRequest::Put { + contract: container, + state: WrappedState::new(state), + related_contracts: RelatedContracts::new(), + subscribe, + blocking_subscribe: false, + } + .into(), + PUT_TIMEOUT, + ) + .await?; + match resp { + HostResponse::ContractResponse(ContractResponse::PutResponse { .. }) => Ok(ids), + other => Err(unexpected("PutResponse", &other)), + } + } + + pub async fn contract_get( + &self, + instance_b58: &str, + subscribe: bool, + ) -> Result> { + let id = parse_instance(instance_b58)?; + let resp = self + .request( + ContractRequest::Get { + key: id, + return_contract_code: false, + subscribe, + blocking_subscribe: false, + } + .into(), + OP_TIMEOUT, + ) + .await + // Depending on version, a never-put contract comes back either + // as a typed NotFound or as an error frame "missing contract: + // " (observed on 0.2.132 local mode). Fold both into one + // shape so services can match on "contract not found" alone. + .map_err(|e| match e { + AdapterError::Node(msg) if msg.contains("missing contract") => { + AdapterError::Node("contract not found".into()) + } + e => e, + })?; + match resp { + HostResponse::ContractResponse(ContractResponse::GetResponse { state, .. }) => { + Ok(state.to_vec()) + } + HostResponse::ContractResponse(ContractResponse::NotFound { .. }) => { + Err(AdapterError::Node("contract not found".into())) + } + other => Err(unexpected("GetResponse", &other)), + } + } + + pub async fn contract_subscribe(&self, instance_b58: &str) -> Result { + let id = parse_instance(instance_b58)?; + let resp = self + .request( + ContractRequest::Subscribe { key: id, summary: None }.into(), + OP_TIMEOUT, + ) + .await?; + match resp { + HostResponse::ContractResponse(ContractResponse::SubscribeResponse { + subscribed, + .. + }) => Ok(subscribed), + other => Err(unexpected("SubscribeResponse", &other)), + } + } + + /// UPDATE with the recorded first-write behavior handled: a node that + /// never held the contract answers "auto-fetch triggered" and asks the + /// client to retry; we do, on the spike's schedule. Returns the + /// post-merge summary bytes. + pub async fn contract_update( + &self, + code: Vec, + params: &[u8], + delta: Vec, + ) -> Result> { + let (_, key) = Self::container(code, params); + let mut last: Option = None; + for attempt in 0..AUTOFETCH_RETRIES { + if attempt > 0 { + tokio::time::sleep(AUTOFETCH_BACKOFF).await; + if !self.is_enabled() { + return Err(AdapterError::Disabled); // kill switch ends retries + } + } + let resp = self + .request( + ContractRequest::Update { + key, + data: UpdateData::Delta(StateDelta::from(delta.clone())), + } + .into(), + OP_TIMEOUT, + ) + .await; + match resp { + Ok(HostResponse::ContractResponse(ContractResponse::UpdateResponse { + summary, + .. + })) => return Ok(summary.as_ref().to_vec()), + Ok(other) => return Err(unexpected("UpdateResponse", &other)), + Err(AdapterError::Node(msg)) if msg.contains("auto-fetch triggered") => { + log::info!( + "freenet: first-write priming on attempt {attempt} (auto-fetch in flight)" + ); + last = Some(AdapterError::Node(msg)); + } + Err(e) => return Err(e), + } + } + Err(last.unwrap_or(AdapterError::Timeout)) + } + + /// The reliable first-write recipe from F5: one GET primes code+state on + /// this node, then the update applies. Use for a contract this node may + /// never have held. + pub async fn get_then_update( + &self, + code: Vec, + params: &[u8], + delta: Vec, + ) -> Result> { + let ids = Self::contract_ids(code.clone(), params); + let _ = self.contract_get(&ids.instance, false).await; // prime; absence is fine + self.contract_update(code, params, delta).await + } + + // ------------------------------------------------------------ delegates + + fn delegate_container(code: Vec, params: &[u8]) -> (DelegateContainer, DelegateKey) { + let params = Parameters::from(params.to_vec()).into_owned(); + let dc = DelegateCode::from(code); + let d = Delegate::from((&dc, ¶ms)); + let c = DelegateContainer::Wasm(DelegateWasmAPIVersion::V1(d)); + let k = c.key().clone(); + (c, k) + } + + /// Compute the delegate key a (code, params) pair addresses — no + /// network. Lets services pin and log their delegate generation. + pub fn delegate_key_b58(code: Vec, params: &[u8]) -> String { + let (_, key) = Self::delegate_container(code, params); + bs58::encode(key.bytes()).into_string() + } + + /// Register (idempotent per node) and return the delegate key (b58). + /// `cipher`/`nonce` are legacy fields the node ignores (core#4140). + pub async fn delegate_register(&self, code: Vec, params: &[u8]) -> Result { + use freenet_stdlib::client_api::DelegateRequest; + let (container, key) = Self::delegate_container(code, params); + let resp = self + .request( + ClientRequest::DelegateOp(DelegateRequest::RegisterDelegate { + delegate: container, + cipher: [0u8; 32], + nonce: [0u8; 24], + }), + OP_TIMEOUT, + ) + .await?; + match resp { + HostResponse::DelegateResponse { .. } => Ok(bs58::encode(key.bytes()).into_string()), + other => Err(unexpected("DelegateResponse", &other)), + } + } + + /// One application message in, the delegate's app-message reply out. + /// NOTE (F1): migration across delegate code generations must be + /// verified by reading state back — `RegisterDelegateWithPredecessors` + /// silently copies nothing in 0.2.132 (GHSA-824h-7x5x-wfmf). + pub async fn delegate_message( + &self, + code: Vec, + params: &[u8], + payload: Vec, + ) -> Result> { + use freenet_stdlib::client_api::DelegateRequest; + let (_, key) = Self::delegate_container(code.clone(), params); + let params_owned = Parameters::from(params.to_vec()).into_owned(); + let resp = self + .request( + ClientRequest::DelegateOp(DelegateRequest::ApplicationMessages { + key, + params: params_owned, + inbound: vec![InboundDelegateMsg::ApplicationMessage( + ApplicationMessage::new(payload), + )], + }), + OP_TIMEOUT, + ) + .await?; + match resp { + HostResponse::DelegateResponse { values, .. } => values + .into_iter() + .find_map(|v| match v { + OutboundDelegateMsg::ApplicationMessage(m) => Some(m.payload), + _ => None, + }) + .ok_or_else(|| AdapterError::Node("no app message in delegate reply".into())), + other => Err(unexpected("DelegateResponse", &other)), + } + } +} + +// ------------------------------------------------------------------ helpers + +async fn connect_ws(port: u16) -> std::result::Result { + let uri = format!("ws://127.0.0.1:{port}/v1/contract/command?encodingProtocol=native"); + let cfg = tokio_tungstenite::tungstenite::protocol::WebSocketConfig::default() + .max_message_size(Some(100 * 1024 * 1024)) + .max_frame_size(Some(16 * 1024 * 1024)); + let (stream, _) = tokio_tungstenite::connect_async_with_config(&uri, Some(cfg), false) + .await + .map_err(|e| e.to_string())?; + Ok(WebApi::start(stream)) +} + +/// Wait out a backoff, returning true if the adapter was disabled meanwhile. +async fn wait_or_disabled(enabled_rx: &mut watch::Receiver, dur: Duration) -> bool { + tokio::select! { + _ = tokio::time::sleep(dur) => !*enabled_rx.borrow(), + changed = enabled_rx.changed() => changed.is_err() || !*enabled_rx.borrow(), + } +} + +fn parse_instance(b58: &str) -> Result { + ContractInstanceId::try_from(b58.to_string()) + .map_err(|e| AdapterError::Other(format!("bad instance id: {e}"))) +} + +fn unexpected(wanted: &str, got: &HostResponse) -> AdapterError { + // Display, not Debug: HostResponse Display is summary-shaped and does + // not dump state bytes. + AdapterError::Node(format!("expected {wanted}, got {}", truncate(&got.to_string(), 120))) +} + +fn truncate(s: &str, n: usize) -> String { + if s.len() <= n { + return s.to_string(); + } + let mut cut = n; + while cut > 0 && !s.is_char_boundary(cut) { + cut -= 1; + } + format!("{}…", &s[..cut]) +} + +// ------------------------------------------------------------------ tests + +#[cfg(test)] +mod tests { + use super::*; + + /// The event-loop path: no tokio context at all. Must not panic, must + /// flip enabled, and disable() must still end it. + #[test] + fn enable_works_outside_any_runtime() { + std::thread::spawn(|| { + assert!(tokio::runtime::Handle::try_current().is_err()); + let a = FreenetAdapter::default(); + a.enable(); + assert!(a.is_enabled()); + std::thread::sleep(std::time::Duration::from_millis(300)); + a.disable(); + assert!(!a.is_enabled()); + assert_eq!(a.status(), AdapterStatus::Disabled); + }) + .join() + .expect("no panic on the runtime-less path"); + } + + #[tokio::test] + async fn disabled_is_the_default_and_ops_refuse() { + let a = FreenetAdapter::default(); + assert_eq!(a.status(), AdapterStatus::Disabled); + assert!(!a.is_enabled()); + let err = a.contract_get("11111111111111111111111111111111", false).await; + assert!(matches!(err, Err(AdapterError::Disabled))); + } + + #[tokio::test] + async fn detect_absent_on_closed_port() { + let a = FreenetAdapter::default(); + a.set_ws_port(1); // nothing listens on port 1 + assert_eq!(a.detect().await, AdapterStatus::NodeAbsent); + } + + #[tokio::test] + async fn detect_occupied_on_non_api_listener() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + loop { + let Ok((mut sock, _)) = listener.accept().await else { break }; + use tokio::io::AsyncWriteExt; + // Answer like a confused HTTP server, then hang up. + let _ = sock.write_all(b"HTTP/1.1 404 Not Found\r\n\r\n").await; + } + }); + let a = FreenetAdapter::default(); + a.set_ws_port(port); + assert_eq!(a.detect().await, AdapterStatus::NodeOccupied); + } + + #[tokio::test] + async fn kill_switch_ends_everything() { + let a = FreenetAdapter::default(); + a.set_ws_port(1); + a.enable(); + assert!(a.is_enabled()); + // Give the supervisor a moment to enter its loop, then kill it. + tokio::time::sleep(Duration::from_millis(50)).await; + a.disable(); + assert_eq!(a.status(), AdapterStatus::Disabled); + assert!(!a.is_enabled()); + let err = a.contract_get("11111111111111111111111111111111", false).await; + assert!(matches!(err, Err(AdapterError::Disabled))); + // A stale supervisor tick must not resurrect a status. + tokio::time::sleep(Duration::from_millis(200)).await; + assert_eq!(a.status(), AdapterStatus::Disabled); + } + + #[tokio::test] + async fn contract_ids_are_deterministic_offline() { + let code = vec![0u8, 1, 2, 3]; + let a = FreenetAdapter::contract_ids(code.clone(), b"params"); + let b = FreenetAdapter::contract_ids(code, b"params"); + assert_eq!(a.instance, b.instance); + assert_eq!(a.code_hash, b.code_hash); + } +} diff --git a/crates/freenet-adapter/tests/live_node.rs b/crates/freenet-adapter/tests/live_node.rs new file mode 100644 index 0000000..6237e57 --- /dev/null +++ b/crates/freenet-adapter/tests/live_node.rs @@ -0,0 +1,95 @@ +//! Live integration against a REAL local Freenet node — the house pattern +//! (`#[ignore]`d corpus tests inside the crate, run on demand): +//! +//! 1. start a node: freenet local local --config-dir --data-dir +//! 2. set SOULSTREAM_F0_WASM to a contract wasm (the F0 spike's +//! research/freenet-f0/f0-contract build works verbatim) +//! 3. cargo test -p freenet-adapter --release -- --ignored +//! +//! Exercises the full adapter loop: detect → enable → put → get → +//! subscribe → update (with the first-write priming path) → notification +//! event → kill switch mid-life. + +use freenet_adapter::{AdapterEvent, AdapterStatus, FreenetAdapter}; +use std::time::Duration; + +fn wasm() -> Option> { + let path = std::env::var("SOULSTREAM_F0_WASM").ok()?; + std::fs::read(path).ok() +} + +#[tokio::test] +#[ignore = "needs a local freenet node on 7509 and SOULSTREAM_F0_WASM"] +async fn full_loop_against_live_node() { + let Some(code) = wasm() else { + panic!("set SOULSTREAM_F0_WASM to a contract wasm path"); + }; + let a = FreenetAdapter::default(); + + // Detection first — a wrong environment should fail here, loudly. + let detected = a.detect().await; + assert_eq!(detected, AdapterStatus::Connected, "node not reachable: {detected:?}"); + + a.enable(); + // Wait for the supervisor to connect. + let mut tries = 0; + while a.status() != AdapterStatus::Connected { + tokio::time::sleep(Duration::from_millis(200)).await; + tries += 1; + assert!(tries < 50, "supervisor never connected: {:?}", a.status()); + } + + // A fresh instance per run: params carry a nonce. + let params = format!("adapter-live-{}", std::process::id()).into_bytes(); + let ids = FreenetAdapter::contract_ids(code.clone(), ¶ms); + + // PUT initial state. + let put_ids = a + .contract_put(code.clone(), ¶ms, br#"["alpha"]"#.to_vec(), false) + .await + .expect("put"); + assert_eq!(put_ids.instance, ids.instance); + + // GET it back. + let state = a.contract_get(&ids.instance, false).await.expect("get"); + assert_eq!(state, br#"["alpha"]"#); + + // SUBSCRIBE, then watch for the update notification through the + // adapter's bounded event bus. + let mut events = a.subscribe(); + assert!(a.contract_subscribe(&ids.instance).await.expect("subscribe")); + + // UPDATE via the primed path (exercises get_then_update's shape even + // though this node already holds the contract). + let summary = a + .get_then_update(code.clone(), ¶ms, br#"["beta"]"#.to_vec()) + .await + .expect("update"); + assert!(!summary.is_empty(), "update summary should carry bytes"); + + // The merged state must be the union. + let state = a.contract_get(&ids.instance, false).await.expect("get2"); + assert_eq!(state, br#"["alpha","beta"]"#); + + // The notification should have crossed the event bus (ids only). + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + let mut saw_update = false; + while tokio::time::Instant::now() < deadline { + match tokio::time::timeout(Duration::from_secs(1), events.recv()).await { + Ok(Ok(AdapterEvent::ContractUpdate { instance, .. })) if instance == ids.instance => { + saw_update = true; + break; + } + Ok(Ok(_)) => continue, + Ok(Err(_)) => break, + Err(_) => continue, + } + } + assert!(saw_update, "no ContractUpdate event reached the bus"); + + // Kill switch mid-life: immediate, and ops refuse afterwards. + a.disable(); + assert_eq!(a.status(), AdapterStatus::Disabled); + let err = a.contract_get(&ids.instance, false).await; + assert!(matches!(err, Err(freenet_adapter::AdapterError::Disabled))); +} diff --git a/crates/persona-state/Cargo.toml b/crates/persona-state/Cargo.toml new file mode 100644 index 0000000..a57de67 --- /dev/null +++ b/crates/persona-state/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "persona-state" +version.workspace = true +edition.workspace = true + +# The persona profile + avatar-asset state model — the byte-exact shapes +# the bundled persona/media contract wasms validate (their sources are the +# F2/F2b spike crates; these definitions must stay bincode-identical to +# them, field by field, or published state stops verifying). + +[dependencies] +serde.workspace = true +bincode = "1" +ed25519-dalek = { version = "2", default-features = false, features = ["alloc"] } +blake3 = "1" + +[dev-dependencies] +ed25519-dalek = { version = "2", features = ["rand_core"] } diff --git a/crates/persona-state/src/lib.rs b/crates/persona-state/src/lib.rs new file mode 100644 index 0000000..6e6e0c5 --- /dev/null +++ b/crates/persona-state/src/lib.rs @@ -0,0 +1,371 @@ +//! Persona profile + avatar-asset state — the shapes the bundled Freenet +//! contracts validate. +//! +//! BYTE-COMPATIBILITY IS THE CONTRACT. `ProfileState` and `MediaAssetV1` +//! must stay bincode-identical (field order, types, sig-body layouts) to +//! the wasm bundled in `src-tauri/assets/` (built from the F2/F2b spike +//! sources, keys pinned by tests there). Change anything here and every +//! published profile stops verifying — that is a migration event. +//! +//! Two signing conventions coexist under `soulstream.profile/v1` (an F2b +//! record): PROFILES sign their RAW sig-body; ASSETS sign blake3(sig-body). +//! `profile_sign_payload` / `asset_sign_payload` return exactly what the +//! identity delegate must be handed for each. + +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use serde::{Deserialize, Serialize}; + +pub const SCHEMA_VERSION: u32 = 1; +pub const SIGN_DOMAIN: &str = "soulstream.profile/v1"; + +pub const PROFILE_PARAMS_FAMILY: &[u8] = b"soulstream.persona.v1"; +pub const MEDIA_PARAMS_FAMILY: &[u8] = b"soulstream.media.v1"; + +pub const MAX_PROFILE_BYTES: usize = 4 * 1024; +pub const MAX_NAME_BYTES: usize = 64; +pub const MAX_BIO_BYTES: usize = 280; + +/// v1 avatar rendition: one square PNG. +pub const V1_MEDIA_TYPE: &str = "image/png"; +pub const V1_DIMENSION: u32 = 256; +pub const MAX_MEDIA_BYTES: usize = 96 * 1024; +pub const MAX_MEDIA_STATE_BYTES: usize = 128 * 1024; + +// ------------------------------------------------------------------ profile + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MediaRefV1 { + pub content_hash: [u8; 32], + pub media_type: String, + pub byte_length: u32, + pub width: u16, + pub height: u16, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProfileState { + pub schema_version: u32, + pub owner: [u8; 32], + pub sequence: u64, + pub display_name: String, + pub bio: String, + pub avatar: Option, + pub sig: Vec, +} + +#[derive(Serialize)] +struct ProfileSigBody<'a> { + schema_version: u32, + owner: &'a [u8; 32], + sequence: u64, + display_name: &'a str, + bio: &'a str, + avatar: &'a Option, +} + +/// What the delegate signs for a profile: the RAW sig-body (F2 convention). +pub fn profile_sign_payload(p: &ProfileState) -> Vec { + bincode::serialize(&ProfileSigBody { + schema_version: p.schema_version, + owner: &p.owner, + sequence: p.sequence, + display_name: &p.display_name, + bio: &p.bio, + avatar: &p.avatar, + }) + .expect("serializes") +} + +fn signable(domain: &str, payload: &[u8]) -> Vec { + let mut msg = Vec::new(); + msg.extend_from_slice(b"soulstream.sig.v1"); + msg.push(0); + msg.extend_from_slice(domain.as_bytes()); + msg.push(0); + msg.extend_from_slice(blake3::hash(payload).as_bytes()); + msg +} + +pub fn verify_sig(public: &[u8; 32], payload: &[u8], sig: &[u8]) -> bool { + let Ok(vk) = VerifyingKey::from_bytes(public) else { return false }; + let Ok(sig) = Signature::from_slice(sig) else { return false }; + vk.verify(&signable(SIGN_DOMAIN, payload), &sig).is_ok() +} + +pub fn make_profile_params(owner: &[u8; 32]) -> Vec { + let mut p = Vec::with_capacity(PROFILE_PARAMS_FAMILY.len() + 32); + p.extend_from_slice(PROFILE_PARAMS_FAMILY); + p.extend_from_slice(owner); + p +} + +#[derive(Debug, PartialEq, Eq)] +pub enum ProfileInvalid { + Oversized(usize), + Malformed, + WrongSchema(u32), + WrongOwner, + NameTooLong(usize), + BioTooLong(usize), + AvatarUnbounded, + BadSignature, +} + +impl core::fmt::Display for ProfileInvalid { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + ProfileInvalid::Oversized(n) => write!(f, "profile {n} bytes over cap {MAX_PROFILE_BYTES}"), + ProfileInvalid::Malformed => write!(f, "bytes do not parse as a profile"), + ProfileInvalid::WrongSchema(v) => write!(f, "unknown profile schema {v}"), + ProfileInvalid::WrongOwner => write!(f, "profile owner differs from the address owner"), + ProfileInvalid::NameTooLong(n) => write!(f, "display name {n} bytes over cap {MAX_NAME_BYTES}"), + ProfileInvalid::BioTooLong(n) => write!(f, "bio {n} bytes over cap {MAX_BIO_BYTES}"), + ProfileInvalid::AvatarUnbounded => write!(f, "avatar reference outside the v1 bounds"), + ProfileInvalid::BadSignature => write!(f, "owner signature does not verify"), + } + } +} + +fn avatar_bounded(r: &MediaRefV1) -> bool { + r.media_type == V1_MEDIA_TYPE + && r.byte_length as usize <= MAX_MEDIA_BYTES + && r.width as u32 == V1_DIMENSION + && r.height as u32 == V1_DIMENSION +} + +/// Gen-2 validation (avatar-accepting) — the generation the bundled +/// contract enforces. Gen-1 states (avatar=None) validate identically. +pub fn validate_profile(owner: &[u8; 32], bytes: &[u8]) -> Result { + if bytes.len() > MAX_PROFILE_BYTES { + return Err(ProfileInvalid::Oversized(bytes.len())); + } + let p: ProfileState = bincode::deserialize(bytes).map_err(|_| ProfileInvalid::Malformed)?; + if p.schema_version != SCHEMA_VERSION { + return Err(ProfileInvalid::WrongSchema(p.schema_version)); + } + if &p.owner != owner { + return Err(ProfileInvalid::WrongOwner); + } + if p.display_name.len() > MAX_NAME_BYTES { + return Err(ProfileInvalid::NameTooLong(p.display_name.len())); + } + if p.bio.len() > MAX_BIO_BYTES { + return Err(ProfileInvalid::BioTooLong(p.bio.len())); + } + if let Some(r) = &p.avatar { + if !avatar_bounded(r) { + return Err(ProfileInvalid::AvatarUnbounded); + } + } + if !verify_sig(&p.owner, &profile_sign_payload(&p), &p.sig) { + return Err(ProfileInvalid::BadSignature); + } + Ok(p) +} + +// ------------------------------------------------------------------ asset + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MediaAssetV1 { + pub schema_version: u32, + pub owner: [u8; 32], + pub asset_id: [u8; 16], + pub revision: u32, + pub media_type: String, + pub width: u32, + pub height: u32, + pub sanitized_static_bytes: Vec, + pub sig: Vec, +} + +#[derive(Serialize)] +struct AssetSigBody<'a> { + schema_version: u32, + owner: &'a [u8; 32], + asset_id: &'a [u8; 16], + revision: u32, + media_type: &'a str, + width: u32, + height: u32, + content_hash: &'a [u8; 32], +} + +fn asset_sig_body(a: &MediaAssetV1) -> Vec { + let content_hash = *blake3::hash(&a.sanitized_static_bytes).as_bytes(); + bincode::serialize(&AssetSigBody { + schema_version: a.schema_version, + owner: &a.owner, + asset_id: &a.asset_id, + revision: a.revision, + media_type: &a.media_type, + width: a.width, + height: a.height, + content_hash: &content_hash, + }) + .expect("serializes") +} + +/// What the delegate signs for an asset: blake3(sig-body) — 32 bytes. +pub fn asset_sign_payload(a: &MediaAssetV1) -> Vec { + blake3::hash(&asset_sig_body(a)).as_bytes().to_vec() +} + +pub fn make_media_params(owner: &[u8; 32], content_hash: &[u8; 32]) -> Vec { + let mut p = Vec::with_capacity(MEDIA_PARAMS_FAMILY.len() + 64); + p.extend_from_slice(MEDIA_PARAMS_FAMILY); + p.extend_from_slice(owner); + p.extend_from_slice(content_hash); + p +} + +#[derive(Debug, PartialEq, Eq)] +pub enum AssetInvalid { + Oversized(usize), + HashMismatch, + Malformed, + WrongSchema(u32), + WrongOwner, + WrongType(String), + WrongDimensions(u32, u32), + BytesOverBudget(usize), + BadSignature, +} + +impl core::fmt::Display for AssetInvalid { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + AssetInvalid::Oversized(n) => write!(f, "asset state {n} bytes over gate {MAX_MEDIA_STATE_BYTES}"), + AssetInvalid::HashMismatch => write!(f, "bytes do not hash to the referenced content hash"), + AssetInvalid::Malformed => write!(f, "bytes do not parse as a media asset"), + AssetInvalid::WrongSchema(v) => write!(f, "unknown media schema {v}"), + AssetInvalid::WrongOwner => write!(f, "asset owner differs from the reference owner"), + AssetInvalid::WrongType(t) => write!(f, "media type {t:?} not accepted (v1: image/png)"), + AssetInvalid::WrongDimensions(w, h) => write!(f, "declared {w}x{h} is not the 256x256 rendition"), + AssetInvalid::BytesOverBudget(n) => write!(f, "{n} media bytes over the {MAX_MEDIA_BYTES} budget"), + AssetInvalid::BadSignature => write!(f, "owner signature does not verify"), + } + } +} + +/// Structural verification of a fetched asset against a signed reference — +/// everything that must pass BEFORE any image decoding happens. +pub fn validate_asset( + owner: &[u8; 32], + content_hash: &[u8; 32], + state_bytes: &[u8], +) -> Result { + if state_bytes.len() > MAX_MEDIA_STATE_BYTES { + return Err(AssetInvalid::Oversized(state_bytes.len())); + } + let a: MediaAssetV1 = bincode::deserialize(state_bytes).map_err(|_| AssetInvalid::Malformed)?; + if a.schema_version != SCHEMA_VERSION { + return Err(AssetInvalid::WrongSchema(a.schema_version)); + } + if &a.owner != owner { + return Err(AssetInvalid::WrongOwner); + } + if a.sanitized_static_bytes.len() > MAX_MEDIA_BYTES { + return Err(AssetInvalid::BytesOverBudget(a.sanitized_static_bytes.len())); + } + if blake3::hash(&a.sanitized_static_bytes).as_bytes() != content_hash { + return Err(AssetInvalid::HashMismatch); + } + if a.media_type != V1_MEDIA_TYPE { + return Err(AssetInvalid::WrongType(a.media_type.clone())); + } + if a.width != V1_DIMENSION || a.height != V1_DIMENSION { + return Err(AssetInvalid::WrongDimensions(a.width, a.height)); + } + if !verify_sig(owner, &asset_sign_payload(&a), &a.sig) { + return Err(AssetInvalid::BadSignature); + } + Ok(a) +} + +// ------------------------------------------------------------------ tests + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::{Signer, SigningKey}; + + fn kp(seed: u8) -> SigningKey { + SigningKey::from_bytes(&[seed; 32]) + } + + fn sign_raw(key: &SigningKey, payload: &[u8]) -> Vec { + key.sign(&signable(SIGN_DOMAIN, payload)).to_bytes().to_vec() + } + + #[test] + fn profile_roundtrip_both_generations() { + let k = kp(1); + let owner = k.verifying_key().to_bytes(); + let mut p = ProfileState { + schema_version: SCHEMA_VERSION, + owner, + sequence: 1, + display_name: "Kyle".into(), + bio: String::new(), + avatar: None, + sig: vec![], + }; + p.sig = sign_raw(&k, &profile_sign_payload(&p)); + let b = bincode::serialize(&p).unwrap(); + assert!(validate_profile(&owner, &b).is_ok()); + // With a bounded avatar ref. + let mut p2 = p.clone(); + p2.sequence = 2; + p2.avatar = Some(MediaRefV1 { + content_hash: [9u8; 32], + media_type: V1_MEDIA_TYPE.into(), + byte_length: 50_000, + width: 256, + height: 256, + }); + p2.sig = sign_raw(&k, &profile_sign_payload(&p2)); + assert!(validate_profile(&owner, &bincode::serialize(&p2).unwrap()).is_ok()); + // Unbounded avatar refused; tamper refused. + let mut p3 = p2.clone(); + p3.avatar.as_mut().unwrap().width = 4096; + p3.sig = sign_raw(&k, &profile_sign_payload(&p3)); + assert_eq!( + validate_profile(&owner, &bincode::serialize(&p3).unwrap()), + Err(ProfileInvalid::AvatarUnbounded) + ); + let mut p4 = p2.clone(); + p4.display_name = "Mallory".into(); + assert_eq!( + validate_profile(&owner, &bincode::serialize(&p4).unwrap()), + Err(ProfileInvalid::BadSignature) + ); + } + + #[test] + fn asset_roundtrip_and_hash_binding() { + let k = kp(1); + let owner = k.verifying_key().to_bytes(); + let mut a = MediaAssetV1 { + schema_version: SCHEMA_VERSION, + owner, + asset_id: [4u8; 16], + revision: 1, + media_type: V1_MEDIA_TYPE.into(), + width: V1_DIMENSION, + height: V1_DIMENSION, + sanitized_static_bytes: vec![7u8; 1000], + sig: vec![], + }; + a.sig = sign_raw(&k, &asset_sign_payload(&a)); + let hash = *blake3::hash(&a.sanitized_static_bytes).as_bytes(); + let b = bincode::serialize(&a).unwrap(); + assert!(validate_asset(&owner, &hash, &b).is_ok()); + assert_eq!(validate_asset(&owner, &[0u8; 32], &b), Err(AssetInvalid::HashMismatch)); + } +} + +/// blake3 of raw bytes — the content-addressing hash used across the +/// persona/media contract families. +pub fn content_hash(bytes: &[u8]) -> [u8; 32] { + *blake3::hash(bytes).as_bytes() +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index dafa60e..eaa1cf1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,6 +29,8 @@ image.workspace = true tokio.workspace = true base64 = "0.22" +bincode = "1" +bs58 = "0.5" # Shazam signature packets (shazam.rs) carry a CRC-32 of their body. crc32fast = "1" # Inflate for the deno release zip (ytdlp.rs) — already in the lock via the @@ -46,6 +48,8 @@ keyring = { version = "3", features = ["windows-native"] } sysinfo = "0.33" soulseek-proto = { path = "../crates/soulseek-proto" } soulseek-client = { path = "../crates/soulseek-client" } +freenet-adapter = { path = "../crates/freenet-adapter" } +persona-state = { path = "../crates/persona-state" } player = { path = "../crates/player" } tagger = { path = "../crates/tagger" } metadata = { path = "../crates/metadata" } diff --git a/src-tauri/assets/identity_delegate.wasm b/src-tauri/assets/identity_delegate.wasm new file mode 100644 index 0000000..910b751 Binary files /dev/null and b/src-tauri/assets/identity_delegate.wasm differ diff --git a/src-tauri/assets/media_contract.wasm b/src-tauri/assets/media_contract.wasm new file mode 100644 index 0000000..34d1a46 Binary files /dev/null and b/src-tauri/assets/media_contract.wasm differ diff --git a/src-tauri/assets/persona_contract.wasm b/src-tauri/assets/persona_contract.wasm new file mode 100644 index 0000000..1c7b383 Binary files /dev/null and b/src-tauri/assets/persona_contract.wasm differ diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index d64257c..074fafe 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -148,6 +148,40 @@ pub struct AppConfig { /// their status and stats stream in while connected. #[serde(default)] pub buddies: Vec, + /// The experimental Freenet social layer (crates/freenet-adapter). + /// None → false: strictly opt-in, and flipping it off is the kill + /// switch — the adapter drops its connection and stops retrying. A + /// missing or broken node never affects Soulseek or playback. + #[serde(default)] + pub freenet_enabled: Option, + /// WebSocket API port of the EXTERNAL local Freenet node. None → 7509. + #[serde(default)] + pub freenet_ws_port: Option, + /// Whether profile avatars fetched over Freenet may be decoded and + /// shown. None → true (the pipeline sanitizes them regardless); false + /// degrades every remote avatar to the identicon, no fetch at all. + #[serde(default)] + pub freenet_load_remote_images: Option, + /// The Community area's local view of a Soulseek contact: petname, group, + /// note and the two local marks. Private to this device by design — none + /// of it is ever published or sent to the person it describes. + #[serde(default)] + pub contacts: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ContactMeta { + pub username: String, + #[serde(default)] + pub petname: Option, + #[serde(default)] + pub group: Option, + #[serde(default)] + pub note: Option, + #[serde(default)] + pub trusted: bool, + #[serde(default)] + pub favorite: bool, } /// Default longest edge for embedded covers when the user hasn't chosen. diff --git a/src-tauri/src/freenet.rs b/src-tauri/src/freenet.rs new file mode 100644 index 0000000..aca223c --- /dev/null +++ b/src-tauri/src/freenet.rs @@ -0,0 +1,355 @@ +//! The app's Freenet surface — deliberately tiny. +//! +//! The webview gets STATUS ONLY: whether the experimental layer is enabled, +//! and what the adapter sees (absent / occupied / incompatible / connected). +//! The privileged node API stays entirely inside the Rust boundary +//! (crates/freenet-adapter); no command here moves contract state, keys, or +//! node operations across the bridge. Future Community services call the +//! adapter crate directly from Rust. +//! +//! Off by default. `freenet_set_enabled(true)` is the explicit opt-in and +//! persists to config.json; the kill switch is the same call with `false` +//! and takes effect immediately (drops the connection, ends every retry). + +use serde::Serialize; +use tauri::{Emitter, Manager}; + +use crate::config; +use crate::state::AppState; + +#[derive(Serialize)] +pub struct FreenetStatus { + pub enabled: bool, + pub ws_port: u16, + pub status: freenet_adapter::AdapterStatus, + /// Whether remote profile avatars may be fetched and decoded at all. + pub load_remote_images: bool, +} + +/// Wire the adapter at startup: apply config (port + opt-in) and forward +/// adapter events to the webview as `freenet:status` — status frames only. +pub fn init(app: &tauri::AppHandle) { + let cfg = config::load(app); + let state = app.state::(); + let adapter = state.freenet.clone(); + adapter.set_ws_port(cfg.freenet_ws_port.unwrap_or(freenet_adapter::DEFAULT_WS_PORT)); + + let handle = app.clone(); + let mut events = adapter.subscribe(); + tauri::async_runtime::spawn(async move { + loop { + match events.recv().await { + Ok(freenet_adapter::AdapterEvent::Status(s)) => { + let _ = handle.emit("freenet:status", &s); + } + // Contract updates carry ids/lengths only, and nothing in + // the UI consumes them yet — swallow rather than emit noise. + Ok(freenet_adapter::AdapterEvent::ContractUpdate { .. }) => {} + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + }); + + if cfg.freenet_enabled.unwrap_or(false) { + adapter.enable(); + // The peer the app set up follows the switch (see ensure_node). + tauri::async_runtime::spawn(ensure_node(app.clone())); + } +} + +#[tauri::command] +pub fn freenet_status(app: tauri::AppHandle, state: tauri::State<'_, AppState>) -> FreenetStatus { + FreenetStatus { + enabled: state.freenet.is_enabled(), + ws_port: state.freenet.ws_port(), + status: state.freenet.status(), + load_remote_images: config::load(&app).freenet_load_remote_images.unwrap_or(true), + } +} + +/// The reader-side policy gate for avatars (profile.rs checks it BEFORE any +/// fetch). Off degrades every remote avatar to the identicon. +#[tauri::command] +pub fn freenet_set_load_remote_images( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, + on: bool, +) -> Result { + let mut cfg = config::load(&app); + cfg.freenet_load_remote_images = Some(on); + config::save(&app, &cfg); + Ok(freenet_status(app, state)) +} + +/// The explicit opt-in / kill switch. Persisted, then applied immediately. +#[tauri::command] +pub fn freenet_set_enabled( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, + enabled: bool, +) -> Result { + let mut cfg = config::load(&app); + cfg.freenet_enabled = Some(enabled); + config::save(&app, &cfg); + if enabled { + state.freenet.enable(); + tauri::async_runtime::spawn(ensure_node(app.clone())); + } else { + state.freenet.disable(); + } + Ok(freenet_status(app, state)) +} + +/// One-shot detection — works while disabled, touches no adapter state. +#[tauri::command] +pub async fn freenet_detect( + state: tauri::State<'_, AppState>, +) -> Result { + Ok(state.freenet.detect().await) +} + +// ---- the local peer ----------------------------------------------------------- +// Starting the installed node from the app, the way the research did it by +// hand (research/freenet-f0, -f2): the peer writes its config.toml on first +// boot with telemetry ON (the alpha default, even in local mode), so a first +// start runs it only until that file exists, stops it, pins +// `telemetry-enabled = false`, and only then launches the real session. The +// peer is a detached background process — it outlives the app on purpose +// (the design's "desktop background peer") and stops with its own button. + +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +#[derive(Debug, Serialize)] +pub struct NodeInfo { + /// Where the peer binary was found, if anywhere. + pub binary: Option, + pub version: Option, + pub config_dir: String, + pub data_dir: String, + pub config_exists: bool, + pub telemetry_off: bool, + /// True while a node THIS app launched is alive. + pub started_by_app: bool, +} + +const TELEMETRY_ON: &str = "telemetry-enabled = true"; +const TELEMETRY_OFF: &str = "telemetry-enabled = false"; +const FIRST_BOOT_WAIT: Duration = Duration::from_secs(20); + +struct NodeDirs { + base: PathBuf, + config: PathBuf, + data: PathBuf, +} + +fn node_dirs(app: &tauri::AppHandle) -> Result { + let base = app + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join("freenet"); + Ok(NodeDirs { config: base.join("config"), data: base.join("data"), base }) +} + +/// No console window for child processes on Windows. +fn quiet(mut c: Command) -> Command { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + c.creation_flags(0x0800_0000); + } + c +} + +fn find_binary(app: &tauri::AppHandle) -> Option { + let name = if cfg!(windows) { "freenet.exe" } else { "freenet" }; + if let Ok(home) = app.path().home_dir() { + let p = home.join(".cargo").join("bin").join(name); + if p.is_file() { + return Some(p); + } + } + let on_path = quiet(Command::new("freenet")) + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + on_path.then(|| PathBuf::from("freenet")) +} + +fn version_of(bin: &Path) -> Option { + let out = quiet(Command::new(bin)).arg("--version").output().ok()?; + let text = String::from_utf8_lossy(&out.stdout); + let line = text.lines().next()?.trim(); + let v = line + .trim_start_matches("Freenet version:") + .trim() + .trim_end_matches("()") + .trim(); + (!v.is_empty()).then(|| v.to_string()) +} + +fn telemetry_off(config: &Path) -> bool { + std::fs::read_to_string(config) + .map(|s| s.contains(TELEMETRY_OFF)) + .unwrap_or(false) +} + +fn pin_telemetry_off(config: &Path) -> Result<(), String> { + let s = std::fs::read_to_string(config).map_err(|e| format!("read config.toml: {e}"))?; + if s.contains(TELEMETRY_OFF) { + return Ok(()); + } + if !s.contains(TELEMETRY_ON) { + return Err("config.toml has no telemetry-enabled line to pin".into()); + } + std::fs::write(config, s.replace(TELEMETRY_ON, TELEMETRY_OFF)) + .map_err(|e| format!("write config.toml: {e}")) +} + +fn spawn_node(bin: &Path, dirs: &NodeDirs, log: &Path) -> Result { + let out = std::fs::File::create(log).map_err(|e| format!("node log: {e}"))?; + let err = out.try_clone().map_err(|e| e.to_string())?; + quiet(Command::new(bin)) + .arg("network") + .arg("--config-dir") + .arg(&dirs.config) + .arg("--data-dir") + .arg(&dirs.data) + .stdin(Stdio::null()) + .stdout(Stdio::from(out)) + .stderr(Stdio::from(err)) + .spawn() + .map_err(|e| format!("start the peer: {e}")) +} + +fn node_info(app: &tauri::AppHandle, state: &AppState) -> Result { + let dirs = node_dirs(app)?; + let bin = find_binary(app); + let config = dirs.config.join("config.toml"); + let started_by_app = { + let mut guard = state.freenet_node.lock().unwrap(); + match guard.as_mut().map(|c| c.try_wait()) { + Some(Ok(None)) => true, + _ => { + *guard = None; + false + } + } + }; + Ok(NodeInfo { + version: bin.as_deref().and_then(version_of), + binary: bin.map(|p| p.display().to_string()), + config_dir: dirs.config.display().to_string(), + data_dir: dirs.data.display().to_string(), + config_exists: config.is_file(), + telemetry_off: telemetry_off(&config), + started_by_app, + }) +} + +#[tauri::command] +pub fn freenet_node_info( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, +) -> Result { + node_info(&app, &state) +} + +/// Start the peer under the app's folder (the first-boot recipe when its +/// config does not exist yet). Returns the running child. +async fn start_node_inner(app: &tauri::AppHandle) -> Result { + let handle = app.clone(); + tauri::async_runtime::spawn_blocking(move || -> Result { + let bin = find_binary(&handle) + .ok_or("no Freenet peer found on this machine — install it (cargo install freenet) and try again")?; + let dirs = node_dirs(&handle)?; + std::fs::create_dir_all(&dirs.config).map_err(|e| e.to_string())?; + std::fs::create_dir_all(&dirs.data).map_err(|e| e.to_string())?; + let config = dirs.config.join("config.toml"); + if !config.is_file() { + let first_log = dirs.base.join("first-boot.log"); + let mut first = spawn_node(&bin, &dirs, &first_log)?; + let deadline = Instant::now() + FIRST_BOOT_WAIT; + while !config.is_file() && Instant::now() < deadline { + if let Ok(Some(status)) = first.try_wait() { + return Err(format!( + "the peer exited during its first boot ({status}) — see {}", + first_log.display() + )); + } + std::thread::sleep(Duration::from_millis(100)); + } + let _ = first.kill(); + let _ = first.wait(); + if !config.is_file() { + return Err("the peer wrote no config.toml within 20s".into()); + } + log::info!("freenet node: first boot wrote {}", config.display()); + } + pin_telemetry_off(&config)?; + let child = spawn_node(&bin, &dirs, &dirs.base.join("node.log"))?; + log::info!("freenet node started (pid {}) from {}", child.id(), bin.display()); + Ok(child) + }) + .await + .map_err(|e| e.to_string())? +} + +/// A peer the app set up follows the switch: whenever the layer is on and +/// the peer is absent, start it — at launch, and when the switch goes on. +/// First-time setup stays explicit (the Start button), so a user who never +/// asked for a peer never gets one; after that it is simply kept running. +/// (A dev-harness restart takes the child down with the app — observed +/// 2026-09-02 — and a reboot does the same; this is what brings it back.) +pub async fn ensure_node(app: tauri::AppHandle) { + let state = app.state::(); + if !state.freenet.is_enabled() { + return; + } + let Ok(dirs) = node_dirs(&app) else { return }; + if !dirs.config.join("config.toml").is_file() { + return; + } + if state.freenet_node.lock().unwrap().is_some() { + return; + } + if !matches!(state.freenet.detect().await, freenet_adapter::AdapterStatus::NodeAbsent) { + return; + } + match start_node_inner(&app).await { + Ok(child) => *state.freenet_node.lock().unwrap() = Some(child), + Err(e) => log::warn!("freenet node: could not start the peer: {e}"), + } +} + +#[tauri::command] +pub async fn freenet_node_start( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, +) -> Result { + if node_info(&app, &state)?.started_by_app { + return node_info(&app, &state); + } + let child = start_node_inner(&app).await?; + *state.freenet_node.lock().unwrap() = Some(child); + node_info(&app, &state) +} + +/// Stops only a node this app started; a hand-started peer is left alone. +#[tauri::command] +pub async fn freenet_node_stop( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, +) -> Result { + let child = state.freenet_node.lock().unwrap().take(); + if let Some(mut c) = child { + let _ = c.kill(); + let _ = c.wait(); + log::info!("freenet node stopped"); + } + node_info(&app, &state) +} diff --git a/src-tauri/src/persona.rs b/src-tauri/src/persona.rs new file mode 100644 index 0000000..2e8a30a --- /dev/null +++ b/src-tauri/src/persona.rs @@ -0,0 +1,395 @@ +//! PersonaService — the SoulStream identity, held by the bundled Freenet +//! identity delegate and reached only through the FreenetAdapter. +//! +//! The delegate wasm ships as an app asset (`assets/identity_delegate.wasm`, +//! sha256 84dd8dbb…, the generation every F1–F2b spike validated). Its key +//! namespace is (code hash, params), so THESE BYTES ARE A COMMITMENT: +//! changing them re-keys the delegate and strands every user's persona — +//! and the platform's copy-forward is disabled (GHSA-824h-7x5x-wfmf), so +//! the only migration is export-artifact → new generation → import, +//! verified by reading the identity back (F1's record). Treat any change +//! as a migration event, never a routine rebuild. +//! +//! Privilege rules, same as everywhere on this boundary: +//! - the PRIVATE key never leaves the delegate; what crosses to the webview +//! is the public key, the key version, and status strings; +//! - the passphrase-sealed recovery artifact is written/read by RUST at a +//! caller-chosen path — its bytes never transit the webview; +//! - signing stays Rust-internal (`sign` is pub(crate) for future +//! Community services, not a Tauri command). +//! +//! Wire pin: `InMsg`/`OutMsg` are bincode enums whose VARIANT ORDER must +//! match research/freenet-f1/identity-delegate/src/lib.rs exactly — bincode +//! encodes the variant index, so reordering is a silent protocol break. + +use freenet_adapter::FreenetAdapter; +use serde::{Deserialize, Serialize}; + +use crate::state::AppState; + +const DELEGATE_WASM: &[u8] = include_bytes!("../assets/identity_delegate.wasm"); +const DELEGATE_PARAMS: &[u8] = b"soulstream-identity-v1"; + +/// Recovery-artifact KDF defaults: Argon2id inside the delegate's wasm. +/// 64 MiB / 3 passes measured comfortably inside the delegate's caps +/// (8–64 MiB, t ≤ 8 — F1). The artifact itself is 93 bytes. +const EXPORT_M_COST_KIB: u32 = 64 * 1024; +const EXPORT_T_COST: u32 = 3; + +// ---- delegate protocol (bincode; variant order is the wire) ---- + +#[derive(Debug, Serialize, Deserialize)] +enum InMsg { + CreateIdentity { force: bool }, + GetPublicKey, + Sign { domain: String, payload: Vec }, + ExportIdentity { passphrase: String, m_cost_kib: u32, t_cost: u32 }, + ImportIdentity { artifact: Vec, passphrase: String, force: bool }, + RotateIdentity, + GetTransition, + RemoveIdentity, +} + +#[derive(Debug, Serialize, Deserialize)] +enum OutMsg { + Identity { public_key: [u8; 32], key_version: u32, created: bool }, + NotInitialized, + Signature { public_key: [u8; 32], key_version: u32, sig: Vec }, + Artifact { bytes: Vec }, + Rotated { old_public: [u8; 32], new_public: [u8; 32], key_version: u32, transition_sig: Vec }, + Transition { old_public: [u8; 32], new_public: [u8; 32], key_version: u32, transition_sig: Vec }, + NoTransition, + Removed, + Refused { reason: String }, +} + +// ---- public shapes (webview-safe: public data only) ---- + +#[derive(Debug, Clone, Serialize)] +pub struct PersonaInfo { + pub public_key: String, + pub key_version: u32, +} + +#[derive(Debug, Serialize)] +pub struct PersonaStatus { + /// Adapter reachable and enabled — persona ops can run at all. + pub available: bool, + pub adapter: freenet_adapter::AdapterStatus, + /// None when available but no identity exists yet. + pub persona: Option, + /// This build's delegate generation (b58 key) — diagnostics, and the + /// value that must not drift between releases. + pub delegate_key: String, +} + +#[derive(Debug, Serialize)] +pub struct RotationInfo { + pub old_public: String, + pub new_public: String, + pub key_version: u32, +} + +// ---- core (adapter-facing, testable without Tauri) ---- + +async fn call(adapter: &FreenetAdapter, msg: InMsg) -> Result { + // Registration is idempotent and cheap; doing it per-op means the + // service self-heals across node restarts and adapter reconnects. + adapter + .delegate_register(DELEGATE_WASM.to_vec(), DELEGATE_PARAMS) + .await + .map_err(|e| e.to_string())?; + let payload = bincode::serialize(&msg).map_err(|e| e.to_string())?; + let reply = adapter + .delegate_message(DELEGATE_WASM.to_vec(), DELEGATE_PARAMS, payload) + .await + .map_err(|e| e.to_string())?; + bincode::deserialize(&reply).map_err(|e| format!("delegate reply did not decode: {e}")) +} + +fn info(public_key: [u8; 32], key_version: u32) -> PersonaInfo { + PersonaInfo { + public_key: bs58::encode(public_key).into_string(), + key_version, + } +} + +pub(crate) async fn current(adapter: &FreenetAdapter) -> Result, String> { + match call(adapter, InMsg::GetPublicKey).await? { + OutMsg::Identity { public_key, key_version, .. } => Ok(Some(info(public_key, key_version))), + OutMsg::NotInitialized => Ok(None), + OutMsg::Refused { reason } => Err(reason), + other => Err(format!("unexpected delegate reply: {other:?}")), + } +} + +pub(crate) async fn create(adapter: &FreenetAdapter) -> Result { + match call(adapter, InMsg::CreateIdentity { force: false }).await? { + OutMsg::Identity { public_key, key_version, .. } => Ok(info(public_key, key_version)), + OutMsg::Refused { reason } => Err(reason), + other => Err(format!("unexpected delegate reply: {other:?}")), + } +} + +/// Seal the identity to a passphrase and write the artifact at `path`. +/// The bytes go disk-direct from Rust; nothing crosses the webview. +pub(crate) async fn export_to( + adapter: &FreenetAdapter, + passphrase: &str, + path: &str, +) -> Result { + match call( + adapter, + InMsg::ExportIdentity { + passphrase: passphrase.to_string(), + m_cost_kib: EXPORT_M_COST_KIB, + t_cost: EXPORT_T_COST, + }, + ) + .await? + { + OutMsg::Artifact { bytes } => { + let n = bytes.len(); + std::fs::write(path, bytes).map_err(|e| format!("writing artifact: {e}"))?; + Ok(n) + } + OutMsg::NotInitialized => Err("no persona exists yet".into()), + OutMsg::Refused { reason } => Err(reason), + other => Err(format!("unexpected delegate reply: {other:?}")), + } +} + +/// Restore from a recovery artifact file. Refuses to overwrite an existing +/// persona unless `force` — same trust-reset semantics the F1 record set. +pub(crate) async fn import_from( + adapter: &FreenetAdapter, + passphrase: &str, + path: &str, + force: bool, +) -> Result { + let artifact = std::fs::read(path).map_err(|e| format!("reading artifact: {e}"))?; + match call( + adapter, + InMsg::ImportIdentity { artifact, passphrase: passphrase.to_string(), force }, + ) + .await? + { + OutMsg::Identity { public_key, key_version, .. } => Ok(info(public_key, key_version)), + OutMsg::Refused { reason } => Err(reason), + other => Err(format!("unexpected delegate reply: {other:?}")), + } +} + +pub(crate) async fn rotate(adapter: &FreenetAdapter) -> Result { + match call(adapter, InMsg::RotateIdentity).await? { + OutMsg::Rotated { old_public, new_public, key_version, .. } => Ok(RotationInfo { + old_public: bs58::encode(old_public).into_string(), + new_public: bs58::encode(new_public).into_string(), + key_version, + }), + OutMsg::NotInitialized => Err("no persona exists yet".into()), + OutMsg::Refused { reason } => Err(reason), + other => Err(format!("unexpected delegate reply: {other:?}")), + } +} + +pub(crate) async fn remove(adapter: &FreenetAdapter) -> Result<(), String> { + match call(adapter, InMsg::RemoveIdentity).await? { + OutMsg::Removed => Ok(()), + OutMsg::Refused { reason } => Err(reason), + other => Err(format!("unexpected delegate reply: {other:?}")), + } +} + +/// Domain-separated signing for the Rust services (profiles today; +/// playlists, links next). Deliberately not a Tauri command. +pub(crate) async fn sign( + adapter: &FreenetAdapter, + domain: &str, + payload: Vec, +) -> Result, String> { + match call(adapter, InMsg::Sign { domain: domain.to_string(), payload }).await? { + OutMsg::Signature { sig, .. } => Ok(sig), + OutMsg::NotInitialized => Err("no persona exists yet".into()), + OutMsg::Refused { reason } => Err(reason), + other => Err(format!("unexpected delegate reply: {other:?}")), + } +} + +// ---- Tauri surface ---- + +#[tauri::command] +pub async fn persona_status(state: tauri::State<'_, AppState>) -> Result { + let adapter = state.freenet.clone(); + let delegate_key = FreenetAdapter::delegate_key_b58(DELEGATE_WASM.to_vec(), DELEGATE_PARAMS); + let adapter_status = adapter.status(); + let available = adapter.is_enabled() + && adapter_status == freenet_adapter::AdapterStatus::Connected; + let persona = if available { current(&adapter).await.ok().flatten() } else { None }; + Ok(PersonaStatus { available, adapter: adapter_status, persona, delegate_key }) +} + +#[tauri::command] +pub async fn persona_create( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, +) -> Result { + let info = create(&state.freenet.clone()).await?; + // Minted here: the profile ledger knows nothing can exist on the + // network yet, so no search is ever spent looking for it. + crate::profile::ledger_note_persona(&app, &info.public_key, true); + Ok(info) +} + +#[tauri::command] +pub async fn persona_export( + state: tauri::State<'_, AppState>, + passphrase: String, + path: String, +) -> Result { + export_to(&state.freenet.clone(), &passphrase, &path).await +} + +#[tauri::command] +pub async fn persona_import( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, + passphrase: String, + path: String, + force: bool, +) -> Result { + let info = import_from(&state.freenet.clone(), &passphrase, &path, force).await?; + // Imported: another device may have published — the first read asks. + crate::profile::ledger_note_persona(&app, &info.public_key, false); + Ok(info) +} + +#[tauri::command] +pub async fn persona_rotate( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, +) -> Result { + let info = rotate(&state.freenet.clone()).await?; + // A new key addresses a new profile contract: nothing published there yet. + crate::profile::ledger_note_persona(&app, &info.new_public, true); + crate::profile::ledger_forget_persona(&app, &info.old_public); + Ok(info) +} + +#[tauri::command] +pub async fn persona_remove( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, +) -> Result<(), String> { + let adapter = state.freenet.clone(); + let gone = current(&adapter).await.ok().flatten(); + remove(&adapter).await?; + if let Some(info) = gone { + crate::profile::ledger_forget_persona(&app, &info.public_key); + } + Ok(()) +} + +// ---- tests ---- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wire_shapes_roundtrip() { + // The variant ORDER is the wire; this pins encode/decode symmetry + // for the message we depend on most. + let msg = InMsg::ImportIdentity { + artifact: vec![1, 2, 3], + passphrase: "pw".into(), + force: true, + }; + let bytes = bincode::serialize(&msg).unwrap(); + // Variant index 4 (0-based) leads the encoding as a u32. + assert_eq!(&bytes[..4], &4u32.to_le_bytes()); + let out = OutMsg::Identity { public_key: [7; 32], key_version: 3, created: false }; + let bytes = bincode::serialize(&out).unwrap(); + let back: OutMsg = bincode::deserialize(&bytes).unwrap(); + assert!(matches!(back, OutMsg::Identity { key_version: 3, .. })); + } + + #[test] + fn bundled_delegate_generation_is_pinned() { + // If this changes, a migration event is happening — see the module + // docs. Update deliberately, never in passing. + let key = FreenetAdapter::delegate_key_b58(DELEGATE_WASM.to_vec(), DELEGATE_PARAMS); + // The gen-2 identity delegate — the generation every F2→F2b spike + // validated (research/freenet-f1's upgrade probe minted it). + assert_eq!(key, "EtHTvPUtxbZ8TXGZJrmkj21xjQNqVmWdWAohVgydBgst"); + assert_eq!(DELEGATE_WASM.len(), 202026); + } + + #[tokio::test] + async fn ops_refuse_while_disabled() { + let adapter = FreenetAdapter::default(); + let err = current(&adapter).await.unwrap_err(); + assert!(err.contains("disabled"), "{err}"); + } + + /// Live: needs a local freenet node on 7509. + /// cargo test -p app --release persona_live -- --ignored + /// Mutates the node's persona namespace (create/remove/rotate) — run + /// against a throwaway node, not one holding an identity you keep. + #[tokio::test] + #[ignore = "needs a local freenet node on 7509; mutates its persona"] + async fn persona_live_full_loop() { + let adapter = FreenetAdapter::default(); + adapter.enable(); + let mut tries = 0; + while adapter.status() != freenet_adapter::AdapterStatus::Connected { + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + tries += 1; + assert!(tries < 50, "adapter never connected: {:?}", adapter.status()); + } + + // Clean slate, then the full lifecycle. + let _ = remove(&adapter).await; + assert!(current(&adapter).await.unwrap().is_none()); + let created = create(&adapter).await.expect("create"); + assert_eq!(created.key_version, 1); + // No silent overwrite. + let dup = create(&adapter).await; + assert!(dup.is_err()); + + // Export → remove → import reproduces the fingerprint. + let dir = std::env::temp_dir().join("soulstream-persona-test"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("recovery.ssid"); + let path_str = path.to_string_lossy().to_string(); + let n = export_to(&adapter, "correct horse battery", &path_str).await.expect("export"); + assert_eq!(n, 93, "the artifact is 93 bytes by construction"); + remove(&adapter).await.expect("remove"); + let restored = import_from(&adapter, "correct horse battery", &path_str, false) + .await + .expect("import"); + assert_eq!(restored.public_key, created.public_key); + // Wrong passphrase refuses. + let bad = import_from(&adapter, "wrong passphrase!", &path_str, true).await; + assert!(bad.is_err()); + + // Rotation bumps the version and changes the key. + let rot = rotate(&adapter).await.expect("rotate"); + assert_eq!(rot.old_public, created.public_key); + assert_eq!(rot.key_version, 2); + let now = current(&adapter).await.unwrap().unwrap(); + assert_eq!(now.public_key, rot.new_public); + + // Signing works for Rust callers under an allowed domain. + let sig = sign(&adapter, "soulstream.profile/v1", b"payload".to_vec()) + .await + .expect("sign"); + assert_eq!(sig.len(), 64); + // ...and refuses an unapproved domain with a typed reason. + let refused = sign(&adapter, "evil.domain/v1", b"payload".to_vec()).await; + assert!(refused.is_err()); + + adapter.disable(); + let _ = std::fs::remove_file(&path); + } +} diff --git a/src-tauri/src/profile.rs b/src-tauri/src/profile.rs new file mode 100644 index 0000000..1ec9a21 --- /dev/null +++ b/src-tauri/src/profile.rs @@ -0,0 +1,786 @@ +//! ProfileService — the persona's publishable face: display name, bio, and +//! a sanitized avatar, published through the bundled persona/media +//! contracts and signed by the identity delegate (persona.rs). +//! +//! Contract bytes are commitments like the delegate's (see persona.rs): +//! `assets/persona_contract.wasm` (gen-2, avatar-accepting) and +//! `assets/media_contract.wasm` (immutable content-addressed images), both +//! straight from the F2/F2b validation runs, code hashes pinned by tests. +//! +//! The avatar pipeline is F2b's, verbatim in spirit: +//! - PUBLISH: bounded decode → center-crop → 256×256 CatmullRom → RGB → +//! deterministic PNG → budget gate. Metadata cannot survive (pixels in, +//! critical chunks out). +//! - VIEW: policy gates BEFORE any fetch → structural verification (hash, +//! signature, declared bounds vs the signed reference) BEFORE decode → +//! bounded decode (sniff, header-dims refusal pre-allocation) → only +//! then do the sanitized bytes go to the webview. Any failure degrades +//! to "no avatar" with a reason, never an error surface. + +use std::collections::BTreeMap; +use std::io::Cursor; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use freenet_adapter::FreenetAdapter; +use image::{imageops::FilterType, ImageEncoder, ImageReader, Limits}; +use persona_state as ps; +use serde::{Deserialize, Serialize}; +use tauri::Manager; + +use crate::state::AppState; +use crate::{config, persona}; + +const PROFILE_CONTRACT_WASM: &[u8] = include_bytes!("../assets/persona_contract.wasm"); +const MEDIA_CONTRACT_WASM: &[u8] = include_bytes!("../assets/media_contract.wasm"); + +const SOURCE_MAX_BYTES: usize = 16 * 1024 * 1024; +const SOURCE_MAX_DIM: u32 = 8192; +const READER_MAX_DIM: u32 = 512; +const READER_MAX_ALLOC: u64 = 32 * 1024 * 1024; + +// ---------------------------------------------------------------- pipeline + +fn limits(max_dim: u32) -> Limits { + let mut l = Limits::default(); + l.max_image_width = Some(max_dim); + l.max_image_height = Some(max_dim); + l.max_alloc = Some(READER_MAX_ALLOC); + l +} + +fn decode_bounded( + bytes: &[u8], + max_dim: u32, + expect_png: bool, +) -> Result { + let mut reader = ImageReader::new(Cursor::new(bytes)) + .with_guessed_format() + .map_err(|e| format!("format sniff failed: {e}"))?; + let fmt = reader + .format() + .ok_or_else(|| "unrecognized image format".to_string())?; + if expect_png && fmt != image::ImageFormat::Png { + return Err(format!("declared image/png but bytes are {fmt:?}")); + } + reader.limits(limits(max_dim)); + reader.decode().map_err(|e| format!("bounded decode refused: {e}")) +} + +/// Publisher normalization — F2b's recipe. Deterministic for a given input. +pub(crate) fn normalize_avatar(source: &[u8]) -> Result, String> { + if source.len() > SOURCE_MAX_BYTES { + return Err(format!("source over {SOURCE_MAX_BYTES} bytes")); + } + let img = decode_bounded(source, SOURCE_MAX_DIM, false)?; + let (w, h) = (img.width(), img.height()); + let side = w.min(h); + let cropped = img.crop_imm((w - side) / 2, (h - side) / 2, side, side); + let resized = cropped.resize_exact(ps::V1_DIMENSION, ps::V1_DIMENSION, FilterType::CatmullRom); + let rgb = resized.to_rgb8(); + let mut out = Vec::new(); + image::codecs::png::PngEncoder::new(&mut out) + .write_image(rgb.as_raw(), ps::V1_DIMENSION, ps::V1_DIMENSION, image::ExtendedColorType::Rgb8) + .map_err(|e| e.to_string())?; + if out.len() > ps::MAX_MEDIA_BYTES { + return Err(format!( + "normalized avatar is {} bytes, over the {} budget — this image compresses poorly; pick another", + out.len(), + ps::MAX_MEDIA_BYTES + )); + } + Ok(out) +} + +/// Reader decode — the LAST gate before bytes reach the webview. Runs only +/// after structural verification against the signed reference. +fn reader_verify_decode(asset: &ps::MediaAssetV1) -> Result<(), String> { + let img = decode_bounded(&asset.sanitized_static_bytes, READER_MAX_DIM, true)?; + if img.width() != asset.width || img.height() != asset.height { + return Err(format!( + "decoded {}x{} contradicts declared {}x{}", + img.width(), + img.height(), + asset.width, + asset.height + )); + } + Ok(()) +} + +// ---------------------------------------------------------------- service + +#[derive(Debug, Serialize)] +pub struct ProfileView { + pub owner: String, + pub sequence: u64, + pub display_name: String, + pub bio: String, + /// Base64 PNG of the VERIFIED, sanitized avatar — present only when the + /// whole gauntlet passed. + pub avatar_png_base64: Option, + /// Why there is no avatar image ("none published", policy, or the + /// failure reason) — the UI renders its identicon on any of these. + pub avatar_note: Option, + /// True when this came from the device's own ledger (what it last + /// published) rather than a network read. + pub from_ledger: bool, +} + +fn decode_owner(b58: &str) -> Result<[u8; 32], String> { + bs58::decode(b58) + .into_vec() + .map_err(|e| format!("bad persona key: {e}"))? + .as_slice() + .try_into() + .map_err(|_| "persona key is not 32 bytes".to_string()) +} + +async fn fetch_profile( + adapter: &FreenetAdapter, + owner: &[u8; 32], +) -> Result, String> { + let ids = FreenetAdapter::contract_ids( + PROFILE_CONTRACT_WASM.to_vec(), + &ps::make_profile_params(owner), + ); + match adapter.contract_get(&ids.instance, false).await { + Ok(bytes) if bytes.is_empty() => Ok(None), + Ok(bytes) => ps::validate_profile(owner, &bytes) + .map(Some) + .map_err(|e| format!("published profile failed verification: {e}")), + Err(e) if e.to_string().contains("not found") => Ok(None), + Err(e) => Err(e.to_string()), + } +} + +// ---------------------------------------------------------------- the ledger +// +// What THIS device knows about its own persona's profile, kept beside the +// node as /freenet/profile-ledger.json, with verified avatar bytes +// cached by content hash next to it. Reading our own profile from the ledger +// is instant and works offline. Asking the network for a profile that was +// never published is not: every such GET is a 12–30 s search of the ring that +// ends in "retry loop exhausted" (observed live 2026-09-02 — three of them +// behind one avatar change, a minute of nothing happening). The network is +// asked only when it might know more than this device: an imported key never +// searched, an UPDATE the network refuses, or an explicit refresh. + +const ABSENT_RECHECK_SECS: u64 = 3600; + +#[derive(Default, Serialize, Deserialize)] +struct Ledger { + #[serde(default)] + personas: BTreeMap, +} + +#[derive(Default, Serialize, Deserialize)] +struct LedgerEntry { + /// Minted on this device: nothing can exist on the network until this + /// device publishes it. + #[serde(default)] + created_here: bool, + /// The last state this device published (bincode, base64) — the truth + /// for the next read-and-bump while this device is the only writer. + #[serde(default)] + published: Option, + /// Unix seconds of the last network search that found nothing. + #[serde(default)] + absent_checked_at: Option, +} + +/// Where this device keeps what it knows. `None` in tests: network only. +pub struct Local { + ledger: PathBuf, + avatars: PathBuf, +} + +impl Local { + pub fn for_app(app: &tauri::AppHandle) -> Result { + let base = app + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join("freenet"); + Ok(Self { ledger: base.join("profile-ledger.json"), avatars: base.join("avatars") }) + } + fn load(&self) -> Ledger { + std::fs::read(&self.ledger) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default() + } + fn save(&self, ledger: &Ledger) { + if let Some(dir) = self.ledger.parent() { + let _ = std::fs::create_dir_all(dir); + } + match serde_json::to_vec_pretty(ledger) { + Ok(b) => { + if let Err(e) = std::fs::write(&self.ledger, b) { + log::warn!("profile ledger not saved: {e}"); + } + } + Err(e) => log::warn!("profile ledger not encoded: {e}"), + } + } + fn note_published(&self, owner: &[u8; 32], bytes: &[u8]) { + use base64::Engine; + let mut l = self.load(); + let e = l.personas.entry(owner_b58(owner)).or_default(); + e.published = Some(base64::engine::general_purpose::STANDARD.encode(bytes)); + e.absent_checked_at = None; + self.save(&l); + } + fn note_absent(&self, owner: &[u8; 32]) { + let mut l = self.load(); + l.personas.entry(owner_b58(owner)).or_default().absent_checked_at = Some(now_secs()); + self.save(&l); + } + fn avatar_path(&self, hash: &[u8; 32]) -> PathBuf { + self.avatars.join(format!("{}.png", hex(hash))) + } + fn cache_avatar(&self, hash: &[u8; 32], png: &[u8]) { + let _ = std::fs::create_dir_all(&self.avatars); + let _ = std::fs::write(self.avatar_path(hash), png); + } + /// Cached bytes only while they still hash to what the signed + /// reference says; anything else is simply not used. + fn cached_avatar(&self, hash: &[u8; 32]) -> Option> { + let bytes = std::fs::read(self.avatar_path(hash)).ok()?; + (blake3_hash(&bytes) == *hash).then_some(bytes) + } +} + +fn owner_b58(owner: &[u8; 32]) -> String { + bs58::encode(owner).into_string() +} +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// persona.rs tells the ledger when a key is minted here or imported. +pub(crate) fn ledger_note_persona(app: &tauri::AppHandle, public_key_b58: &str, created_here: bool) { + let Ok(local) = Local::for_app(app) else { return }; + let mut l = local.load(); + l.personas.insert( + public_key_b58.to_string(), + LedgerEntry { created_here, published: None, absent_checked_at: None }, + ); + local.save(&l); +} +/// ...and when it is gone. +pub(crate) fn ledger_forget_persona(app: &tauri::AppHandle, public_key_b58: &str) { + let Ok(local) = Local::for_app(app) else { return }; + let mut l = local.load(); + if l.personas.remove(public_key_b58).is_some() { + local.save(&l); + } +} + +/// Our own profile from what this device knows; the network only when it +/// might know more (an imported key never searched, or `force`). The bool +/// says which one answered. +async fn fetch_own_profile( + adapter: &FreenetAdapter, + owner: &[u8; 32], + local: Option<&Local>, + force: bool, +) -> Result<(Option, bool), String> { + if let (Some(l), false) = (local, force) { + let ledger = l.load(); + if let Some(e) = ledger.personas.get(&owner_b58(owner)) { + if let Some(b64) = &e.published { + use base64::Engine; + let known = base64::engine::general_purpose::STANDARD + .decode(b64) + .ok() + .and_then(|b| ps::validate_profile(owner, &b).ok()); + if let Some(p) = known { + return Ok((Some(p), true)); + } + } else if e.created_here { + return Ok((None, true)); + } else if e + .absent_checked_at + .is_some_and(|t| now_secs().saturating_sub(t) < ABSENT_RECHECK_SECS) + { + return Ok((None, true)); + } + } + } + let found = fetch_profile(adapter, owner).await?; + if let Some(l) = local { + match &found { + Some(p) => { + if let Ok(b) = bincode::serialize(p) { + l.note_published(owner, &b); + } + } + None => l.note_absent(owner), + } + } + Ok((found, false)) +} + +/// Publish a signed profile revision. Read-and-bump (F5's rule) against what +/// this device knows; if the network refuses the update because another +/// writer moved first, read it for real once and bump past it. +async fn publish_profile( + adapter: &FreenetAdapter, + owner: [u8; 32], + display_name: String, + bio: String, + avatar: Option, + local: Option<&Local>, +) -> Result { + if display_name.len() > ps::MAX_NAME_BYTES { + return Err(format!("display name over {} bytes", ps::MAX_NAME_BYTES)); + } + if bio.len() > ps::MAX_BIO_BYTES { + return Err(format!("bio over {} bytes", ps::MAX_BIO_BYTES)); + } + let (existing, _) = fetch_own_profile(adapter, &owner, local, false).await?; + let mut sequence = existing.as_ref().map(|p| p.sequence + 1).unwrap_or(1); + let params = ps::make_profile_params(&owner); + let mut retried = false; + loop { + let mut p = ps::ProfileState { + schema_version: ps::SCHEMA_VERSION, + owner, + sequence, + display_name: display_name.clone(), + bio: bio.clone(), + avatar: avatar.clone(), + sig: vec![], + }; + p.sig = persona::sign(adapter, ps::SIGN_DOMAIN, ps::profile_sign_payload(&p)).await?; + let bytes = bincode::serialize(&p).map_err(|e| e.to_string())?; + let result = if existing.is_some() { + adapter + .contract_update(PROFILE_CONTRACT_WASM.to_vec(), ¶ms, bytes.clone()) + .await + .map(|_| ()) + } else { + adapter + .contract_put(PROFILE_CONTRACT_WASM.to_vec(), ¶ms, bytes.clone(), false) + .await + .map(|_| ()) + }; + match result { + Ok(_) => { + if let Some(l) = local { + l.note_published(&owner, &bytes); + } + return Ok(p); + } + Err(e) if existing.is_some() && local.is_some() && !retried => { + retried = true; + match fetch_profile(adapter, &owner).await? { + Some(fresh) if fresh.sequence >= sequence => { + log::info!( + "profile: network is at rev {}, ledger said {} — bumping past it", + fresh.sequence, + sequence - 1 + ); + sequence = fresh.sequence + 1; + } + _ => return Err(e.to_string()), + } + } + Err(e) => return Err(e.to_string()), + } + } +} + +async fn own_key(adapter: &FreenetAdapter) -> Result<[u8; 32], String> { + let info = persona::current(adapter) + .await? + .ok_or("no persona exists yet — create one first")?; + decode_owner(&info.public_key) +} + +fn verify_png_dims(bytes: &[u8], w: u32, h: u32) -> Result<(), String> { + let img = decode_bounded(bytes, READER_MAX_DIM, true)?; + if img.width() != w || img.height() != h { + return Err(format!("decoded {}x{} contradicts declared {w}x{h}", img.width(), img.height())); + } + Ok(()) +} + +/// The avatar half of the read gauntlet, in the design's order: policy → +/// cache by hash → single fetch → verify chain → bounded decode → note. +async fn render_view( + adapter: &FreenetAdapter, + owner: [u8; 32], + p: ps::ProfileState, + load_remote_images: bool, + local: Option<&Local>, + from_ledger: bool, +) -> ProfileView { + use base64::Engine; + let mut v = ProfileView { + owner: owner_b58(&owner), + sequence: p.sequence, + display_name: p.display_name.clone(), + bio: p.bio.clone(), + avatar_png_base64: None, + avatar_note: None, + from_ledger, + }; + let Some(r) = &p.avatar else { + v.avatar_note = Some("none published".into()); + return v; + }; + if !load_remote_images { + v.avatar_note = Some("remote profile images disabled".into()); + return v; + } + if let Some(png) = local.and_then(|l| l.cached_avatar(&r.content_hash)) { + if png.len() as u32 == r.byte_length + && verify_png_dims(&png, r.width as u32, r.height as u32).is_ok() + { + v.avatar_png_base64 = Some(base64::engine::general_purpose::STANDARD.encode(&png)); + return v; + } + } + let ids = FreenetAdapter::contract_ids( + MEDIA_CONTRACT_WASM.to_vec(), + &ps::make_media_params(&owner, &r.content_hash), + ); + // Single attempt (no retry storm — F2b): failure degrades to a note. + let state_bytes = match adapter.contract_get(&ids.instance, false).await { + Ok(b) => b, + Err(e) => { + v.avatar_note = Some(format!("avatar unavailable: {e}")); + return v; + } + }; + match ps::validate_asset(&owner, &r.content_hash, &state_bytes) { + Ok(asset) => { + let matches_ref = asset.media_type == r.media_type + && asset.sanitized_static_bytes.len() as u32 == r.byte_length + && asset.width == r.width as u32 + && asset.height == r.height as u32; + if !matches_ref { + v.avatar_note = Some("asset contradicts the signed reference".into()); + } else if let Err(e) = reader_verify_decode(&asset) { + v.avatar_note = Some(format!("decode refused: {e}")); + } else { + if let Some(l) = local { + l.cache_avatar(&r.content_hash, &asset.sanitized_static_bytes); + } + v.avatar_png_base64 = Some( + base64::engine::general_purpose::STANDARD.encode(&asset.sanitized_static_bytes), + ); + } + } + Err(e) => v.avatar_note = Some(format!("verification failed: {e}")), + } + v +} + +/// The full read gauntlet for anyone's profile — always the network. +async fn view( + adapter: &FreenetAdapter, + owner: [u8; 32], + load_remote_images: bool, + local: Option<&Local>, +) -> Result, String> { + let Some(p) = fetch_profile(adapter, &owner).await? else { + return Ok(None); + }; + Ok(Some(render_view(adapter, owner, p, load_remote_images, local, false).await)) +} + +/// Our own profile — what this device knows, unless `force` asks the network. +async fn view_own( + adapter: &FreenetAdapter, + owner: [u8; 32], + load_remote_images: bool, + local: Option<&Local>, + force: bool, +) -> Result, String> { + let (p, from_ledger) = fetch_own_profile(adapter, &owner, local, force).await?; + let Some(p) = p else { + return Ok(None); + }; + Ok(Some(render_view(adapter, owner, p, load_remote_images, local, from_ledger).await)) +} + +async fn set_avatar( + adapter: &FreenetAdapter, + image_path: &str, + local: Option<&Local>, +) -> Result { + let owner = own_key(adapter).await?; + let source = std::fs::read(image_path).map_err(|e| format!("reading image: {e}"))?; + let png = normalize_avatar(&source)?; + let content_hash = blake3_hash(&png); + let (current, _) = fetch_own_profile(adapter, &owner, local, false).await?; + let revision = current.as_ref().map(|p| p.sequence + 1).unwrap_or(1) as u32; + let mut asset_id = [0u8; 16]; + asset_id.copy_from_slice(&blake3_hash(&[&owner[..], b"avatar"].concat())[..16]); + let mut asset = ps::MediaAssetV1 { + schema_version: ps::SCHEMA_VERSION, + owner, + asset_id, + revision, + media_type: ps::V1_MEDIA_TYPE.into(), + width: ps::V1_DIMENSION, + height: ps::V1_DIMENSION, + sanitized_static_bytes: png.clone(), + sig: vec![], + }; + asset.sig = persona::sign(adapter, ps::SIGN_DOMAIN, ps::asset_sign_payload(&asset)).await?; + let asset_bytes = bincode::serialize(&asset).map_err(|e| e.to_string())?; + // Media object FIRST (design order), then the profile that points at it. + adapter + .contract_put( + MEDIA_CONTRACT_WASM.to_vec(), + &ps::make_media_params(&owner, &content_hash), + asset_bytes, + false, + ) + .await + .map_err(|e| e.to_string())?; + // Ours by construction — cache it so the view never has to fetch it back. + if let Some(l) = local { + l.cache_avatar(&content_hash, &png); + } + let avatar_ref = ps::MediaRefV1 { + content_hash, + media_type: ps::V1_MEDIA_TYPE.into(), + byte_length: png.len() as u32, + width: ps::V1_DIMENSION as u16, + height: ps::V1_DIMENSION as u16, + }; + let (name, bio) = current + .map(|p| (p.display_name, p.bio)) + .unwrap_or_else(|| (String::new(), String::new())); + publish_profile(adapter, owner, name, bio, Some(avatar_ref), local).await?; + view_own(adapter, owner, true, local, false) + .await? + .ok_or("profile vanished after publish".into()) +} + +fn blake3_hash(bytes: &[u8]) -> [u8; 32] { + // Route through persona-state's blake3 so src-tauri needs no direct dep. + ps::content_hash(bytes) +} + +// ---------------------------------------------------------------- commands + +#[tauri::command] +pub async fn profile_get( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, + persona: Option, + force: Option, +) -> Result, String> { + let adapter = state.freenet.clone(); + let load = config::load(&app).freenet_load_remote_images.unwrap_or(true); + let local = Local::for_app(&app)?; + match persona { + Some(b58) => view(&adapter, decode_owner(&b58)?, load, Some(&local)).await, + None => { + let owner = own_key(&adapter).await?; + view_own(&adapter, owner, load, Some(&local), force.unwrap_or(false)).await + } + } +} + +#[tauri::command] +pub async fn profile_publish( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, + display_name: String, + bio: String, +) -> Result { + let adapter = state.freenet.clone(); + let local = Local::for_app(&app)?; + let owner = own_key(&adapter).await?; + let (current, _) = fetch_own_profile(&adapter, &owner, Some(&local), false).await?; + let avatar = current.and_then(|p| p.avatar); + publish_profile(&adapter, owner, display_name, bio, avatar, Some(&local)).await?; + view_own(&adapter, owner, true, Some(&local), false) + .await? + .ok_or("profile vanished after publish".into()) +} + +#[tauri::command] +pub async fn profile_set_avatar( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, + image_path: String, +) -> Result { + let local = Local::for_app(&app)?; + set_avatar(&state.freenet.clone(), &image_path, Some(&local)).await +} + +/// Withdraw the avatar pointer. Truthful semantics (F2b): previously +/// published copies of the old image remain fetchable on the network — +/// this changes what the profile points at, it does not erase. +#[tauri::command] +pub async fn profile_clear_avatar( + app: tauri::AppHandle, + state: tauri::State<'_, AppState>, +) -> Result { + let adapter = state.freenet.clone(); + let local = Local::for_app(&app)?; + let owner = own_key(&adapter).await?; + let (current, _) = fetch_own_profile(&adapter, &owner, Some(&local), false).await?; + let current = current.ok_or("no profile published yet")?; + publish_profile(&adapter, owner, current.display_name, current.bio, None, Some(&local)).await?; + view_own(&adapter, owner, true, Some(&local), false) + .await? + .ok_or("profile vanished after publish".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn photo_jpeg() -> Vec { + let w = 900u32; + let h = 600u32; + let mut rgb = image::RgbImage::new(w, h); + for (x, y, p) in rgb.enumerate_pixels_mut() { + *p = image::Rgb([(x * 255 / w) as u8, (y * 255 / h) as u8, (((x + y) * 255) / (w + h)) as u8]); + } + let mut jpeg = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 85) + .encode_image(&image::DynamicImage::ImageRgb8(rgb)) + .unwrap(); + // Splice an EXIF APP1 + COM segment after SOI — must not survive. + let exif = b"Exif\0\0SPIKEGPS-LAT51-LON7"; + let com = b"SECRETCOMMENT-nope"; + let mut out = Vec::new(); + out.extend_from_slice(&jpeg[..2]); + out.extend_from_slice(&[0xFF, 0xE1]); + out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes()); + out.extend_from_slice(exif); + out.extend_from_slice(&[0xFF, 0xFE]); + out.extend_from_slice(&((com.len() + 2) as u16).to_be_bytes()); + out.extend_from_slice(com); + out.extend_from_slice(&jpeg[2..]); + out + } + + #[test] + fn contract_generations_are_pinned() { + // Commitment bytes, like the delegate's — changing either is a + // migration event (copy-forward is disabled upstream). + let p = FreenetAdapter::contract_ids(PROFILE_CONTRACT_WASM.to_vec(), b"x"); + assert_eq!(p.code_hash, "EeQoECY38qpiHSqMwd4W1KHQMDyRqKqVsq3fV56Me7g4"); + let m = FreenetAdapter::contract_ids(MEDIA_CONTRACT_WASM.to_vec(), b"x"); + assert_eq!(m.code_hash, "6miQr6YTLEzjnoTCr9kFuapQLMq4z2PVqK6QSQzJpotT"); + } + + #[test] + fn normalize_is_deterministic_and_strips_metadata() { + let src = photo_jpeg(); + let a = normalize_avatar(&src).unwrap(); + let b = normalize_avatar(&src).unwrap(); + assert_eq!(a, b, "same input must produce identical bytes"); + assert!(a.len() <= ps::MAX_MEDIA_BYTES); + for pat in [&b"SPIKEGPS"[..], &b"SECRETCOMMENT"[..], &b"Exif"[..]] { + assert!(!a.windows(pat.len()).any(|w| w == pat)); + } + } + + #[test] + fn reader_refuses_lies_and_bombs() { + let png = normalize_avatar(&photo_jpeg()).unwrap(); + let asset = |bytes: Vec, w: u32, h: u32| ps::MediaAssetV1 { + schema_version: ps::SCHEMA_VERSION, + owner: [1; 32], + asset_id: [2; 16], + revision: 1, + media_type: ps::V1_MEDIA_TYPE.into(), + width: w, + height: h, + sanitized_static_bytes: bytes, + sig: vec![0; 64], + }; + // dimension lie + assert!(reader_verify_decode(&asset(png.clone(), 64, 64)).is_err()); + // SVG declared png → sniff refusal + let svg = b"".to_vec(); + assert!(reader_verify_decode(&asset(svg, 256, 256)).is_err()); + // IHDR bomb: tiny bytes claiming 60000x60000 — refused pre-allocation + let mut bomb = png.clone(); + bomb[16..20].copy_from_slice(&60000u32.to_be_bytes()); + bomb[20..24].copy_from_slice(&60000u32.to_be_bytes()); + let mut h = crc32fast::Hasher::new(); + h.update(&bomb[12..29]); + let crc = h.finalize(); + bomb[29..33].copy_from_slice(&crc.to_be_bytes()); + let t0 = std::time::Instant::now(); + assert!(reader_verify_decode(&asset(bomb, 256, 256)).is_err()); + assert!(t0.elapsed().as_millis() < 500, "bomb refusal must be immediate"); + } + + /// Live: needs a local freenet node on 7509. Mutates its persona and + /// profile namespaces — throwaway node only. + /// cargo test -p app --release profile_live -- --ignored + #[tokio::test] + #[ignore = "needs a local freenet node on 7509; mutates persona+profile"] + async fn profile_live_full_loop() { + let adapter = FreenetAdapter::default(); + adapter.enable(); + let mut tries = 0; + while adapter.status() != freenet_adapter::AdapterStatus::Connected { + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + tries += 1; + assert!(tries < 50, "adapter never connected: {:?}", adapter.status()); + } + // Fresh persona for the run. + let _ = persona::remove(&adapter).await; + let created = persona::create(&adapter).await.expect("create persona"); + let owner = decode_owner(&created.public_key).unwrap(); + + // Publish name + bio; read back through the network. + publish_profile(&adapter, owner, "Kyle".into(), "first face".into(), None, None) + .await + .expect("publish"); + let v = view(&adapter, owner, true, None).await.unwrap().expect("view"); + assert_eq!(v.display_name, "Kyle"); + assert_eq!(v.sequence, 1); + assert_eq!(v.avatar_note.as_deref(), Some("none published")); + + // Avatar: temp jpeg → normalize → publish → view carries the PNG. + let dir = std::env::temp_dir().join("soulstream-profile-test"); + std::fs::create_dir_all(&dir).unwrap(); + let img_path = dir.join("source.jpg"); + std::fs::write(&img_path, photo_jpeg()).unwrap(); + let v = set_avatar(&adapter, &img_path.to_string_lossy(), None) + .await + .expect("set avatar"); + assert!(v.avatar_png_base64.is_some(), "note: {:?}", v.avatar_note); + assert_eq!(v.sequence, 2); + + // Policy gate: remote images off → no avatar bytes, honest note. + let v = view(&adapter, owner, false, None).await.unwrap().unwrap(); + assert!(v.avatar_png_base64.is_none()); + assert_eq!(v.avatar_note.as_deref(), Some("remote profile images disabled")); + + // Withdraw: pointer clears; sequence bumps. + let owner_key = owner; + let cur = fetch_profile(&adapter, &owner_key).await.unwrap().unwrap(); + publish_profile(&adapter, owner_key, cur.display_name, cur.bio, None, None) + .await + .expect("clear"); + let v = view(&adapter, owner, true, None).await.unwrap().unwrap(); + assert!(v.avatar_png_base64.is_none()); + assert_eq!(v.sequence, 3); + + adapter.disable(); + let _ = std::fs::remove_file(&img_path); + } +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index d50724b..d0204ad 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -9,4 +9,10 @@ pub struct AppState { /// never routes it — so the resolver strips these terms up front instead /// of discovering the silence by timeout. Lowercased on receipt. pub excluded_search_phrases: Mutex>, + /// The experimental Freenet layer's one handle (default: disabled, inert). + /// The privilege boundary: node operations never leave the Rust side. + pub freenet: freenet_adapter::FreenetAdapter, + /// The local Freenet peer THIS app launched (freenet.rs), if any. A node + /// started by hand is not here — the adapter finds it all the same. + pub freenet_node: std::sync::Mutex>, }