From 3da576a94e9adfd1e00f0e3987a34dc88136c176 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Fri, 3 Jul 2026 00:09:11 +0200 Subject: [PATCH] Refactor Rust core architecture --- Cargo.lock | 811 +++++++++++++++++- Cargo.toml | 2 +- crates/{vnidrop-core => vnidrop}/Cargo.toml | 8 +- crates/vnidrop/src/access_policy.rs | 81 ++ crates/vnidrop/src/api.rs | 123 +++ crates/vnidrop/src/error.rs | 23 + crates/vnidrop/src/filesystem.rs | 227 +++++ crates/vnidrop/src/lib.rs | 22 + crates/vnidrop/src/logging.rs | 29 + crates/vnidrop/src/repository.rs | 228 +++++ .../src/lib.rs => vnidrop/src/runtime.rs} | 797 ++++++----------- crates/vnidrop/src/secret.rs | 30 + crates/vnidrop/src/tests.rs | 151 ++++ crates/vnidrop/src/ticket.rs | 69 ++ crates/vnidrop/src/util.rs | 17 + .../tests/local_transfer.rs | 10 +- crates/{vnidrop-core => vnidrop}/uniffi.toml | 0 shared/build.gradle.kts | 4 +- .../com/vnidrop/app/core/CoreRepository.kt | 16 +- .../com/vnidrop/app/SharedLogicIOSTest.kt | 6 +- .../com/vnidrop/app/CoreNativeLoadTest.kt | 8 +- 21 files changed, 2072 insertions(+), 590 deletions(-) rename crates/{vnidrop-core => vnidrop}/Cargo.toml (70%) create mode 100644 crates/vnidrop/src/access_policy.rs create mode 100644 crates/vnidrop/src/api.rs create mode 100644 crates/vnidrop/src/error.rs create mode 100644 crates/vnidrop/src/filesystem.rs create mode 100644 crates/vnidrop/src/lib.rs create mode 100644 crates/vnidrop/src/logging.rs create mode 100644 crates/vnidrop/src/repository.rs rename crates/{vnidrop-core/src/lib.rs => vnidrop/src/runtime.rs} (62%) create mode 100644 crates/vnidrop/src/secret.rs create mode 100644 crates/vnidrop/src/tests.rs create mode 100644 crates/vnidrop/src/ticket.rs create mode 100644 crates/vnidrop/src/util.rs rename crates/{vnidrop-core => vnidrop}/tests/local_transfer.rs (88%) rename crates/{vnidrop-core => vnidrop}/uniffi.toml (100%) diff --git a/Cargo.lock b/Cargo.lock index 0079cc4..c418cf9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -216,6 +216,15 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "atomic-polyfill" version = "1.0.3" @@ -326,6 +335,9 @@ name = "bitflags" version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] [[package]] name = "blake3" @@ -341,6 +353,15 @@ dependencies = [ "cpufeatures 0.3.0", ] +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block-buffer" version = "0.12.1" @@ -448,7 +469,7 @@ checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -509,6 +530,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -584,6 +611,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + [[package]] name = "critical-section" version = "1.2.0" @@ -608,6 +650,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -660,9 +711,9 @@ dependencies = [ "cfg-if", "cpufeatures 0.3.0", "curve25519-dalek-derive", - "digest", + "digest 0.11.3", "fiat-crypto", - "rand_core", + "rand_core 0.10.1", "rustc_version", "serde", "subtle", @@ -741,14 +792,25 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468 0.7.0", + "zeroize", +] + [[package]] name = "der" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" dependencies = [ - "const-oid", - "pem-rfc7468", + "const-oid 0.10.2", + "pem-rfc7468 1.0.0", "zeroize", ] @@ -832,13 +894,25 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "digest" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer", + "block-buffer 0.12.1", "crypto-common 0.2.2", ] @@ -876,15 +950,21 @@ dependencies = [ "winapi", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "ed25519" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ - "pkcs8", + "pkcs8 0.11.0", "serdect", - "signature", + "signature 3.0.0", ] [[package]] @@ -895,10 +975,10 @@ checksum = "b011170fe4f04665565b4110afef66774fe9ffff278f3eb5b81cc73d26e27d60" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core", + "rand_core 0.10.1", "serde", - "sha2", - "signature", + "sha2 0.11.0", + "signature 3.0.0", "subtle", "zeroize", ] @@ -908,6 +988,9 @@ name = "either" version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] [[package]] name = "embedded-io" @@ -948,6 +1031,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + [[package]] name = "event-listener" version = "5.4.1" @@ -975,7 +1069,7 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef975e30683b2d965054bb0a836f8973857c4ebf6acf274fe46617cd285060d8" dependencies = [ - "foldhash", + "foldhash 0.2.0", "libm", "portable-atomic", "siphasher 1.0.3", @@ -999,12 +1093,29 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin 0.9.8", +] + [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1084,6 +1195,17 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + [[package]] name = "futures-io" version = "0.3.32" @@ -1222,7 +1344,7 @@ dependencies = [ "js-sys", "libc", "r-efi", - "rand_core", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -1293,6 +1415,17 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -1301,7 +1434,16 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", ] [[package]] @@ -1355,7 +1497,7 @@ dependencies = [ "idna", "ipnet", "jni 0.22.4", - "rand", + "rand 0.10.2", "rustls", "thiserror 2.0.18", "tinyvec", @@ -1377,7 +1519,7 @@ dependencies = [ "jni 0.22.4", "once_cell", "prefix-trie", - "rand", + "rand 0.10.2", "ring", "thiserror 2.0.18", "tinyvec", @@ -1402,7 +1544,7 @@ dependencies = [ "ndk-context", "once_cell", "parking_lot", - "rand", + "rand 0.10.2", "resolv-conf", "rustls", "smallvec", @@ -1413,6 +1555,33 @@ dependencies = [ "tracing", ] +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.4.2" @@ -1680,7 +1849,7 @@ dependencies = [ "hyper", "hyper-util", "log", - "rand", + "rand 0.10.2", "tokio", "url", "xmltree", @@ -1693,7 +1862,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", ] [[package]] @@ -1770,7 +1939,7 @@ dependencies = [ "pin-project", "portable-atomic", "portmapper", - "rand", + "rand 0.10.2", "reqwest", "rustc-hash", "rustls", @@ -1800,7 +1969,7 @@ dependencies = [ "ed25519-dalek", "getrandom 0.4.3", "n0-error", - "rand", + "rand 0.10.2", "serde", "url", "zeroize", @@ -1835,7 +2004,7 @@ dependencies = [ "nested_enum_utils", "noq", "postcard", - "rand", + "rand 0.10.2", "range-collections", "redb", "ref-cast", @@ -1862,7 +2031,7 @@ dependencies = [ "n0-future", "ndk-context", "portable-atomic", - "rand", + "rand 0.10.2", "rustls", "simple-dns", "strum", @@ -1939,7 +2108,7 @@ dependencies = [ "num_enum", "pin-project", "postcard", - "rand", + "rand 0.10.2", "reqwest", "rustls", "rustls-pki-types", @@ -2114,6 +2283,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin 0.9.8", +] [[package]] name = "libc" @@ -2127,6 +2299,29 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.9.0", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2173,7 +2368,7 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" dependencies = [ - "hashbrown", + "hashbrown 0.17.1", ] [[package]] @@ -2197,6 +2392,16 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + [[package]] name = "memchr" version = "2.8.2" @@ -2466,7 +2671,7 @@ dependencies = [ "getrandom 0.4.3", "identity-hash", "lru-slab", - "rand", + "rand 0.10.2", "rand_pcg", "ring", "rustc-hash", @@ -2513,6 +2718,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2528,6 +2749,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2535,6 +2767,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2733,7 +2966,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -2754,6 +2987,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "pem-rfc7468" version = "1.0.0" @@ -2805,16 +3047,43 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der 0.7.10", + "pkcs8 0.10.2", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + [[package]] name = "pkcs8" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "der", - "spki", + "der 0.8.0", + "spki 0.8.0", ] +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "plain" version = "0.2.3" @@ -2872,7 +3141,7 @@ dependencies = [ "n0-future", "netwatch", "num_enum", - "rand", + "rand 0.10.2", "serde", "smallvec", "socket2", @@ -2934,6 +3203,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prefix-trie" version = "0.8.4" @@ -3019,6 +3297,17 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.10.2" @@ -3027,7 +3316,26 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -3042,7 +3350,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -3090,6 +3398,15 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_syscall" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" +dependencies = [ + "bitflags", +] + [[package]] name = "ref-cast" version = "1.0.25" @@ -3196,6 +3513,26 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "signature 2.2.0", + "spki 0.7.3", + "subtle", + "zeroize", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3478,6 +3815,18 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serdect" version = "0.4.3" @@ -3488,12 +3837,34 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha1_smol" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.11.0" @@ -3502,7 +3873,7 @@ checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "digest", + "digest 0.11.3", ] [[package]] @@ -3530,6 +3901,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + [[package]] name = "signature" version = "3.0.0" @@ -3636,6 +4017,16 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + [[package]] name = "spki" version = "0.8.0" @@ -3643,7 +4034,192 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", - "der", + "der 0.8.0", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.118", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-sqlite", + "syn 2.0.118", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "crc", + "digest 0.10.7", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.6", + "rsa", + "sha1", + "sha2 0.10.9", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.6", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "url", ] [[package]] @@ -3658,6 +4234,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" @@ -3691,6 +4278,12 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "1.0.109" @@ -3977,7 +4570,7 @@ dependencies = [ "getrandom 0.4.3", "http", "httparse", - "rand", + "rand 0.10.2", "ring", "rustls-pki-types", "sha1_smol", @@ -4083,6 +4676,19 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" @@ -4145,12 +4751,33 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -4336,6 +4963,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "vergen" version = "9.1.0" @@ -4380,7 +5013,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] -name = "vnidrop-core" +name = "vnidrop" version = "0.1.0" dependencies = [ "anyhow", @@ -4396,9 +5029,13 @@ dependencies = [ "num_cpus", "serde", "serde_json", + "sqlx", "tempfile", "thiserror 2.0.18", "tokio", + "tracing", + "tracing-appender", + "tracing-subscriber", "uniffi", "uuid", "walkdir", @@ -4429,6 +5066,12 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" version = "0.2.126" @@ -4544,6 +5187,16 @@ dependencies = [ "nom", ] +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + [[package]] name = "widestring" version = "1.2.1" @@ -4702,6 +5355,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -4735,6 +5397,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -4766,6 +5443,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4778,6 +5461,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -4790,6 +5479,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -4808,6 +5503,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -4820,6 +5521,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -4832,6 +5539,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -4844,6 +5557,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -4974,6 +5693,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/Cargo.toml b/Cargo.toml index 2f21561..c0bf7fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/vnidrop-core"] +members = ["crates/vnidrop"] resolver = "2" [profile.release] diff --git a/crates/vnidrop-core/Cargo.toml b/crates/vnidrop/Cargo.toml similarity index 70% rename from crates/vnidrop-core/Cargo.toml rename to crates/vnidrop/Cargo.toml index 90678ac..b569c45 100644 --- a/crates/vnidrop-core/Cargo.toml +++ b/crates/vnidrop/Cargo.toml @@ -1,10 +1,10 @@ [package] -name = "vnidrop-core" +name = "vnidrop" version = "0.1.0" edition = "2021" [lib] -name = "vnidrop_core" +name = "vnidrop" crate-type = ["cdylib", "staticlib", "rlib"] [dependencies] @@ -21,7 +21,11 @@ n0-future = "0.3.1" num_cpus = "1.17.0" serde = { version = "1", features = ["derive"] } serde_json = "1" +sqlx = { version = "0.8.6", default-features = false, features = ["runtime-tokio", "sqlite", "macros", "migrate"] } thiserror = "2.0.18" +tracing = "0.1.41" +tracing-appender = "0.2.4" +tracing-subscriber = { version = "0.3.20", features = ["env-filter", "fmt"] } tokio = { version = "1.52.3", features = ["full"] } uniffi = { version = "=0.29.4", features = ["tokio"] } uuid = { version = "1.23.3", features = ["v4", "serde"] } diff --git a/crates/vnidrop/src/access_policy.rs b/crates/vnidrop/src/access_policy.rs new file mode 100644 index 0000000..1547774 --- /dev/null +++ b/crates/vnidrop/src/access_policy.rs @@ -0,0 +1,81 @@ +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; + +use tokio::sync::RwLock; + +use crate::api::TransferAccessMode; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum AccessDecision { + Allow, + Deny { reason: &'static str }, +} + +#[derive(Debug, Default)] +pub(crate) struct AccessPolicy { + modes: RwLock>, + approved_sessions: RwLock>, +} + +impl AccessPolicy { + pub(crate) fn new() -> Arc { + Arc::new(Self::default()) + } + + pub(crate) async fn set_mode(&self, transfer_id: u64, mode: TransferAccessMode) { + self.modes.write().await.insert(transfer_id, mode); + } + + pub(crate) async fn remove_transfer(&self, transfer_id: u64) { + self.modes.write().await.remove(&transfer_id); + self.approved_sessions + .write() + .await + .retain(|(id, _)| *id != transfer_id); + } + + pub(crate) async fn approve_endpoint(&self, transfer_id: u64, endpoint_id: String) { + self.approved_sessions + .write() + .await + .insert((transfer_id, endpoint_id)); + } + + pub(crate) async fn decide( + &self, + transfer_id: u64, + endpoint_id: Option<&str>, + ) -> AccessDecision { + match self + .modes + .read() + .await + .get(&transfer_id) + .cloned() + .unwrap_or(TransferAccessMode::Public) + { + TransferAccessMode::Public => AccessDecision::Allow, + TransferAccessMode::ApprovalRequired => { + let Some(endpoint_id) = endpoint_id else { + return AccessDecision::Deny { + reason: "missing-endpoint-id", + }; + }; + if self + .approved_sessions + .read() + .await + .contains(&(transfer_id, endpoint_id.to_string())) + { + AccessDecision::Allow + } else { + AccessDecision::Deny { + reason: "approval-required", + } + } + } + } + } +} diff --git a/crates/vnidrop/src/api.rs b/crates/vnidrop/src/api.rs new file mode 100644 index 0000000..7471f0d --- /dev/null +++ b/crates/vnidrop/src/api.rs @@ -0,0 +1,123 @@ +use iroh_blobs::Hash; +use serde::{Deserialize, Serialize}; + +use crate::util::{non_empty, now_ms}; + +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct CoreEvent { + pub id: String, + pub timestamp: i64, + pub scope: String, + pub transfer_id: Option, + pub direction: Option, + pub phase: String, + pub kind: String, + pub data_json: String, +} + +#[uniffi::export(with_foreign)] +pub trait CoreEventSink: Send + Sync { + fn on_event(&self, event: CoreEvent); +} + +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct RuntimeStatus { + pub endpoint_id: String, + pub addr: String, + pub active_transfers: u64, + pub active_shares: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Enum)] +pub enum SourceKind { + Path, + AndroidContentUri, + IosSecurityScopedUrl, +} + +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct ShareSource { + pub kind: SourceKind, + pub value: String, + pub display_name: Option, + pub is_directory: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct ShareMetadataInput { + pub transfer_id: u64, + pub transfer_name: Option, + pub sender_name: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Enum)] +pub enum TransferAccessMode { + Public, + ApprovalRequired, +} + +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct StoredTransfer { + pub transfer_id: u64, + pub direction: String, + pub status: String, + pub transfer_name: Option, + pub content_hash: Option, + pub ticket: Option, + pub file_count: u64, + pub total_size: u64, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct ShareResult { + pub transfer_id: u64, + pub ticket: String, + pub blob_ticket: String, + pub hash: String, + pub transfer_name: String, + pub file_count: u64, + pub total_size: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct TransferMetadata { + pub version: u8, + pub transfer_id: u64, + pub transfer_name: String, + pub sender_name: Option, + pub created_at: i64, + pub content_hash: String, + pub file_count: u64, + pub total_size: u64, +} + +impl TransferMetadata { + pub(crate) fn new( + transfer_id: u64, + transfer_name: impl Into, + sender_name: Option, + content_hash: Hash, + file_count: u64, + total_size: u64, + ) -> Self { + Self { + version: 1, + transfer_id, + transfer_name: transfer_name.into(), + sender_name: sender_name.and_then(non_empty), + created_at: now_ms(), + content_hash: content_hash.to_string(), + file_count, + total_size, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct TicketInspection { + pub kind: String, + pub blob_ticket: String, + pub metadata: Option, +} diff --git a/crates/vnidrop/src/error.rs b/crates/vnidrop/src/error.rs new file mode 100644 index 0000000..b6dc915 --- /dev/null +++ b/crates/vnidrop/src/error.rs @@ -0,0 +1,23 @@ +use std::io; + +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum VnidropError { + #[error("{reason}")] + Generic { reason: String }, +} + +impl From for VnidropError { + fn from(error: anyhow::Error) -> Self { + Self::Generic { + reason: error.to_string(), + } + } +} + +impl From for VnidropError { + fn from(error: io::Error) -> Self { + Self::Generic { + reason: error.to_string(), + } + } +} diff --git a/crates/vnidrop/src/filesystem.rs b/crates/vnidrop/src/filesystem.rs new file mode 100644 index 0000000..77f25ed --- /dev/null +++ b/crates/vnidrop/src/filesystem.rs @@ -0,0 +1,227 @@ +use std::{ + io::{self, Read, Write}, + path::{Component, Path, PathBuf}, +}; + +use anyhow::{Context, Result}; +use bytes::Bytes; +use iroh_blobs::{api::TempTag, Hash}; + +use crate::{ + api::{ShareSource, SourceKind}, + util::non_empty, +}; + +const STREAM_BUFFER_LEN: usize = 1024 * 1024; + +#[derive(Debug)] +pub(crate) struct TransferImport { + pub(crate) tag: TempTag, + pub(crate) root_hash: Hash, + pub(crate) total_size: u64, + pub(crate) file_count: u64, + pub(crate) default_name: String, +} + +#[derive(Debug)] +pub(crate) struct ImportSourceFile { + pub(crate) path: PathBuf, + pub(crate) collection_name: String, +} + +pub(crate) fn collect_import_files(sources: Vec) -> Result> { + let mut files = Vec::new(); + for source in sources { + match source.kind { + SourceKind::Path | SourceKind::IosSecurityScopedUrl => { + let path = source_path(&source)?; + let display_name = source + .display_name + .clone() + .and_then(non_empty) + .or_else(|| { + path.file_name() + .and_then(|name| name.to_str()) + .map(ToOwned::to_owned) + }) + .unwrap_or_else(|| "transfer".to_string()); + if source.is_directory || path.is_dir() { + collect_dir_files(&path, &display_name, &mut files)?; + } else { + files.push(ImportSourceFile { + path, + collection_name: validated_relative_string(&display_name)?, + }); + } + } + SourceKind::AndroidContentUri => { + anyhow::bail!( + "Android content URI streaming needs platform file descriptor glue before it can be imported without copying" + ); + } + } + } + if files.is_empty() { + anyhow::bail!("no files found in selected sources"); + } + Ok(files) +} + +fn source_path(source: &ShareSource) -> Result { + if matches!(source.kind, SourceKind::IosSecurityScopedUrl) + && source.value.starts_with("file://") + { + let without_scheme = source.value.trim_start_matches("file://"); + return Ok(PathBuf::from(percent_decode_file_url_path(without_scheme)?)); + } + Ok(PathBuf::from(&source.value)) +} + +fn collect_dir_files( + root: &Path, + display_name: &str, + files: &mut Vec, +) -> Result<()> { + for entry in walkdir::WalkDir::new(root).follow_links(false) { + let entry = entry?; + if !entry.file_type().is_file() { + continue; + } + let relative = entry + .path() + .strip_prefix(root) + .context("failed to compute relative path")?; + let collection_name = path_to_string(Path::new(display_name).join(relative), true)?; + files.push(ImportSourceFile { + path: entry.path().to_path_buf(), + collection_name, + }); + } + Ok(()) +} + +pub(crate) fn default_collection_name(files: &[ImportSourceFile]) -> String { + files + .first() + .and_then(|file| file.collection_name.split('/').next()) + .filter(|name| !name.is_empty()) + .unwrap_or("transfer") + .to_string() +} + +pub(crate) fn safe_output_path(output_dir: &Path, relative_path: &str) -> Result { + let relative = Path::new(relative_path); + path_to_string(relative, true)?; + Ok(output_dir.join(relative)) +} + +pub(crate) fn read_stream_from_blocking_reader( + mut reader: R, +) -> impl futures::Stream> + Send + Sync + 'static +where + R: Read + Send + 'static, +{ + let (tx, rx) = async_channel::bounded(2); + std::thread::spawn(move || { + let mut buffer = vec![0; STREAM_BUFFER_LEN]; + loop { + match reader.read(&mut buffer) { + Ok(0) => break, + Ok(read) => { + if tx + .send_blocking(Ok(Bytes::copy_from_slice(&buffer[..read]))) + .is_err() + { + break; + } + } + Err(error) => { + let _ = tx.send_blocking(Err(error)); + break; + } + } + } + }); + rx +} + +pub(crate) fn write_stream_to_blocking_writer( + mut writer: W, + rx: async_channel::Receiver>>, +) -> io::Result<()> +where + W: Write, +{ + while let Ok(item) = rx.recv_blocking() { + match item? { + Some(bytes) => writer.write_all(&bytes)?, + None => break, + } + } + writer.flush() +} + +pub(crate) async fn wait_for_writer( + task: std::thread::JoinHandle>, +) -> Result> { + tokio::task::spawn_blocking(move || { + task.join() + .map_err(|_| anyhow::anyhow!("export writer thread panicked")) + }) + .await? +} + +pub(crate) fn validated_relative_string(name: &str) -> Result { + path_to_string(Path::new(name), true) +} + +pub(crate) fn path_to_string(path: impl AsRef, must_be_relative: bool) -> Result { + let mut path_str = String::new(); + let parts = path + .as_ref() + .components() + .filter_map(|component| match component { + Component::Normal(x) => { + let Some(component) = x.to_str() else { + return Some(Err(anyhow::anyhow!("invalid character in path"))); + }; + if !component.contains('/') && !component.contains('\\') { + Some(Ok(component)) + } else { + Some(Err(anyhow::anyhow!("invalid path component {component:?}"))) + } + } + Component::RootDir => { + if must_be_relative { + Some(Err(anyhow::anyhow!("invalid root path component"))) + } else { + path_str.push('/'); + None + } + } + other => Some(Err(anyhow::anyhow!("invalid path component {other:?}"))), + }) + .collect::>>()?; + path_str.push_str(&parts.join("/")); + Ok(path_str) +} + +pub(crate) fn percent_decode_file_url_path(value: &str) -> Result { + let bytes = value.as_bytes(); + let mut output = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' { + if i + 2 >= bytes.len() { + anyhow::bail!("invalid percent escape in file URL"); + } + let hex = std::str::from_utf8(&bytes[i + 1..i + 3])?; + output.push(u8::from_str_radix(hex, 16).context("invalid percent escape in file URL")?); + i += 3; + } else { + output.push(bytes[i]); + i += 1; + } + } + Ok(String::from_utf8(output)?) +} diff --git a/crates/vnidrop/src/lib.rs b/crates/vnidrop/src/lib.rs new file mode 100644 index 0000000..e268451 --- /dev/null +++ b/crates/vnidrop/src/lib.rs @@ -0,0 +1,22 @@ +mod access_policy; +mod api; +mod error; +mod filesystem; +mod logging; +mod repository; +mod runtime; +mod secret; +mod ticket; +mod util; + +pub use api::{ + CoreEvent, CoreEventSink, RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource, + SourceKind, StoredTransfer, TicketInspection, TransferAccessMode, TransferMetadata, +}; +pub use error::VnidropError; +pub use runtime::VnidropCore; + +uniffi::setup_scaffolding!(); + +#[cfg(test)] +mod tests; diff --git a/crates/vnidrop/src/logging.rs b/crates/vnidrop/src/logging.rs new file mode 100644 index 0000000..4dca45e --- /dev/null +++ b/crates/vnidrop/src/logging.rs @@ -0,0 +1,29 @@ +use std::{path::Path, sync::OnceLock}; + +use anyhow::Result; +use tracing_subscriber::{fmt, layer::SubscriberExt, EnvFilter}; + +static LOG_GUARD: OnceLock = OnceLock::new(); + +pub(crate) fn init_logging(app_data_dir: &Path) -> Result<()> { + if LOG_GUARD.get().is_some() { + return Ok(()); + } + + let log_dir = app_data_dir.join("logs"); + std::fs::create_dir_all(&log_dir)?; + let file_appender = tracing_appender::rolling::daily(log_dir, "vnidrop.log"); + let (writer, guard) = tracing_appender::non_blocking(file_appender); + let filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("vnidrop=debug,iroh=info,iroh_blobs=info,warn")); + + let subscriber = tracing_subscriber::registry() + .with(filter) + .with(fmt::layer().with_writer(writer).with_ansi(false)); + + if tracing::subscriber::set_global_default(subscriber).is_ok() { + let _ = LOG_GUARD.set(guard); + } + + Ok(()) +} diff --git a/crates/vnidrop/src/repository.rs b/crates/vnidrop/src/repository.rs new file mode 100644 index 0000000..15d429a --- /dev/null +++ b/crates/vnidrop/src/repository.rs @@ -0,0 +1,228 @@ +use std::{path::Path, str::FromStr}; + +use anyhow::Result; +use sqlx::{ + sqlite::{SqliteConnectOptions, SqlitePoolOptions}, + Row, SqlitePool, +}; + +use crate::api::{CoreEvent, StoredTransfer}; +use crate::util::now_ms; + +#[derive(Debug, Clone)] +pub(crate) struct Repository { + pool: SqlitePool, +} + +impl Repository { + pub(crate) async fn open(app_data_dir: &Path) -> Result { + let db_path = app_data_dir.join("vnidrop.sqlite3"); + let options = SqliteConnectOptions::from_str("sqlite://")? + .filename(db_path) + .create_if_missing(true); + let pool = SqlitePoolOptions::new() + .max_connections(4) + .connect_with(options) + .await?; + let repository = Self { pool }; + repository.ensure_schema().await?; + Ok(repository) + } + + async fn ensure_schema(&self) -> Result<()> { + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS transfers ( + transfer_id INTEGER PRIMARY KEY, + direction TEXT NOT NULL, + status TEXT NOT NULL, + transfer_name TEXT, + content_hash TEXT, + ticket TEXT, + file_count INTEGER NOT NULL DEFAULT 0, + total_size INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + "#, + ) + .execute(&self.pool) + .await?; + + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS transfer_events ( + id TEXT PRIMARY KEY, + timestamp INTEGER NOT NULL, + scope TEXT NOT NULL, + transfer_id INTEGER, + direction TEXT, + phase TEXT NOT NULL, + kind TEXT NOT NULL, + data_json TEXT NOT NULL + ); + "#, + ) + .execute(&self.pool) + .await?; + + sqlx::query( + "CREATE INDEX IF NOT EXISTS idx_transfer_events_transfer_id ON transfer_events(transfer_id, timestamp);", + ) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub(crate) async fn upsert_transfer( + &self, + transfer_id: u64, + direction: &str, + status: &str, + transfer_name: Option<&str>, + content_hash: Option<&str>, + ticket: Option<&str>, + file_count: u64, + total_size: u64, + ) -> Result<()> { + let now = now_ms(); + sqlx::query( + r#" + INSERT INTO transfers ( + transfer_id, direction, status, transfer_name, content_hash, ticket, + file_count, total_size, created_at, updated_at + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9) + ON CONFLICT(transfer_id) DO UPDATE SET + direction = excluded.direction, + status = excluded.status, + transfer_name = excluded.transfer_name, + content_hash = excluded.content_hash, + ticket = excluded.ticket, + file_count = excluded.file_count, + total_size = excluded.total_size, + updated_at = excluded.updated_at; + "#, + ) + .bind(transfer_id as i64) + .bind(direction) + .bind(status) + .bind(transfer_name) + .bind(content_hash) + .bind(ticket) + .bind(file_count as i64) + .bind(total_size as i64) + .bind(now) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub(crate) async fn update_transfer_status( + &self, + transfer_id: u64, + status: &str, + ) -> Result<()> { + sqlx::query("UPDATE transfers SET status = ?1, updated_at = ?2 WHERE transfer_id = ?3") + .bind(status) + .bind(now_ms()) + .bind(transfer_id as i64) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub(crate) async fn insert_event(&self, event: &CoreEvent) -> Result<()> { + sqlx::query( + r#" + INSERT OR REPLACE INTO transfer_events ( + id, timestamp, scope, transfer_id, direction, phase, kind, data_json + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8); + "#, + ) + .bind(&event.id) + .bind(event.timestamp) + .bind(&event.scope) + .bind(event.transfer_id.map(|value| value as i64)) + .bind(&event.direction) + .bind(&event.phase) + .bind(&event.kind) + .bind(&event.data_json) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub(crate) async fn list_transfers(&self) -> Result> { + let rows = sqlx::query( + r#" + SELECT transfer_id, direction, status, transfer_name, content_hash, ticket, + file_count, total_size, created_at, updated_at + FROM transfers + ORDER BY updated_at DESC + "#, + ) + .fetch_all(&self.pool) + .await?; + Ok(rows.into_iter().map(row_to_transfer).collect()) + } + + pub(crate) async fn list_events(&self, transfer_id: Option) -> Result> { + let rows = if let Some(transfer_id) = transfer_id { + sqlx::query( + r#" + SELECT id, timestamp, scope, transfer_id, direction, phase, kind, data_json + FROM transfer_events + WHERE transfer_id = ?1 + ORDER BY timestamp ASC + "#, + ) + .bind(transfer_id as i64) + .fetch_all(&self.pool) + .await? + } else { + sqlx::query( + r#" + SELECT id, timestamp, scope, transfer_id, direction, phase, kind, data_json + FROM transfer_events + ORDER BY timestamp DESC + LIMIT 500 + "#, + ) + .fetch_all(&self.pool) + .await? + }; + Ok(rows.into_iter().map(row_to_event).collect()) + } +} + +fn row_to_transfer(row: sqlx::sqlite::SqliteRow) -> StoredTransfer { + StoredTransfer { + transfer_id: row.get::("transfer_id") as u64, + direction: row.get("direction"), + status: row.get("status"), + transfer_name: row.get("transfer_name"), + content_hash: row.get("content_hash"), + ticket: row.get("ticket"), + file_count: row.get::("file_count") as u64, + total_size: row.get::("total_size") as u64, + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + } +} + +fn row_to_event(row: sqlx::sqlite::SqliteRow) -> CoreEvent { + CoreEvent { + id: row.get("id"), + timestamp: row.get("timestamp"), + scope: row.get("scope"), + transfer_id: row + .get::, _>("transfer_id") + .map(|value| value as u64), + direction: row.get("direction"), + phase: row.get("phase"), + kind: row.get("kind"), + data_json: row.get("data_json"), + } +} diff --git a/crates/vnidrop-core/src/lib.rs b/crates/vnidrop/src/runtime.rs similarity index 62% rename from crates/vnidrop-core/src/lib.rs rename to crates/vnidrop/src/runtime.rs index a139bb7..2c9cbe2 100644 --- a/crates/vnidrop-core/src/lib.rs +++ b/crates/vnidrop/src/runtime.rs @@ -1,29 +1,17 @@ use std::{ collections::HashMap, fs::File, - io::{self, Read, Write}, - path::{Component, Path, PathBuf}, - str::FromStr, + io, + path::{Path, PathBuf}, sync::{Arc, Mutex}, - time::{SystemTime, UNIX_EPOCH}, }; use anyhow::{Context, Result}; use bytes::Bytes; -use data_encoding::{BASE64URL_NOPAD, HEXLOWER}; use futures_lite::StreamExt as _; -use iroh::{ - endpoint::presets, - protocol::Router, - Endpoint, SecretKey, -}; +use iroh::{endpoint::presets, protocol::Router, Endpoint}; use iroh_blobs::{ - api::{ - blobs::AddProgressItem, - proto::ExportRangesItem, - remote::GetProgressItem, - TempTag, - }, + api::{blobs::AddProgressItem, proto::ExportRangesItem, remote::GetProgressItem, TempTag}, format::collection::Collection, get::request::get_hash_seq_and_sizes, provider::events::{EventMask, EventSender, ProviderMessage, RequestUpdate}, @@ -32,207 +20,26 @@ use iroh_blobs::{ BlobFormat, BlobsProtocol, Hash, }; use n0_future::BufferedStreamExt; -use serde::{Deserialize, Serialize}; use serde_json::json; use tokio::sync::{mpsc, oneshot, Mutex as TokioMutex}; -const VNIDROP_TICKET_PREFIX: &str = "vnd1:"; -const STREAM_BUFFER_LEN: usize = 1024 * 1024; - -#[derive(Debug, thiserror::Error, uniffi::Error)] -pub enum VnidropError { - #[error("{reason}")] - Generic { reason: String }, -} - -impl From for VnidropError { - fn from(error: anyhow::Error) -> Self { - Self::Generic { - reason: error.to_string(), - } - } -} - -impl From for VnidropError { - fn from(error: io::Error) -> Self { - Self::Generic { - reason: error.to_string(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] -pub struct CoreEvent { - pub id: String, - pub timestamp: i64, - pub scope: String, - pub transfer_id: Option, - pub direction: Option, - pub phase: String, - pub kind: String, - pub data_json: String, -} - -#[uniffi::export(with_foreign)] -pub trait CoreEventSink: Send + Sync { - fn on_event(&self, event: CoreEvent); -} - -#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] -pub struct RuntimeStatus { - pub endpoint_id: String, - pub addr: String, - pub active_transfers: u64, - pub active_shares: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Enum)] -pub enum SourceKind { - Path, - AndroidContentUri, - IosSecurityScopedUrl, -} - -#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] -pub struct ShareSource { - pub kind: SourceKind, - pub value: String, - pub display_name: Option, - pub is_directory: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] -pub struct ShareMetadataInput { - pub transfer_id: u64, - pub transfer_name: Option, - pub sender_name: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] -pub struct ShareResult { - pub transfer_id: u64, - pub ticket: String, - pub blob_ticket: String, - pub hash: String, - pub transfer_name: String, - pub file_count: u64, - pub total_size: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] -pub struct TransferMetadata { - pub version: u8, - pub transfer_id: u64, - pub transfer_name: String, - pub sender_name: Option, - pub created_at: i64, - pub content_hash: String, - pub file_count: u64, - pub total_size: u64, -} - -impl TransferMetadata { - fn new( - transfer_id: u64, - transfer_name: impl Into, - sender_name: Option, - content_hash: Hash, - file_count: u64, - total_size: u64, - ) -> Self { - Self { - version: 1, - transfer_id, - transfer_name: transfer_name.into(), - sender_name: sender_name.and_then(non_empty), - created_at: now_ms(), - content_hash: content_hash.to_string(), - file_count, - total_size, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct VnidropTicket { - version: u8, - blob_ticket: String, - metadata: TransferMetadata, -} - -impl VnidropTicket { - fn new(blob_ticket: BlobTicket, metadata: TransferMetadata) -> Self { - Self { - version: 1, - blob_ticket: blob_ticket.to_string(), - metadata, - } - } - - fn encode(&self) -> Result { - let bytes = serde_json::to_vec(self)?; - Ok(format!( - "{VNIDROP_TICKET_PREFIX}{}", - BASE64URL_NOPAD.encode(&bytes) - )) - } - - fn decode(value: &str) -> Result { - let encoded = value - .strip_prefix(VNIDROP_TICKET_PREFIX) - .context("not a VniDrop ticket")?; - let bytes = BASE64URL_NOPAD - .decode(encoded.as_bytes()) - .context("invalid VniDrop ticket encoding")?; - serde_json::from_slice(&bytes).context("invalid VniDrop ticket payload") - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] -pub struct TicketInspection { - pub kind: String, - pub blob_ticket: String, - pub metadata: Option, -} - -#[derive(Debug, Clone)] -struct ParsedTransferTicket { - blob_ticket: BlobTicket, - metadata: Option, -} - -fn parse_transfer_ticket(value: &str) -> Result { - if value.starts_with(VNIDROP_TICKET_PREFIX) { - let ticket = VnidropTicket::decode(value)?; - let blob_ticket = BlobTicket::from_str(&ticket.blob_ticket) - .context("invalid BlobTicket inside VniDrop ticket")?; - return Ok(ParsedTransferTicket { - blob_ticket, - metadata: Some(ticket.metadata), - }); - } - - let blob_ticket = BlobTicket::from_str(value).context("invalid BlobTicket")?; - Ok(ParsedTransferTicket { - blob_ticket, - metadata: None, - }) -} - -#[derive(Debug)] -struct TransferImport { - tag: TempTag, - root_hash: Hash, - total_size: u64, - file_count: u64, - default_name: String, -} - -#[derive(Debug)] -struct ImportSourceFile { - path: PathBuf, - collection_name: String, -} +use crate::{ + access_policy::{AccessDecision, AccessPolicy}, + api::{ + CoreEvent, CoreEventSink, RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource, + StoredTransfer, TicketInspection, TransferAccessMode, TransferMetadata, + }, + error::VnidropError, + filesystem::{ + collect_import_files, default_collection_name, read_stream_from_blocking_reader, + safe_output_path, wait_for_writer, write_stream_to_blocking_writer, TransferImport, + }, + logging::init_logging, + repository::Repository, + secret::load_or_create_secret, + ticket::{parse_transfer_ticket, ParsedTransferTicket, VnidropTicket}, + util::{non_empty, now_ms, unique_transfer_id}, +}; #[derive(uniffi::Object)] pub struct VnidropCore { @@ -244,10 +51,13 @@ struct CoreInner { endpoint: Endpoint, router: Router, store: FsStore, + repository: Repository, + access_policy: Arc, event_sink: Arc, active_transfers: TokioMutex>>, active_shares: TokioMutex>, hash_to_transfer: TokioMutex>, + connection_endpoints: TokioMutex>, sequence: Mutex, } @@ -260,7 +70,7 @@ impl VnidropCore { ) -> Result, VnidropError> { let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() - .thread_name("vnidrop-core") + .thread_name("vnidrop") .build()?; let app_data_dir = PathBuf::from(app_data_dir); let inner = runtime.block_on(CoreInner::start(app_data_dir, event_sink))?; @@ -288,7 +98,10 @@ impl VnidropCore { receiver_name: Option, ) -> Result<(), VnidropError> { self.runtime - .block_on(self.inner.receive(ticket, PathBuf::from(output_dir), receiver_name)) + .block_on( + self.inner + .receive(ticket, PathBuf::from(output_dir), receiver_name), + ) .map_err(Into::into) } @@ -298,6 +111,41 @@ impl VnidropCore { .map_err(Into::into) } + pub fn set_transfer_access_mode( + &self, + transfer_id: u64, + mode: TransferAccessMode, + ) -> Result<(), VnidropError> { + self.runtime + .block_on(self.inner.set_transfer_access_mode(transfer_id, mode)) + .map_err(Into::into) + } + + pub fn approve_endpoint_for_transfer( + &self, + transfer_id: u64, + endpoint_id: String, + ) -> Result<(), VnidropError> { + self.runtime + .block_on( + self.inner + .approve_endpoint_for_transfer(transfer_id, endpoint_id), + ) + .map_err(Into::into) + } + + pub fn list_transfers(&self) -> Result, VnidropError> { + self.runtime + .block_on(self.inner.repository.list_transfers()) + .map_err(Into::into) + } + + pub fn list_events(&self, transfer_id: Option) -> Result, VnidropError> { + self.runtime + .block_on(self.inner.repository.list_events(transfer_id)) + .map_err(Into::into) + } + pub fn inspect_ticket(&self, ticket: String) -> Result { let parsed = parse_transfer_ticket(&ticket).context("failed to parse transfer ticket")?; Ok(TicketInspection { @@ -317,12 +165,11 @@ impl VnidropCore { } impl CoreInner { - async fn start( - app_data_dir: PathBuf, - event_sink: Arc, - ) -> Result> { + async fn start(app_data_dir: PathBuf, event_sink: Arc) -> Result> { tokio::fs::create_dir_all(&app_data_dir).await?; + init_logging(&app_data_dir)?; let secret_key = load_or_create_secret(&app_data_dir).await?; + let repository = Repository::open(&app_data_dir).await?; let store_root = app_data_dir.join("blobs"); let store = FsStore::load(&store_root).await?; let endpoint = Endpoint::builder(presets::N0) @@ -341,10 +188,13 @@ impl CoreInner { endpoint, router, store, + repository, + access_policy: AccessPolicy::new(), event_sink, active_transfers: TokioMutex::new(HashMap::new()), active_shares: TokioMutex::new(HashMap::new()), hash_to_transfer: TokioMutex::new(HashMap::new()), + connection_endpoints: TokioMutex::new(HashMap::new()), sequence: Mutex::new(1), }); @@ -387,7 +237,8 @@ impl CoreInner { json!({ "source_count": sources.len() }), ); let import = self.import_sources(metadata.transfer_id, sources).await?; - let blob_ticket = BlobTicket::new(self.endpoint.addr(), import.root_hash, BlobFormat::HashSeq); + let blob_ticket = + BlobTicket::new(self.endpoint.addr(), import.root_hash, BlobFormat::HashSeq); let transfer_name = metadata .transfer_name .and_then(non_empty) @@ -408,10 +259,25 @@ impl CoreInner { .lock() .await .insert(import.root_hash.to_string(), metadata.transfer_id); + self.access_policy + .set_mode(metadata.transfer_id, TransferAccessMode::Public) + .await; self.active_shares .lock() .await .insert(metadata.transfer_id, import.tag); + self.repository + .upsert_transfer( + metadata.transfer_id, + "send", + "sharing", + Some(&transfer_name), + Some(&import.root_hash.to_string()), + Some(&ticket), + import.file_count, + import.total_size, + ) + .await?; self.emit_transfer( metadata.transfer_id, @@ -482,6 +348,32 @@ impl CoreInner { "receiver_name": receiver_name, }), ); + self.repository + .upsert_transfer( + transfer_id, + "receive", + "receiving", + parsed + .metadata + .as_ref() + .map(|metadata| metadata.transfer_name.as_str()), + parsed + .metadata + .as_ref() + .map(|metadata| metadata.content_hash.as_str()), + None, + parsed + .metadata + .as_ref() + .map(|metadata| metadata.file_count) + .unwrap_or_default(), + parsed + .metadata + .as_ref() + .map(|metadata| metadata.total_size) + .unwrap_or_default(), + ) + .await?; tokio::fs::create_dir_all(&output_dir).await?; self.emit_transfer(transfer_id, "receive", "network", "connecting", json!({})); @@ -527,6 +419,9 @@ impl CoreInner { let collection = Collection::load(hash_and_format.hash, self.store.as_ref()).await?; self.export_collection(transfer_id, total_files, output_dir, collection) .await?; + self.repository + .update_transfer_status(transfer_id, "done") + .await?; self.emit_transfer(transfer_id, "receive", "lifecycle", "done", json!({})); Ok(()) } @@ -541,19 +436,66 @@ impl CoreInner { "cancel-requested", json!({}), ); + self.repository + .update_transfer_status(transfer_id, "cancelled") + .await?; return Ok(()); } - if self.active_shares.lock().await.remove(&transfer_id).is_some() { + if self + .active_shares + .lock() + .await + .remove(&transfer_id) + .is_some() + { self.hash_to_transfer .lock() .await .retain(|_, id| *id != transfer_id); + self.access_policy.remove_transfer(transfer_id).await; + self.repository + .update_transfer_status(transfer_id, "stopped") + .await?; self.emit_transfer(transfer_id, "send", "lifecycle", "share-stopped", json!({})); return Ok(()); } anyhow::bail!("transfer not found") } + async fn set_transfer_access_mode( + &self, + transfer_id: u64, + mode: TransferAccessMode, + ) -> Result<()> { + self.access_policy.set_mode(transfer_id, mode.clone()).await; + self.emit_transfer( + transfer_id, + "send", + "access", + "mode-updated", + json!({ "mode": format!("{mode:?}") }), + ); + Ok(()) + } + + async fn approve_endpoint_for_transfer( + &self, + transfer_id: u64, + endpoint_id: String, + ) -> Result<()> { + self.access_policy + .approve_endpoint(transfer_id, endpoint_id.clone()) + .await; + self.emit_transfer( + transfer_id, + "send", + "access", + "endpoint-approved", + json!({ "endpoint_id": endpoint_id }), + ); + Ok(()) + } + async fn shutdown(&self) { self.emit_endpoint("shutdown", "service-shutdown", json!({})); if let Err(error) = self.router.shutdown().await { @@ -582,7 +524,11 @@ impl CoreInner { let stream = read_stream_from_blocking_reader(reader); let import = core.store.add_stream(stream).await; let (tag, size) = core - .consume_add_progress(transfer_id, file.collection_name.clone(), import.stream().await) + .consume_add_progress( + transfer_id, + file.collection_name.clone(), + import.stream().await, + ) .await?; Result::<_>::Ok((file.collection_name, tag, size)) } @@ -714,7 +660,9 @@ impl CoreInner { ExportRangesItem::Size(size) => file_size = size, ExportRangesItem::Data(leaf) => { if leaf.offset != exported { - anyhow::bail!("export stream for {relative_path} yielded out-of-order data"); + anyhow::bail!( + "export stream for {relative_path} yielded out-of-order data" + ); } exported += leaf.data.len() as u64; tx.send(Ok(Some(leaf.data))) @@ -767,6 +715,12 @@ impl CoreInner { "endpoint_id": message.inner.endpoint_id.map(|id| id.to_string()), }), ); + if let Some(endpoint_id) = message.inner.endpoint_id { + self.connection_endpoints + .lock() + .await + .insert(message.inner.connection_id, endpoint_id.to_string()); + } let _ = message.tx.send(Ok(())).await; } ProviderMessage::ClientConnectedNotify(message) => { @@ -778,10 +732,48 @@ impl CoreInner { "endpoint_id": message.inner.endpoint_id.map(|id| id.to_string()), }), ); + if let Some(endpoint_id) = message.inner.endpoint_id { + self.connection_endpoints + .lock() + .await + .insert(message.inner.connection_id, endpoint_id.to_string()); + } + } + ProviderMessage::ConnectionClosed(message) => { + self.connection_endpoints + .lock() + .await + .remove(&message.inner.connection_id); + self.emit_endpoint( + "provider", + "connection-closed", + json!({ "connection_id": message.inner.connection_id }), + ); } ProviderMessage::GetRequestReceived(message) => { let transfer_id = self.transfer_for_hash(message.inner.request.hash).await; if let Some(transfer_id) = transfer_id { + let decision = self + .access_decision(transfer_id, message.inner.connection_id) + .await; + if let AccessDecision::Deny { reason } = decision { + self.emit_transfer( + transfer_id, + "send", + "access", + "request-denied", + json!({ + "connection_id": message.inner.connection_id, + "request_id": message.inner.request_id, + "reason": reason, + }), + ); + let _ = message + .tx + .send(Err(iroh_blobs::provider::events::AbortReason::Permission)) + .await; + return; + } self.track_request_updates( transfer_id, message.inner.connection_id, @@ -792,7 +784,8 @@ impl CoreInner { let _ = message.tx.send(Ok(())).await; } ProviderMessage::GetRequestReceivedNotify(message) => { - if let Some(transfer_id) = self.transfer_for_hash(message.inner.request.hash).await { + if let Some(transfer_id) = self.transfer_for_hash(message.inner.request.hash).await + { self.track_request_updates( transfer_id, message.inner.connection_id, @@ -802,8 +795,31 @@ impl CoreInner { } } ProviderMessage::GetManyRequestReceived(message) => { - let transfer_id = self.transfer_for_any_hash(&message.inner.request.hashes).await; + let transfer_id = self + .transfer_for_any_hash(&message.inner.request.hashes) + .await; if let Some(transfer_id) = transfer_id { + let decision = self + .access_decision(transfer_id, message.inner.connection_id) + .await; + if let AccessDecision::Deny { reason } = decision { + self.emit_transfer( + transfer_id, + "send", + "access", + "request-denied", + json!({ + "connection_id": message.inner.connection_id, + "request_id": message.inner.request_id, + "reason": reason, + }), + ); + let _ = message + .tx + .send(Err(iroh_blobs::provider::events::AbortReason::Permission)) + .await; + return; + } self.track_request_updates( transfer_id, message.inner.connection_id, @@ -814,7 +830,10 @@ impl CoreInner { let _ = message.tx.send(Ok(())).await; } ProviderMessage::GetManyRequestReceivedNotify(message) => { - if let Some(transfer_id) = self.transfer_for_any_hash(&message.inner.request.hashes).await { + if let Some(transfer_id) = self + .transfer_for_any_hash(&message.inner.request.hashes) + .await + { self.track_request_updates( transfer_id, message.inner.connection_id, @@ -881,6 +900,18 @@ impl CoreInner { .find_map(|hash| map.get(&hash.to_string()).copied()) } + async fn access_decision(&self, transfer_id: u64, connection_id: u64) -> AccessDecision { + let endpoint_id = self + .connection_endpoints + .lock() + .await + .get(&connection_id) + .cloned(); + self.access_policy + .decide(transfer_id, endpoint_id.as_deref()) + .await + } + fn track_request_updates( self: &Arc, transfer_id: u64, @@ -971,7 +1002,7 @@ impl CoreInner { let id = format!("{timestamp}-{}", *sequence); *sequence += 1; drop(sequence); - self.event_sink.on_event(CoreEvent { + let event = CoreEvent { id, timestamp, scope: scope.to_string(), @@ -980,308 +1011,14 @@ impl CoreInner { phase: phase.to_string(), kind: kind.to_string(), data_json: data.to_string(), + }; + let repository = self.repository.clone(); + let event_for_repository = event.clone(); + tokio::spawn(async move { + if let Err(error) = repository.insert_event(&event_for_repository).await { + tracing::warn!(%error, "failed to persist core event"); + } }); - } -} - -fn collect_import_files(sources: Vec) -> Result> { - let mut files = Vec::new(); - for source in sources { - match source.kind { - SourceKind::Path | SourceKind::IosSecurityScopedUrl => { - let path = source_path(&source)?; - let display_name = source - .display_name - .clone() - .and_then(non_empty) - .or_else(|| path.file_name().and_then(|name| name.to_str()).map(ToOwned::to_owned)) - .unwrap_or_else(|| "transfer".to_string()); - if source.is_directory || path.is_dir() { - collect_dir_files(&path, &display_name, &mut files)?; - } else { - files.push(ImportSourceFile { - path, - collection_name: validated_relative_string(&display_name)?, - }); - } - } - SourceKind::AndroidContentUri => { - anyhow::bail!( - "Android content URI streaming needs platform file descriptor glue before it can be imported without copying" - ); - } - } - } - if files.is_empty() { - anyhow::bail!("no files found in selected sources"); - } - Ok(files) -} - -fn source_path(source: &ShareSource) -> Result { - if matches!(source.kind, SourceKind::IosSecurityScopedUrl) && source.value.starts_with("file://") { - let without_scheme = source.value.trim_start_matches("file://"); - return Ok(PathBuf::from(percent_decode_file_url_path(without_scheme)?)); - } - Ok(PathBuf::from(&source.value)) -} - -fn collect_dir_files(root: &Path, display_name: &str, files: &mut Vec) -> Result<()> { - for entry in walkdir::WalkDir::new(root).follow_links(false) { - let entry = entry?; - if !entry.file_type().is_file() { - continue; - } - let relative = entry - .path() - .strip_prefix(root) - .context("failed to compute relative path")?; - let collection_name = path_to_string(Path::new(display_name).join(relative), true)?; - files.push(ImportSourceFile { - path: entry.path().to_path_buf(), - collection_name, - }); - } - Ok(()) -} - -fn default_collection_name(files: &[ImportSourceFile]) -> String { - files - .first() - .and_then(|file| file.collection_name.split('/').next()) - .filter(|name| !name.is_empty()) - .unwrap_or("transfer") - .to_string() -} - -fn safe_output_path(output_dir: &Path, relative_path: &str) -> Result { - let relative = Path::new(relative_path); - path_to_string(relative, true)?; - Ok(output_dir.join(relative)) -} - -fn read_stream_from_blocking_reader( - mut reader: R, -) -> impl futures::Stream> + Send + Sync + 'static -where - R: Read + Send + 'static, -{ - let (tx, rx) = async_channel::bounded(2); - std::thread::spawn(move || { - let mut buffer = vec![0; STREAM_BUFFER_LEN]; - loop { - match reader.read(&mut buffer) { - Ok(0) => break, - Ok(read) => { - if tx - .send_blocking(Ok(Bytes::copy_from_slice(&buffer[..read]))) - .is_err() - { - break; - } - } - Err(error) => { - let _ = tx.send_blocking(Err(error)); - break; - } - } - } - }); - rx -} - -fn write_stream_to_blocking_writer( - mut writer: W, - rx: async_channel::Receiver>>, -) -> io::Result<()> -where - W: Write, -{ - while let Ok(item) = rx.recv_blocking() { - match item? { - Some(bytes) => writer.write_all(&bytes)?, - None => break, - } - } - writer.flush() -} - -async fn wait_for_writer(task: std::thread::JoinHandle>) -> Result> { - tokio::task::spawn_blocking(move || { - task.join() - .map_err(|_| anyhow::anyhow!("export writer thread panicked")) - }) - .await? -} - -fn validated_relative_string(name: &str) -> Result { - path_to_string(Path::new(name), true) -} - -fn path_to_string(path: impl AsRef, must_be_relative: bool) -> Result { - let mut path_str = String::new(); - let parts = path - .as_ref() - .components() - .filter_map(|component| match component { - Component::Normal(x) => { - let Some(component) = x.to_str() else { - return Some(Err(anyhow::anyhow!("invalid character in path"))); - }; - if !component.contains('/') && !component.contains('\\') { - Some(Ok(component)) - } else { - Some(Err(anyhow::anyhow!("invalid path component {component:?}"))) - } - } - Component::RootDir => { - if must_be_relative { - Some(Err(anyhow::anyhow!("invalid root path component"))) - } else { - path_str.push('/'); - None - } - } - other => Some(Err(anyhow::anyhow!("invalid path component {other:?}"))), - }) - .collect::>>()?; - path_str.push_str(&parts.join("/")); - Ok(path_str) -} - -fn percent_decode_file_url_path(value: &str) -> Result { - let bytes = value.as_bytes(); - let mut output = Vec::with_capacity(bytes.len()); - let mut i = 0; - while i < bytes.len() { - if bytes[i] == b'%' { - if i + 2 >= bytes.len() { - anyhow::bail!("invalid percent escape in file URL"); - } - let hex = std::str::from_utf8(&bytes[i + 1..i + 3])?; - output.push(u8::from_str_radix(hex, 16).context("invalid percent escape in file URL")?); - i += 3; - } else { - output.push(bytes[i]); - i += 1; - } - } - Ok(String::from_utf8(output)?) -} - -async fn load_or_create_secret(app_data_dir: &Path) -> Result { - if let Ok(secret) = std::env::var("IROH_SECRET") { - return SecretKey::from_str(&secret).context("invalid IROH_SECRET"); - } - - let path = app_data_dir.join("iroh.secret"); - match tokio::fs::read_to_string(&path).await { - Ok(secret) => { - let bytes = HEXLOWER - .decode(secret.trim().as_bytes()) - .context("invalid persisted iroh secret encoding")?; - let bytes: [u8; 32] = bytes - .try_into() - .map_err(|_| anyhow::anyhow!("invalid persisted iroh secret length"))?; - Ok(SecretKey::from_bytes(&bytes)) - } - Err(error) if error.kind() == io::ErrorKind::NotFound => { - let secret = SecretKey::generate(); - tokio::fs::write(&path, HEXLOWER.encode(&secret.to_bytes())).await?; - Ok(secret) - } - Err(error) => Err(error.into()), - } -} - -fn non_empty(value: String) -> Option { - let trimmed = value.trim(); - (!trimmed.is_empty()).then(|| trimmed.to_string()) -} - -fn now_ms() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_millis() as i64) - .unwrap_or_default() -} - -fn unique_transfer_id() -> u64 { - now_ms() as u64 -} - -uniffi::setup_scaffolding!(); - -#[cfg(test)] -mod tests { - use super::*; - - struct TestSink; - - impl CoreEventSink for TestSink { - fn on_event(&self, _event: CoreEvent) {} - } - - #[test] - fn metadata_ticket_round_trips() { - let secret = SecretKey::generate(); - let addr = iroh::EndpointAddr::new(secret.public()); - let blob_ticket = BlobTicket::new(addr, Hash::new([7; 32]), BlobFormat::HashSeq); - let metadata = TransferMetadata::new( - 42, - "Summer photos", - Some("hammed".to_string()), - blob_ticket.hash(), - 3, - 2048, - ); - let encoded = VnidropTicket::new(blob_ticket.clone(), metadata.clone()) - .encode() - .unwrap(); - let parsed = parse_transfer_ticket(&encoded).unwrap(); - - assert_eq!(parsed.blob_ticket.hash(), blob_ticket.hash()); - assert_eq!(parsed.metadata.unwrap().transfer_name, metadata.transfer_name); - } - - #[test] - fn invalid_ticket_is_rejected() { - assert!(parse_transfer_ticket("not-a-ticket").is_err()); - } - - #[tokio::test] - async fn secret_persists() { - let temp = tempfile::tempdir().unwrap(); - let first = load_or_create_secret(temp.path()).await.unwrap(); - let second = load_or_create_secret(temp.path()).await.unwrap(); - assert_eq!(first.to_bytes(), second.to_bytes()); - } - - #[test] - fn path_validation_rejects_unsafe_paths() { - assert!(path_to_string(Path::new("../escape"), true).is_err()); - assert!(path_to_string(Path::new("/absolute"), true).is_err()); - assert!(validated_relative_string("bad\\name").is_err()); - } - - #[test] - fn file_url_decodes_spaces() { - assert_eq!( - percent_decode_file_url_path("/tmp/My%20File.txt").unwrap(), - "/tmp/My File.txt" - ); - } - - #[test] - fn can_initialize_core() { - let temp = tempfile::tempdir().unwrap(); - let core = VnidropCore::initialize( - temp.path().to_string_lossy().to_string(), - Arc::new(TestSink), - ) - .unwrap(); - let status = core.status(); - assert!(!status.endpoint_id.is_empty()); - core.shutdown(); + self.event_sink.on_event(event); } } diff --git a/crates/vnidrop/src/secret.rs b/crates/vnidrop/src/secret.rs new file mode 100644 index 0000000..546b051 --- /dev/null +++ b/crates/vnidrop/src/secret.rs @@ -0,0 +1,30 @@ +use std::{io, path::Path, str::FromStr}; + +use anyhow::{Context, Result}; +use data_encoding::HEXLOWER; +use iroh::SecretKey; + +pub(crate) async fn load_or_create_secret(app_data_dir: &Path) -> Result { + if let Ok(secret) = std::env::var("IROH_SECRET") { + return SecretKey::from_str(&secret).context("invalid IROH_SECRET"); + } + + let path = app_data_dir.join("iroh.secret"); + match tokio::fs::read_to_string(&path).await { + Ok(secret) => { + let bytes = HEXLOWER + .decode(secret.trim().as_bytes()) + .context("invalid persisted iroh secret encoding")?; + let bytes: [u8; 32] = bytes + .try_into() + .map_err(|_| anyhow::anyhow!("invalid persisted iroh secret length"))?; + Ok(SecretKey::from_bytes(&bytes)) + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + let secret = SecretKey::generate(); + tokio::fs::write(&path, HEXLOWER.encode(&secret.to_bytes())).await?; + Ok(secret) + } + Err(error) => Err(error.into()), + } +} diff --git a/crates/vnidrop/src/tests.rs b/crates/vnidrop/src/tests.rs new file mode 100644 index 0000000..f8b5c8f --- /dev/null +++ b/crates/vnidrop/src/tests.rs @@ -0,0 +1,151 @@ +#[cfg(test)] +mod tests { + use std::{path::Path, sync::Arc}; + + use iroh::SecretKey; + use iroh_blobs::{ticket::BlobTicket, BlobFormat, Hash}; + + use crate::{ + access_policy::{AccessDecision, AccessPolicy}, + api::{CoreEvent, CoreEventSink, TransferMetadata}, + filesystem::{path_to_string, percent_decode_file_url_path, validated_relative_string}, + repository::Repository, + runtime::VnidropCore, + secret::load_or_create_secret, + ticket::{parse_transfer_ticket, VnidropTicket}, + TransferAccessMode, + }; + + struct TestSink; + + impl CoreEventSink for TestSink { + fn on_event(&self, _event: CoreEvent) {} + } + + #[test] + fn metadata_ticket_round_trips() { + let secret = SecretKey::generate(); + let addr = iroh::EndpointAddr::new(secret.public()); + let blob_ticket = BlobTicket::new(addr, Hash::new([7; 32]), BlobFormat::HashSeq); + let metadata = TransferMetadata::new( + 42, + "Summer photos", + Some("hammed".to_string()), + blob_ticket.hash(), + 3, + 2048, + ); + let encoded = VnidropTicket::new(blob_ticket.clone(), metadata.clone()) + .encode() + .unwrap(); + let parsed = parse_transfer_ticket(&encoded).unwrap(); + + assert_eq!(parsed.blob_ticket.hash(), blob_ticket.hash()); + assert_eq!( + parsed.metadata.unwrap().transfer_name, + metadata.transfer_name + ); + } + + #[test] + fn invalid_ticket_is_rejected() { + assert!(parse_transfer_ticket("not-a-ticket").is_err()); + } + + #[tokio::test] + async fn secret_persists() { + let temp = tempfile::tempdir().unwrap(); + let first = load_or_create_secret(temp.path()).await.unwrap(); + let second = load_or_create_secret(temp.path()).await.unwrap(); + assert_eq!(first.to_bytes(), second.to_bytes()); + } + + #[test] + fn path_validation_rejects_unsafe_paths() { + assert!(path_to_string(Path::new("../escape"), true).is_err()); + assert!(path_to_string(Path::new("/absolute"), true).is_err()); + assert!(validated_relative_string("bad\\name").is_err()); + } + + #[test] + fn file_url_decodes_spaces() { + assert_eq!( + percent_decode_file_url_path("/tmp/My%20File.txt").unwrap(), + "/tmp/My File.txt" + ); + } + + #[test] + fn can_initialize_core() { + let temp = tempfile::tempdir().unwrap(); + let core = VnidropCore::initialize( + temp.path().to_string_lossy().to_string(), + Arc::new(TestSink), + ) + .unwrap(); + let status = core.status(); + assert!(!status.endpoint_id.is_empty()); + core.shutdown(); + } + + #[tokio::test] + async fn repository_persists_transfers_and_events() { + let temp = tempfile::tempdir().unwrap(); + let repository = Repository::open(temp.path()).await.unwrap(); + repository + .upsert_transfer( + 7, + "send", + "sharing", + Some("demo"), + Some("hash"), + Some("ticket"), + 1, + 12, + ) + .await + .unwrap(); + repository + .insert_event(&CoreEvent { + id: "event-1".to_string(), + timestamp: 10, + scope: "transfer".to_string(), + transfer_id: Some(7), + direction: Some("send".to_string()), + phase: "ticket".to_string(), + kind: "created".to_string(), + data_json: "{}".to_string(), + }) + .await + .unwrap(); + + let transfers = repository.list_transfers().await.unwrap(); + assert_eq!(transfers.len(), 1); + assert_eq!(transfers[0].transfer_name.as_deref(), Some("demo")); + + let events = repository.list_events(Some(7)).await.unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].kind, "created"); + } + + #[tokio::test] + async fn access_policy_requires_approved_endpoint_when_locked() { + let policy = AccessPolicy::new(); + policy + .set_mode(99, TransferAccessMode::ApprovalRequired) + .await; + + assert_eq!( + policy.decide(99, Some("node-a")).await, + AccessDecision::Deny { + reason: "approval-required" + } + ); + + policy.approve_endpoint(99, "node-a".to_string()).await; + assert_eq!( + policy.decide(99, Some("node-a")).await, + AccessDecision::Allow + ); + } +} diff --git a/crates/vnidrop/src/ticket.rs b/crates/vnidrop/src/ticket.rs new file mode 100644 index 0000000..a8b470e --- /dev/null +++ b/crates/vnidrop/src/ticket.rs @@ -0,0 +1,69 @@ +use std::str::FromStr; + +use anyhow::{Context, Result}; +use data_encoding::BASE64URL_NOPAD; +use iroh_blobs::ticket::BlobTicket; +use serde::{Deserialize, Serialize}; + +use crate::api::TransferMetadata; + +const VNIDROP_TICKET_PREFIX: &str = "vnd1:"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct VnidropTicket { + version: u8, + blob_ticket: String, + metadata: TransferMetadata, +} + +impl VnidropTicket { + pub(crate) fn new(blob_ticket: BlobTicket, metadata: TransferMetadata) -> Self { + Self { + version: 1, + blob_ticket: blob_ticket.to_string(), + metadata, + } + } + + pub(crate) fn encode(&self) -> Result { + let bytes = serde_json::to_vec(self)?; + Ok(format!( + "{VNIDROP_TICKET_PREFIX}{}", + BASE64URL_NOPAD.encode(&bytes) + )) + } + + fn decode(value: &str) -> Result { + let encoded = value + .strip_prefix(VNIDROP_TICKET_PREFIX) + .context("not a VniDrop ticket")?; + let bytes = BASE64URL_NOPAD + .decode(encoded.as_bytes()) + .context("invalid VniDrop ticket encoding")?; + serde_json::from_slice(&bytes).context("invalid VniDrop ticket payload") + } +} + +#[derive(Debug, Clone)] +pub(crate) struct ParsedTransferTicket { + pub(crate) blob_ticket: BlobTicket, + pub(crate) metadata: Option, +} + +pub(crate) fn parse_transfer_ticket(value: &str) -> Result { + if value.starts_with(VNIDROP_TICKET_PREFIX) { + let ticket = VnidropTicket::decode(value)?; + let blob_ticket = BlobTicket::from_str(&ticket.blob_ticket) + .context("invalid BlobTicket inside VniDrop ticket")?; + return Ok(ParsedTransferTicket { + blob_ticket, + metadata: Some(ticket.metadata), + }); + } + + let blob_ticket = BlobTicket::from_str(value).context("invalid BlobTicket")?; + Ok(ParsedTransferTicket { + blob_ticket, + metadata: None, + }) +} diff --git a/crates/vnidrop/src/util.rs b/crates/vnidrop/src/util.rs new file mode 100644 index 0000000..cb62494 --- /dev/null +++ b/crates/vnidrop/src/util.rs @@ -0,0 +1,17 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +pub(crate) fn non_empty(value: String) -> Option { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + +pub(crate) fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as i64) + .unwrap_or_default() +} + +pub(crate) fn unique_transfer_id() -> u64 { + now_ms() as u64 +} diff --git a/crates/vnidrop-core/tests/local_transfer.rs b/crates/vnidrop/tests/local_transfer.rs similarity index 88% rename from crates/vnidrop-core/tests/local_transfer.rs rename to crates/vnidrop/tests/local_transfer.rs index c7e489e..2b10584 100644 --- a/crates/vnidrop-core/tests/local_transfer.rs +++ b/crates/vnidrop/tests/local_transfer.rs @@ -1,8 +1,6 @@ use std::sync::{Arc, Mutex}; -use vnidrop_core::{ - CoreEvent, CoreEventSink, ShareMetadataInput, ShareSource, SourceKind, VnidropCore, -}; +use vnidrop::{CoreEvent, CoreEventSink, ShareMetadataInput, ShareSource, SourceKind, VnidropCore}; #[derive(Default)] struct RecordingSink { @@ -29,7 +27,11 @@ fn two_local_cores_transfer_file() { ) .unwrap(); let receiver = VnidropCore::initialize( - receiver_dir.path().join("core").to_string_lossy().to_string(), + receiver_dir + .path() + .join("core") + .to_string_lossy() + .to_string(), Arc::new(RecordingSink::default()), ) .unwrap(); diff --git a/crates/vnidrop-core/uniffi.toml b/crates/vnidrop/uniffi.toml similarity index 100% rename from crates/vnidrop-core/uniffi.toml rename to crates/vnidrop/uniffi.toml diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index a723fa3..8c08aaf 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -72,14 +72,14 @@ android { } cargo { - packageDirectory = layout.projectDirectory.dir("../crates/vnidrop-core") + packageDirectory = layout.projectDirectory.dir("../crates/vnidrop") publishJvmArtifacts = true androidTargetsToBuild.set(setOf(RustAndroidTarget.Arm64)) } uniffi { generateFromLibrary { - namespace = "vnidrop_core" + namespace = "vnidrop" } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt index 595370d..3a12e08 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt @@ -8,14 +8,14 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import kotlin.random.Random -import uniffi.vnidrop_core.CoreEvent -import uniffi.vnidrop_core.CoreEventSink -import uniffi.vnidrop_core.ShareMetadataInput -import uniffi.vnidrop_core.ShareResult -import uniffi.vnidrop_core.ShareSource -import uniffi.vnidrop_core.SourceKind -import uniffi.vnidrop_core.TicketInspection -import uniffi.vnidrop_core.VnidropCore +import uniffi.vnidrop.CoreEvent +import uniffi.vnidrop.CoreEventSink +import uniffi.vnidrop.ShareMetadataInput +import uniffi.vnidrop.ShareResult +import uniffi.vnidrop.ShareSource +import uniffi.vnidrop.SourceKind +import uniffi.vnidrop.TicketInspection +import uniffi.vnidrop.VnidropCore data class CoreUiState( val isInitialized: Boolean = false, diff --git a/shared/src/iosTest/kotlin/com/vnidrop/app/SharedLogicIOSTest.kt b/shared/src/iosTest/kotlin/com/vnidrop/app/SharedLogicIOSTest.kt index 2f090d5..50b507b 100644 --- a/shared/src/iosTest/kotlin/com/vnidrop/app/SharedLogicIOSTest.kt +++ b/shared/src/iosTest/kotlin/com/vnidrop/app/SharedLogicIOSTest.kt @@ -3,9 +3,9 @@ package com.vnidrop.app import platform.Foundation.NSTemporaryDirectory import kotlin.test.Test import kotlin.test.assertTrue -import uniffi.vnidrop_core.CoreEvent -import uniffi.vnidrop_core.CoreEventSink -import uniffi.vnidrop_core.VnidropCore +import uniffi.vnidrop.CoreEvent +import uniffi.vnidrop.CoreEventSink +import uniffi.vnidrop.VnidropCore class SharedLogicIOSTest { diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/CoreNativeLoadTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/CoreNativeLoadTest.kt index dd16da8..1ce31d6 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/CoreNativeLoadTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/CoreNativeLoadTest.kt @@ -3,14 +3,14 @@ package com.vnidrop.app import java.nio.file.Files import kotlin.test.Test import kotlin.test.assertTrue -import uniffi.vnidrop_core.CoreEvent -import uniffi.vnidrop_core.CoreEventSink -import uniffi.vnidrop_core.VnidropCore +import uniffi.vnidrop.CoreEvent +import uniffi.vnidrop.CoreEventSink +import uniffi.vnidrop.VnidropCore class CoreNativeLoadTest { @Test fun generatedBindingsCanInitializeRustCore() { - val coreDir = Files.createTempDirectory("vnidrop-core-jvm-test") + val coreDir = Files.createTempDirectory("vnidrop-jvm-test") val core = VnidropCore.initialize( appDataDir = coreDir.toString(), eventSink = object : CoreEventSink {