diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 078f931142..9e71967640 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -68,7 +68,7 @@ jobs: # Install - name: Install release tools - uses: jaxxstorm/action-install-gh-release@v1.11.0 + uses: jaxxstorm/action-install-gh-release@v1.14.0 with: token: ${{ secrets.GH_RELEASE_DOWNLOAD }} repo: saveoursecrets/release-tools diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 952b2c77ef..4f83d583e8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,7 +47,7 @@ jobs: - name: Check cosign install run: cosign version - name: Install release tools - uses: jaxxstorm/action-install-gh-release@v1.11.0 + uses: jaxxstorm/action-install-gh-release@v1.14.0 with: token: ${{ secrets.GH_RELEASE_DOWNLOAD }} repo: saveoursecrets/release-tools diff --git a/.gitignore b/.gitignore index 02827d8656..b3b83059d1 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ server.lock tests/server_public_key.txt geiger-report.txt .aider* +node_modules diff --git a/Cargo.lock b/Cargo.lock index afe1dc12d2..02b13da88b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -54,8 +54,9 @@ dependencies = [ [[package]] name = "age" -version = "0.10.0" -source = "git+https://github.com/tmpfs/rage?branch=secrecy-0.10#766a3ed1f8769060afe63c4e3a30c1a356b0832e" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57fc171f4874fa10887e47088f81a55fcf030cd421aa31ec2b370cafebcc608a" dependencies = [ "age-core", "base64 0.21.7", @@ -81,8 +82,9 @@ dependencies = [ [[package]] name = "age-core" -version = "0.10.0" -source = "git+https://github.com/tmpfs/rage?branch=secrecy-0.10#766a3ed1f8769060afe63c4e3a30c1a356b0832e" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2bf6a89c984ca9d850913ece2da39e1d200563b0a94b002b253beee4c5acf99" dependencies = [ "base64 0.21.7", "chacha20poly1305", @@ -107,15 +109,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "aho-corasick" -version = "0.7.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac" -dependencies = [ - "memchr", -] - [[package]] name = "aho-corasick" version = "1.1.3" @@ -126,16 +119,19 @@ dependencies = [ ] [[package]] -name = "aliasable" -version = "0.1.3" +name = "allocator-api2" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] -name = "allocator-api2" -version = "0.2.18" +name = "android-build" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" +checksum = "a133d38cebf328adaea4bc1891d9568e14a394b50e4f4ba5f63dc14e8beaaee9" +dependencies = [ + "windows-sys 0.52.0", +] [[package]] name = "android-tzdata" @@ -154,9 +150,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.17" +version = "0.6.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23a1e53f0f5d86382dafe1cf314783b2044280f406e7e1506368220ad11b1338" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" dependencies = [ "anstyle", "anstyle-parse", @@ -169,9 +165,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8365de52b16c035ff4fcafe0092ba9390540e3e352870ac09933bebcaa2c8c56" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" [[package]] name = "anstyle-parse" @@ -201,41 +197,11 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "anticipate" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527a4816019990ba61cc1b43f2208865a9e8a612bfaf50db1b6337b2e8307dc2" -dependencies = [ - "conpty", - "nix 0.26.4", - "ptyprocess", - "regex", - "thiserror", -] - -[[package]] -name = "anticipate-runner" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "092b50675bfca2765af4162662642f7e55dcab7865cb087a0b6a2c2cda97cce1" -dependencies = [ - "anticipate", - "comma", - "logos", - "ouroboros", - "probability", - "rand", - "thiserror", - "tracing", - "unicode-segmentation", -] - [[package]] name = "anyhow" -version = "1.0.91" +version = "1.0.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c042108f3ed77fd83760a5fd79b53be043192bb3b9dba91d8c574c0ada7850c8" +checksum = "c1fd03a028ef38ba2276dce7e33fcd6369c158a1bca17946c4b1b701891c1ff7" [[package]] name = "arboard" @@ -288,7 +254,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror", + "thiserror 1.0.69", "time", ] @@ -298,9 +264,9 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" dependencies = [ - "proc-macro2 1.0.89", - "quote 1.0.37", - "syn 2.0.85", + "proc-macro2", + "quote", + "syn 2.0.90", "synstructure", ] @@ -310,16 +276,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ - "proc-macro2 1.0.89", - "quote 1.0.37", - "syn 2.0.85", + "proc-macro2", + "quote", + "syn 2.0.90", ] [[package]] name = "async-compression" -version = "0.4.17" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cb8f1d480b0ea3783ab015936d2a55c87e219676f0c0b7dec61494043f21857" +checksum = "df895a515f70646414f4b45c0b79082783b80552b373a68283012928df56f522" dependencies = [ "flate2", "futures-core", @@ -328,6 +294,21 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-fd-lock" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7569377d7062165f6f7834d9cb3051974a2d141433cc201c2f94c149e993cccf" +dependencies = [ + "async-trait", + "cfg-if 1.0.0", + "pin-project", + "rustix 0.38.42", + "thiserror 1.0.69", + "tokio", + "windows-sys 0.52.0", +] + [[package]] name = "async-once-cell" version = "0.5.4" @@ -340,9 +321,9 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ - "proc-macro2 1.0.89", - "quote 1.0.37", - "syn 2.0.85", + "proc-macro2", + "quote", + "syn 2.0.90", ] [[package]] @@ -362,9 +343,9 @@ version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ - "proc-macro2 1.0.89", - "quote 1.0.37", - "syn 2.0.85", + "proc-macro2", + "quote", + "syn 2.0.90", ] [[package]] @@ -373,9 +354,9 @@ version = "0.1.83" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" dependencies = [ - "proc-macro2 1.0.89", - "quote 1.0.37", - "syn 2.0.85", + "proc-macro2", + "quote", + "syn 2.0.90", ] [[package]] @@ -388,7 +369,7 @@ dependencies = [ "crc32fast", "futures-lite", "pin-project", - "thiserror", + "thiserror 1.0.69", "tokio", "tokio-util", ] @@ -407,9 +388,9 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "axum" -version = "0.7.7" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "504e3947307ac8326a5437504c517c4b56716c9d98fac0028c2acc7ca47d70ae" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", "axum-core", @@ -433,10 +414,10 @@ dependencies = [ "serde_path_to_error", "serde_urlencoded", "sha1", - "sync_wrapper 1.0.1", + "sync_wrapper", "tokio", "tokio-tungstenite", - "tower 0.5.1", + "tower 0.5.2", "tower-layer", "tower-service", "tracing", @@ -457,7 +438,7 @@ dependencies = [ "mime", "pin-project-lite", "rustversion", - "sync_wrapper 1.0.1", + "sync_wrapper", "tower-layer", "tower-service", "tracing", @@ -465,36 +446,47 @@ dependencies = [ [[package]] name = "axum-extra" -version = "0.9.4" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73c3220b188aea709cf1b6c5f9b01c3bd936bb08bd2b5184a12b35ac8131b1f9" +checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04" dependencies = [ "axum", "axum-core", "bytes", + "fastrand", "futures-util", "headers", "http", "http-body", "http-body-util", "mime", + "multer", "pin-project-lite", "serde", - "tower 0.5.1", + "tower 0.5.2", "tower-layer", "tower-service", - "tracing", ] [[package]] -name = "axum-macros" -version = "0.4.2" +name = "axum-prometheus" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +checksum = "739e2585f5376f5bdd129324ded72d3261fdd5b7c411a645920328fb5dc875d4" dependencies = [ - "proc-macro2 1.0.89", - "quote 1.0.37", - "syn 2.0.85", + "axum", + "bytes", + "futures-core", + "http", + "http-body", + "matchit", + "metrics", + "metrics-exporter-prometheus", + "once_cell", + "pin-project", + "tokio", + "tower 0.4.13", + "tower-http 0.5.2", ] [[package]] @@ -698,9 +690,9 @@ checksum = "c3ac9f8b63eca6fd385229b3675f6cc0dc5c8a5c8a54a59d4f52ffd670d87b0c" [[package]] name = "bytemuck" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8334215b81e418a0a7bdb8ef0849474f40bb10c8b71f1c4ed315cff49f32494d" +checksum = "8b37c88a63ffd85d15b406896cc343916d7cf57838a847b3a6f2ca5d39a5695a" [[package]] name = "byteorder" @@ -716,19 +708,35 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da" +checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" [[package]] name = "cc" -version = "1.1.31" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2e7962b54006dcfcc61cb72735f4d89bb97061dd6a7ed882ec6b8ee53714c6f" +checksum = "c31a0499c1dc64f458ad13872de75c0eb7e3fdb0e67964610c914b034fc5956e" dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + [[package]] name = "cfg-if" version = "0.1.10" @@ -753,6 +761,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "cfg_eval" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45565fc9416b9896014f5732ac776f810ee53a66730c17e4020c3ec064a8f88f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + [[package]] name = "chacha20" version = "0.9.1" @@ -786,14 +805,14 @@ dependencies = [ "derive_builder 0.10.2", "getrandom", "rand", - "thiserror", + "thiserror 1.0.69", ] [[package]] name = "chrono" -version = "0.4.38" +version = "0.4.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" dependencies = [ "android-tzdata", "iana-time-zone", @@ -817,9 +836,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.20" +version = "4.5.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97f376d85a664d5837dbae44bf546e6477a679ff6610010f17276f686d867e8" +checksum = "3135e7ec2ef7b10c6ed8950f0f792ed96ee093fa088608f1c76e569722700c84" dependencies = [ "clap_builder", "clap_derive", @@ -827,15 +846,15 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.20" +version = "4.5.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19bc80abd44e4bed93ca373a0704ccbd1b710dc5749406201bb018272808dc54" +checksum = "30582fc632330df2bd26877bde0c1f4470d57c582bbc070376afcd04d8cb4838" dependencies = [ "anstream", "anstyle", "clap_lex", "strsim 0.11.1", - "terminal_size 0.4.0", + "terminal_size 0.4.1", ] [[package]] @@ -845,16 +864,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" dependencies = [ "heck 0.5.0", - "proc-macro2 1.0.89", - "quote 1.0.37", - "syn 2.0.85", + "proc-macro2", + "quote", + "syn 2.0.90", ] [[package]] name = "clap_lex" -version = "0.7.2" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" [[package]] name = "clipboard-win" @@ -873,27 +892,22 @@ checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" [[package]] name = "colored" -version = "2.1.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbf2150cce219b664a8a70df7a1f933836724b503f8a413af9365b4dcc4d90b8" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" dependencies = [ "lazy_static", - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] -name = "comma" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55b672471b4e9f9e95499ea597ff64941a309b2cdbffcc46f2cc5e2d971fd335" - -[[package]] -name = "conpty" -version = "0.5.1" +name = "combine" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b72b06487a0d4683349ad74d62e87ad639b09667082b3c495c5b6bab7d84b3da" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" dependencies = [ - "windows", + "bytes", + "memchr", ] [[package]] @@ -936,6 +950,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -944,9 +968,9 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpufeatures" -version = "0.2.14" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "608697df725056feaccfa42cffdaeeec3fccc4ffc38358ecd19b243e716a78e0" +checksum = "16b80225097f2e5ae4e7179dd2266824648f3e2f49d9134d584b76389d31c4c3" dependencies = [ "libc", ] @@ -962,18 +986,27 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.13" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ba6d68e24814cb8de6bb986db8222d3a027d15872cabc0d18817bc3c0e4471" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33480d6946193aa8033910124896ca395333cae7e2d1113d1fef6c3272217df2" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.20" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crossterm" @@ -985,7 +1018,7 @@ dependencies = [ "crossterm_winapi", "mio", "parking_lot", - "rustix 0.38.37", + "rustix 0.38.42", "signal-hook", "signal-hook-mio", "winapi", @@ -1095,9 +1128,9 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ - "proc-macro2 1.0.89", - "quote 1.0.37", - "syn 2.0.85", + "proc-macro2", + "quote", + "syn 2.0.90", ] [[package]] @@ -1128,8 +1161,8 @@ checksum = "8e91455b86830a1c21799d94524df0845183fa55bafd9aa137b01c7d1065fa36" dependencies = [ "fnv", "ident_case", - "proc-macro2 1.0.89", - "quote 1.0.37", + "proc-macro2", + "quote", "strsim 0.10.0", "syn 1.0.109", ] @@ -1142,10 +1175,10 @@ checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" dependencies = [ "fnv", "ident_case", - "proc-macro2 1.0.89", - "quote 1.0.37", + "proc-macro2", + "quote", "strsim 0.11.1", - "syn 2.0.85", + "syn 2.0.90", ] [[package]] @@ -1155,7 +1188,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29b5acf0dea37a7f66f7b25d2c5e93fd46f8f6968b1a5d7a3e02e97768afc95a" dependencies = [ "darling_core 0.12.4", - "quote 1.0.37", + "quote", "syn 1.0.109", ] @@ -1166,8 +1199,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ "darling_core 0.20.10", - "quote 1.0.37", - "syn 2.0.85", + "quote", + "syn 2.0.90", ] [[package]] @@ -1190,6 +1223,30 @@ version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2" +[[package]] +name = "dbus" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bb21987b9fb1613058ba3843121dd18b163b254d8a6e797e144cbac14d96d1b" +dependencies = [ + "libc", + "libdbus-sys", + "winapi", +] + +[[package]] +name = "dbus-secret-service" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42a16374481d92aed73ae45b1f120207d8e71d24fb89f357fadbd8f946fd84b" +dependencies = [ + "dbus", + "futures-util", + "num", + "once_cell", + "rand", +] + [[package]] name = "der" version = "0.7.9" @@ -1224,17 +1281,6 @@ dependencies = [ "serde", ] -[[package]] -name = "derivative" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c6d883546668a3e2011b6a716a7330b82eabb0151b138217f632c8243e17135" -dependencies = [ - "proc-macro2 0.4.30", - "quote 0.6.13", - "syn 0.15.44", -] - [[package]] name = "derive_builder" version = "0.10.2" @@ -1260,8 +1306,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66e616858f6187ed828df7c64a6d71720d83767a7f19740b2d1b6fe6327b36e5" dependencies = [ "darling 0.12.4", - "proc-macro2 1.0.89", - "quote 1.0.37", + "proc-macro2", + "quote", "syn 1.0.109", ] @@ -1272,9 +1318,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ "darling 0.20.10", - "proc-macro2 1.0.89", - "quote 1.0.37", - "syn 2.0.85", + "proc-macro2", + "quote", + "syn 2.0.90", ] [[package]] @@ -1294,7 +1340,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core 0.20.2", - "syn 2.0.85", + "syn 2.0.90", ] [[package]] @@ -1321,9 +1367,9 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ - "proc-macro2 1.0.89", - "quote 1.0.37", - "syn 2.0.85", + "proc-macro2", + "quote", + "syn 2.0.90", ] [[package]] @@ -1391,6 +1437,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if 1.0.0", +] + [[package]] name = "endian-type" version = "0.1.2" @@ -1412,9 +1467,9 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1ab991c1362ac86c61ab6f556cff143daa22e5a15e4e189df818b2fd19fe65b" dependencies = [ - "proc-macro2 1.0.89", - "quote 1.0.37", - "syn 2.0.85", + "proc-macro2", + "quote", + "syn 2.0.90", ] [[package]] @@ -1425,12 +1480,12 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1484,15 +1539,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" dependencies = [ "bit-set", - "regex-automata 0.4.8", + "regex-automata 0.4.9", "regex-syntax 0.8.5", ] [[package]] name = "fastrand" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "fd-lock" @@ -1501,15 +1556,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e5768da2206272c81ef0b5e951a41862938a6070da63bcea197899942d3b947" dependencies = [ "cfg-if 1.0.0", - "rustix 0.38.37", + "rustix 0.38.42", "windows-sys 0.52.0", ] [[package]] name = "fdeflate" -version = "0.3.5" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8090f921a24b04994d9929e204f50b498a33ea6ba559ffaa05e04f7ee7fb5ab" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" dependencies = [ "simd-adler32", ] @@ -1530,16 +1585,6 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" -[[package]] -name = "file-guard" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21ef72acf95ec3d7dbf61275be556299490a245f017cf084bd23b4f68cf9407c" -dependencies = [ - "libc", - "winapi", -] - [[package]] name = "filetime" version = "0.2.25" @@ -1581,9 +1626,9 @@ checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" [[package]] name = "flate2" -version = "1.0.34" +version = "1.0.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1b589b4dc103969ad3cf85c950899926ec64300a1a46d76c03a6072957036f0" +checksum = "c936bfdafb507ebbf50b8074c54fa31c5be9a1e7e5f467dd659697041407d07c" dependencies = [ "crc32fast", "miniz_oxide", @@ -1630,7 +1675,7 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a530c4694a6a8d528794ee9bbd8ba0122e779629ac908d15ad5a7ae7763a33d" dependencies = [ - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -1648,6 +1693,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "funty" version = "2.0.0" @@ -1704,9 +1758,9 @@ checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] name = "futures-lite" -version = "2.3.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52527eb5074e35e9339c6b4e8d12600c7128b68fb25dcb9fa9dec18f7c25f3a5" +checksum = "cef40d21ae2c515b51041df9ed313ed21e572df340ea58a922a0aefe7e8891a1" dependencies = [ "fastrand", "futures-core", @@ -1721,9 +1775,9 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ - "proc-macro2 1.0.89", - "quote 1.0.37", - "syn 2.0.85", + "proc-macro2", + "quote", + "syn 2.0.90", ] [[package]] @@ -1806,6 +1860,98 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "gio" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1981edf8679d2f2c8ec3120015867f45aa0a1c2d5e3e129ca2f7dda174d3d2a9" +dependencies = [ + "bitflags 1.3.2", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.17.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ccf87c30a12c469b6d958950f6a9c09f2be20b7773f7e70d20b867fdf2628c3" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.17.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fad45ba8d4d2cea612b432717e834f48031cd8853c8aaf43b2c79fec8d144b" +dependencies = [ + "bitflags 1.3.2", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.17.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca5c79337338391f1ab8058d6698125034ce8ef31b72a442437fa6c8580de26" +dependencies = [ + "anyhow", + "heck 0.4.1", + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "glib-sys" +version = "0.17.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d80aa6ea7bba0baac79222204aa786a6293078c210abe69ef1336911d4bdc4f0" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.17.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd34c3317740a6358ec04572c1bcfd3ac0b5b6529275fae255b237b314bb8062" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + [[package]] name = "group" version = "0.13.0" @@ -1819,9 +1965,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.6" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e8ac6999421f49a846c2d4411f337e53497d8ec55d67753beffa43c5d9205" +checksum = "ccae279728d634d083c00f6099cb58f01cc99c145b84b8be2f6c74618d79922e" dependencies = [ "atomic-waker", "bytes", @@ -1829,7 +1975,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.6.0", + "indexmap 2.7.0", "slab", "tokio", "tokio-util", @@ -1854,9 +2000,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.0" +version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" [[package]] name = "headers" @@ -1929,18 +2075,18 @@ dependencies = [ [[package]] name = "home" -version = "0.5.9" +version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] name = "http" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +checksum = "f16ca2af56261c99fba8bac40a10251ce8188205a4c448fbb745a2e4daa76fea" dependencies = [ "bytes", "fnv", @@ -1990,9 +2136,9 @@ checksum = "91f255a4535024abf7640cb288260811fc14794f62b063652ed349f9a6c2348e" [[package]] name = "hyper" -version = "1.5.0" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbbff0a806a4728c99295b254c8838933b5b082d75e3cb70c8dab21fdfbcfa9a" +checksum = "256fb8d4bd6413123cc9d91832d78325c48ff41677595be797d90f42969beae0" dependencies = [ "bytes", "futures-channel", @@ -2011,9 +2157,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.3" +version = "0.27.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333" +checksum = "f6884a48c6826ec44f524c7456b163cebe9e55a18d7b5e307cb4f100371cc767" dependencies = [ "futures-util", "http", @@ -2029,9 +2175,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.9" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41296eb09f183ac68eec06e03cdbea2e759633d4067b2f6552fc2e009bcad08b" +checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" dependencies = [ "bytes", "futures-channel", @@ -2056,7 +2202,7 @@ dependencies = [ "log", "serde", "serde_derive", - "thiserror", + "thiserror 1.0.69", "unic-langid", ] @@ -2076,7 +2222,7 @@ dependencies = [ "log", "parking_lot", "rust-embed", - "thiserror", + "thiserror 1.0.69", "unic-langid", "walkdir", ] @@ -2095,10 +2241,10 @@ dependencies = [ "i18n-embed", "lazy_static", "proc-macro-error2", - "proc-macro2 1.0.89", - "quote 1.0.37", + "proc-macro2", + "quote", "strsim 0.11.1", - "syn 2.0.85", + "syn 2.0.90", "unic-langid", ] @@ -2110,9 +2256,9 @@ checksum = "0f2cc0e0523d1fe6fc2c6f66e5038624ea8091b3e7748b5e8e0c84b1698db6c2" dependencies = [ "find-crate", "i18n-config", - "proc-macro2 1.0.89", - "quote 1.0.37", - "syn 2.0.85", + "proc-macro2", + "quote", + "syn 2.0.90", ] [[package]] @@ -2126,7 +2272,7 @@ dependencies = [ "iana-time-zone-haiku", "js-sys", "wasm-bindgen", - "windows-core", + "windows-core 0.52.0", ] [[package]] @@ -2139,40 +2285,169 @@ dependencies = [ ] [[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "idna" -version = "0.5.0" +name = "icu_collections" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" dependencies = [ - "unicode-bidi", - "unicode-normalization", + "displaydoc", + "yoke", + "zerofrom", + "zerovec", ] [[package]] -name = "image" -version = "0.25.4" +name = "icu_locid" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc144d44a31d753b02ce64093d532f55ff8dc4ebf2ffb8a63c0dda691385acae" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" dependencies = [ - "bytemuck", - "byteorder-lite", - "num-traits", - "png", + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", ] [[package]] -name = "impl-codec" -version = "0.7.0" +name = "icu_locid_transform" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67aa010c1e3da95bf151bd8b4c059b2ed7e75387cdb969b4f8f2723a43f9941" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" dependencies = [ - "parity-scale-codec", + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6f44aed642f18953a158afeb30206f4d50da59fbc66ecb53c66488de73563b" +dependencies = [ + "bytemuck", + "byteorder-lite", + "num-traits", + "png", +] + +[[package]] +name = "impl-codec" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67aa010c1e3da95bf151bd8b4c059b2ed7e75387cdb969b4f8f2723a43f9941" +dependencies = [ + "parity-scale-codec", ] [[package]] @@ -2195,13 +2470,13 @@ dependencies = [ [[package]] name = "impl-trait-for-tuples" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d7a9f6330b71fea57921c9b61c47ee6e84f72d394754eff6163ae67e7395eb" +checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" dependencies = [ - "proc-macro2 1.0.89", - "quote 1.0.37", - "syn 1.0.109", + "proc-macro2", + "quote", + "syn 2.0.90", ] [[package]] @@ -2217,15 +2492,35 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" +checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" dependencies = [ "equivalent", - "hashbrown 0.15.0", + "hashbrown 0.15.2", "serde", ] +[[package]] +name = "inotify" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.3" @@ -2235,6 +2530,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if 1.0.0", +] + [[package]] name = "intl-memoizer" version = "0.5.2" @@ -2254,6 +2558,12 @@ dependencies = [ "unic-langid", ] +[[package]] +name = "inventory" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f958d3d68f4167080a18141e10381e7634563984a537f2a49a30fd8e53ac5767" + [[package]] name = "io-lifetimes" version = "1.0.11" @@ -2278,20 +2588,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708" [[package]] -name = "is_terminal_polyfill" -version = "1.70.1" +name = "is-docker" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] [[package]] -name = "itertools" -version = "0.12.1" +name = "is-wsl" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" dependencies = [ - "either", + "is-docker", + "once_cell", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + [[package]] name = "itertools" version = "0.13.0" @@ -2303,16 +2623,39 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.11" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if 1.0.0", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" [[package]] name = "js-sys" -version = "0.3.72" +version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" +checksum = "6717b6b5b077764fb5966237269cb3c64edddde4b14ce42647430a78ced9e7b7" dependencies = [ + "once_cell", "wasm-bindgen", ] @@ -2332,13 +2675,13 @@ dependencies = [ [[package]] name = "kdam" -version = "0.5.2" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "526586ea01a9a132b5f8d3a60f6d6b41b411550236f5ee057795f20b37316957" +checksum = "7ed2186610f797a95b55e61c420a81d3b9079ac9776d382f41cf35ce0643a90a" dependencies = [ "kdam_derive", - "terminal_size 0.3.0", - "windows-sys 0.52.0", + "terminal_size 0.4.1", + "windows-sys 0.59.0", ] [[package]] @@ -2347,8 +2690,8 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fb5e25f9b861a88faa9d272ca4376e1a13c9a37d36de623f013c7bbb0ae2baa1" dependencies = [ - "quote 1.0.37", - "syn 2.0.85", + "quote", + "syn 2.0.90", ] [[package]] @@ -2367,7 +2710,41 @@ dependencies = [ "anyhow", "logos", "plist", - "thiserror", + "thiserror 2.0.8", +] + +[[package]] +name = "keyring" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f8fe839464d4e4b37d756d7e910063696af79a7e877282cb1825e4ec5f10833" +dependencies = [ + "byteorder", + "dbus-secret-service", + "log", + "security-framework 2.11.1", + "security-framework 3.1.0", + "windows-sys 0.59.0", +] + +[[package]] +name = "kqueue" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7447f1ca1b7b563588a205fe93dea8df60fd981423a768bc1c0ded35ed147d0c" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", ] [[package]] @@ -2387,15 +2764,18 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.161" +version = "0.2.169" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1" +checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" [[package]] -name = "libm" -version = "0.2.8" +name = "libdbus-sys" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" +checksum = "06085512b750d640299b79be4bad3d2fa90a9c00b1fd9e1b46364f66f0485c72" +dependencies = [ + "pkg-config", +] [[package]] name = "libredox" @@ -2420,6 +2800,12 @@ version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +[[package]] +name = "litemap" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" + [[package]] name = "lock_api" version = "0.4.12" @@ -2438,33 +2824,34 @@ checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" [[package]] name = "logos" -version = "0.14.2" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c6b6e02facda28ca5fb8dbe4b152496ba3b1bd5a4b40bb2b1b2d8ad74e0f39b" +checksum = "ab6f536c1af4c7cc81edf73da1f8029896e7e1e16a219ef09b184e76a296f3db" dependencies = [ "logos-derive", ] [[package]] name = "logos-codegen" -version = "0.14.2" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b32eb6b5f26efacd015b000bfc562186472cd9b34bdba3f6b264e2a052676d10" +checksum = "189bbfd0b61330abea797e5e9276408f2edbe4f822d7ad08685d67419aafb34e" dependencies = [ "beef", "fnv", "lazy_static", - "proc-macro2 1.0.89", - "quote 1.0.37", + "proc-macro2", + "quote", "regex-syntax 0.8.5", - "syn 2.0.85", + "rustc_version", + "syn 2.0.90", ] [[package]] name = "logos-derive" -version = "0.14.2" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e5d0c5463c911ef55624739fc353238b4e310f0144be1f875dc42fec6bfd5ec" +checksum = "ebfe8e1a19049ddbfccbd14ac834b215e11b85b90bab0c2dba7c7b92fb5d5cba" dependencies = [ "logos-codegen", ] @@ -2497,12 +2884,48 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] -name = "memoffset" -version = "0.7.1" +name = "metrics" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +checksum = "884adb57038347dfbaf2d5065887b6cf4312330dc8e94bc30a1a839bd79d3261" dependencies = [ - "autocfg", + "ahash", + "portable-atomic", +] + +[[package]] +name = "metrics-exporter-prometheus" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4f0c8427b39666bf970460908b213ec09b3b350f20c0c2eabcbba51704a08e6" +dependencies = [ + "base64 0.22.1", + "http-body-util", + "hyper", + "hyper-util", + "indexmap 2.7.0", + "ipnet", + "metrics", + "metrics-util", + "quanta", + "thiserror 1.0.69", + "tokio", + "tracing", +] + +[[package]] +name = "metrics-util" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4259040465c955f9f2f1a4a8a16dc46726169bca0f88e8fb2dbeced487c3e828" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", + "hashbrown 0.14.5", + "metrics", + "num_cpus", + "quanta", + "sketches-ddsketch", ] [[package]] @@ -2529,9 +2952,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.8.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" +checksum = "4ffbe83022cedc1d264172192511ae958937694cd57ce297164951b8b3568394" dependencies = [ "adler2", "simd-adler32", @@ -2539,23 +2962,45 @@ dependencies = [ [[package]] name = "mio" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" dependencies = [ - "hermit-abi", "libc", "log", "wasi", "windows-sys 0.52.0", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "multimap" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03" +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "nibble_vec" version = "0.1.0" @@ -2565,19 +3010,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "nix" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" -dependencies = [ - "bitflags 1.3.2", - "cfg-if 1.0.0", - "libc", - "memoffset", - "pin-utils", -] - [[package]] name = "nix" version = "0.28.0" @@ -2618,6 +3050,35 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db1b4163932b207be6e3a06412aed4d84cca40dc087419f231b3a38cba2ca8e9" +[[package]] +name = "notify" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +dependencies = [ + "bitflags 2.6.0", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.52.0", +] + +[[package]] +name = "notify-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +dependencies = [ + "instant", + "serde", +] + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -2628,6 +3089,20 @@ dependencies = [ "winapi", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -2638,6 +3113,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -2653,6 +3137,28 @@ 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-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2755,6 +3261,17 @@ dependencies = [ "objc2", ] +[[package]] +name = "objc2-local-authentication" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "430605e43490dc3837b7d50d8daedacb9f7926da3935a8cd09651a6a9d071b71" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-metal" version = "0.2.2" @@ -2811,28 +3328,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] -name = "ouroboros" -version = "0.18.4" +name = "open" +version = "5.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "944fa20996a25aded6b4795c6d63f10014a7a83f8be9828a11860b08c5fc4a67" +checksum = "3ecd52f0b8d15c40ce4820aa251ed5de032e5d91fab27f7db2f40d42a8bdf69c" dependencies = [ - "aliasable", - "ouroboros_macro", - "static_assertions", -] - -[[package]] -name = "ouroboros_macro" -version = "0.18.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39b0deead1528fd0e5947a8546a9642a9777c25f6e1e26f34c97b204bbb465bd" -dependencies = [ - "heck 0.4.1", - "itertools 0.12.1", - "proc-macro2 1.0.89", - "proc-macro2-diagnostics", - "quote 1.0.37", - "syn 2.0.85", + "is-wsl", + "libc", + "pathdiff", ] [[package]] @@ -2861,9 +3364,9 @@ version = "3.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d830939c76d294956402033aee57a6da7b438f2294eb94864c37b0569053a42c" dependencies = [ - "proc-macro-crate", - "proc-macro2 1.0.89", - "quote 1.0.37", + "proc-macro-crate 3.2.0", + "proc-macro2", + "quote", "syn 1.0.109", ] @@ -2896,6 +3399,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "parse-zoneinfo" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" +dependencies = [ + "regex", +] + [[package]] name = "password-hash" version = "0.5.0" @@ -2907,6 +3419,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "pbkdf2" version = "0.12.2" @@ -2940,7 +3458,45 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset", - "indexmap 2.6.0", + "indexmap 2.7.0", +] + +[[package]] +name = "phf" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8d39688d359e6b34654d328e262234662d16cc0f60ec8dcbe5e718709342a5a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_shared" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" +dependencies = [ + "siphasher", ] [[package]] @@ -2958,9 +3514,9 @@ version = "1.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c0f5fad0874fc7abcd4d750e76917eaebbecaa2c20bde22e1dbeeba8beb758c" dependencies = [ - "proc-macro2 1.0.89", - "quote 1.0.37", - "syn 2.0.85", + "proc-macro2", + "quote", + "syn 2.0.90", ] [[package]] @@ -2985,6 +3541,12 @@ dependencies = [ "spki", ] +[[package]] +name = "pkg-config" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" + [[package]] name = "plist" version = "1.7.0" @@ -2992,7 +3554,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42cf17e9a1800f5f396bc67d193dc9411b59012a5876445ef450d449881e1016" dependencies = [ "base64 0.22.1", - "indexmap 2.6.0", + "indexmap 2.7.0", "quick-xml", "serde", "time", @@ -3000,9 +3562,9 @@ dependencies = [ [[package]] name = "png" -version = "0.17.14" +version = "0.17.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f9d46a34a05a6a57566bc2bfae066ef07585a6e3fa30fbbdff5936380623f0" +checksum = "b67582bd5b65bdff614270e2ea89a1cf15bef71245cc1e5f7ea126977144211d" dependencies = [ "bitflags 1.3.2", "crc32fast", @@ -3011,6 +3573,31 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polkit" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7866121c1e115212fd6e4eca8f84e03af65eda1a3d57babf849a946c791559c" +dependencies = [ + "bitflags 1.3.2", + "gio", + "glib", + "polkit-sys", +] + +[[package]] +name = "polkit-sys" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc4bc8e597191fc76cbd8a1b1d22a9a8dbbbf599f9e7af58ab5703303423d53" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -3034,6 +3621,12 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "280dc24453071f1b63954171985a0b0d30058d287960968b9b2aca264c8d4ee6" + [[package]] name = "powerfmt" version = "0.2.0" @@ -3065,8 +3658,8 @@ version = "0.2.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64d1ec885c64d0457d564db4ec299b2dae3f9c02808b8ad9c3a089c591b18033" dependencies = [ - "proc-macro2 1.0.89", - "syn 2.0.85", + "proc-macro2", + "syn 2.0.90", ] [[package]] @@ -3082,16 +3675,6 @@ dependencies = [ "uint", ] -[[package]] -name = "probability" -version = "0.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42746b805e424b759d46c22c65dc66ccca057a2db96e9db4fda6c337a287e485" -dependencies = [ - "random", - "special", -] - [[package]] name = "probly-search" version = "2.0.1" @@ -3102,73 +3685,85 @@ dependencies = [ "typed-generational-arena", ] +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + [[package]] name = "proc-macro-crate" version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b" dependencies = [ - "toml_edit", + "toml_edit 0.22.22", ] [[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" +name = "proc-macro-error" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" dependencies = [ - "proc-macro2 1.0.89", - "quote 1.0.37", + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", ] [[package]] -name = "proc-macro-error2" -version = "2.0.1" +name = "proc-macro-error-attr" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" dependencies = [ - "proc-macro-error-attr2", - "proc-macro2 1.0.89", - "quote 1.0.37", - "syn 2.0.85", + "proc-macro2", + "quote", + "version_check", ] [[package]] -name = "proc-macro2" -version = "0.4.30" +name = "proc-macro-error-attr2" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf3d2011ab5c909338f7887f4fc896d35932e29146c12c8d01da6b22a80ba759" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" dependencies = [ - "unicode-xid", + "proc-macro2", + "quote", ] [[package]] -name = "proc-macro2" -version = "1.0.89" +name = "proc-macro-error2" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" dependencies = [ - "unicode-ident", + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.90", ] [[package]] -name = "proc-macro2-diagnostics" -version = "0.10.1" +name = "proc-macro2" +version = "1.0.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" dependencies = [ - "proc-macro2 1.0.89", - "quote 1.0.37", - "syn 2.0.85", - "version_check", - "yansi", + "unicode-ident", ] [[package]] name = "prost" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b0487d90e047de87f984913713b85c601c05609aad5b0df4b4573fbf69aa13f" +checksum = "2c0fef6c4230e4ccf618a35c59d7ede15dea37de8427500f50aff708806e42ec" dependencies = [ "bytes", "prost-derive", @@ -3176,13 +3771,12 @@ dependencies = [ [[package]] name = "prost-build" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1318b19085f08681016926435853bbf7858f9c082d0999b80550ff5d9abe15" +checksum = "d0f3e5beed80eb580c68e2c600937ac2c4eedabdfd5ef1e5b7ea4f3fba84497b" dependencies = [ - "bytes", "heck 0.5.0", - "itertools 0.13.0", + "itertools", "log", "multimap", "once_cell", @@ -3191,28 +3785,28 @@ dependencies = [ "prost", "prost-types", "regex", - "syn 2.0.85", + "syn 2.0.90", "tempfile", ] [[package]] name = "prost-derive" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9552f850d5f0964a4e4d0bf306459ac29323ddfbae05e35a7c0d35cb0803cc5" +checksum = "157c5a9d7ea5c2ed2d9fb8f495b64759f7816c7eaea54ba3978f0d63000162e3" dependencies = [ "anyhow", - "itertools 0.13.0", - "proc-macro2 1.0.89", - "quote 1.0.37", - "syn 2.0.85", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.90", ] [[package]] name = "prost-types" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4759aa0d3a6232fb8dbdb97b61de2c20047c68aca932c7ed76da9d788508d670" +checksum = "cc2f1e56baa61e93533aebc21af4d2134b70f66275e0fcdf3cbe43d77ff7e8fc" dependencies = [ "prost", ] @@ -3274,15 +3868,6 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dc55d7dec32ecaf61e0bd90b3d2392d721a28b95cfd23c3e176eccefbeab2f2" -[[package]] -name = "ptyprocess" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e05aef7befb11a210468a2d77d978dde2c6381a0381e33beb575e91f57fe8cf" -dependencies = [ - "nix 0.26.4", -] - [[package]] name = "qrcodegen" version = "1.8.0" @@ -3300,6 +3885,21 @@ dependencies = [ "qrcodegen", ] +[[package]] +name = "quanta" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773ce68d0bb9bc7ef20be3536ffe94e223e1f365bd374108b2659fac0c65cfe6" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + [[package]] name = "quick-xml" version = "0.32.0" @@ -3311,45 +3911,49 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c7c5fdde3cdae7203427dc4f0a68fe0ed09833edc525a03456b153b79828684" +checksum = "62e96808277ec6f97351a2380e6c25114bc9e67037775464979f3037c92d05ef" dependencies = [ "bytes", "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash 2.0.0", + "rustc-hash 2.1.0", "rustls", "socket2", - "thiserror", + "thiserror 2.0.8", "tokio", "tracing", ] [[package]] name = "quinn-proto" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fadfaed2cd7f389d0161bb73eeb07b7b78f8691047a6f3e73caaeae55310a4a6" +checksum = "a2fe5ef3495d7d2e377ff17b1a8ce2ee2ec2a18cde8b6ad6619d65d0701c135d" dependencies = [ "bytes", + "getrandom", "rand", "ring", - "rustc-hash 2.0.0", + "rustc-hash 2.1.0", "rustls", + "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.8", "tinyvec", "tracing", + "web-time", ] [[package]] name = "quinn-udp" -version = "0.5.5" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fe68c2e9e1a1234e218683dbdf9f9dfcb094113c5ac2b938dfcb9bab4c4140b" +checksum = "1c40286217b4ba3a71d644d752e6a0b71f13f1b6a2c5311acfcbe0c2418ed904" dependencies = [ + "cfg_aliases 0.2.1", "libc", "once_cell", "socket2", @@ -3357,22 +3961,13 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "quote" -version = "0.6.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce23b6b870e8f94f81fb0a363d65d86675884b34a09043c81e5562f11c1f8e1" -dependencies = [ - "proc-macro2 0.4.30", -] - [[package]] name = "quote" version = "1.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" dependencies = [ - "proc-macro2 1.0.89", + "proc-macro2", ] [[package]] @@ -3422,28 +4017,32 @@ dependencies = [ ] [[package]] -name = "random" -version = "0.13.2" +name = "raw-cpuid" +version = "11.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "474c42c904f04dfe2a595a02f71e1a0e5e92ffb5761cc9a4c02140b93b8dd504" +checksum = "1ab240315c661615f2ee9f0f2cd32d5a7343a84d5ebcccb99d46e6637565e7b0" +dependencies = [ + "bitflags 2.6.0", +] [[package]] name = "rcgen" -version = "0.12.1" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48406db8ac1f3cbc7dcdb56ec355343817958a356ff430259bb07baf7607e1e1" +checksum = "54077e1872c46788540de1ea3d7f4ccb1983d12f9aa909b234468676c1a36779" dependencies = [ "pem", "ring", + "rustls-pki-types", "time", "yasna", ] [[package]] name = "redox_syscall" -version = "0.5.7" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" +checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" dependencies = [ "bitflags 2.6.0", ] @@ -3454,9 +4053,9 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ - "aho-corasick 1.1.3", + "aho-corasick", "memchr", - "regex-automata 0.4.8", + "regex-automata 0.4.9", "regex-syntax 0.8.5", ] @@ -3471,11 +4070,11 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.8" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ - "aho-corasick 1.1.3", + "aho-corasick", "memchr", "regex-syntax 0.8.5", ] @@ -3494,9 +4093,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "reqwest" -version = "0.12.8" +version = "0.12.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f713147fbe92361e52392c73b8c9e48c04c6625bce969ef54dc901e58e042a7b" +checksum = "a77c62af46e79de0a562e1a9849205ffcb7fc1238876e9bd743357570e04046f" dependencies = [ "base64 0.22.1", "bytes", @@ -3522,7 +4121,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper 1.0.1", + "sync_wrapper", "tokio", "tokio-rustls", "tokio-util", @@ -3536,6 +4135,15 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "retry" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9166d72162de3575f950507683fac47e30f6f2c3836b71b7fbc61aa517c9c5f4" +dependencies = [ + "rand", +] + [[package]] name = "rev_buf_reader" version = "0.3.0" @@ -3580,6 +4188,38 @@ dependencies = [ "rustc-hex", ] +[[package]] +name = "robius-android-env" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "087fcb3061ccc432658a605cb868edd44e0efb08e7a159b486f02804a7616bef" +dependencies = [ + "jni", + "ndk-context", +] + +[[package]] +name = "robius-authentication" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28907f02c4dfd480f1dfff498f9f574fe71271f09f7e1e00fe527e20e72f5061" +dependencies = [ + "android-build", + "block2", + "cfg-if 1.0.0", + "gio", + "jni", + "log", + "objc2", + "objc2-foundation", + "objc2-local-authentication", + "polkit", + "retry", + "robius-android-env", + "windows", + "windows-core 0.56.0", +] + [[package]] name = "rs_merkle" version = "1.4.2" @@ -3606,10 +4246,10 @@ version = "8.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6125dbc8867951125eec87294137f4e9c2c96566e61bf72c45095a7c77761478" dependencies = [ - "proc-macro2 1.0.89", - "quote 1.0.37", + "proc-macro2", + "quote", "rust-embed-utils", - "syn 2.0.85", + "syn 2.0.90", "walkdir", ] @@ -3637,9 +4277,9 @@ checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustc-hash" -version = "2.0.0" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "583034fd73374156e66797ed8e5b0d5690409c9226b22d87cb7f19821c05d152" +checksum = "c7fb8039b3032c191086b10f11f319a6e99e1e82889c5cc6046f515c9db1d497" [[package]] name = "rustc-hex" @@ -3681,22 +4321,22 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.37" +version = "0.38.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" +checksum = "f93dc38ecbab2eb790ff964bb77fa94faf256fd3e73285fd7ba0903b76bedb85" dependencies = [ "bitflags 2.6.0", "errno", "libc", "linux-raw-sys 0.4.14", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] name = "rustls" -version = "0.23.15" +version = "0.23.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fbb44d7acc4e873d613422379f69f237a1b141928c02f6bc6ccfddddc2d7993" +checksum = "5065c3f250cbd332cd894be57c40fa52387247659b14a2d6041d121547903b1b" dependencies = [ "once_cell", "ring", @@ -3717,9 +4357,12 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.10.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16f1201b3c9a7ee8039bcadc17b7e605e2945b27eee7631788c1bd2b0643674b" +checksum = "d2bf47e6ff922db3825eb750c4e2ff784c6ff8fb9e13046ef6a1d1c5401b0b37" +dependencies = [ + "web-time", +] [[package]] name = "rustls-webpki" @@ -3762,13 +4405,13 @@ dependencies = [ [[package]] name = "rustyline-derive" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5af959c8bf6af1aff6d2b463a57f71aae53d1332da58419e30ad8dc7011d951" +checksum = "327e9d075f6df7e25fbf594f1be7ef55cf0d567a6cb5112eeccbbd51ceb48e0d" dependencies = [ - "proc-macro2 1.0.89", - "quote 1.0.37", - "syn 2.0.85", + "proc-macro2", + "quote", + "syn 2.0.90", ] [[package]] @@ -3797,11 +4440,10 @@ dependencies = [ [[package]] name = "sanitize-filename" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ed72fbaf78e6f2d41744923916966c4fbe3d7c74e3037a8ee482f1115572603" +checksum = "bc984f4f9ceb736a7bb755c3e3bd17dc56370af2600c9780dcc48c66453da34d" dependencies = [ - "lazy_static", "regex", ] @@ -3853,7 +4495,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags 2.6.0", - "core-foundation", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81d3f8c9bfcc3cbb6b0179eb57042d75b1582bdc65c3cb95f3fa999509c03cbc" +dependencies = [ + "bitflags 2.6.0", + "core-foundation 0.10.0", "core-foundation-sys", "libc", "security-framework-sys", @@ -3861,9 +4516,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.12.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea4a292869320c0272d7bc55a5a6aafaff59b4f63404a003887b679a2e05b4b6" +checksum = "1863fd3768cd83c56a7f60faa4dc0d403f1b6df0a38c3c25f44b7894e45370d5" dependencies = [ "core-foundation-sys", "libc", @@ -3875,49 +4530,61 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e14e4d63b804dc0c7ec4a1e52bcb63f02c7ac94476755aa579edac21e01f915d" dependencies = [ - "self_cell 1.0.4", + "self_cell 1.1.0", ] [[package]] name = "self_cell" -version = "1.0.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d369a96f978623eb3dc28807c4852d6cc617fed53da5d3c400feff1ef34a714a" +checksum = "c2fdfc24bc566f839a2da4c4295b82db7d25a24253867d5c64355abb5799bdbe" [[package]] name = "semver" -version = "1.0.23" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" +checksum = "3cb6eb87a131f756572d7fb904f6e7b68633f09cca868c5df1c4b8d1a694bbba" dependencies = [ "serde", ] [[package]] name = "serde" -version = "1.0.213" +version = "1.0.216" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ea7893ff5e2466df8d720bb615088341b295f849602c6956047f8f80f0e9bc1" +checksum = "0b9781016e935a97e8beecf0c933758c97a5520d32930e460142b4cd80c6338e" dependencies = [ "serde_derive", ] +[[package]] +name = "serde-xml-rs" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65162e9059be2f6a3421ebbb4fef3e74b7d9e7c60c50a0e292c6239f19f1edfa" +dependencies = [ + "log", + "serde", + "thiserror 1.0.69", + "xml-rs", +] + [[package]] name = "serde_derive" -version = "1.0.213" +version = "1.0.216" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e85ad2009c50b58e87caa8cd6dac16bdf511bbfb7af6c33df902396aa480fa5" +checksum = "46f859dbbf73865c6627ed570e78961cd3ac92407a2d117204c49232485da55e" dependencies = [ - "proc-macro2 1.0.89", - "quote 1.0.37", - "syn 2.0.85", + "proc-macro2", + "quote", + "syn 2.0.90", ] [[package]] name = "serde_json" -version = "1.0.132" +version = "1.0.133" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d726bfaff4b320266d395898905d0eba0345aae23b54aee3a737e260fd46db03" +checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" dependencies = [ "itoa", "memchr", @@ -3925,6 +4592,56 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_json_path" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e176fbf9bd62f75c2d8be33207fa13af2f800a506635e89759e46f934c520f4d" +dependencies = [ + "inventory", + "nom", + "regex", + "serde", + "serde_json", + "serde_json_path_core", + "serde_json_path_macros", + "thiserror 1.0.69", +] + +[[package]] +name = "serde_json_path_core" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea3bfd54a421bec8328aefede43ac9f18c8c7ded3b2afc8addd44b4813d99fd0" +dependencies = [ + "inventory", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "serde_json_path_macros" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee05bac728cc5232af5c23896b34fbdd17cf0bb0c113440588aeeb1b57c6ba1f" +dependencies = [ + "inventory", + "serde_json_path_core", + "serde_json_path_macros_internal", +] + +[[package]] +name = "serde_json_path_macros_internal" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aafbefbe175fa9bf03ca83ef89beecff7d2a95aaacd5732325b90ac8c3bd7b90" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + [[package]] name = "serde_path_to_error" version = "0.1.16" @@ -3966,7 +4683,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.6.0", + "indexmap 2.7.0", "serde", "serde_derive", "serde_json", @@ -3981,9 +4698,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d846214a9854ef724f3da161b426242d8de7c1fc7de2f89bb1efcb154dca79d" dependencies = [ "darling 0.20.10", - "proc-macro2 1.0.89", - "quote 1.0.37", - "syn 2.0.85", + "proc-macro2", + "quote", + "syn 2.0.90", ] [[package]] @@ -4085,6 +4802,18 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "sketches-ddsketch" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85636c14b73d81f541e525f585c0a2109e6744e1565b5c1668e31c70c10ed65c" + [[package]] name = "slab" version = "0.4.9" @@ -4124,9 +4853,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.5.7" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" dependencies = [ "libc", "windows-sys 0.52.0", @@ -4134,11 +4863,10 @@ dependencies = [ [[package]] name = "sos" -version = "0.15.1" +version = "0.16.1" dependencies = [ "arboard", "async-recursion", - "axum-server", "binary-stream", "clap", "crossterm", @@ -4148,7 +4876,6 @@ dependencies = [ "futures", "human_bytes", "kdam", - "num_cpus", "once_cell", "parking_lot", "rustls", @@ -4161,7 +4888,7 @@ dependencies = [ "sos-net", "tempfile", "terminal-banner", - "thiserror", + "thiserror 2.0.8", "tokio", "tokio-rustls", "toml 0.8.19", @@ -4172,20 +4899,16 @@ dependencies = [ [[package]] name = "sos-account-extras" -version = "0.15.1" +version = "0.16.1" dependencies = [ - "anyhow", - "arboard", - "once_cell", "rustc_version", "serde", "serde_json", "serde_with", "sos-sdk", - "thiserror", + "thiserror 2.0.8", "tokio", "tracing", - "zeroize", ] [[package]] @@ -4197,7 +4920,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror", + "thiserror 2.0.8", "time", "url", ] @@ -4215,16 +4938,14 @@ dependencies = [ name = "sos-integration-tests" version = "0.1.0" dependencies = [ - "anticipate-runner", "anyhow", "async-recursion", + "async-trait", "binary-stream", - "clap", "copy_dir", "futures", "http", - "indexmap 2.6.0", - "kdam", + "indexmap 2.7.0", "maplit2", "once_cell", "parking_lot", @@ -4232,34 +4953,68 @@ dependencies = [ "secrecy", "serde", "serde_json", + "sos-account-extras", + "sos-ipc", "sos-net", + "sos-sdk", "sos-server", - "sos_test_utils", + "sos-test-utils", + "sos-web", "tempfile", - "thiserror", + "thiserror 2.0.8", "tokio", "tracing", "tracing-subscriber", ] +[[package]] +name = "sos-ipc" +version = "0.16.6" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bytes", + "futures", + "futures-util", + "http", + "http-body-util", + "hyper", + "hyper-util", + "matchit", + "notify", + "once_cell", + "open", + "parking_lot", + "rustc_version", + "secrecy", + "serde", + "serde_json", + "serde_with", + "sos-platform-authenticator", + "sos-protocol", + "sos-sdk", + "thiserror 2.0.8", + "tokio", + "tokio-util", + "tower 0.5.2", + "tracing", + "typeshare", +] + [[package]] name = "sos-net" -version = "0.15.1" +version = "0.16.1" dependencies = [ "anyhow", "async-recursion", - "async-stream", "async-trait", "binary-stream", - "bs58", - "colored", "futures", "hex", "http", - "indexmap 2.6.0", + "indexmap 2.7.0", "prost", "rand", - "reqwest", "rs_merkle", "rustc_version", "secrecy", @@ -4271,9 +5026,8 @@ dependencies = [ "sos-account-extras", "sos-protocol", "sos-sdk", - "thiserror", + "thiserror 2.0.8", "tokio", - "tokio-tungstenite", "tokio-util", "tracing", "tracing-subscriber", @@ -4282,38 +5036,61 @@ dependencies = [ "uuid", ] +[[package]] +name = "sos-platform-authenticator" +version = "0.1.0" +dependencies = [ + "http", + "keyring", + "robius-authentication", + "rustc_version", + "secrecy", + "security-framework 3.1.0", + "thiserror 2.0.8", + "tracing", +] + [[package]] name = "sos-protocol" -version = "0.15.1" +version = "0.16.3" dependencies = [ "anyhow", + "async-stream", "async-trait", + "binary-stream", + "bs58", + "bytes", "futures", - "indexmap 2.6.0", + "http", + "indexmap 2.7.0", "prost", "prost-build", "protoc-bin-vendored", + "reqwest", "rs_merkle", "rustc_version", "serde", + "serde_json", "sos-sdk", - "thiserror", + "thiserror 2.0.8", "tokio", + "tokio-tungstenite", + "tokio-util", "tracing", + "typeshare", "url", "uuid", ] [[package]] name = "sos-sdk" -version = "0.15.1" +version = "0.16.2" dependencies = [ "aes-gcm", "age", "anyhow", "argon2", "async-once-cell", - "async-recursion", "async-stream", "async-trait", "async_zip", @@ -4328,14 +5105,12 @@ dependencies = [ "enum-iterator", "etcetera", "ethereum-types", - "file-guard", "filetime", "futures", "futures-util", "getrandom", "hex", - "http", - "indexmap 2.6.0", + "indexmap 2.7.0", "k256", "keychain_parser", "mime_guess", @@ -4349,25 +5124,28 @@ dependencies = [ "rustc_version", "sanitize-filename", "secrecy", - "security-framework", + "security-framework 3.1.0", "serde", "serde_json", + "serde_json_path", "serde_with", "sha1", "sha2", "sha3", - "sos-vfs 0.2.2", - "sos_test_utils", + "sos-test-utils", + "sos-vfs", "subtle", "tempfile", - "thiserror", + "thiserror 2.0.8", "time", + "time-tz", "tokio", "tokio-util", "totp-rs", "tracing", "tracing-appender", "tracing-subscriber", + "typeshare", "unicode-segmentation", "url", "urn", @@ -4376,17 +5154,18 @@ dependencies = [ "vsss-rs", "walkdir", "whoami", + "xclipboard", "zxcvbn", ] [[package]] name = "sos-server" -version = "0.15.1" +version = "0.16.1" dependencies = [ "async-trait", "axum", "axum-extra", - "axum-macros", + "axum-prometheus", "axum-server", "binary-stream", "bs58", @@ -4395,21 +5174,21 @@ dependencies = [ "futures", "hex", "http", - "indexmap 2.6.0", + "indexmap 2.7.0", "rustc_version", "rustls", "serde", "serde_json", "sos-cli-helpers", "sos-protocol", - "thiserror", + "thiserror 2.0.8", "tokio", "tokio-rustls", "tokio-rustls-acme", "tokio-stream", "tokio-util", "toml 0.8.19", - "tower-http", + "tower-http 0.6.2", "tracing", "tracing-subscriber", "url", @@ -4419,34 +5198,7 @@ dependencies = [ ] [[package]] -name = "sos-vfs" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9009f68f8a2f7b64f24916907114467b74f25c2e7cb16a89885a0601cb7e4664" -dependencies = [ - "async-recursion", - "bitflags 1.3.2", - "futures", - "once_cell", - "parking_lot", - "tokio", -] - -[[package]] -name = "sos-vfs" -version = "0.2.3" -dependencies = [ - "anyhow", - "async-recursion", - "bitflags 2.6.0", - "futures", - "once_cell", - "parking_lot", - "tokio", -] - -[[package]] -name = "sos_test_utils" +name = "sos-test-utils" version = "0.1.0" dependencies = [ "anyhow", @@ -4467,12 +5219,31 @@ dependencies = [ ] [[package]] -name = "special" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b89cf0d71ae639fdd8097350bfac415a41aabf1d5ddd356295fdc95f09760382" +name = "sos-vfs" +version = "0.2.5" +dependencies = [ + "anyhow", + "async-fd-lock", + "async-recursion", + "bitflags 2.6.0", + "futures", + "once_cell", + "parking_lot", + "tokio", +] + +[[package]] +name = "sos-web" +version = "0.16.5" dependencies = [ - "libm", + "async-trait", + "indexmap 2.7.0", + "rustc_version", + "sos-protocol", + "sos-sdk", + "thiserror 2.0.8", + "tokio", + "tracing", ] [[package]] @@ -4491,6 +5262,12 @@ dependencies = [ "der", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + [[package]] name = "static_assertions" version = "1.1.0" @@ -4515,50 +5292,33 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "syn" -version = "0.15.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ca4b3b69a77cbe1ffc9e198781b7acb0c7365a883670e8f1c1bc66fba79a5c5" -dependencies = [ - "proc-macro2 0.4.30", - "quote 0.6.13", - "unicode-xid", -] - [[package]] name = "syn" version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ - "proc-macro2 1.0.89", - "quote 1.0.37", + "proc-macro2", + "quote", "unicode-ident", ] [[package]] name = "syn" -version = "2.0.85" +version = "2.0.90" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5023162dfcd14ef8f32034d8bcd4cc5ddc61ef7a247c024a33e24e1f24d21b56" +checksum = "919d3b74a5dd0ccd15aeb8f93e7006bd9e14c295087c9896a110f490752bcf31" dependencies = [ - "proc-macro2 1.0.89", - "quote 1.0.37", + "proc-macro2", + "quote", "unicode-ident", ] [[package]] name = "sync_wrapper" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" - -[[package]] -name = "sync_wrapper" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" dependencies = [ "futures-core", ] @@ -4569,9 +5329,22 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" dependencies = [ - "proc-macro2 1.0.89", - "quote 1.0.37", - "syn 2.0.85", + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.19", + "version-compare", ] [[package]] @@ -4580,16 +5353,22 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + [[package]] name = "tempfile" -version = "3.13.0" +version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f2c9fc62d0beef6951ccffd757e241266a2c833136efbe35af6cd2567dca5b" +checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c" dependencies = [ "cfg-if 1.0.0", "fastrand", "once_cell", - "rustix 0.38.37", + "rustix 0.38.42", "windows-sys 0.59.0", ] @@ -4606,31 +5385,21 @@ dependencies = [ [[package]] name = "terminal_size" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e6bf6f19e9f8ed8d4048dc22981458ebcf406d67e94cd422e5ecd73d63b3237" -dependencies = [ - "rustix 0.37.27", - "windows-sys 0.48.0", -] - -[[package]] -name = "terminal_size" -version = "0.3.0" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21bebf2b7c9e0a515f6e0f8c51dc0f8e4696391e6f1ff30379559f8365fb0df7" +checksum = "8e6bf6f19e9f8ed8d4048dc22981458ebcf406d67e94cd422e5ecd73d63b3237" dependencies = [ - "rustix 0.38.37", + "rustix 0.37.27", "windows-sys 0.48.0", ] [[package]] name = "terminal_size" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f599bd7ca042cfdf8f4512b277c02ba102247820f9d9d4a9f521f496751a6ef" +checksum = "5352447f921fda68cf61b4101566c0bdb5104eff6804d0678e5227580ab6a4e9" dependencies = [ - "rustix 0.38.37", + "rustix 0.38.42", "windows-sys 0.59.0", ] @@ -4648,22 +5417,42 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.65" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08f5383f3e0071702bf93ab5ee99b52d26936be9dedd9413067cbdcddcb6141a" +dependencies = [ + "thiserror-impl 2.0.8", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d11abd9594d9b38965ef50805c5e469ca9cc6f197f883f717e0269a3057b3d5" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ - "thiserror-impl", + "proc-macro2", + "quote", + "syn 2.0.90", ] [[package]] name = "thiserror-impl" -version = "1.0.65" +version = "2.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae71770322cbd277e69d762a16c444af02aa0575ac0d174f0b9562d3b37f8602" +checksum = "f2f357fcec90b3caef6623a099691be676d033b40a058ac95d2a6ade6fa0c943" dependencies = [ - "proc-macro2 1.0.89", - "quote 1.0.37", - "syn 2.0.85", + "proc-macro2", + "quote", + "syn 2.0.90", ] [[package]] @@ -4672,8 +5461,8 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "58e6318948b519ba6dc2b442a6d0b904ebfb8d411a3ad3e07843615a72249758" dependencies = [ - "proc-macro2 1.0.89", - "quote 1.0.37", + "proc-macro2", + "quote", "syn 1.0.109", ] @@ -4698,9 +5487,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.36" +version = "0.3.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" dependencies = [ "deranged", "itoa", @@ -4722,14 +5511,33 @@ checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "time-macros" -version = "0.2.18" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de" dependencies = [ "num-conv", "time-core", ] +[[package]] +name = "time-tz" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "733bc522e97980eb421cbf381160ff225bd14262a48a739110f6653c6258d625" +dependencies = [ + "cfg-if 1.0.0", + "js-sys", + "parse-zoneinfo", + "phf", + "phf_codegen", + "serde", + "serde-xml-rs", + "thiserror 1.0.69", + "time", + "wasm-bindgen", + "windows-sys 0.32.0", +] + [[package]] name = "tiny-keccak" version = "2.0.2" @@ -4746,6 +5554,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" dependencies = [ "displaydoc", + "zerovec", ] [[package]] @@ -4765,9 +5574,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.41.0" +version = "1.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145f3413504347a2be84393cc8a7d2fb4d863b375909ea59f2158261aa258bbb" +checksum = "5cec9b21b0450273377fc97bd4c33a8acffc8c996c987a7c5b319a0083707551" dependencies = [ "backtrace", "bytes", @@ -4787,57 +5596,55 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ - "proc-macro2 1.0.89", - "quote 1.0.37", - "syn 2.0.85", + "proc-macro2", + "quote", + "syn 2.0.90", ] [[package]] name = "tokio-rustls" -version = "0.26.0" +version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" +checksum = "5f6d0975eaace0cf0fcadee4e4aaa5da15b5c079146f2cffb67c113be122bf37" dependencies = [ "rustls", - "rustls-pki-types", "tokio", ] [[package]] name = "tokio-rustls-acme" -version = "0.4.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4ee7cbca7da86fa030e33b0deac55bad0e0bf8ab909f1a84666f04447f6339b" +checksum = "3184e8e292a828dd4bca5b2a60aba830ec5ed873a66c9ebb6e65038fa649e827" dependencies = [ "async-trait", "axum-server", - "base64 0.21.7", + "base64 0.22.1", "chrono", "futures", "log", "num-bigint", "pem", - "proc-macro2 1.0.89", + "proc-macro2", "rcgen", "reqwest", "ring", "rustls", "serde", "serde_json", - "thiserror", + "thiserror 2.0.8", "time", "tokio", "tokio-rustls", - "url", "webpki-roots", "x509-parser", ] [[package]] name = "tokio-stream" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f4e6ce100d0eb49a2734f8c0812bcd324cf357d21810932c5df6b96ef2b86f1" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" dependencies = [ "futures-core", "pin-project-lite", @@ -4862,9 +5669,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.12" +version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a" +checksum = "d7fcaa8d55a2bdd6b83ace262b016eca0d79ee02818c5c1bcdf0305114081078" dependencies = [ "bytes", "futures-core", @@ -4892,7 +5699,7 @@ dependencies = [ "serde", "serde_spanned", "toml_datetime", - "toml_edit", + "toml_edit 0.22.22", ] [[package]] @@ -4904,17 +5711,28 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.7.0", + "toml_datetime", + "winnow 0.5.40", +] + [[package]] name = "toml_edit" version = "0.22.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" dependencies = [ - "indexmap 2.6.0", + "indexmap 2.7.0", "serde", "serde_spanned", "toml_datetime", - "winnow", + "winnow 0.6.20", ] [[package]] @@ -4952,14 +5770,14 @@ dependencies = [ [[package]] name = "tower" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2873938d487c3cfb9aed7546dc9f2711d867c9f90c46b889989a2cb84eba6b4f" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper 0.1.2", + "sync_wrapper", "tokio", "tower-layer", "tower-service", @@ -4968,9 +5786,25 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.1" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags 2.6.0", + "bytes", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8437150ab6bbc8c5f0f519e3d5ed4aa883a83dd4cdd3d1b21f9482936046cb97" +checksum = "403fa3b783d4b626a8ad51d766ab03cb6d2dbfc46b1c5d4448395e6628dc9697" dependencies = [ "bitflags 2.6.0", "bytes", @@ -4996,9 +5830,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.40" +version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ "log", "pin-project-lite", @@ -5013,27 +5847,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf" dependencies = [ "crossbeam-channel", - "thiserror", + "thiserror 1.0.69", "time", "tracing-subscriber", ] [[package]] name = "tracing-attributes" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" dependencies = [ - "proc-macro2 1.0.89", - "quote 1.0.37", - "syn 2.0.85", + "proc-macro2", + "quote", + "syn 2.0.90", ] [[package]] name = "tracing-core" -version = "0.1.32" +version = "0.1.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" dependencies = [ "once_cell", "valuable", @@ -5052,9 +5886,9 @@ dependencies = [ [[package]] name = "tracing-serde" -version = "0.1.3" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc6b213177105856957181934e4920de57730fc69bf42c37ee5bb664d406d9e1" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" dependencies = [ "serde", "tracing-core", @@ -5062,9 +5896,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.18" +version = "0.3.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" dependencies = [ "matchers", "nu-ansi-term", @@ -5103,7 +5937,7 @@ dependencies = [ "rustls", "rustls-pki-types", "sha1", - "thiserror", + "thiserror 1.0.69", "utf-8", ] @@ -5118,12 +5952,11 @@ dependencies = [ [[package]] name = "typed-generational-arena" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bf29f9e3fa0ef5fa0fccf55a1c4347c032a196324e152611d5af93641ed64c0" +checksum = "3478ec5cc6caaa9ed86791e8970e320841c3362a7a14b81a5c5c3f9e254b8a44" dependencies = [ "cfg-if 0.1.10", - "derivative", "nonzero_ext", "num-traits", ] @@ -5134,6 +5967,28 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +[[package]] +name = "typeshare" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19be0f411120091e76e13e5a0186d8e2bcc3e7e244afdb70152197f1a8486ceb" +dependencies = [ + "chrono", + "serde", + "serde_json", + "typeshare-annotation", +] + +[[package]] +name = "typeshare-annotation" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a615d6c2764852a2e88a4f16e9ce1ea49bb776b5872956309e170d63a042a34f" +dependencies = [ + "quote", + "syn 2.0.90", +] + [[package]] name = "uint" version = "0.10.0" @@ -5171,17 +6026,11 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e51b68083f157f853b6379db119d1c1be0e6e4dec98101079dec41f6f5cf6df" -[[package]] -name = "unicode-bidi" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ab17db44d7388991a428b2ee655ce0c212e862eff1768a455c58f9aad6e7893" - [[package]] name = "unicode-ident" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" +checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" [[package]] name = "unicode-linebreak" @@ -5189,15 +6038,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" -[[package]] -name = "unicode-normalization" -version = "0.1.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" -dependencies = [ - "tinyvec", -] - [[package]] name = "unicode-segmentation" version = "1.12.0" @@ -5216,12 +6056,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" -[[package]] -name = "unicode-xid" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc72304796d0818e357ead4e000d19c9c174ab23dc11093ac919054d20a6a7fc" - [[package]] name = "universal-hash" version = "0.5.1" @@ -5246,14 +6080,13 @@ checksum = "0200d0fc04d809396c2ad43f3c95da3582a2556eba8d453c1087f4120ee352ff" dependencies = [ "fnv", "lazy_static", - "serde", ] [[package]] name = "url" -version = "2.5.2" +version = "2.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" dependencies = [ "form_urlencoded", "idna", @@ -5282,6 +6115,18 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -5290,11 +6135,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "utoipa" -version = "5.1.3" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d9ba0ade4e2f024cd1842dfbaf9dbc540639fc082299acf7649d71bd14eaca3" +checksum = "68e76d357bc95c7d0939c92c04c9269871a8470eea39cb1f0231eeadb0c47d0f" dependencies = [ - "indexmap 2.6.0", + "indexmap 2.7.0", "serde", "serde_json", "utoipa-gen", @@ -5302,13 +6147,13 @@ dependencies = [ [[package]] name = "utoipa-gen" -version = "5.1.3" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cf390d6503c9c9eac988447c38ba934a707b0b768b14511a493b4fc0e8ecb00" +checksum = "564b03f8044ad6806bdc0d635e88be24967e785eef096df6b2636d2cc1e05d4b" dependencies = [ - "proc-macro2 1.0.89", - "quote 1.0.37", - "syn 2.0.85", + "proc-macro2", + "quote", + "syn 2.0.90", "uuid", ] @@ -5343,22 +6188,30 @@ checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" [[package]] name = "vcard4" -version = "0.5.2" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92713aa20d2ffffdbc8e62cd3ebc14b448fc7d58b00f6976da8cef5419297101" +checksum = "6f37cbf15f76a5cb6bf6a4d7d6004471cfe0974eac80605fee1cb8c837c9df5d" dependencies = [ - "aho-corasick 0.7.20", - "base64 0.21.7", + "aho-corasick", + "base64 0.22.1", + "cfg_eval", "language-tags", "logos", "serde", - "thiserror", + "serde_with", + "thiserror 2.0.8", "time", "unicode-segmentation", "uriparse", "zeroize", ] +[[package]] +name = "version-compare" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" + [[package]] name = "version_check" version = "0.9.5" @@ -5414,9 +6267,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.95" +version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" +checksum = "a474f6281d1d70c17ae7aa6a613c87fce69a127e2624002df63dcb39d6cf6396" dependencies = [ "cfg-if 1.0.0", "once_cell", @@ -5425,59 +6278,59 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.95" +version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" +checksum = "5f89bb38646b4f81674e8f5c3fb81b562be1fd936d84320f3264486418519c79" dependencies = [ "bumpalo", "log", - "once_cell", - "proc-macro2 1.0.89", - "quote 1.0.37", - "syn 2.0.85", + "proc-macro2", + "quote", + "syn 2.0.90", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.45" +version = "0.4.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7ec4f8827a71586374db3e87abdb5a2bb3a15afed140221307c3ec06b1f63b" +checksum = "38176d9b44ea84e9184eff0bc34cc167ed044f816accfe5922e54d84cf48eca2" dependencies = [ "cfg-if 1.0.0", "js-sys", + "once_cell", "wasm-bindgen", "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.95" +version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" +checksum = "2cc6181fd9a7492eef6fef1f33961e3695e4579b9872a6f7c83aee556666d4fe" dependencies = [ - "quote 1.0.37", + "quote", "wasm-bindgen-macro-support", ] [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.95" +version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" +checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2" dependencies = [ - "proc-macro2 1.0.89", - "quote 1.0.37", - "syn 2.0.85", + "proc-macro2", + "quote", + "syn 2.0.90", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.95" +version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" +checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6" [[package]] name = "wasm-streams" @@ -5494,9 +6347,19 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.72" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04dd7223427d52553d3702c004d3b2fe07c148165faa56313cb00211e31c12bc" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6488b90108c040df0fe62fa815cbdee25124641df01814dd7282749234c6112" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", "wasm-bindgen", @@ -5504,9 +6367,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.26.6" +version = "0.26.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841c67bff177718f1d4dfefde8d8f0e78f9b6589319ba88312f567fc5841a958" +checksum = "5d642ff16b7e79272ae451b7322067cdc17cadf68c23264be9d94a32319efe7e" dependencies = [ "rustls-pki-types", ] @@ -5555,11 +6418,12 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows" -version = "0.44.0" +version = "0.56.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e745dab35a0c4c77aa3ce42d595e13d2003d6902d6b08c9ef5fc326d08da12b" +checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132" dependencies = [ - "windows-targets 0.42.2", + "windows-core 0.56.0", + "windows-targets 0.52.6", ] [[package]] @@ -5571,17 +6435,60 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-implement" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "windows-interface" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", +] + [[package]] name = "windows-registry" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" dependencies = [ - "windows-result", + "windows-result 0.2.0", "windows-strings", "windows-targets 0.52.6", ] +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.2.0" @@ -5597,10 +6504,32 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" dependencies = [ - "windows-result", + "windows-result 0.2.0", "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df6e476185f92a12c072be4a189a0210dcdcf512a1891d6dff9edb874deadc6" +dependencies = [ + "windows_aarch64_msvc 0.32.0", + "windows_i686_gnu 0.32.0", + "windows_i686_msvc 0.32.0", + "windows_x86_64_gnu 0.32.0", + "windows_x86_64_msvc 0.32.0", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -5692,6 +6621,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_msvc" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8e92753b1c443191654ec532f14c199742964a061be25d77d7a96f09db20bf5" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -5710,6 +6645,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_i686_gnu" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a711c68811799e017b6038e0922cb27a5e2f43a2ddb609fe0b6f3eeda9de615" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -5734,6 +6675,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_msvc" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c11bb1a02615db74680b32a68e2d61f553cc24c4eb5b4ca10311740e44172" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -5752,6 +6699,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_x86_64_gnu" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c912b12f7454c6620635bbff3450962753834be2a594819bd5e945af18ec64bc" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -5788,6 +6741,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_msvc" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "504a2476202769977a040c6364301a3f65d0cc9e3fb08600b2bda150a0488316" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -5806,6 +6765,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "0.6.20" @@ -5815,6 +6783,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + [[package]] name = "wyz" version = "0.5.1" @@ -5831,7 +6811,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d91ffca73ee7f68ce055750bf9f6eca0780b8c85eff9bc046a3b0da41755e12" dependencies = [ "gethostname", - "rustix 0.38.37", + "rustix 0.38.42", "x11rb-protocol", ] @@ -5866,10 +6846,29 @@ dependencies = [ "nom", "oid-registry", "rusticata-macros", - "thiserror", + "thiserror 1.0.69", "time", ] +[[package]] +name = "xclipboard" +version = "0.16.0" +dependencies = [ + "anyhow", + "arboard", + "rustc_version", + "thiserror 2.0.8", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "xml-rs" +version = "0.8.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea8b391c9a790b496184c29f7f93b9ed5b16abb306c05415b68bcc16e4d06432" + [[package]] name = "yansi" version = "1.0.1" @@ -5885,6 +6884,30 @@ dependencies = [ "time", ] +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.7.35" @@ -5901,9 +6924,30 @@ version = "0.7.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ - "proc-macro2 1.0.89", - "quote 1.0.37", - "syn 2.0.85", + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "zerofrom" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", + "synstructure", ] [[package]] @@ -5921,9 +6965,31 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ - "proc-macro2 1.0.89", - "quote 1.0.37", - "syn 2.0.85", + "proc-macro2", + "quote", + "syn 2.0.90", +] + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.90", ] [[package]] @@ -5935,7 +7001,7 @@ dependencies = [ "chrono", "derive_builder 0.20.2", "fancy-regex", - "itertools 0.13.0", + "itertools", "lazy_static", "regex", "serde", diff --git a/Cargo.toml b/Cargo.toml index 592a5834f4..05346d2124 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,21 +4,36 @@ members = [ "crates/account_extras", "crates/artifact", "crates/cli_helpers", + "crates/clipboard", "crates/keychain_parser", "crates/integration_tests", + "crates/ipc", "crates/net", + "crates/platform_authenticator", "crates/protocol", "crates/sdk", "crates/server", "crates/sos", "crates/test_utils", - "crates/vfs" + "crates/vfs", + "crates/web", ] [workspace.dependencies] +sos-sdk = { version = "0.16", path = "crates/sdk" } +sos-vfs = { version = "0.2.2", path = "crates/vfs" } +xclipboard = { version = "0.16", path = "crates/clipboard" } + +reqwest = { version = "0.12.5", default-features = false, features = ["json", "rustls-tls", "stream"] } +tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"]} +async-stream = "0.3" + +async-fd-lock = "0.2" csv-async = { version = "1", features = ["tokio", "with_serde"] } -thiserror = "1" +thiserror = "2" anyhow = "1" +zstd = "0.13" +flate2 = "1" tracing = "0.1" tracing-subscriber = { version = "0.3.16", features = ["env-filter"] } secrecy = { version = "0.10", features = ["serde"] } @@ -29,6 +44,7 @@ serde_with = { version = "3", features = ["base64"] } tokio-util = { version = "0.7", default-features = false, features = ["io", "compat"] } async-trait = "0.1" async-recursion = "1" +typeshare = "1" http = "1" uuid = { version = "1", features = ["serde", "v4"] } hex = { version = "0.4", features = ["serde"] } @@ -41,6 +57,7 @@ once_cell = "1" rand = "0.8" url = { version = "2", features = ["serde"] } time = { version = "0.3.19", features = ["serde-human-readable", "local-offset", "wasm-bindgen"] } +time-tz = { version = "2", features = ["system"] } futures = "0.3" bs58 = "0.4" urlencoding = "2" @@ -49,17 +66,21 @@ indexmap = { version = "2.2", features = ["serde"] } toml = "0.8" bitflags = { version = "2", features = ["serde"] } enum-iterator = "2" -file-guard = "0.2" tempfile = "3.5" prost = "0.13" clap = { version = "4.3.19", features = ["derive", "wrap_help", "env"] } colored = "2" arboard = { version = "3", default-features = false } zeroize = "1" +bytes = "1.8" +serde_json_path = "0.7" +base64 = "0.22" +notify = { version = "7", features = ["serde"]} axum-server = { version = "0.7", features = ["tls-rustls-no-provider"] } tokio-rustls = { version = "0.26", default-features = false, features = ["tls12"] } rustls = { version = "0.23", default-features = false, features = ["ring"] } +keyring = { version = "3.5", features = ["apple-native", "windows-native", "sync-secret-service"] } [workspace.dependencies.rs_merkle] version = "1.4.2" diff --git a/Makefile.toml b/Makefile.toml index 0cafadf0e1..9009714622 100644 --- a/Makefile.toml +++ b/Makefile.toml @@ -13,11 +13,29 @@ BUILD_TARGET = "${CARGO_MAKE_RUST_TARGET_TRIPLE}" # tests take a while due to so many exec calls RUST_TEST_TIME_INTEGRATION = "120000,240000" +[tasks.typegen] +script = ''' +RUST_LOG=debug typeshare crates/sdk crates/ipc crates/protocol --lang=typescript --output-file=packages/types/types.ts && cat packages/types/preamble.ts packages/types/types.ts > packages/types/index.ts +cd packages/types && npm run fmt && npm run lint +''' + [tasks.format] workspace = true command = "cargo" args = ["fmt"] +[tasks.wasm] +command = "cargo" +args = [ + "check", + "-p", + "sos-web", + "--features", + "account,contacts,search", + "--target", + "wasm32-unknown-unknown", +] + [tasks.format-check] command = "cargo" args = ["fmt", "--all", "--", "--check"] @@ -44,12 +62,21 @@ args = [ "--doc", ] +[tasks.unused] +command = "cargo" +toolchain = "nightly" +args = [ + "udeps", + "--all-targets", +] + [tasks.doc] toolchain = "nightly" command = "cargo" args = [ "doc", "--workspace", + "--all-features", "--open", "--no-deps" ] @@ -99,10 +126,15 @@ command = "cargo" args = ["test", "--all", "--features", "enable-cli-tests"] dependencies = ["clean-cli"] +# build test helper executables +[tasks.build-test] +command = "cargo" +args = ["build", "-p", "sos-integration-tests"] + [tasks.test-lite] command = "cargo" args = ["nextest", "run"] -dependencies = ["clean-cli"] +dependencies = ["clean-cli", "build-test"] [tasks.genhtml] script = ''' diff --git a/crates/account_extras/Cargo.toml b/crates/account_extras/Cargo.toml index 5f2150da85..27188f6c18 100644 --- a/crates/account_extras/Cargo.toml +++ b/crates/account_extras/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sos-account-extras" -version = "0.15.1" +version = "0.16.1" edition = "2021" description = "Extra features for Save Our Secrets local accounts." homepage = "https://saveoursecrets.com" @@ -11,7 +11,6 @@ repository = "https://github.com/saveoursecrets/sdk" preferences = [] security-report = [] system-messages = [] -clipboard = ["dep:arboard", "dep:zeroize"] [dependencies] thiserror.workspace = true @@ -19,17 +18,13 @@ serde.workspace = true serde_json.workspace = true serde_with.workspace = true tokio.workspace = true -once_cell.workspace = true tracing.workspace = true -arboard = { workspace = true, optional = true } -zeroize = { workspace = true, optional = true } [dependencies.sos-sdk] -version = "0.15.1" +version = "0.16" path = "../sdk" [dev-dependencies] -anyhow.workspace = true tokio = { version = "1", features = ["full"] } [build-dependencies] diff --git a/crates/account_extras/src/error.rs b/crates/account_extras/src/error.rs index bf7451a74a..718b5f636a 100644 --- a/crates/account_extras/src/error.rs +++ b/crates/account_extras/src/error.rs @@ -39,9 +39,4 @@ pub enum Error { /// Errors generated by the SDK library. #[error(transparent)] Sdk(#[from] sos_sdk::Error), - - /// Error generated when a preference is not a string. - #[cfg(feature = "clipboard")] - #[error(transparent)] - ArBoard(#[from] arboard::Error), } diff --git a/crates/account_extras/src/lib.rs b/crates/account_extras/src/lib.rs index 26255b4354..8b632636f3 100644 --- a/crates/account_extras/src/lib.rs +++ b/crates/account_extras/src/lib.rs @@ -19,6 +19,3 @@ pub mod security_report; #[cfg(feature = "system-messages")] pub mod system_messages; - -#[cfg(feature = "clipboard")] -pub mod clipboard; diff --git a/crates/account_extras/src/preferences.rs b/crates/account_extras/src/preferences.rs index 88c3cedb64..8a7e641df5 100644 --- a/crates/account_extras/src/preferences.rs +++ b/crates/account_extras/src/preferences.rs @@ -1,11 +1,11 @@ -//! Preferences for each account. +//! Global preferences and account-specific preferences +//! cached in-memory. //! //! Preference are stored as a JSON map //! of named keys to typed data similar to //! the shared preferences provided by an operating //! system library. use crate::{Error, Result}; -use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; use sos_sdk::{ constants::JSON_EXT, identity::PublicIdentity, signer::ecdsa::Address, @@ -17,43 +17,77 @@ use tokio::sync::Mutex; /// File thats stores account-level preferences. pub const PREFERENCES_FILE: &str = "preferences"; -/// Path to the file used to store account-level preferences. -/// -/// # Panics -/// -/// If this set of paths are global (no user identifier). -pub fn preferences_path(paths: &Paths) -> PathBuf { - if paths.is_global() { - panic!("preferences are not accessible for global paths"); - } - let mut vault_path = paths.user_dir().join(PREFERENCES_FILE); - vault_path.set_extension(JSON_EXT); - vault_path +/// Path to the file used to store global or +/// account-level preferences. +fn preferences_path(paths: &Paths) -> PathBuf { + let mut preferences_path = if paths.is_global() { + paths.documents_dir().join(PREFERENCES_FILE) + } else { + paths.user_dir().join(PREFERENCES_FILE) + }; + preferences_path.set_extension(JSON_EXT); + preferences_path } -static CACHE: Lazy>>>> = - Lazy::new(|| Mutex::new(HashMap::new())); - -/// Cache of preferences stored by account address. -pub struct CachedPreferences; +/// Global preferences and account preferences loaded into memory. +pub struct CachedPreferences { + data_dir: Option, + globals: Arc>, + accounts: Mutex>>>, +} impl CachedPreferences { - /// Initialize preferences for each referenced identity. - pub async fn initialize( + /// Create new cached preferences. + pub fn new(data_dir: Option) -> Result { + Ok(Self { + data_dir, + globals: Arc::new(Mutex::new(Default::default())), + accounts: Mutex::new(HashMap::new()), + }) + } + + /// Load global preferences. + pub async fn load_global_preferences(&mut self) -> Result<()> { + let global_dir = if let Some(data_dir) = self.data_dir.clone() { + data_dir + } else { + Paths::data_dir()? + }; + let paths = Paths::new_global(&global_dir); + let file = preferences_path(&paths); + let globals = if vfs::try_exists(&file).await? { + let mut prefs = Preferences::new(&paths); + prefs.load().await?; + prefs + } else { + Preferences::new(&paths) + }; + self.globals = Arc::new(Mutex::new(globals)); + Ok(()) + } + + /// Load and initialize account preferences from disc. + pub async fn load_account_preferences( + &self, accounts: &[PublicIdentity], - data_dir: Option, ) -> Result<()> { for account in accounts { - Self::new_account(account.address(), data_dir.clone()).await?; + self.new_account(account.address()).await?; } Ok(()) } + /// Global preferences for all accounts. + pub fn global_preferences(&self) -> Arc> { + self.globals.clone() + } + /// Preferences for an account. pub async fn account_preferences( + &self, address: &Address, ) -> Option>> { - let cache = CACHE.lock().await; + let cache = self.accounts.lock().await; cache.get(address).map(Arc::clone) } @@ -61,17 +95,14 @@ impl CachedPreferences { /// /// If a preferences file exists for an account it is loaded /// into memory otherwise empty preferences are used. - pub async fn new_account( - address: &Address, - data_dir: Option, - ) -> Result<()> { - let data_dir = if let Some(data_dir) = data_dir { + pub async fn new_account(&self, address: &Address) -> Result<()> { + let data_dir = if let Some(data_dir) = self.data_dir.clone() { data_dir } else { Paths::data_dir()? }; - let mut cache = CACHE.lock().await; + let mut cache = self.accounts.lock().await; let paths = Paths::new(&data_dir, address.to_string()); let file = preferences_path(&paths); let prefs = if vfs::try_exists(&file).await? { @@ -151,7 +182,7 @@ impl From> for Preference { } /// Preferences for an account. -#[derive(Default, Clone, Serialize, Deserialize)] +#[derive(Default, Debug, Clone, Serialize, Deserialize)] pub struct Preferences { /// Preference values. #[serde(flatten)] @@ -180,7 +211,7 @@ impl Preferences { /// If the file does not exist this is a noop. pub async fn load(&mut self) -> Result<()> { if vfs::try_exists(&self.path).await? { - let content = vfs::read(&self.path).await?; + let content = vfs::read_exclusive(&self.path).await?; let prefs: Preferences = serde_json::from_slice(&content)?; self.values = prefs.values; } @@ -312,7 +343,7 @@ impl Preferences { /// Save these preferences to disc. async fn save(&self) -> Result<()> { let buf = serde_json::to_vec_pretty(self)?; - vfs::write(&self.path, buf).await?; + vfs::write_exclusive(&self.path, buf).await?; Ok(()) } } diff --git a/crates/account_extras/src/security_report.rs b/crates/account_extras/src/security_report.rs index 9c53de7406..f7f7824222 100644 --- a/crates/account_extras/src/security_report.rs +++ b/crates/account_extras/src/security_report.rs @@ -36,7 +36,8 @@ where .collect(); for target in targets { - let storage = account.storage().await?; + let storage = + account.storage().await.ok_or(sos_sdk::Error::NoStorage)?; let reader = storage.read().await; let folder = reader.cache().get(target.id()).unwrap(); diff --git a/crates/account_extras/src/system_messages.rs b/crates/account_extras/src/system_messages.rs index 88973e4352..96b4fd61c0 100644 --- a/crates/account_extras/src/system_messages.rs +++ b/crates/account_extras/src/system_messages.rs @@ -190,7 +190,7 @@ impl SystemMessages { /// If the file does not exist this is a noop. pub async fn load(&mut self) -> Result<()> { if vfs::try_exists(&self.path).await? { - let content = vfs::read(&self.path).await?; + let content = vfs::read_exclusive(&self.path).await?; let sys: SystemMessages = serde_json::from_slice(&content)?; self.messages = sys.messages; } @@ -292,14 +292,14 @@ impl SystemMessages { /// Sorted list of system messages. pub fn sorted_list(&self) -> Vec<(&Urn, &SysMessage)> { let mut messages: Vec<_> = self.messages.iter().collect(); - messages.sort_by(|a, b| a.1.partial_cmp(b.1).unwrap()); + messages.sort_by(|a, b| a.1.cmp(b.1)); messages } /// Save system messages to disc. async fn save(&self) -> Result<()> { let buf = serde_json::to_vec_pretty(self)?; - vfs::write(&self.path, buf).await?; + vfs::write_exclusive(&self.path, buf).await?; let _ = self.channel.send(self.counts()); Ok(()) } diff --git a/crates/clipboard/Cargo.toml b/crates/clipboard/Cargo.toml new file mode 100644 index 0000000000..cd4c9862e9 --- /dev/null +++ b/crates/clipboard/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "xclipboard" +version = "0.16.0" +edition = "2021" +description = "Cross-platform clipboard with extra features." +homepage = "https://saveoursecrets.com" +license = "MIT OR Apache-2.0" +repository = "https://github.com/saveoursecrets/sdk" + +[dependencies] +thiserror.workspace = true +tokio.workspace = true +tracing.workspace = true +zeroize = { workspace = true } + +[target.'cfg(all(not(target_os = "android"), not(target_os = "ios")))'.dependencies.arboard] +workspace = true + +[dev-dependencies] +anyhow.workspace = true +tokio = { version = "1", features = ["full"] } + +[build-dependencies] +rustc_version = "0.4.1" diff --git a/crates/clipboard/build.rs b/crates/clipboard/build.rs new file mode 100644 index 0000000000..77ffa17cbb --- /dev/null +++ b/crates/clipboard/build.rs @@ -0,0 +1,20 @@ +use rustc_version::{version_meta, Channel}; + +fn main() { + println!("cargo::rustc-check-cfg=cfg(CHANNEL_NIGHTLY)"); + println!("cargo::rustc-check-cfg=cfg(NOT_CI)"); + + // Set cfg flags depending on release channel + let channel = match version_meta().unwrap().channel { + Channel::Stable => "CHANNEL_STABLE", + Channel::Beta => "CHANNEL_BETA", + Channel::Nightly => "CHANNEL_NIGHTLY", + Channel::Dev => "CHANNEL_DEV", + }; + println!("cargo:rustc-cfg={}", channel); + if option_env!("CI").is_some() { + println!("cargo:rustc-cfg={}", "CI") + } else { + println!("cargo:rustc-cfg={}", "NOT_CI") + } +} diff --git a/crates/clipboard/src/android.rs b/crates/clipboard/src/android.rs new file mode 100644 index 0000000000..8dbf5cf9a7 --- /dev/null +++ b/crates/clipboard/src/android.rs @@ -0,0 +1,72 @@ +//! Access to the native system clipboard. +use crate::Result; +use std::{borrow::Cow, sync::Arc}; +use tokio::{ + sync::Mutex, + time::{sleep, Duration}, +}; +use zeroize::Zeroize; + +/// Native system clipboard. +#[derive(Clone)] +pub struct Clipboard { + timeout_seconds: u16, +} + +impl Clipboard { + /// Create a native clipboard using the default + /// timeout of 90 seconds. + pub fn new() -> Result { + Self::new_timeout(90) + } + + /// Create a native clipboard with a timeout. + pub fn new_timeout(timeout_seconds: u16) -> Result { + Ok(Self { timeout_seconds }) + } + + /// Fetches UTF-8 text from the clipboard and returns it. + /// + /// # Errors + /// + /// Returns error if clipboard is empty or contents are not UTF-8 text. + pub async fn get_text(&self) -> Result { + unimplemented!(); + } + + /// Places the text onto the clipboard. Any valid UTF-8 + /// string is accepted. + /// + /// # Errors + /// + /// Returns error if text failed to be stored on the clipboard. + pub async fn set_text<'a, T: Into>>( + &self, + text: T, + ) -> Result<()> { + unimplemented!(); + } + + /// Clears any contents that may be present from the + /// platform's default clipboard, regardless of the format of the data. + /// + /// # Errors + /// + /// Returns error on Windows or Linux if clipboard cannot be cleared. + pub async fn clear(&self) -> Result<()> { + unimplemented!(); + } + + /// Places text on to the clipboard and sets a timeout to clear + /// the text from the clipboard. + /// + /// The text is only cleared if the clipboard contents match the + /// initial value to allow for the user changing the clipboard + /// content elsewhere whilst the the timeout is active. + pub async fn set_text_timeout<'a, T: Into>>( + &self, + text: T, + ) -> Result<()> { + unimplemented!(); + } +} diff --git a/crates/account_extras/src/clipboard.rs b/crates/clipboard/src/desktop.rs similarity index 82% rename from crates/account_extras/src/clipboard.rs rename to crates/clipboard/src/desktop.rs index c82fa6e14f..771b91f5db 100644 --- a/crates/account_extras/src/clipboard.rs +++ b/crates/clipboard/src/desktop.rs @@ -1,6 +1,5 @@ //! Access to the native system clipboard. use crate::Result; -use arboard::Clipboard; use std::{borrow::Cow, sync::Arc}; use tokio::{ sync::Mutex, @@ -14,12 +13,13 @@ use zeroize::Zeroize; /// setting clipboard text with a timeout to clear the clipboard /// content. /// -pub struct NativeClipboard { - clipboard: Clipboard, +#[derive(Clone)] +pub struct Clipboard { + clipboard: Arc>, timeout_seconds: u16, } -impl NativeClipboard { +impl Clipboard { /// Create a native clipboard using the default /// timeout of 90 seconds. pub fn new() -> Result { @@ -29,7 +29,7 @@ impl NativeClipboard { /// Create a native clipboard with a timeout. pub fn new_timeout(timeout_seconds: u16) -> Result { Ok(Self { - clipboard: Clipboard::new()?, + clipboard: Arc::new(Mutex::new(arboard::Clipboard::new()?)), timeout_seconds, }) } @@ -39,8 +39,9 @@ impl NativeClipboard { /// # Errors /// /// Returns error if clipboard is empty or contents are not UTF-8 text. - pub fn get_text(&mut self) -> Result { - Ok(self.clipboard.get_text()?) + pub async fn get_text(&self) -> Result { + let mut clipboard = self.clipboard.lock().await; + Ok(clipboard.get_text()?) } /// Places the text onto the clipboard. Any valid UTF-8 @@ -49,11 +50,12 @@ impl NativeClipboard { /// # Errors /// /// Returns error if text failed to be stored on the clipboard. - pub fn set_text<'a, T: Into>>( - &mut self, + pub async fn set_text<'a, T: Into>>( + &self, text: T, ) -> Result<()> { - Ok(self.clipboard.set_text(text)?) + let mut clipboard = self.clipboard.lock().await; + Ok(clipboard.set_text(text)?) } /// Clears any contents that may be present from the @@ -62,8 +64,9 @@ impl NativeClipboard { /// # Errors /// /// Returns error on Windows or Linux if clipboard cannot be cleared. - pub fn clear(&mut self) -> Result<()> { - Ok(self.clipboard.clear()?) + pub async fn clear(&self) -> Result<()> { + let mut clipboard = self.clipboard.lock().await; + Ok(clipboard.clear()?) } /// Places text on to the clipboard and sets a timeout to clear @@ -73,7 +76,7 @@ impl NativeClipboard { /// initial value to allow for the user changing the clipboard /// content elsewhere whilst the the timeout is active. pub async fn set_text_timeout<'a, T: Into>>( - &mut self, + &self, text: T, ) -> Result<()> { let text: Cow<'a, str> = text.into(); @@ -82,12 +85,12 @@ impl NativeClipboard { let source_text: Arc> = Arc::new(Mutex::new(text.clone().into_owned())); - self.set_text(text)?; + self.set_text(text).await?; tokio::task::spawn(async move { sleep(Duration::from_secs(seconds.into())).await; - match Clipboard::new() { + match arboard::Clipboard::new() { Ok(mut clipboard) => match clipboard.get_text() { Ok(text) => { let mut reader = source_text.lock().await; diff --git a/crates/clipboard/src/error.rs b/crates/clipboard/src/error.rs new file mode 100644 index 0000000000..b5f0fb8d98 --- /dev/null +++ b/crates/clipboard/src/error.rs @@ -0,0 +1,10 @@ +use thiserror::Error; + +/// Error type for the clipboard library. +#[derive(Debug, Error)] +pub enum Error { + /// Error generated by the desktop clipboard library. + #[cfg(all(not(target_os = "android"), not(target_os = "ios")))] + #[error(transparent)] + ArBoard(#[from] arboard::Error), +} diff --git a/crates/clipboard/src/ios.rs b/crates/clipboard/src/ios.rs new file mode 100644 index 0000000000..8dbf5cf9a7 --- /dev/null +++ b/crates/clipboard/src/ios.rs @@ -0,0 +1,72 @@ +//! Access to the native system clipboard. +use crate::Result; +use std::{borrow::Cow, sync::Arc}; +use tokio::{ + sync::Mutex, + time::{sleep, Duration}, +}; +use zeroize::Zeroize; + +/// Native system clipboard. +#[derive(Clone)] +pub struct Clipboard { + timeout_seconds: u16, +} + +impl Clipboard { + /// Create a native clipboard using the default + /// timeout of 90 seconds. + pub fn new() -> Result { + Self::new_timeout(90) + } + + /// Create a native clipboard with a timeout. + pub fn new_timeout(timeout_seconds: u16) -> Result { + Ok(Self { timeout_seconds }) + } + + /// Fetches UTF-8 text from the clipboard and returns it. + /// + /// # Errors + /// + /// Returns error if clipboard is empty or contents are not UTF-8 text. + pub async fn get_text(&self) -> Result { + unimplemented!(); + } + + /// Places the text onto the clipboard. Any valid UTF-8 + /// string is accepted. + /// + /// # Errors + /// + /// Returns error if text failed to be stored on the clipboard. + pub async fn set_text<'a, T: Into>>( + &self, + text: T, + ) -> Result<()> { + unimplemented!(); + } + + /// Clears any contents that may be present from the + /// platform's default clipboard, regardless of the format of the data. + /// + /// # Errors + /// + /// Returns error on Windows or Linux if clipboard cannot be cleared. + pub async fn clear(&self) -> Result<()> { + unimplemented!(); + } + + /// Places text on to the clipboard and sets a timeout to clear + /// the text from the clipboard. + /// + /// The text is only cleared if the clipboard contents match the + /// initial value to allow for the user changing the clipboard + /// content elsewhere whilst the the timeout is active. + pub async fn set_text_timeout<'a, T: Into>>( + &self, + text: T, + ) -> Result<()> { + unimplemented!(); + } +} diff --git a/crates/clipboard/src/lib.rs b/crates/clipboard/src/lib.rs new file mode 100644 index 0000000000..d3ac6ab29a --- /dev/null +++ b/crates/clipboard/src/lib.rs @@ -0,0 +1,31 @@ +//! Cross-platform keyboard with extra features to time out +//! sensitive data and listen for clipboard change events. +#![deny(missing_docs)] +#![forbid(unsafe_code)] +#![cfg_attr(all(doc, CHANNEL_NIGHTLY), feature(doc_auto_cfg))] + +mod error; + +/// Errors generated by the clipboard library. +pub use error::Error; + +/// Result type for the clipboard library. +pub type Result = std::result::Result; + +#[cfg(all(not(target_os = "android"), not(target_os = "ios")))] +mod desktop; + +#[cfg(all(not(target_os = "android"), not(target_os = "ios")))] +pub use desktop::*; + +#[cfg(target_os = "android")] +mod android; + +#[cfg(target_os = "android")] +pub use android::*; + +#[cfg(target_os = "ios")] +mod ios; + +#[cfg(target_os = "ios")] +pub use ios::*; diff --git a/crates/account_extras/tests/clipboard.rs b/crates/clipboard/tests/clipboard.rs similarity index 67% rename from crates/account_extras/tests/clipboard.rs rename to crates/clipboard/tests/clipboard.rs index ecb6ec7703..3f4f1c12fe 100644 --- a/crates/account_extras/tests/clipboard.rs +++ b/crates/clipboard/tests/clipboard.rs @@ -1,6 +1,6 @@ use anyhow::Result; -#[cfg(all(feature = "clipboard", NOT_CI))] +#[cfg(NOT_CI)] #[tokio::test] async fn clipboard() -> Result<()> { // NOTE: we must run these tests in serial @@ -12,48 +12,50 @@ async fn clipboard() -> Result<()> { Ok(()) } -#[cfg(feature = "clipboard")] async fn clipboard_timeout() -> Result<()> { - use sos_account_extras::clipboard::NativeClipboard; use std::time::Duration; + use xclipboard::Clipboard; - let mut clipboard = NativeClipboard::new_timeout(1)?; + let clipboard = Clipboard::new_timeout(1)?; let text = "mock-secret"; clipboard.set_text_timeout(text).await?; - let value = clipboard.get_text()?; + let value = clipboard.get_text().await?; assert_eq!(text, value); tokio::time::sleep(Duration::from_secs(2)).await; // Should error when the clipboard is empty - assert!(clipboard.get_text().is_err()); + #[cfg(not(target_os = "linux"))] + assert!(clipboard.get_text().await.is_err()); + + #[cfg(target_os = "linux")] + assert_eq!(String::new(), clipboard.get_text().await?); Ok(()) } -#[cfg(feature = "clipboard")] async fn clipboard_timeout_preserve() -> Result<()> { - use sos_account_extras::clipboard::NativeClipboard; use std::time::Duration; + use xclipboard::Clipboard; - let mut clipboard = NativeClipboard::new_timeout(1)?; + let clipboard = Clipboard::new_timeout(1)?; let text = "mock-secret"; let other_value = "mock-value"; clipboard.set_text_timeout(text).await?; - let value = clipboard.get_text()?; + let value = clipboard.get_text().await?; assert_eq!(text, value); // Set to another value so the clipboard will not // be cleared on timeout - clipboard.set_text(other_value)?; + clipboard.set_text(other_value).await?; tokio::time::sleep(Duration::from_secs(2)).await; // Verify the clipboard was not cleared on timeout - let value = clipboard.get_text()?; + let value = clipboard.get_text().await?; assert_eq!(other_value, value); Ok(()) diff --git a/crates/integration_tests/Cargo.toml b/crates/integration_tests/Cargo.toml index 5e327f6915..0f6f10f039 100644 --- a/crates/integration_tests/Cargo.toml +++ b/crates/integration_tests/Cargo.toml @@ -9,35 +9,80 @@ publish = false default = [] enable-cli-tests = [] +[dependencies] +tokio.workspace = true +http.workspace = true +anyhow.workspace = true + +[dependencies.sos-ipc] +features = [ + "extension-helper-server", + "extension-helper-client", + "account", + "archive", + "contacts", + "migrate", + "search", + "files", +] +path = "../ipc" + +[dependencies.sos-account-extras] +features = ["preferences"] +path = "../account_extras/" + +[dependencies.sos-sdk] +path = "../sdk" + +# must enable matching features here +[dependencies.sos-web] +path = "../web" +features = [ + "account", + "archive", + "clipboard", + "contacts", + "files", + "migrate", + "search", +] + [dev-dependencies] binary-stream.workspace = true tracing.workspace = true tracing-subscriber.workspace = true serde_json.workspace = true +async-trait.workspace = true thiserror.workspace = true async-recursion.workspace = true futures.workspace = true parking_lot.workspace = true once_cell.workspace = true serde.workspace = true -clap.workspace = true indexmap.workspace = true -tokio.workspace = true anyhow.workspace = true secrecy.workspace = true http.workspace = true -sos-server = { path = "../server" } -sos_test_utils = { path = "../test_utils" } - copy_dir = "0.1" maplit2 = "1" tempfile = "3.5" -kdam = { version = "0.5", features = ["rich", "spinner"] } pretty_assertions = "1.4" -anticipate-runner = { version = "0.5.1" } +sos-test-utils = { path = "../test_utils" } [dev-dependencies.sos-net] features = ["full"] path = "../net" +[dev-dependencies.sos-server] +default-features = false +features = ["listen", "audit", "pairing"] +path = "../server" + +[[bin]] +name = "test-extension-helper" +path = "src/test_extension_helper.rs" + +[[bin]] +name = "test-preferences-concurrency" +path = "src/test_preferences_concurrency.rs" diff --git a/crates/integration_tests/src/lib.rs b/crates/integration_tests/src/lib.rs index 925d628f3b..8443c82753 100644 --- a/crates/integration_tests/src/lib.rs +++ b/crates/integration_tests/src/lib.rs @@ -1,2 +1 @@ //! Stub file. - diff --git a/crates/integration_tests/src/test_extension_helper.rs b/crates/integration_tests/src/test_extension_helper.rs new file mode 100644 index 0000000000..04ad11843d --- /dev/null +++ b/crates/integration_tests/src/test_extension_helper.rs @@ -0,0 +1,67 @@ +use sos_ipc::{ + extension_helper::server::{ + ExtensionHelperOptions, ExtensionHelperServer, + }, + ServiceAppInfo, +}; +use sos_sdk::prelude::{ + AccountSwitcherOptions, LocalAccount, LocalAccountSwitcher, Paths, +}; +use std::{path::PathBuf, sync::Arc}; +use tokio::sync::RwLock; + +#[macro_export] +#[allow(missing_fragment_specifier)] +macro_rules! println { + ($($any:tt)*) => { + compile_error!("println! macro is forbidden, use eprintln! instead"); + }; +} + +/// Executable used to test the native bridge. +#[doc(hidden)] +#[tokio::main] +pub async fn main() -> anyhow::Result<()> { + let mut args = std::env::args().into_iter().collect::>(); + + // Callers must pass a data directory so that each + // test is isolated + let data_dir = args.pop().map(PathBuf::from); + // Extension identifier is a mock value but mimics the argument + // that browser's will pass + let extension_id = args.pop().unwrap_or_else(String::new).to_string(); + + // Load any accounts on disc + let paths = Paths::new_global(data_dir.as_ref().unwrap()); + let options = AccountSwitcherOptions { + paths: Some(paths), + ..Default::default() + }; + let mut accounts = LocalAccountSwitcher::new_with_options(options); + accounts + .load_accounts( + |identity| { + let app_dir = data_dir.clone(); + Box::pin(async move { + Ok(LocalAccount::new_unauthenticated( + *identity.address(), + app_dir, + ) + .await?) + }) + }, + data_dir.clone(), + ) + .await?; + + // Start the server + let info = ServiceAppInfo { + name: "test_extension_helper".to_string(), + version: "0.0.0".to_string(), + }; + let accounts = Arc::new(RwLock::new(accounts)); + let options = ExtensionHelperOptions::new(extension_id, info); + let server = ExtensionHelperServer::new(options, accounts).await?; + server.listen().await; + Ok(()) +} diff --git a/crates/integration_tests/src/test_preferences_concurrency.rs b/crates/integration_tests/src/test_preferences_concurrency.rs new file mode 100644 index 0000000000..43d671ad19 --- /dev/null +++ b/crates/integration_tests/src/test_preferences_concurrency.rs @@ -0,0 +1,28 @@ +use sos_account_extras::preferences::CachedPreferences; +use std::path::PathBuf; + +/// Helper executable used to test concurrent writes +/// to the global preferences file. +#[doc(hidden)] +#[tokio::main] +pub async fn main() -> anyhow::Result<()> { + let mut args = std::env::args().into_iter().collect::>(); + + // Callers must pass a data directory so that each + // test is isolated + let data_dir = args.pop().map(PathBuf::from); + + // Value to write + let value = args.pop().unwrap(); + + let mut preferences = CachedPreferences::new(data_dir)?; + preferences.load_global_preferences().await?; + + let prefs = preferences.global_preferences(); + let mut prefs = prefs.lock().await; + prefs + .insert("concurrent.string".to_owned(), value.into()) + .await?; + + Ok(()) +} diff --git a/crates/integration_tests/tests/access_control/allow.rs b/crates/integration_tests/tests/access_control/allow.rs index bc3dd6d8ea..7f7c9b8754 100644 --- a/crates/integration_tests/tests/access_control/allow.rs +++ b/crates/integration_tests/tests/access_control/allow.rs @@ -6,7 +6,9 @@ use crate::test_utils::{ }; use http::StatusCode; use sos_net::{ - sdk::prelude::*, AccountSync, Error as ClientError, NetworkAccount, + protocol::{AccountSync, Error as ProtocolError, NetworkError}, + sdk::prelude::*, + Error as ClientError, NetworkAccount, }; use sos_server::AccessControlConfig; @@ -56,7 +58,9 @@ async fn access_control_allow() -> Result<()> { if let Some(err) = sync_error.first_error() { assert!(matches!( err, - ClientError::ResponseCode(StatusCode::FORBIDDEN) + ClientError::Protocol(ProtocolError::Network( + NetworkError::ResponseCode(StatusCode::FORBIDDEN) + )) )); } else { panic!("expecting multiple sync error (forbidden)"); diff --git a/crates/integration_tests/tests/access_control/deny.rs b/crates/integration_tests/tests/access_control/deny.rs index b335663ea8..983b41ec5a 100644 --- a/crates/integration_tests/tests/access_control/deny.rs +++ b/crates/integration_tests/tests/access_control/deny.rs @@ -6,7 +6,9 @@ use crate::test_utils::{ }; use http::StatusCode; use sos_net::{ - sdk::prelude::*, AccountSync, Error as ClientError, NetworkAccount, + protocol::{AccountSync, Error as ProtocolError, NetworkError}, + sdk::prelude::*, + Error as ClientError, NetworkAccount, }; use sos_server::AccessControlConfig; @@ -55,7 +57,9 @@ async fn access_control_deny() -> Result<()> { if let Some(err) = sync_error.first_error() { assert!(matches!( err, - ClientError::ResponseCode(StatusCode::FORBIDDEN) + ClientError::Protocol(ProtocolError::Network( + NetworkError::ResponseCode(StatusCode::FORBIDDEN) + )) )); } else { panic!("expecting multiple sync error (forbidden)"); diff --git a/crates/integration_tests/tests/auto_merge/create_secrets.rs b/crates/integration_tests/tests/auto_merge/create_secrets.rs index acd859ef8a..0fc440a4fa 100644 --- a/crates/integration_tests/tests/auto_merge/create_secrets.rs +++ b/crates/integration_tests/tests/auto_merge/create_secrets.rs @@ -3,7 +3,7 @@ use crate::test_utils::{ teardown, }; use anyhow::Result; -use sos_net::{sdk::prelude::*, AccountSync}; +use sos_net::{protocol::AccountSync, sdk::prelude::*}; /// Tests making conflicting changes to a folder whilst /// a server is offline and resolving the conflicts with diff --git a/crates/integration_tests/tests/auto_merge/delete_secrets.rs b/crates/integration_tests/tests/auto_merge/delete_secrets.rs index 1b57447f0c..f8598807d2 100644 --- a/crates/integration_tests/tests/auto_merge/delete_secrets.rs +++ b/crates/integration_tests/tests/auto_merge/delete_secrets.rs @@ -3,7 +3,7 @@ use crate::test_utils::{ teardown, }; use anyhow::Result; -use sos_net::{sdk::prelude::*, AccountSync}; +use sos_net::{protocol::AccountSync, sdk::prelude::*}; /// Tests making deletes to a folder whilst /// a server is offline and resolving the conflicts with diff --git a/crates/integration_tests/tests/auto_merge/edit_secrets.rs b/crates/integration_tests/tests/auto_merge/edit_secrets.rs index 5c47c30146..cde2cd15f5 100644 --- a/crates/integration_tests/tests/auto_merge/edit_secrets.rs +++ b/crates/integration_tests/tests/auto_merge/edit_secrets.rs @@ -3,7 +3,7 @@ use crate::test_utils::{ teardown, }; use anyhow::Result; -use sos_net::{sdk::prelude::*, AccountSync}; +use sos_net::{protocol::AccountSync, sdk::prelude::*}; /// Tests making conflicting edits to a folder whilst /// a server is offline and resolving the conflicts with diff --git a/crates/integration_tests/tests/auto_merge/scan_commits.rs b/crates/integration_tests/tests/auto_merge/scan_commits.rs index e9e42b69e7..38d69678f6 100644 --- a/crates/integration_tests/tests/auto_merge/scan_commits.rs +++ b/crates/integration_tests/tests/auto_merge/scan_commits.rs @@ -1,9 +1,8 @@ use crate::test_utils::{mock, simulate_device, spawn, teardown}; use anyhow::Result; use sos_net::{ - protocol::{EventLogType, ScanRequest}, + protocol::{EventLogType, RemoteSyncHandler, ScanRequest, SyncClient}, sdk::prelude::*, - SyncClient, }; /// Tests scanning commit hashes on remote servers. @@ -18,6 +17,7 @@ async fn auto_merge_scan_commits() -> Result<()> { // Prepare a mock device let mut device = simulate_device(TEST_ID, 1, Some(&server)).await?; let origin = device.origin.clone(); + let address = device.owner.address().clone(); let default_folder = device.default_folder.clone(); // Create some commits to a folder so it has 4 total @@ -60,7 +60,7 @@ async fn auto_merge_scan_commits() -> Result<()> { limit: 1, offset: 0, }; - let res = client.scan(req).await?; + let res = client.scan(&address, req).await?; assert_eq!(1, res.proofs.len()); // Get commit proofs of the account event log @@ -69,7 +69,7 @@ async fn auto_merge_scan_commits() -> Result<()> { limit: 256, offset: 0, }; - let res = client.scan(req).await?; + let res = client.scan(&address, req).await?; assert!(!res.proofs.is_empty()); // Get commit proofs of the device event log @@ -78,7 +78,7 @@ async fn auto_merge_scan_commits() -> Result<()> { limit: 256, offset: 0, }; - let res = client.scan(req).await?; + let res = client.scan(&address, req).await?; assert!(!res.proofs.is_empty()); // Get commit proofs of the files event log @@ -87,7 +87,7 @@ async fn auto_merge_scan_commits() -> Result<()> { limit: 256, offset: 0, }; - let res = client.scan(req).await?; + let res = client.scan(&address, req).await?; // No files yet! assert!(res.proofs.is_empty()); @@ -102,7 +102,7 @@ async fn auto_merge_scan_commits() -> Result<()> { limit: 256, offset: 0, }; - let folder_desc = client.scan(req).await?; + let folder_desc = client.scan(&address, req).await?; assert_eq!(4, folder_desc.proofs.len()); for proof in &folder_desc.proofs { let comparison = event_log.tree().compare(proof)?; @@ -119,7 +119,7 @@ async fn auto_merge_scan_commits() -> Result<()> { limit: 256, offset: 0, }; - let folder_asc = client.scan(req).await?; + let folder_asc = client.scan(&address, req).await?; assert_eq!(4, folder_asc.proofs.len()); // Scan in chunks of 2 from the end @@ -128,11 +128,11 @@ async fn auto_merge_scan_commits() -> Result<()> { limit: 2, offset: 0, }; - let folder_chunk_1 = client.scan(req.clone()).await?; + let folder_chunk_1 = client.scan(&address, req.clone()).await?; assert_eq!(2, folder_chunk_1.offset); // Scan next chunk req.offset = folder_chunk_1.offset; - let folder_chunk_2 = client.scan(req).await?; + let folder_chunk_2 = client.scan(&address, req).await?; assert_eq!(4, folder_chunk_2.offset); // Collect all the server proofs scanned @@ -156,7 +156,7 @@ async fn auto_merge_scan_commits() -> Result<()> { limit: 256, offset: 64, }; - let res = client.scan(req).await?; + let res = client.scan(&address, req).await?; assert!(res.proofs.is_empty()); device.owner.sign_out().await?; diff --git a/crates/integration_tests/tests/event_log/compact_events.rs b/crates/integration_tests/tests/event_log/compact_events.rs index 37c3420885..55db1a0e59 100644 --- a/crates/integration_tests/tests/event_log/compact_events.rs +++ b/crates/integration_tests/tests/event_log/compact_events.rs @@ -58,7 +58,7 @@ async fn event_log_compact() -> Result<()> { // Check the in-memory commit tree let new_root = { - let storage = account.storage().await?; + let storage = account.storage().await.unwrap(); let reader = storage.read().await; let folder = reader.cache().get(default_folder.id()).unwrap(); let event_log = folder.event_log(); diff --git a/crates/integration_tests/tests/file_transfers/multi_server.rs b/crates/integration_tests/tests/file_transfers/multi_server.rs index 3860542264..da3d8eb8ad 100644 --- a/crates/integration_tests/tests/file_transfers/multi_server.rs +++ b/crates/integration_tests/tests/file_transfers/multi_server.rs @@ -7,7 +7,7 @@ use crate::test_utils::{ mock::files::{create_file_secret, update_file_secret}, simulate_device, spawn, teardown, wait_for_num_transfers, }; -use sos_net::{sdk::prelude::*, AccountSync}; +use sos_net::{protocol::AccountSync, sdk::prelude::*}; /// Tests uploading an external file to multiple servers. #[tokio::test] diff --git a/crates/integration_tests/tests/file_transfers/offline_multi.rs b/crates/integration_tests/tests/file_transfers/offline_multi.rs index 27bfbbf67e..78d39f0ed0 100644 --- a/crates/integration_tests/tests/file_transfers/offline_multi.rs +++ b/crates/integration_tests/tests/file_transfers/offline_multi.rs @@ -8,7 +8,7 @@ use crate::test_utils::{ mock::files::{create_file_secret, update_file_secret}, simulate_device, spawn, teardown, wait_for_file, wait_for_file_not_exist, }; -use sos_net::{sdk::prelude::*, AccountSync}; +use sos_net::{protocol::AccountSync, sdk::prelude::*}; /// Tests uploading an external file to multiple servers /// when the first server is offline. diff --git a/crates/integration_tests/tests/file_transfers/single_server.rs b/crates/integration_tests/tests/file_transfers/single_server.rs index c2f6be75dc..9bc50b4772 100644 --- a/crates/integration_tests/tests/file_transfers/single_server.rs +++ b/crates/integration_tests/tests/file_transfers/single_server.rs @@ -7,7 +7,7 @@ use crate::test_utils::{ mock::files::{create_file_secret, update_file_secret}, simulate_device, spawn, teardown, wait_for_num_transfers, }; -use sos_net::{sdk::prelude::*, AccountSync}; +use sos_net::{protocol::AccountSync, sdk::prelude::*}; /// Tests uploading an external file. #[tokio::test] diff --git a/crates/integration_tests/tests/integrity/account_integrity.rs b/crates/integration_tests/tests/integrity/account_integrity.rs index 5e6e2767e3..c6bb58b206 100644 --- a/crates/integration_tests/tests/integrity/account_integrity.rs +++ b/crates/integration_tests/tests/integrity/account_integrity.rs @@ -274,7 +274,11 @@ async fn account_integrity_cancel() -> Result<()> { match event { FolderIntegrityEvent::OpenFolder(_) => { canceled = true; - cancel_tx.send(()).unwrap(); + // The process may have already completed + // and the cancel receiver may have already + // been dropped which would cause the send() + // to fail + let _ = cancel_tx.send(()); } _ => {} } diff --git a/crates/integration_tests/tests/ipc/extension_helper_chunks.rs b/crates/integration_tests/tests/ipc/extension_helper_chunks.rs new file mode 100644 index 0000000000..232731e65c --- /dev/null +++ b/crates/integration_tests/tests/ipc/extension_helper_chunks.rs @@ -0,0 +1,35 @@ +use anyhow::Result; +use http::StatusCode; +use sos_ipc::{ + extension_helper::client::ExtensionHelperClient, + local_transport::{HttpMessage, LocalRequest}, +}; +use sos_sdk::prelude::Paths; +use sos_test_utils::{setup, teardown}; + +/// Test to verify the chunking logic for large responses +/// from the server. +#[tokio::test] +async fn integration_ipc_extension_helper_chunks() -> Result<()> { + const TEST_ID: &str = "ipc_extension_helper_chunks"; + // crate::test_utils::init_tracing(); + + let mut dirs = setup(TEST_ID, 1).await?; + let data_dir = dirs.clients.remove(0); + Paths::scaffold(Some(data_dir.clone())).await?; + let data_dir = data_dir.display().to_string(); + + let request = LocalRequest::get("/large-file".parse().unwrap()); + + let (command, arguments) = super::extension_helper_cmd(&data_dir); + let mut client = ExtensionHelperClient::new(command, arguments).await?; + let response = client.send(request).await?; + assert_eq!(StatusCode::OK, response.status().unwrap()); + assert_eq!(1, response.request_id()); + + client.kill().await?; + + teardown(TEST_ID).await; + + Ok(()) +} diff --git a/crates/integration_tests/tests/ipc/extension_helper_info.rs b/crates/integration_tests/tests/ipc/extension_helper_info.rs new file mode 100644 index 0000000000..42ffab8c8d --- /dev/null +++ b/crates/integration_tests/tests/ipc/extension_helper_info.rs @@ -0,0 +1,37 @@ +use anyhow::Result; +use http::StatusCode; +use sos_ipc::{ + extension_helper::client::ExtensionHelperClient, + local_transport::{HttpMessage, LocalRequest}, + ServiceAppInfo, +}; +use sos_sdk::prelude::Paths; +use sos_test_utils::{setup, teardown}; + +/// Tests getting app info using a GET request to the / endpoint. +#[tokio::test] +async fn integration_ipc_extension_helper_info() -> Result<()> { + const TEST_ID: &str = "ipc_extension_helper_info"; + // crate::test_utils::init_tracing(); + + let mut dirs = setup(TEST_ID, 1).await?; + let data_dir = dirs.clients.remove(0); + Paths::scaffold(Some(data_dir.clone())).await?; + let data_dir = data_dir.display().to_string(); + + let request = LocalRequest::get("/".parse().unwrap()); + let (command, arguments) = super::extension_helper_cmd(&data_dir); + let mut client = ExtensionHelperClient::new(command, arguments).await?; + let response = client.send(request).await?; + assert_eq!(StatusCode::OK, response.status().unwrap()); + assert_eq!(1, response.request_id()); + + let info: ServiceAppInfo = serde_json::from_slice(response.body())?; + assert_eq!("test_extension_helper", info.name); + + client.kill().await?; + + teardown(TEST_ID).await; + + Ok(()) +} diff --git a/crates/integration_tests/tests/ipc/extension_helper_list_accounts.rs b/crates/integration_tests/tests/ipc/extension_helper_list_accounts.rs new file mode 100644 index 0000000000..80015a273f --- /dev/null +++ b/crates/integration_tests/tests/ipc/extension_helper_list_accounts.rs @@ -0,0 +1,85 @@ +use anyhow::Result; +use http::StatusCode; +use sos_ipc::{ + extension_helper::client::ExtensionHelperClient, + local_transport::{HttpMessage, LocalRequest}, +}; +use sos_sdk::prelude::{ + generate_passphrase, LocalAccount, Paths, PublicIdentity, +}; + +use sos_test_utils::{setup, teardown}; + +/// Test listing accounts via the native bridge when there +/// are no accounts present. +#[tokio::test] +async fn integration_ipc_extension_helper_list_accounts_empty() -> Result<()> +{ + const TEST_ID: &str = "ipc_extension_helper_list_accounts_empty"; + // crate::test_utils::init_tracing(); + + let mut dirs = setup(TEST_ID, 1).await?; + let data_dir = dirs.clients.remove(0); + Paths::scaffold(Some(data_dir.clone())).await?; + let data_dir = data_dir.display().to_string(); + + let request = LocalRequest::get("/accounts".parse().unwrap()); + + let (command, arguments) = super::extension_helper_cmd(&data_dir); + let mut client = ExtensionHelperClient::new(command, arguments).await?; + let response = client.send(request).await?; + assert_eq!(StatusCode::OK, response.status().unwrap()); + assert_eq!(1, response.request_id()); + assert!(response.is_json()); + + // No accounts configured for this test spec + let accounts: Vec = + serde_json::from_slice(response.body())?; + assert_eq!(0, accounts.len()); + + client.kill().await?; + + teardown(TEST_ID).await; + + Ok(()) +} + +/// Test listing accounts via the native bridge. +#[tokio::test] +async fn integration_ipc_extension_helper_list_accounts() -> Result<()> { + const TEST_ID: &str = "ipc_extension_helper_list_accounts"; + // crate::test_utils::init_tracing(); + + let mut dirs = setup(TEST_ID, 1).await?; + let data_dir = dirs.clients.remove(0); + Paths::scaffold(Some(data_dir.clone())).await?; + + let (password, _) = generate_passphrase()?; + let _account = LocalAccount::new_account( + TEST_ID.to_string(), + password, + Some(data_dir.clone()), + ) + .await?; + + let request = LocalRequest::get("/accounts".parse().unwrap()); + + let data_dir = data_dir.display().to_string(); + let (command, arguments) = super::extension_helper_cmd(&data_dir); + let mut client = ExtensionHelperClient::new(command, arguments).await?; + let response = client.send(request).await?; + assert_eq!(StatusCode::OK, response.status().unwrap()); + assert_eq!(1, response.request_id()); + assert!(response.is_json()); + + // Single account configured for this test spec + let accounts: Vec = + serde_json::from_slice(response.body())?; + assert_eq!(1, accounts.len()); + + client.kill().await?; + + teardown(TEST_ID).await; + + Ok(()) +} diff --git a/crates/integration_tests/tests/ipc/extension_helper_probe.rs b/crates/integration_tests/tests/ipc/extension_helper_probe.rs new file mode 100644 index 0000000000..623fdd7f7f --- /dev/null +++ b/crates/integration_tests/tests/ipc/extension_helper_probe.rs @@ -0,0 +1,36 @@ +use anyhow::Result; +use http::StatusCode; +use sos_ipc::{ + extension_helper::client::ExtensionHelperClient, + local_transport::{HttpMessage, LocalRequest}, +}; +use sos_sdk::prelude::Paths; +use sos_test_utils::{setup, teardown}; + +/// Test checking for aliveness with a HEAD request to the / endpoint. +/// +/// The extension can use this to check if it is currently connected +/// to the executable serving native messaging API requests. +#[tokio::test] +async fn integration_ipc_extension_helper_probe() -> Result<()> { + const TEST_ID: &str = "ipc_extension_helper_probe"; + // crate::test_utils::init_tracing(); + + let mut dirs = setup(TEST_ID, 1).await?; + let data_dir = dirs.clients.remove(0); + Paths::scaffold(Some(data_dir.clone())).await?; + let data_dir = data_dir.display().to_string(); + + let request = LocalRequest::head("/".parse().unwrap()); + let (command, arguments) = super::extension_helper_cmd(&data_dir); + let mut client = ExtensionHelperClient::new(command, arguments).await?; + let response = client.send(request).await?; + assert_eq!(StatusCode::OK, response.status().unwrap()); + assert_eq!(1, response.request_id()); + + client.kill().await?; + + teardown(TEST_ID).await; + + Ok(()) +} diff --git a/crates/integration_tests/tests/ipc/main.rs b/crates/integration_tests/tests/ipc/main.rs new file mode 100644 index 0000000000..167d793dbe --- /dev/null +++ b/crates/integration_tests/tests/ipc/main.rs @@ -0,0 +1,23 @@ +mod extension_helper_chunks; +mod extension_helper_info; +mod extension_helper_list_accounts; +mod extension_helper_probe; +mod memory_server; + +pub use sos_test_utils as test_utils; + +pub fn extension_helper_cmd<'a>( + data_dir: &'a str, +) -> (&'static str, Vec<&'a str>) { + let command = "cargo"; + let arguments = vec![ + "run", + "-q", + "--bin", + "test-extension-helper", + "--", + "sos-test-extension-helper", // mock extension name + data_dir, // data directory for isolated tests + ]; + (command, arguments) +} diff --git a/crates/integration_tests/tests/ipc/memory_server.rs b/crates/integration_tests/tests/ipc/memory_server.rs new file mode 100644 index 0000000000..d05f45c1e3 --- /dev/null +++ b/crates/integration_tests/tests/ipc/memory_server.rs @@ -0,0 +1,72 @@ +use anyhow::Result; +use sos_ipc::{ + local_transport::{HttpMessage, LocalRequest}, + memory_server::LocalMemoryServer, + ServiceAppInfo, WebAccounts, +}; +use sos_net::sdk::{prelude::LocalAccountSwitcher, Paths}; +use sos_test_utils::teardown; +use std::sync::Arc; +use tokio::sync::RwLock; + +use crate::test_utils::setup; + +/// Test the in-memory HTTP server in isolation outside +/// of the context of the native bridge code. +/// +/// Runs a simple GET request and basic concurrency test +/// making multiple simultaneous requests. +#[tokio::test] +async fn integration_ipc_memory_server() -> Result<()> { + const TEST_ID: &str = "ipc_memory_server"; + // crate::test_utils::init_tracing(); + + let mut dirs = setup(TEST_ID, 1).await?; + let data_dir = dirs.clients.remove(0); + + Paths::scaffold(Some(data_dir.clone())).await?; + let paths = Paths::new_global(data_dir.clone()); + + // Setup empty accounts + let accounts = LocalAccountSwitcher::from(paths); + let ipc_accounts = Arc::new(RwLock::new(accounts)); + + let name = "mock-service"; + let version = "1.0.0"; + + let app_info = ServiceAppInfo { + name: name.to_string(), + version: version.to_string(), + }; + + let client = LocalMemoryServer::listen( + WebAccounts::new(ipc_accounts), + app_info.clone(), + ) + .await?; + let request = LocalRequest::get("/".parse().unwrap()); + let response = client.send(request).await?; + let result: ServiceAppInfo = serde_json::from_slice(response.body())?; + assert_eq!(app_info, result); + + let mut futures = Vec::new(); + for _ in 0..100 { + let client = client.clone(); + futures.push(Box::pin(async move { + let request = LocalRequest::get("/".parse().unwrap()); + let response = client.send(request).await?; + let result: ServiceAppInfo = + serde_json::from_slice(response.body())?; + Ok::<_, anyhow::Error>(result) + })); + } + + let results = futures::future::try_join_all(futures).await?; + for res in results { + assert_eq!(app_info, res); + } + + teardown(TEST_ID).await; + + Ok(()) +} diff --git a/crates/integration_tests/tests/local_account/folder_lifecycle.rs b/crates/integration_tests/tests/local_account/folder_lifecycle.rs index 62bcbe4f1f..089c6c138e 100644 --- a/crates/integration_tests/tests/local_account/folder_lifecycle.rs +++ b/crates/integration_tests/tests/local_account/folder_lifecycle.rs @@ -56,11 +56,7 @@ async fn local_folder_lifecycle() -> Result<()> { // Changed the currently open folder by reading // from an explicit folder - let current_folder = { - let storage = account.storage().await?; - let reader = storage.read().await; - reader.current_folder() - }; + let current_folder = account.current_folder().await?; assert_eq!(Some(&folder), current_folder.as_ref()); // Export the folder and save the password for the exported diff --git a/crates/integration_tests/tests/local_account/search/favorites.rs b/crates/integration_tests/tests/local_account/search/favorites.rs index 714eacf6b3..686b8f5c12 100644 --- a/crates/integration_tests/tests/local_account/search/favorites.rs +++ b/crates/integration_tests/tests/local_account/search/favorites.rs @@ -32,9 +32,8 @@ async fn local_search_favorites() -> Result<()> { .await?; // No favorites yet - let documents = account - .query_view(vec![DocumentView::Favorites], None) - .await?; + let documents = + account.query_view(&[DocumentView::Favorites], None).await?; assert_eq!(0, documents.len()); // Mark a secret as favorite @@ -45,9 +44,8 @@ async fn local_search_favorites() -> Result<()> { .await?; // Should have a favorite now - let documents = account - .query_view(vec![DocumentView::Favorites], None) - .await?; + let documents = + account.query_view(&[DocumentView::Favorites], None).await?; assert_eq!(1, documents.len()); // No longer a favorite @@ -58,9 +56,8 @@ async fn local_search_favorites() -> Result<()> { .await?; // Not in the favorites view anymore - let documents = account - .query_view(vec![DocumentView::Favorites], None) - .await?; + let documents = + account.query_view(&[DocumentView::Favorites], None).await?; assert_eq!(0, documents.len()); teardown(TEST_ID).await; diff --git a/crates/integration_tests/tests/local_account/search/mod.rs b/crates/integration_tests/tests/local_account/search/mod.rs index ca92cfda89..211a5e1c70 100644 --- a/crates/integration_tests/tests/local_account/search/mod.rs +++ b/crates/integration_tests/tests/local_account/search/mod.rs @@ -6,3 +6,4 @@ mod sign_in; mod statistics; mod update_secret; mod view_query; +mod view_query_websites; diff --git a/crates/integration_tests/tests/local_account/search/view_query.rs b/crates/integration_tests/tests/local_account/search/view_query.rs index ec266a8d1a..a5d28d3c9c 100644 --- a/crates/integration_tests/tests/local_account/search/view_query.rs +++ b/crates/integration_tests/tests/local_account/search/view_query.rs @@ -86,14 +86,13 @@ async fn local_search_view_query() -> Result<()> { account.insert_secrets(new_folder_docs).await?; // Get all documents in the index. - let documents = - account.query_view(vec![Default::default()], None).await?; + let documents = account.query_view(&[Default::default()], None).await?; assert_eq!(total_docs, documents.len()); // Get all documents ignoring some types let documents = account .query_view( - vec![DocumentView::All { + &[DocumentView::All { ignored_types: Some(vec![SecretType::Account]), }], None, @@ -102,17 +101,16 @@ async fn local_search_view_query() -> Result<()> { assert_eq!(15, documents.len()); // Find favorites - let documents = account - .query_view(vec![DocumentView::Favorites], None) - .await?; + let documents = + account.query_view(&[DocumentView::Favorites], None).await?; assert_eq!(1, documents.len()); // Query by specific document identifiers let identifiers = (&ids[0..4]).into_iter().map(|id| *id).collect(); let documents = account .query_view( - vec![DocumentView::Documents { - vault_id: *default_folder.id(), + &[DocumentView::Documents { + folder_id: *default_folder.id(), identifiers, }], None, @@ -123,7 +121,7 @@ async fn local_search_view_query() -> Result<()> { // Find contacts let documents = account .query_view( - vec![DocumentView::Contact { + &[DocumentView::Contact { include_types: None, }], None, @@ -133,20 +131,20 @@ async fn local_search_view_query() -> Result<()> { // Find by type let documents = account - .query_view(vec![DocumentView::TypeId(SecretType::Account)], None) + .query_view(&[DocumentView::TypeId(SecretType::Account)], None) .await?; assert_eq!(2, documents.len()); // Find all in a specific folder let documents = account - .query_view(vec![DocumentView::Vault(*default_folder.id())], None) + .query_view(&[DocumentView::Vault(*default_folder.id())], None) .await?; assert_eq!(14, documents.len()); // Find by tags let documents = account .query_view( - vec![DocumentView::Tags(vec!["new_folder".to_owned()])], + &[DocumentView::Tags(vec!["new_folder".to_owned()])], None, ) .await?; @@ -161,8 +159,8 @@ async fn local_search_view_query() -> Result<()> { // in the archive let documents = account .query_view( - vec![Default::default()], - Some(ArchiveFilter { + &[Default::default()], + Some(&ArchiveFilter { id: *archive_folder.id(), include_documents: false, }), @@ -174,8 +172,8 @@ async fn local_search_view_query() -> Result<()> { // in the archive let documents = account .query_view( - vec![Default::default()], - Some(ArchiveFilter { + &[Default::default()], + Some(&ArchiveFilter { id: *archive_folder.id(), include_documents: true, }), diff --git a/crates/integration_tests/tests/local_account/search/view_query_websites.rs b/crates/integration_tests/tests/local_account/search/view_query_websites.rs new file mode 100644 index 0000000000..84e2781cde --- /dev/null +++ b/crates/integration_tests/tests/local_account/search/view_query_websites.rs @@ -0,0 +1,145 @@ +use crate::test_utils::{mock, setup, teardown}; +use anyhow::Result; +use sos_net::sdk::prelude::*; + +/// Tests querying the search index using a websites view. +#[tokio::test] +async fn local_search_view_query_websites() -> Result<()> { + const TEST_ID: &str = "search_view_query_websites"; + //crate::test_utils::init_tracing(); + + let mut dirs = setup(TEST_ID, 1).await?; + let data_dir = dirs.clients.remove(0); + + let account_name = TEST_ID.to_string(); + let (password, _) = generate_passphrase()?; + + let mut account = LocalAccount::new_account( + account_name.clone(), + password.clone(), + Some(data_dir.clone()), + ) + .await?; + + let key: AccessKey = password.clone().into(); + account.sign_in(&key).await?; + let default_folder = account.default_folder().await.unwrap(); + account.open_folder(&default_folder).await?; + + let default_folder_docs = vec![ + mock::login( + "login:no_websites", + "me@example.com", + generate_passphrase()?.0, + ), + mock::login_websites( + "login:apple.com", + "me@apple.com", + generate_passphrase()?.0, + vec![ + "https://apple.com".parse()?, + "https://developer.apple.com".parse()?, + ], + ), + mock::login_websites( + "login:google.com", + "me@google.com", + generate_passphrase()?.0, + vec!["https://google.com".parse()?, "https://gmail.com".parse()?], + ), + // Origin match + mock::login_websites( + "login:a1.com", + "me@a.com", + generate_passphrase()?.0, + vec!["https://a.com/foo".parse()?], + ), + mock::login_websites( + "login:a2.com", + "me@a.com", + generate_passphrase()?.0, + vec!["https://a.com/bar".parse()?], + ), + // Origin mismatch (different schemes) + mock::login_websites( + "login:b1.com", + "me@a.com", + generate_passphrase()?.0, + vec!["https://b.com/foo".parse()?], + ), + mock::login_websites( + "login:b2.com", + "me@a.com", + generate_passphrase()?.0, + vec!["http://b.com/bar".parse()?], + ), + ]; + + account.insert_secrets(default_folder_docs).await?; + + // Find all documents with associated websites + let documents = account + .query_view( + &[DocumentView::Websites { + matches: None, + exact: true, + }], + None, + ) + .await?; + assert_eq!(6, documents.len()); + + // Find documents with an exact website match + let documents = account + .query_view( + &[DocumentView::Websites { + matches: Some(vec!["https://apple.com".parse()?]), + exact: true, + }], + None, + ) + .await?; + assert_eq!(1, documents.len()); + + // Find documents by origin match + let documents = account + .query_view( + &[DocumentView::Websites { + matches: Some(vec!["https://a.com".parse()?]), + exact: false, + }], + None, + ) + .await?; + assert_eq!(2, documents.len()); + + let mut websites: Vec<&str> = Vec::new(); + for doc in &documents { + if let Some(sites) = doc.extra().websites() { + websites.append(&mut sites.into_iter().map(|u| &u[..]).collect()); + } + } + + assert!(websites.contains(&"https://a.com/foo")); + assert!(websites.contains(&"https://a.com/bar")); + + // Find documents by origin (ignoring mismatched scheme) + let documents = account + .query_view( + &[DocumentView::Websites { + matches: Some(vec!["https://b.com".parse()?]), + exact: false, + }], + None, + ) + .await?; + assert_eq!(1, documents.len()); + + // Check we can run queries as well as views + let documents = account.query_map("app", Default::default()).await?; + assert_eq!(1, documents.len()); + + teardown(TEST_ID).await; + + Ok(()) +} diff --git a/crates/integration_tests/tests/network_account/archive_unarchive.rs b/crates/integration_tests/tests/network_account/archive_unarchive.rs index ee75e077e7..abc6b8615d 100644 --- a/crates/integration_tests/tests/network_account/archive_unarchive.rs +++ b/crates/integration_tests/tests/network_account/archive_unarchive.rs @@ -3,7 +3,7 @@ use crate::test_utils::{ simulate_device_with_builder, spawn, teardown, }; use anyhow::Result; -use sos_net::{sdk::prelude::*, AccountSync}; +use sos_net::{protocol::AccountSync, sdk::prelude::*}; /// Tests moving to and from an archive folder. #[tokio::test] diff --git a/crates/integration_tests/tests/network_account/authenticator_sync.rs b/crates/integration_tests/tests/network_account/authenticator_sync.rs index 063171b9aa..1a0788eb15 100644 --- a/crates/integration_tests/tests/network_account/authenticator_sync.rs +++ b/crates/integration_tests/tests/network_account/authenticator_sync.rs @@ -1,6 +1,6 @@ use crate::test_utils::{mock, simulate_device, spawn, teardown}; use anyhow::Result; -use sos_net::{sdk::prelude::*, AccountSync}; +use sos_net::{protocol::AccountSync, sdk::prelude::*}; /// Tests syncing an authenticator folder after /// disabling the NO_SYNC flag. diff --git a/crates/integration_tests/tests/network_account/change_account_password.rs b/crates/integration_tests/tests/network_account/change_account_password.rs index 31ea32b75d..f670bf303f 100644 --- a/crates/integration_tests/tests/network_account/change_account_password.rs +++ b/crates/integration_tests/tests/network_account/change_account_password.rs @@ -2,12 +2,11 @@ use crate::test_utils::{ assert_local_remote_events_eq, mock, simulate_device, spawn, teardown, }; use anyhow::Result; -use sos_net::{sdk::prelude::*, AccountSync}; +use sos_net::{protocol::AccountSync, sdk::prelude::*}; /// Tests changing the account password and force syncing /// the updated and diverged account data. #[tokio::test] -#[cfg_attr(windows, ignore = "fix auto lock bug on windows (#451)")] async fn network_sync_change_account_password() -> Result<()> { const TEST_ID: &str = "sync_change_account_password"; // crate::test_utils::init_tracing(); diff --git a/crates/integration_tests/tests/network_account/change_cipher.rs b/crates/integration_tests/tests/network_account/change_cipher.rs index 25c89dbfee..32ca9dc1e2 100644 --- a/crates/integration_tests/tests/network_account/change_cipher.rs +++ b/crates/integration_tests/tests/network_account/change_cipher.rs @@ -2,12 +2,11 @@ use crate::test_utils::{ assert_local_remote_events_eq, mock, simulate_device, spawn, teardown, }; use anyhow::Result; -use sos_net::{sdk::prelude::*, AccountSync}; +use sos_net::{protocol::AccountSync, sdk::prelude::*}; /// Tests changing the account cipher and force syncing /// the updated and diverged account data. #[tokio::test] -#[cfg_attr(windows, ignore = "fix auto lock bug on windows (#451)")] async fn network_sync_change_cipher() -> Result<()> { const TEST_ID: &str = "sync_change_cipher"; // crate::test_utils::init_tracing(); diff --git a/crates/integration_tests/tests/network_account/change_folder_password.rs b/crates/integration_tests/tests/network_account/change_folder_password.rs index 67ef15ab93..29c2032bf9 100644 --- a/crates/integration_tests/tests/network_account/change_folder_password.rs +++ b/crates/integration_tests/tests/network_account/change_folder_password.rs @@ -2,12 +2,11 @@ use crate::test_utils::{ assert_local_remote_events_eq, mock, simulate_device, spawn, teardown, }; use anyhow::Result; -use sos_net::{sdk::prelude::*, AccountSync}; +use sos_net::{protocol::AccountSync, sdk::prelude::*}; /// Tests changing a folder password and force syncing /// the updated folder events log. #[tokio::test] -#[cfg_attr(windows, ignore = "fix auto lock bug on windows (#451)")] async fn network_sync_change_folder_password() -> Result<()> { const TEST_ID: &str = "sync_change_folder_password"; // crate::test_utils::init_tracing(); diff --git a/crates/integration_tests/tests/network_account/compact_account.rs b/crates/integration_tests/tests/network_account/compact_account.rs index c0b5aa50c7..a9cb006c02 100644 --- a/crates/integration_tests/tests/network_account/compact_account.rs +++ b/crates/integration_tests/tests/network_account/compact_account.rs @@ -2,7 +2,7 @@ use crate::test_utils::{ assert_local_remote_events_eq, mock, simulate_device, spawn, teardown, }; use anyhow::Result; -use sos_net::{sdk::prelude::*, AccountSync}; +use sos_net::{protocol::AccountSync, sdk::prelude::*}; /// Tests compacting all the folders in an account and /// syncing the changes to another device. diff --git a/crates/integration_tests/tests/network_account/compact_folder.rs b/crates/integration_tests/tests/network_account/compact_folder.rs index c925272532..2aee845a99 100644 --- a/crates/integration_tests/tests/network_account/compact_folder.rs +++ b/crates/integration_tests/tests/network_account/compact_folder.rs @@ -2,7 +2,7 @@ use crate::test_utils::{ assert_local_remote_events_eq, mock, simulate_device, spawn, teardown, }; use anyhow::Result; -use sos_net::{sdk::prelude::*, AccountSync}; +use sos_net::{protocol::AccountSync, sdk::prelude::*}; /// Tests compacting a single folders and /// syncing the changes to another device. diff --git a/crates/integration_tests/tests/network_account/delete_account.rs b/crates/integration_tests/tests/network_account/delete_account.rs index 4459080bab..4f315843c6 100644 --- a/crates/integration_tests/tests/network_account/delete_account.rs +++ b/crates/integration_tests/tests/network_account/delete_account.rs @@ -1,6 +1,9 @@ use crate::test_utils::{simulate_device, spawn, teardown}; use anyhow::Result; -use sos_net::{sdk::prelude::*, SyncClient}; +use sos_net::{ + protocol::{RemoteSyncHandler, SyncClient}, + sdk::prelude::*, +}; /// Tests creating and then deleting all the account data /// on a remote server. @@ -25,7 +28,7 @@ async fn network_sync_delete_account() -> Result<()> { // Get the remote out of the owner so we can // assert on equality between local and remote let bridge = device.owner.remove_server(&origin).await?.unwrap(); - bridge.client().delete_account().await?; + bridge.client().delete_account(&address).await?; // All the local data still exists let local_paths = device.owner.paths(); diff --git a/crates/integration_tests/tests/network_account/listen_folder_delete.rs b/crates/integration_tests/tests/network_account/listen_folder_delete.rs index a1355765b1..3579ec56d3 100644 --- a/crates/integration_tests/tests/network_account/listen_folder_delete.rs +++ b/crates/integration_tests/tests/network_account/listen_folder_delete.rs @@ -70,7 +70,7 @@ async fn network_sync_listen_folder_delete() -> Result<()> { wait_for_cond(move || wait_files.iter().all(|p| !p.exists())).await; let updated_summaries: Vec = { - let storage = device1.owner.storage().await?; + let storage = device1.owner.storage().await.unwrap(); let reader = storage.read().await; reader.list_folders().to_vec() }; diff --git a/crates/integration_tests/tests/network_account/listen_folder_import.rs b/crates/integration_tests/tests/network_account/listen_folder_import.rs index caae8bc194..dbca547bfd 100644 --- a/crates/integration_tests/tests/network_account/listen_folder_import.rs +++ b/crates/integration_tests/tests/network_account/listen_folder_import.rs @@ -41,7 +41,7 @@ async fn network_sync_listen_folder_import() -> Result<()> { // path when sync happens device1.owner.open_folder(&new_folder).await?; let mut vault = { - let storage = device1.owner.storage().await?; + let storage = device1.owner.storage().await.unwrap(); let reader = storage.read().await; let folder = reader.cache().get(new_folder.id()).unwrap(); folder.keeper().vault().clone() @@ -85,7 +85,7 @@ async fn network_sync_listen_folder_import() -> Result<()> { // Expected folders on the local account must be computed // again after creating the new folder for the assertions let expected_summaries: Vec = { - let storage = device1.owner.storage().await?; + let storage = device1.owner.storage().await.unwrap(); let reader = storage.read().await; reader.list_folders().to_vec() }; diff --git a/crates/integration_tests/tests/network_account/multiple_remotes.rs b/crates/integration_tests/tests/network_account/multiple_remotes.rs index d9c0c3d5ff..76332f821d 100644 --- a/crates/integration_tests/tests/network_account/multiple_remotes.rs +++ b/crates/integration_tests/tests/network_account/multiple_remotes.rs @@ -2,7 +2,7 @@ use crate::test_utils::{ assert_local_remote_events_eq, mock, simulate_device, spawn, teardown, }; use anyhow::Result; -use sos_net::{sdk::prelude::*, AccountSync}; +use sos_net::{protocol::AccountSync, sdk::prelude::*}; /// Tests syncing a single client with multiple /// remote servers. diff --git a/crates/integration_tests/tests/network_account/multiple_remotes_fallback.rs b/crates/integration_tests/tests/network_account/multiple_remotes_fallback.rs index a4b457653d..f62209c246 100644 --- a/crates/integration_tests/tests/network_account/multiple_remotes_fallback.rs +++ b/crates/integration_tests/tests/network_account/multiple_remotes_fallback.rs @@ -3,7 +3,7 @@ use crate::test_utils::{ teardown, }; use anyhow::Result; -use sos_net::{sdk::prelude::*, AccountSync}; +use sos_net::{protocol::AccountSync, sdk::prelude::*}; /// Tests syncing a single client with multiple /// remote servers when one of the servers is offline. diff --git a/crates/integration_tests/tests/network_account/no_sync.rs b/crates/integration_tests/tests/network_account/no_sync.rs index 5f145cb55d..ff4cf7feb4 100644 --- a/crates/integration_tests/tests/network_account/no_sync.rs +++ b/crates/integration_tests/tests/network_account/no_sync.rs @@ -1,8 +1,9 @@ use crate::test_utils::{setup, simulate_device, spawn, teardown}; use anyhow::Result; use sos_net::{ - protocol::SyncStorage, sdk::prelude::*, AccountSync, NetworkAccount, - SyncClient, + protocol::{AccountSync, RemoteSyncHandler, SyncClient, SyncStorage}, + sdk::prelude::*, + NetworkAccount, }; /// Tests syncing with the NO_SYNC flag set before the account @@ -76,6 +77,7 @@ async fn network_no_sync_update_account() -> Result<()> { // Prepare mock device let mut device = simulate_device(TEST_ID, 2, Some(&server)).await?; + let address = device.owner.address().clone(); // Create folder with AUTHENTICATOR flag let options = NewFolderOptions { @@ -110,7 +112,7 @@ async fn network_no_sync_update_account() -> Result<()> { // for redundancy. let local_status = device.owner.sync_status().await?; let bridge = device.owner.remove_server(&origin).await?.unwrap(); - let remote_status = bridge.client().sync_status().await?; + let remote_status = bridge.client().sync_status(&address).await?; let local_folder = local_status.folders.get(folder.id()).unwrap(); let remote_folder = remote_status.folders.get(folder.id()).unwrap(); diff --git a/crates/integration_tests/tests/network_account/offline_manual.rs b/crates/integration_tests/tests/network_account/offline_manual.rs index 8a14956282..40169ecf22 100644 --- a/crates/integration_tests/tests/network_account/offline_manual.rs +++ b/crates/integration_tests/tests/network_account/offline_manual.rs @@ -3,7 +3,7 @@ use crate::test_utils::{ sync_pause, teardown, }; use anyhow::Result; -use sos_net::{sdk::prelude::*, AccountSync}; +use sos_net::{protocol::AccountSync, sdk::prelude::*}; /// Tests syncing events between two clients after /// a server goes offline and a client commits changes diff --git a/crates/integration_tests/tests/network_account/recover_remote_folder.rs b/crates/integration_tests/tests/network_account/recover_remote_folder.rs index a00e859b9e..df4754ed88 100644 --- a/crates/integration_tests/tests/network_account/recover_remote_folder.rs +++ b/crates/integration_tests/tests/network_account/recover_remote_folder.rs @@ -2,7 +2,7 @@ use crate::test_utils::{ assert_local_remote_events_eq, mock, simulate_device, spawn, teardown, }; use anyhow::Result; -use sos_net::{sdk::prelude::*, AccountSync}; +use sos_net::{protocol::AccountSync, sdk::prelude::*}; /// Tests recovering a folder from a remote origin after /// it has been removed from the local account. diff --git a/crates/integration_tests/tests/network_account/rename_account.rs b/crates/integration_tests/tests/network_account/rename_account.rs index 5cb3dcc207..cd056d08ef 100644 --- a/crates/integration_tests/tests/network_account/rename_account.rs +++ b/crates/integration_tests/tests/network_account/rename_account.rs @@ -2,7 +2,7 @@ use crate::test_utils::{ assert_local_remote_events_eq, simulate_device, spawn, teardown, }; use anyhow::Result; -use sos_net::{sdk::prelude::*, AccountSync}; +use sos_net::{protocol::AccountSync, sdk::prelude::*}; /// Tests changing the account name is synced. #[tokio::test] diff --git a/crates/integration_tests/tests/network_account/send_folder_create.rs b/crates/integration_tests/tests/network_account/send_folder_create.rs index 99096bedf3..dcf41fbe25 100644 --- a/crates/integration_tests/tests/network_account/send_folder_create.rs +++ b/crates/integration_tests/tests/network_account/send_folder_create.rs @@ -38,7 +38,7 @@ async fn network_sync_folder_create() -> Result<()> { // Expected folders on the local account must be computed // again after creating the new folder for the assertions let folders: Vec = { - let storage = device.owner.storage().await?; + let storage = device.owner.storage().await.unwrap(); let reader = storage.read().await; reader.list_folders().to_vec() }; diff --git a/crates/integration_tests/tests/network_account/send_folder_delete.rs b/crates/integration_tests/tests/network_account/send_folder_delete.rs index faf369c159..7e43fb38dc 100644 --- a/crates/integration_tests/tests/network_account/send_folder_delete.rs +++ b/crates/integration_tests/tests/network_account/send_folder_delete.rs @@ -47,7 +47,7 @@ async fn network_sync_folder_delete() -> Result<()> { assert!(sync_result.first_error().is_none()); let updated_summaries: Vec = { - let storage = device.owner.storage().await?; + let storage = device.owner.storage().await.unwrap(); let reader = storage.read().await; reader.list_folders().to_vec() }; diff --git a/crates/integration_tests/tests/network_account/send_folder_import.rs b/crates/integration_tests/tests/network_account/send_folder_import.rs index 3b60bc167c..52330de547 100644 --- a/crates/integration_tests/tests/network_account/send_folder_import.rs +++ b/crates/integration_tests/tests/network_account/send_folder_import.rs @@ -37,7 +37,7 @@ async fn network_sync_folder_import() -> Result<()> { // path when sync happens device.owner.open_folder(&new_folder).await?; let mut vault = { - let storage = device.owner.storage().await?; + let storage = device.owner.storage().await.unwrap(); let reader = storage.read().await; let folder = reader.cache().get(new_folder.id()).unwrap(); folder.keeper().vault().clone() @@ -64,7 +64,7 @@ async fn network_sync_folder_import() -> Result<()> { // Expected folders on the local account must be computed // again after creating the new folder for the assertions let folders: Vec = { - let storage = device.owner.storage().await?; + let storage = device.owner.storage().await.unwrap(); let reader = storage.read().await; reader.list_folders().to_vec() }; diff --git a/crates/integration_tests/tests/network_account/send_secret_create.rs b/crates/integration_tests/tests/network_account/send_secret_create.rs index ff2ae664ef..a8bf97e88b 100644 --- a/crates/integration_tests/tests/network_account/send_secret_create.rs +++ b/crates/integration_tests/tests/network_account/send_secret_create.rs @@ -3,7 +3,7 @@ use crate::test_utils::{ teardown, }; use anyhow::Result; -use sos_net::{sdk::prelude::*, AccountSync}; +use sos_net::{protocol::AccountSync, sdk::prelude::*}; /// Tests syncing create secret events between two /// clients. diff --git a/crates/integration_tests/tests/network_account/send_secret_move.rs b/crates/integration_tests/tests/network_account/send_secret_move.rs index 92808bae00..4818d922ba 100644 --- a/crates/integration_tests/tests/network_account/send_secret_move.rs +++ b/crates/integration_tests/tests/network_account/send_secret_move.rs @@ -3,7 +3,7 @@ use crate::test_utils::{ teardown, }; use anyhow::Result; -use sos_net::{sdk::prelude::*, AccountSync}; +use sos_net::{protocol::AccountSync, sdk::prelude::*}; /// Tests syncing move secret events between two /// clients. diff --git a/crates/integration_tests/tests/network_account/websocket_reconnect.rs b/crates/integration_tests/tests/network_account/websocket_reconnect.rs index abd87d748a..a449da4e9b 100644 --- a/crates/integration_tests/tests/network_account/websocket_reconnect.rs +++ b/crates/integration_tests/tests/network_account/websocket_reconnect.rs @@ -1,8 +1,12 @@ -use crate::test_utils::{simulate_device, spawn, teardown}; +use crate::test_utils::{simulate_device, spawn, teardown, wait_num_websocket_connections}; use anyhow::Result; -use sos_net::{sdk::prelude::*, HttpClient, ListenOptions, NetworkRetry}; -use std::{sync::Arc, time::Duration}; +use sos_net::{ + protocol::network_client::{ListenOptions, NetworkRetry}, + sdk::prelude::*, +}; +use std::sync::Arc; use tokio::sync::Mutex; +use std::time::Duration; /// Tests websocket reconnect logic. #[tokio::test] @@ -41,11 +45,8 @@ async fn network_websocket_reconnect() -> Result<()> { .unwrap(); }); - // Wait a little to give the websocket time to connect - tokio::time::sleep(Duration::from_millis(100)).await; - - let num_conns = HttpClient::num_connections(server.origin.url()).await?; - assert_eq!(1, num_conns); + // Wait for the connection + wait_num_websocket_connections(&server.origin, 1).await?; // Drop the server handle to shutdown the server drop(server); @@ -61,8 +62,8 @@ async fn network_websocket_reconnect() -> Result<()> { // connection tokio::time::sleep(Duration::from_millis(5000)).await; - let num_conns = HttpClient::num_connections(server.origin.url()).await?; - assert_eq!(1, num_conns); + // Wait for the connection + wait_num_websocket_connections(&server.origin, 1).await?; let mut writer = main_device.lock().await; writer.owner.sign_out().await?; diff --git a/crates/integration_tests/tests/network_account/websocket_shutdown_explicit.rs b/crates/integration_tests/tests/network_account/websocket_shutdown_explicit.rs index 25fa1794b5..25df42aa09 100644 --- a/crates/integration_tests/tests/network_account/websocket_shutdown_explicit.rs +++ b/crates/integration_tests/tests/network_account/websocket_shutdown_explicit.rs @@ -1,7 +1,6 @@ -use crate::test_utils::{simulate_device, spawn, teardown}; +use crate::test_utils::{simulate_device, spawn, teardown, wait_num_websocket_connections}; use anyhow::Result; -use sos_net::{sdk::prelude::*, HttpClient}; -use std::time::Duration; +use sos_net::sdk::prelude::*; /// Tests websocket shutdown logic. #[tokio::test] @@ -20,20 +19,12 @@ async fn network_websocket_shutdown_explicit() -> Result<()> { // Start the websocket connection device.listen().await?; - // Wait a moment for the connection to complete - tokio::time::sleep(Duration::from_millis(50)).await; - - let num_conns = HttpClient::num_connections(server.origin.url()).await?; - assert_eq!(1, num_conns); + wait_num_websocket_connections(&server.origin, 1).await?; // Close the websocket connection device.owner.stop_listening(&origin).await; - // Wait a moment for the connection to close - tokio::time::sleep(Duration::from_millis(50)).await; - - let num_conns = HttpClient::num_connections(server.origin.url()).await?; - assert_eq!(0, num_conns); + wait_num_websocket_connections(&server.origin, 0).await?; device.owner.sign_out().await?; diff --git a/crates/integration_tests/tests/network_account/websocket_shutdown_signout.rs b/crates/integration_tests/tests/network_account/websocket_shutdown_signout.rs index ec611688d8..bb1f6810a6 100644 --- a/crates/integration_tests/tests/network_account/websocket_shutdown_signout.rs +++ b/crates/integration_tests/tests/network_account/websocket_shutdown_signout.rs @@ -1,7 +1,6 @@ -use crate::test_utils::{simulate_device, spawn, teardown}; +use crate::test_utils::{simulate_device, spawn, teardown, wait_num_websocket_connections}; use anyhow::Result; -use sos_net::{sdk::prelude::*, HttpClient}; -use std::time::Duration; +use sos_net::sdk::prelude::*; /// Tests websocket shutdown logic on sign out. #[tokio::test] @@ -18,20 +17,12 @@ async fn network_websocket_shutdown_signout() -> Result<()> { // Start the websocket connection device.listen().await?; - // Wait a moment for the connection to complete - tokio::time::sleep(Duration::from_millis(50)).await; - - let num_conns = HttpClient::num_connections(server.origin.url()).await?; - assert_eq!(1, num_conns); + wait_num_websocket_connections(&server.origin, 1).await?; // Sign out of the account device.owner.sign_out().await?; - // Wait a moment for the connection to close - tokio::time::sleep(Duration::from_millis(50)).await; - - let num_conns = HttpClient::num_connections(server.origin.url()).await?; - assert_eq!(0, num_conns); + wait_num_websocket_connections(&server.origin, 0).await?; teardown(TEST_ID).await; diff --git a/crates/integration_tests/tests/pairing/device_revoke.rs b/crates/integration_tests/tests/pairing/device_revoke.rs index 43044bef1e..b29ab48f86 100644 --- a/crates/integration_tests/tests/pairing/device_revoke.rs +++ b/crates/integration_tests/tests/pairing/device_revoke.rs @@ -4,7 +4,11 @@ use crate::test_utils::{ }; use anyhow::Result; use http::StatusCode; -use sos_net::{sdk::prelude::*, AccountSync, Error as ClientError}; +use sos_net::{ + protocol::{AccountSync, Error as ProtocolError, NetworkError}, + sdk::prelude::*, + Error as ClientError, +}; /// Tests pairing a new device and revoking trust in the device. #[tokio::test] @@ -59,7 +63,9 @@ async fn pairing_device_revoke() -> Result<()> { if let Err(ClientError::RevokeDeviceSync(err)) = revoke_error { assert!(matches!( &*err, - ClientError::ResponseJson(StatusCode::FORBIDDEN, _) + ClientError::Protocol(ProtocolError::Network( + NetworkError::ResponseJson(StatusCode::FORBIDDEN, _) + )) )); } else { panic!("expecting revoke device sync error"); @@ -71,7 +77,9 @@ async fn pairing_device_revoke() -> Result<()> { if let Some(err) = sync_result.first_error() { assert!(matches!( err, - ClientError::ResponseJson(StatusCode::FORBIDDEN, _) + ClientError::Protocol(ProtocolError::Network( + NetworkError::ResponseJson(StatusCode::FORBIDDEN, _) + )) )); } else { panic!("expecting multiple sync error (forbidden)"); diff --git a/crates/integration_tests/tests/pairing/pairing_account_name.rs b/crates/integration_tests/tests/pairing/pairing_account_name.rs index 89c609028c..880a215133 100644 --- a/crates/integration_tests/tests/pairing/pairing_account_name.rs +++ b/crates/integration_tests/tests/pairing/pairing_account_name.rs @@ -3,7 +3,7 @@ use crate::test_utils::{ simulate_device, spawn, teardown, }; use anyhow::Result; -use sos_net::{sdk::prelude::*, AccountSync}; +use sos_net::{protocol::AccountSync, sdk::prelude::*}; /// Tests the protocol for pairing devices respects /// an account name that has been changed. diff --git a/crates/integration_tests/tests/pairing/pairing_inverted.rs b/crates/integration_tests/tests/pairing/pairing_inverted.rs index 05608c8250..6d3e4d6be5 100644 --- a/crates/integration_tests/tests/pairing/pairing_inverted.rs +++ b/crates/integration_tests/tests/pairing/pairing_inverted.rs @@ -3,7 +3,7 @@ use crate::test_utils::{ simulate_device, spawn, teardown, }; use anyhow::Result; -use sos_net::{sdk::prelude::*, AccountSync}; +use sos_net::{protocol::AccountSync, sdk::prelude::*}; /// Tests the protocol for pairing devices using the inverted flow. #[tokio::test] diff --git a/crates/integration_tests/tests/pairing/pairing_protocol.rs b/crates/integration_tests/tests/pairing/pairing_protocol.rs index 00d3bed773..9406cf96c9 100644 --- a/crates/integration_tests/tests/pairing/pairing_protocol.rs +++ b/crates/integration_tests/tests/pairing/pairing_protocol.rs @@ -3,7 +3,7 @@ use crate::test_utils::{ simulate_device, spawn, teardown, }; use anyhow::Result; -use sos_net::{sdk::prelude::*, AccountSync}; +use sos_net::{protocol::AccountSync, sdk::prelude::*}; /// Tests the protocol for pairing devices. #[tokio::test] diff --git a/crates/integration_tests/tests/preferences/concurrent_write.rs b/crates/integration_tests/tests/preferences/concurrent_write.rs new file mode 100644 index 0000000000..5279e1c67c --- /dev/null +++ b/crates/integration_tests/tests/preferences/concurrent_write.rs @@ -0,0 +1,50 @@ +use crate::{ + test_preferences_concurrency, + test_utils::{setup, teardown}, +}; +use anyhow::Result; +use sos_net::extras::preferences::*; +use sos_sdk::prelude::Paths; +use tokio::process::Command; + +/// Tests concurrent writes to the global preferences. +#[tokio::test] +#[ignore] +async fn preferences_concurrent_write() -> Result<()> { + const TEST_ID: &str = "preferences_concurrent_write"; + //crate::test_utils::init_tracing(); + + let mut dirs = setup(TEST_ID, 1).await?; + let data_dir = dirs.clients.remove(0); + Paths::scaffold(Some(data_dir.clone())).await?; + + // Spawn processes to modify the global preferences + let mut futures = Vec::new(); + for i in 0..5 { + let cmd_dir = data_dir.clone(); + futures.push(async move { + let dir = cmd_dir.display().to_string(); + let value = i.to_string(); + let (command, arguments) = + test_preferences_concurrency(&dir, &value); + let mut child = Command::new(command) + .args(arguments) + .spawn() + .expect("failed to spawn"); + let status = child.wait().await?; + Ok::<_, anyhow::Error>(status) + }); + } + futures::future::try_join_all(futures).await?; + + let mut preferences = CachedPreferences::new(Some(data_dir.clone()))?; + preferences.load_global_preferences().await?; + let prefs = preferences.global_preferences(); + let prefs = prefs.lock().await; + let value = prefs.get_string("concurrent.string")?; + assert!(matches!(value, Some(Preference::String(_)))); + + teardown(TEST_ID).await; + + Ok(()) +} diff --git a/crates/integration_tests/tests/preferences/global_preferences.rs b/crates/integration_tests/tests/preferences/global_preferences.rs new file mode 100644 index 0000000000..0720a3a62b --- /dev/null +++ b/crates/integration_tests/tests/preferences/global_preferences.rs @@ -0,0 +1,28 @@ +use crate::{ + assert_preferences, + test_utils::{setup, teardown}, +}; +use anyhow::Result; +use sos_net::extras::preferences::*; + +/// Tests the global preferences for all accounts. +#[tokio::test] +async fn preferences_global() -> Result<()> { + const TEST_ID: &str = "preferences_global"; + //crate::test_utils::init_tracing(); + + let mut dirs = setup(TEST_ID, 1).await?; + let data_dir = dirs.clients.remove(0); + + let mut preferences = CachedPreferences::new(Some(data_dir.clone()))?; + preferences.load_global_preferences().await?; + + let prefs = preferences.global_preferences(); + let mut prefs = prefs.lock().await; + + assert_preferences(&mut prefs).await?; + + teardown(TEST_ID).await; + + Ok(()) +} diff --git a/crates/integration_tests/tests/preferences/local_account.rs b/crates/integration_tests/tests/preferences/local_account.rs index 472f73fca7..3fee83d284 100644 --- a/crates/integration_tests/tests/preferences/local_account.rs +++ b/crates/integration_tests/tests/preferences/local_account.rs @@ -1,4 +1,7 @@ -use crate::test_utils::{setup, teardown}; +use crate::{ + assert_preferences, + test_utils::{setup, teardown}, +}; use anyhow::Result; use sos_net::{extras::preferences::*, sdk::prelude::*}; @@ -27,64 +30,15 @@ async fn preferences_local_account() -> Result<()> { let identity = account.public_identity().await?.clone(); let identities = vec![identity]; - CachedPreferences::initialize(&identities, Some(data_dir.clone())) - .await?; + let preferences = CachedPreferences::new(Some(data_dir.clone()))?; + preferences.load_account_preferences(&identities).await?; - let prefs = - CachedPreferences::account_preferences(account.address()).await; + let prefs = preferences.account_preferences(account.address()).await; assert!(prefs.is_some()); let prefs = prefs.unwrap(); let mut prefs = prefs.lock().await; - // Create preferences - prefs.insert("mock.bool".to_owned(), true.into()).await?; - - prefs - .insert("mock.int".to_owned(), (-15 as i64).into()) - .await?; - - prefs - .insert("mock.double".to_owned(), (3.14 as f64).into()) - .await?; - - prefs - .insert("mock.string".to_owned(), "message".to_owned().into()) - .await?; - let list = vec!["item-1".to_owned(), "item-2".to_owned()]; - prefs - .insert("mock.string-list".to_owned(), list.into()) - .await?; - - // Retrieve preferences - let missing = prefs.get_unchecked("mock.non-existent"); - assert!(missing.is_none()); - - let boolean = prefs.get_bool("mock.bool")?; - assert!(matches!(boolean, Some(Preference::Bool(_)))); - - let int = prefs.get_number("mock.int")?; - assert!(matches!(int, Some(Preference::Number(_)))); - - let double = prefs.get_number("mock.double")?; - assert!(matches!(double, Some(Preference::Number(_)))); - - let string = prefs.get_string("mock.string")?; - assert!(matches!(string, Some(Preference::String(_)))); - - let string_list = prefs.get_string_list("mock.string-list")?; - assert!(matches!(string_list, Some(Preference::StringList(_)))); - - // Remove preferences - let removed = prefs.remove("mock.bool").await?; - assert!(matches!(removed, Some(Preference::Bool(true)))); - - // Clear preferences - prefs.clear().await?; - - // Reload - prefs.load().await?; - let items = prefs.iter().collect::>(); - assert!(items.is_empty()); + assert_preferences(&mut prefs).await?; account.sign_out().await?; diff --git a/crates/integration_tests/tests/preferences/main.rs b/crates/integration_tests/tests/preferences/main.rs index 2573a6d103..877695a604 100644 --- a/crates/integration_tests/tests/preferences/main.rs +++ b/crates/integration_tests/tests/preferences/main.rs @@ -1,8 +1,80 @@ -#[cfg(not(target_arch = "wasm32"))] +mod concurrent_write; +mod global_preferences; mod local_account; - -#[cfg(not(target_arch = "wasm32"))] mod no_account; - -#[cfg(not(target_arch = "wasm32"))] pub use sos_test_utils as test_utils; + +use sos_net::extras::preferences::{Preference, Preferences}; + +pub fn test_preferences_concurrency<'a>( + data_dir: &'a str, + value: &'a str, +) -> (&'static str, Vec<&'a str>) { + let command = "cargo"; + let arguments = vec![ + "run", + "-q", + "--bin", + "test-preferences-concurrency", + "--", + value, + data_dir, // data directory for isolated tests + ]; + (command, arguments) +} + +pub(crate) async fn assert_preferences( + prefs: &mut Preferences, +) -> anyhow::Result<()> { + // Create preferences + prefs.insert("mock.bool".to_owned(), true.into()).await?; + + prefs + .insert("mock.int".to_owned(), (-15 as i64).into()) + .await?; + + prefs + .insert("mock.double".to_owned(), (3.14 as f64).into()) + .await?; + + prefs + .insert("mock.string".to_owned(), "message".to_owned().into()) + .await?; + let list = vec!["item-1".to_owned(), "item-2".to_owned()]; + prefs + .insert("mock.string-list".to_owned(), list.into()) + .await?; + + // Retrieve preferences + let missing = prefs.get_unchecked("mock.non-existent"); + assert!(missing.is_none()); + + let boolean = prefs.get_bool("mock.bool")?; + assert!(matches!(boolean, Some(Preference::Bool(_)))); + + let int = prefs.get_number("mock.int")?; + assert!(matches!(int, Some(Preference::Number(_)))); + + let double = prefs.get_number("mock.double")?; + assert!(matches!(double, Some(Preference::Number(_)))); + + let string = prefs.get_string("mock.string")?; + assert!(matches!(string, Some(Preference::String(_)))); + + let string_list = prefs.get_string_list("mock.string-list")?; + assert!(matches!(string_list, Some(Preference::StringList(_)))); + + // Remove preferences + let removed = prefs.remove("mock.bool").await?; + assert!(matches!(removed, Some(Preference::Bool(true)))); + + // Clear preferences + prefs.clear().await?; + + // Reload + prefs.load().await?; + let items = prefs.iter().collect::>(); + assert!(items.is_empty()); + + Ok(()) +} diff --git a/crates/integration_tests/tests/preferences/no_account.rs b/crates/integration_tests/tests/preferences/no_account.rs index f326fcb3ac..de9e619b6a 100644 --- a/crates/integration_tests/tests/preferences/no_account.rs +++ b/crates/integration_tests/tests/preferences/no_account.rs @@ -28,10 +28,12 @@ async fn preferences_no_account() -> Result<()> { // Prepare the preferences let accounts = vec![identity]; - CachedPreferences::initialize(accounts.as_slice(), Some(data_dir)) + let preferences = CachedPreferences::new(Some(data_dir.clone()))?; + preferences + .load_account_preferences(accounts.as_slice()) .await?; - let prefs = CachedPreferences::account_preferences(&address).await; + let prefs = preferences.account_preferences(&address).await; assert!(prefs.is_some()); let prefs = prefs.unwrap(); diff --git a/crates/integration_tests/tests/web_account/linked_account.rs b/crates/integration_tests/tests/web_account/linked_account.rs new file mode 100644 index 0000000000..9af420aa7b --- /dev/null +++ b/crates/integration_tests/tests/web_account/linked_account.rs @@ -0,0 +1,110 @@ +use anyhow::Result; +use async_trait::async_trait; +use sos_net::HttpClient; +use sos_net::{ + protocol::RemoteSync, + protocol::RemoteSyncHandler, + sdk::{ + crypto::AccessKey, + prelude::{generate_passphrase, Account, Identity, SecretChange}, + Paths, + }, + NetworkAccount, NetworkAccountSwitcher, +}; +use sos_sdk::prelude::AccountSwitcherOptions; +use sos_web_account::{LinkedAccount, LocalIntegration}; +use std::sync::Arc; +use tokio::sync::RwLock; + +use crate::test_utils::{mock, setup, simulate_device, spawn, teardown}; + +/// Test for syncing between a linked account and another +/// account. +#[tokio::test] +async fn integration_ipc_linked_account() -> Result<()> { + const TEST_ID: &str = "ipc_linked_account"; + // crate::test_utils::init_tracing(); + // + + // Spawn a backend server and wait for it to be listening + let server = spawn(TEST_ID, None, None).await?; + let device = simulate_device(TEST_ID, 1, Some(&server)).await?; + let origin = server.origin.clone(); + let url = server.origin.url().clone(); + let password = device.password.clone(); + + println!("url: {:#?}", url); + + let mut dirs = setup(TEST_ID, 2).await?; + let data_dir = dirs.clients.remove(0); + let linked_data_dir = dirs.clients.remove(0); + + Paths::scaffold(Some(data_dir.clone())).await?; + Paths::scaffold(Some(linked_data_dir.clone())).await?; + let paths = Paths::new_global(data_dir.clone()); + + // For the test just re-use the account client + let client = device.owner.remote_client(&origin).await.unwrap(); + + let address = device.owner.address().clone(); + + // Integration mananges the accounts on the linked app + let integration = LocalIntegration::new(origin, client); + + // Prepare the local client using our test transport + let local_client = integration.client().clone(); + + // Prepare the linked account and add to the integration + let linked_account = LinkedAccount::new_unauthenticated( + address, + local_client, + Some(linked_data_dir), + ) + .await?; + let accounts = integration.accounts(); + let mut accounts = accounts.write().await; + accounts.add_account(linked_account); + accounts.switch_account(&address); + + let linked_account = accounts.selected_account_mut().unwrap(); + + // Initial sync fetches the data from the other app + let sync_result = linked_account.sync().await; + // println!("{:#?}", sync_result); + assert!(sync_result.result.is_ok()); + + // Make sure the account is recognized on disc + let accounts_list = + Identity::list_accounts(Some(&linked_account.paths())).await?; + assert_eq!(1, accounts_list.len()); + + // Should be able to sign in to the linked account + let key: AccessKey = password.into(); + linked_account.sign_in(&key).await?; + + // Create secret in the linked account + let (meta, secret) = mock::note("note", TEST_ID); + let SecretChange { id, .. } = linked_account + .create_secret(meta, secret, Default::default()) + .await?; + + // Read from the linked account + let (linked_secret_data, _) = + linked_account.read_secret(&id, Default::default()).await?; + + // Secret is immediately available on the local account + let local_secret_data = { + let (data, _) = + device.owner.read_secret(&id, Default::default()).await?; + assert_eq!(&id, data.id()); + assert_eq!("note", data.meta().label()); + data + }; + + // Secrets must be identical + assert_eq!(linked_secret_data, local_secret_data); + + teardown(TEST_ID).await; + + Ok(()) +} diff --git a/crates/integration_tests/tests/web_account/main.rs b/crates/integration_tests/tests/web_account/main.rs new file mode 100644 index 0000000000..a63a7016f6 --- /dev/null +++ b/crates/integration_tests/tests/web_account/main.rs @@ -0,0 +1,2 @@ +// mod linked_account; +pub use sos_test_utils as test_utils; diff --git a/crates/ipc/Cargo.toml b/crates/ipc/Cargo.toml new file mode 100644 index 0000000000..b622a33783 --- /dev/null +++ b/crates/ipc/Cargo.toml @@ -0,0 +1,95 @@ +[package] +name = "sos-ipc" +version = "0.16.6" +edition = "2021" +description = "Inter-process communication for the Save Our Secrets SDK." +homepage = "https://saveoursecrets.com" +license = "MIT OR Apache-2.0" +repository = "https://github.com/saveoursecrets/sdk" + +[package.metadata.docs.rs] +features = [ + "local-transport", + "integration", + "extension-helper-server", +] +rustdoc-args = ["--cfg", "docsrs"] + +[features] +local-transport = [ + "serde_with", + "async-trait", +] +memory-http-server = [] +account = ["sos-sdk/account", "sos-protocol/account"] +archive = ["sos-sdk/archive", "sos-protocol/archive"] +clipboard = ["sos-sdk/clipboard"] +contacts = ["sos-sdk/contacts", "sos-protocol/contacts"] +files = ["sos-sdk/files", "sos-protocol/files"] +migrate = ["sos-sdk/migrate", "sos-protocol/migrate"] +search = ["sos-sdk/search", "sos-protocol/search"] +extension-helper-server = [ + "memory-http-server", + "open", + "tokio/io-std", + "once_cell", + "local-transport", + "hyper/http1", + "hyper/client", + "hyper/server", + "tower", + "matchit", + "http-body-util", + "hyper-util", + "clipboard", + "base64", + "notify", + "sos-platform-authenticator", +] +extension-helper-client = [ + "local-transport", + "tokio/process", + "tokio/io-std", + "futures", +] + +[dependencies] +thiserror.workspace = true +tracing.workspace = true +serde.workspace = true +serde_json.workspace = true +typeshare.workspace = true +parking_lot.workspace = true +http.workspace = true +bytes.workspace = true +secrecy.workspace = true +futures = { workspace = true, optional = true } +once_cell = { workspace = true, optional = true } +async-trait = { workspace = true, optional = true } +serde_with = { workspace = true, optional = true } +base64 = { workspace = true, optional = true } +notify = { workspace = true, optional = true } + +sos-sdk = { version = "0.16", path = "../sdk", features = ["account"] } +sos-protocol = { version = "0.16", path = "../protocol" } +sos-platform-authenticator = { version = "0.1", path = "../platform_authenticator", optional = true } + +tokio = { version = "1", features = ["rt", "macros", "io-util", "sync"] } +tokio-util = { version = "0.7", features = ["codec"] } +futures-util = { version = "0.3", features = ["sink"] } + +# server and client +hyper = { version = "1", optional = true } +http-body-util = { version = "0.1", optional = true } +hyper-util = { version = "0.1", features = ["tokio"], optional = true } + +# server +tower = { version = "0.5", features = ["util"], optional = true } +matchit = { version = "0.7", optional = true } + +# native bridge +open = { version = "5", optional = true } + +[build-dependencies] +rustc_version = "0.4.1" + diff --git a/crates/ipc/build.rs b/crates/ipc/build.rs new file mode 100644 index 0000000000..5976a1c6d5 --- /dev/null +++ b/crates/ipc/build.rs @@ -0,0 +1,14 @@ +use rustc_version::{version_meta, Channel}; + +fn main() { + println!("cargo::rustc-check-cfg=cfg(CHANNEL_NIGHTLY)"); + + // Set cfg flags depending on release channel + let channel = match version_meta().unwrap().channel { + Channel::Stable => "CHANNEL_STABLE", + Channel::Beta => "CHANNEL_BETA", + Channel::Nightly => "CHANNEL_NIGHTLY", + Channel::Dev => "CHANNEL_DEV", + }; + println!("cargo:rustc-cfg={}", channel); +} diff --git a/crates/ipc/src/error.rs b/crates/ipc/src/error.rs new file mode 100644 index 0000000000..dfd417bdd1 --- /dev/null +++ b/crates/ipc/src/error.rs @@ -0,0 +1,105 @@ +use sos_protocol::{AsConflict, ConflictError}; +use sos_sdk::prelude::{Address, VaultId}; +use std::path::PathBuf; +use thiserror::Error; + +/// Error type for the library. +#[derive(Error, Debug)] +pub enum Error { + /// Errors generated converting file system notify events. + #[cfg(any(feature = "extension-helper-server"))] + #[error(transparent)] + FileNotifyEvent(#[from] FileEventError), + + /// Errors generated by the IO module. + #[error(transparent)] + Io(#[from] std::io::Error), + + /// Errors generated by the JSON library. + #[error(transparent)] + Json(#[from] serde_json::Error), + + /// Errors generated by the SDK library. + #[error(transparent)] + Sdk(#[from] sos_sdk::Error), + + /// Errors generated by the protocol library. + #[error(transparent)] + Protocol(#[from] sos_protocol::Error), + + /// Errors generated when a URI is invalid. + #[error(transparent)] + HttpUri(#[from] http::uri::InvalidUri), + + /// Error generated by the HTTP library. + #[error(transparent)] + Http(#[from] http::Error), + + /// Error generated by the HTTP library. + #[error(transparent)] + StatusCode(#[from] http::status::InvalidStatusCode), + + /// Errors generated by the hyper library. + #[cfg(any(feature = "extension-helper-server"))] + #[error(transparent)] + Hyper(#[from] hyper::Error), + + /// Errors generated by the file system notification library. + #[cfg(any(feature = "extension-helper-server"))] + #[error(transparent)] + Notify(#[from] notify::Error), + + /// Errors generated from network responses. + #[error(transparent)] + Network(#[from] sos_protocol::NetworkError), + + /// Errors generated on conflict. + #[error(transparent)] + Conflict(#[from] sos_protocol::ConflictError), +} + +impl AsConflict for Error { + fn is_conflict(&self) -> bool { + matches!(self, Error::Conflict(_)) + } + + fn is_hard_conflict(&self) -> bool { + matches!(self, Error::Conflict(ConflictError::Hard)) + } + + fn take_conflict(self) -> Option { + match self { + Self::Conflict(err) => Some(err), + _ => None, + } + } +} + +#[cfg(any(feature = "extension-helper-server"))] +/// Error type converting from file system notify events. +#[derive(Error, Debug)] +pub enum FileEventError { + /// Error generated when a file system event does not have a path. + #[error("no path available for file system event")] + NoEventPath, + + /// Error generated when a file system event path does not have a stem. + #[error("no file stem for event path {0:?}")] + EventPathStem(PathBuf), + + /// Error generated when a file system event does not have a path. + #[error("no account for {0}")] + NoAccount(Address), + + /// Error generated when a file system event does not have a path. + #[error("no folder for {0}")] + NoFolder(VaultId), + + /// Error generated updating the search index. + #[error("failed to update search index, reason: {0}")] + UpdateSearchIndex(String), + + /// Error reloading the identity folder. + #[error("failed to reload identity folder, reason: {0}")] + ReloadIdentityFolder(String), +} diff --git a/crates/ipc/src/extension_helper/client.rs b/crates/ipc/src/extension_helper/client.rs new file mode 100644 index 0000000000..afaf79863d --- /dev/null +++ b/crates/ipc/src/extension_helper/client.rs @@ -0,0 +1,92 @@ +//! Proxy to an IPC server using length-prefixed JSON encoding +//! read from stdin and written to stdout. +//! +//! Used to test the browser native messaging API integration. + +use crate::local_transport::{HttpMessage, LocalRequest, LocalResponse}; +use crate::Result; +use futures_util::{SinkExt, StreamExt}; +use std::process::Stdio; +use std::sync::atomic::{AtomicU64, Ordering}; +use tokio::process::{Child, Command}; +use tokio_util::codec::{FramedRead, FramedWrite, LengthDelimitedCodec}; + +use super::{CHUNK_LIMIT, CHUNK_SIZE}; + +/// Client that spawns a native bridge and sends +/// and receives messages from the spawned executable. +/// +/// Used to test the native bridge server. +pub struct ExtensionHelperClient { + child: Child, + stdin: FramedWrite, + stdout: FramedRead, + id: AtomicU64, +} + +impl ExtensionHelperClient { + /// Create a native bridge client. + pub async fn new(command: C, arguments: I) -> Result + where + C: AsRef, + I: IntoIterator, + S: AsRef, + { + let mut child = Command::new(command) + .args(arguments) + .stdout(Stdio::piped()) + .stdin(Stdio::piped()) + .spawn()?; + + let stdout = child.stdout.take().unwrap(); + let stdin = child.stdin.take().unwrap(); + + let stdin = LengthDelimitedCodec::builder() + .native_endian() + .new_write(stdin); + + let stdout = LengthDelimitedCodec::builder() + .native_endian() + .new_read(stdout); + + Ok(Self { + child, + stdin, + stdout, + id: AtomicU64::new(1), + }) + } + + /// Send a request to the spawned native bridge. + pub async fn send( + &mut self, + mut request: LocalRequest, + ) -> Result { + let message_id = self.id.fetch_add(1, Ordering::SeqCst); + request.set_request_id(message_id); + let chunks = request.into_chunks(CHUNK_LIMIT, CHUNK_SIZE); + + for request in chunks { + let message = serde_json::to_vec(&request)?; + self.stdin.send(message.into()).await?; + } + + let mut chunks: Vec = Vec::new(); + while let Some(response) = self.stdout.next().await { + let response = response?; + let response: LocalResponse = serde_json::from_slice(&response)?; + let chunks_len = response.chunks_len(); + chunks.push(response); + if chunks.len() == chunks_len as usize { + break; + } + } + + Ok(LocalResponse::from_chunks(chunks)) + } + + /// Kill the child process. + pub async fn kill(&mut self) -> Result<()> { + Ok(self.child.kill().await?) + } +} diff --git a/crates/ipc/src/extension_helper/mod.rs b/crates/ipc/src/extension_helper/mod.rs new file mode 100644 index 0000000000..8a497d56b9 --- /dev/null +++ b/crates/ipc/src/extension_helper/mod.rs @@ -0,0 +1,15 @@ +//! Proxy to an IPC server using length-prefixed JSON encoding +//! read from stdin and written to stdout. +//! +//! Used to support the native messaging API provided +//! by browser extensions. + +/// Body size limit before breaking into chunks. +pub const CHUNK_LIMIT: usize = 256 * 1024; +/// Size of each chunk. +pub const CHUNK_SIZE: usize = 128 * 1024; + +#[cfg(feature = "extension-helper-client")] +pub mod client; +#[cfg(feature = "extension-helper-server")] +pub mod server; diff --git a/crates/ipc/src/extension_helper/server.rs b/crates/ipc/src/extension_helper/server.rs new file mode 100644 index 0000000000..05fef5dd8e --- /dev/null +++ b/crates/ipc/src/extension_helper/server.rs @@ -0,0 +1,307 @@ +//! Server for the native messaging API extension helper. + +use crate::{ + local_transport::{HttpMessage, LocalRequest, LocalResponse}, + memory_server::{LocalMemoryClient, LocalMemoryServer}, + web_service::WebAccounts, + Result, ServiceAppInfo, +}; +use futures_util::{SinkExt, StreamExt}; +use http::{ + header::{CONTENT_LENGTH, CONTENT_TYPE}, + StatusCode, +}; +use sos_protocol::{ + constants::MIME_TYPE_JSON, ErrorReply, Merge, SyncStorage, +}; +use sos_sdk::{ + logs::Logger, + prelude::{Account, AccountSwitcher, ErrorExt}, +}; +use std::sync::Arc; +use tokio::sync::{mpsc, RwLock}; +use tokio_util::codec::{FramedRead, LengthDelimitedCodec}; + +use super::{CHUNK_LIMIT, CHUNK_SIZE}; + +const HARD_LIMIT: usize = 1024 * 1024; + +/// Options for a native bridge. +#[derive(Debug, Default)] +pub struct ExtensionHelperOptions { + /// Identifier of the extension. + pub extension_id: String, + /// Service information. + pub service_info: ServiceAppInfo, +} + +impl ExtensionHelperOptions { + /// Create new options. + pub fn new(extension_id: String, service_info: ServiceAppInfo) -> Self { + Self { + extension_id, + service_info, + } + } +} + +/// Server for a native bridge proxy. +pub struct ExtensionHelperServer +where + A: Account + + SyncStorage + + Merge + + Sync + + Send + + 'static, + R: 'static, + E: std::fmt::Debug + + std::error::Error + + ErrorExt + + From + + From + + 'static, +{ + #[allow(dead_code)] + options: ExtensionHelperOptions, + /// Client for the server. + client: LocalMemoryClient, + /// User accounts. + accounts: WebAccounts, +} + +impl ExtensionHelperServer +where + A: Account + + SyncStorage + + Merge + + Sync + + Send + + 'static, + R: 'static, + E: std::fmt::Debug + + std::error::Error + + ErrorExt + + From + + From + + 'static, +{ + /// Create a server. + pub async fn new( + options: ExtensionHelperOptions, + accounts: Arc>>, + ) -> Result { + let log_level = std::env::var("SOS_NATIVE_BRIDGE_LOG_LEVEL") + .map(|s| s.to_string()) + .ok() + .unwrap_or("debug".to_string()); + + // Always send log messages to disc as the browser + // extension reads from stdout + let logger = Logger::new(None); + if let Err(err) = logger.init_file_subscriber(Some(log_level)) { + eprintln!("{}", err); + std::process::exit(1); + } + + tracing::info!(options = ?options, "extension_helper"); + + let accounts = WebAccounts::new(accounts); + let client = LocalMemoryServer::listen( + accounts.clone(), + options.service_info.clone(), + ) + .await?; + + Ok(Self { + options, + client, + accounts, + }) + } + + /// Start a native bridge server listening. + pub async fn listen(&self) { + let mut stdin = LengthDelimitedCodec::builder() + .native_endian() + .new_read(tokio::io::stdin()); + + let mut stdout = LengthDelimitedCodec::builder() + .native_endian() + .new_write(tokio::io::stdout()); + + let (tx, mut rx) = mpsc::unbounded_channel::(); + + // Send account file system change notifications + // over stdout using the RESET_CONTENT status code + let mut notifications = self.accounts.subscribe(); + let notifications_tx = tx.clone(); + tokio::task::spawn(async move { + while let Ok(event) = notifications.recv().await { + let body = serde_json::to_vec(&event) + .expect("to convert event to JSON"); + let mut response = LocalResponse::default(); + response.status = StatusCode::RESET_CONTENT.into(); + response.set_json_content_type(); + response.body = body; + if let Err(e) = notifications_tx.send(response) { + tracing::error!(error = %e); + } + } + }); + + // Read request chunks into a single request + async fn read_chunked_request( + stdin: &mut FramedRead, + ) -> Result { + let mut chunks: Vec = Vec::new(); + while let Some(Ok(buffer)) = stdin.next().await { + let req = serde_json::from_slice::(&buffer)?; + let chunks_len = req.chunks_len(); + chunks.push(req); + if chunks.len() == chunks_len as usize { + break; + } + } + + Ok(LocalRequest::from_chunks(chunks)) + } + + loop { + let mut channel = tx.clone(); + tokio::select! { + result = read_chunked_request(&mut stdin) => { + match result { + Ok(request) => { + let client = self.client.clone(); + if let Err(e) = handle_request( + client, channel.clone(), request).await { + self.internal_error( + StatusCode::INTERNAL_SERVER_ERROR, + e, + &mut channel); + } + } + Err(e) => { + self.internal_error( + StatusCode::BAD_REQUEST, + e, + &mut channel); + } + } + } + Some(response) = rx.recv() => { + tracing::trace!( + response = ?response, + "sos_extension_helper::response", + ); + + match serde_json::to_vec(&response) { + Ok(output) => { + tracing::debug!( + len = %output.len(), + "extension_helper::stdout", + ); + if output.len() > HARD_LIMIT { + tracing::error!( + "extension_helper::exceeds_limit"); + } + if let Err(e) = stdout.send(output.into()).await { + tracing::error!( + error = %e, + "extension_helper::stdout_write", + ); + std::process::exit(1); + } + } + Err(e) => { + tracing::error!( + error = %e, "extension_helper::serde_json"); + std::process::exit(1); + } + } + + } + } + } + } + + fn internal_error( + &self, + status: StatusCode, + err: impl std::fmt::Display, + tx: &mut mpsc::UnboundedSender, + ) { + let mut response = LocalResponse::with_id(status, 0); + let error = ErrorReply::new_message(status, err); + let bytes = serde_json::to_vec(&error).unwrap(); + response.headers_mut().insert( + CONTENT_TYPE.to_string(), + vec![MIME_TYPE_JSON.to_string()], + ); + response.headers_mut().insert( + CONTENT_LENGTH.to_string(), + vec![bytes.len().to_string()], + ); + response.body = bytes; + + // let tx = channel.clone(); + if let Err(e) = tx.send(response.into()) { + tracing::warn!( + error = %e, + "extension_helper::response_channel"); + } + } +} + +async fn handle_request( + client: LocalMemoryClient, + tx: mpsc::UnboundedSender, + request: LocalRequest, +) -> Result<()> { + let task = tokio::task::spawn(async move { + tracing::trace!( + request = ?request, + "sos_extension_helper::request", + ); + + let request_id = request.request_id(); + let response = client.send(request).await; + + let mut result = match response { + Ok(response) => response, + Err(e) => { + tracing::error!(error = %e); + StatusCode::INTERNAL_SERVER_ERROR.into() + } + }; + + result.set_request_id(request_id); + + // Send response in chunks to avoid the 1MB + // hard limit + let chunks = result.into_chunks(CHUNK_LIMIT, CHUNK_SIZE); + + if chunks.len() > 1 { + tracing::debug!( + len = %chunks.len(), + "extension_helper::chunks"); + for (index, chunk) in chunks.iter().enumerate() { + tracing::debug!( + index = %index, + len = %chunk.body.len(), + "extension_helper::chunk"); + } + } + for chunk in chunks { + if let Err(e) = tx.send(chunk) { + tracing::warn!( + error = %e, + "extension_helper::response_channel"); + } + } + + Ok(()) + }); + task.await.unwrap() +} diff --git a/crates/ipc/src/lib.rs b/crates/ipc/src/lib.rs new file mode 100644 index 0000000000..ecd8056b98 --- /dev/null +++ b/crates/ipc/src/lib.rs @@ -0,0 +1,69 @@ +#![deny(missing_docs)] +#![forbid(unsafe_code)] +#![cfg_attr(all(doc, CHANNEL_NIGHTLY), feature(doc_auto_cfg))] +//! Inter-process communication library supporting the +//! native messaging API for browser extensions. + +mod error; + +/// Forbid usage of println! macro. +/// +/// The native bridge code writes to stdout and +/// using println! in the wrong place will cause +/// strange errors with the tokio FramedRead typically +/// something like "frame size too big" because we have +/// inadvertently written a bad length prefix to stdout. +#[macro_export] +#[allow(missing_fragment_specifier)] +macro_rules! println { + ($($any:tt)*) => { + compile_error!("println! macro is forbidden, use eprintln! instead"); + }; +} + +#[cfg(feature = "memory-http-server")] +pub mod memory_server; + +#[cfg(any( + feature = "extension-helper-server", + feature = "extension-helper-client" +))] +pub mod extension_helper; +#[cfg(feature = "extension-helper-server")] +mod web_service; +#[cfg(feature = "extension-helper-server")] +pub(crate) use web_service::LocalWebService; +#[cfg(feature = "extension-helper-server")] +pub use web_service::WebAccounts; +#[cfg(feature = "local-transport")] +pub mod local_transport; + +pub use error::Error; + +#[cfg(feature = "extension-helper-server")] +pub use error::FileEventError; + +/// Result type for the library. +pub type Result = std::result::Result; + +use serde::{Deserialize, Serialize}; + +/// Information about the service. +#[typeshare::typeshare] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ServiceAppInfo { + /// App name. + pub name: String, + /// App version. + pub version: String, +} + +impl Default for ServiceAppInfo { + fn default() -> Self { + Self { + name: env!("CARGO_PKG_NAME").to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + } + } +} diff --git a/crates/ipc/src/local_transport.rs b/crates/ipc/src/local_transport.rs new file mode 100644 index 0000000000..dc9ab3b4e0 --- /dev/null +++ b/crates/ipc/src/local_transport.rs @@ -0,0 +1,516 @@ +//! Types used for HTTP communication between apps +//! on the same device. +//! +//! Wraps the `http` request and response types so we can +//! serialize and deserialize from JSON for transfer via +//! the browser native messaging API. +//! +//! Browsers limit each length-prefixed JSON encoded message +//! to 1MB so these types provide functions to split a message +//! into chunks. + +use crate::{Error, Result}; + +use sos_protocol::constants::{MIME_TYPE_JSON, X_SOS_REQUEST_ID}; + +use async_trait::async_trait; +use bytes::Bytes; +use http::{ + header::{CONTENT_ENCODING, CONTENT_TYPE}, + Method, Request, Response, StatusCode, Uri, +}; +use serde::{Deserialize, Serialize}; +use serde_with::{serde_as, DisplayFromStr}; +use std::{collections::HashMap, fmt, time::Duration}; +use typeshare::typeshare; + +/// Generic local transport. +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +pub trait LocalTransport { + /// Send a request over the local transport. + async fn call(&mut self, request: LocalRequest) -> LocalResponse; +} + +/// HTTP headers encoded as strings. +pub type Headers = HashMap>; + +/// Trait for requests and responses. +pub trait HttpMessage { + /// Message headers. + fn headers(&self) -> &Headers; + + /// Mutable message headers. + fn headers_mut(&mut self) -> &mut Headers; + + /// Message body. + fn body(&self) -> &[u8]; + + /// Mutable message body. + fn body_mut(&mut self) -> &mut Vec; + + /// Consume the message body. + fn into_body(self) -> Vec; + + /// Number of chunks. + fn chunks_len(&self) -> u32; + + /// Zero-based chunk index of this message. + fn chunk_index(&self) -> u32; + + /// Convert this message into a collection of chunks. + /// + /// If the size of the body is less than limit then + /// only this message is included. + /// + /// Conversion is performed on the number of bytes in the + /// body but the native messaging API restricts the serialized + /// JSON to 1MB so it's wise to choose a value smaller + /// than the 1MB limit so there is some headroom for the JSON + /// serialization overhead. + fn into_chunks(self, limit: usize, chunk_size: usize) -> Vec + where + Self: Sized; + + /// Extract a request id. + /// + /// If no header is present or the value is invalid zero + /// is returned. + fn request_id(&self) -> u64 { + if let Some(values) = self.headers().get(X_SOS_REQUEST_ID) { + if let Some(value) = values.first().map(|v| v.as_str()) { + let Ok(id) = value.parse::() else { + return 0; + }; + id + } else { + 0 + } + } else { + 0 + } + } + + /// Set a request id. + fn set_request_id(&mut self, id: u64) { + self.headers_mut() + .insert(X_SOS_REQUEST_ID.to_owned(), vec![id.to_string()]); + } + + /// Read a content type header. + fn content_type(&self) -> Option<&str> { + if let Some(values) = self.headers().get(CONTENT_TYPE.as_str()) { + values.first().map(|v| v.as_str()) + } else { + None + } + } + + /// Read a content encoding header. + fn content_encoding(&self) -> Option<&str> { + if let Some(values) = self.headers().get(CONTENT_ENCODING.as_str()) { + values.first().map(|v| v.as_str()) + } else { + None + } + } + + /// Set an `application/json` content type header. + fn set_json_content_type(&mut self) { + self.headers_mut().insert( + CONTENT_TYPE.as_str().to_owned(), + vec![MIME_TYPE_JSON.to_string()], + ); + } + + /// Determine if this message is JSON. + fn is_json(&self) -> bool { + let Some(value) = self.content_type() else { + return false; + }; + value == MIME_TYPE_JSON + } + + /// Convert the message into parts. + fn into_parts(mut self) -> (Headers, Vec) + where + Self: Sized, + { + let headers = + std::mem::replace(self.headers_mut(), Default::default()); + (headers, self.into_body()) + } + + /// Convert the body to bytes. + fn bytes(self) -> Bytes + where + Self: Sized, + { + self.into_body().into() + } + + /// Convert from a collection of chunks into a response. + /// + /// # Panics + /// + /// If chunks is empty. + fn from_chunks(mut chunks: Vec) -> Self + where + Self: Sized, + { + chunks.sort_by(|a, b| a.chunk_index().cmp(&b.chunk_index())); + let mut it = chunks.into_iter(); + let mut message = it.next().expect("to have one chunk"); + for chunk in it { + let mut body = chunk.into_body(); + message.body_mut().append(&mut body); + } + message + } +} + +/// Request that can be sent to a local data source. +/// +/// Supports serde so this type is compatible with the +/// browser extension which transfers JSON via the +/// native messaging API. +/// +/// The body will usually be protobuf-encoded binary data. +#[typeshare] +#[serde_as] +#[derive(Clone, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LocalRequest { + /// Request method. + #[serde_as(as = "DisplayFromStr")] + pub method: Method, + /// Request URL. + #[serde_as(as = "DisplayFromStr")] + pub uri: Uri, + /// Request headers. + #[serde_as(as = "Vec<(_, _)>")] + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub headers: Headers, + /// Request body. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub body: Vec, + /// Number of chunks for this message. + pub chunks_length: u32, + /// Chunk index for this message. + pub chunk_index: u32, +} + +impl Default for LocalRequest { + fn default() -> Self { + Self { + method: Method::GET, + uri: Uri::builder().path_and_query("/").build().unwrap(), + headers: Default::default(), + body: Default::default(), + chunks_length: 1, + chunk_index: 0, + } + } +} + +impl LocalRequest { + /// Create a GET request from a URI. + pub fn get(uri: Uri) -> Self { + Self { + method: Method::GET, + uri, + headers: Default::default(), + body: Default::default(), + chunks_length: 1, + chunk_index: 0, + } + } + + /// Create a HEAD request from a URI. + pub fn head(uri: Uri) -> Self { + Self { + method: Method::HEAD, + uri, + headers: Default::default(), + body: Default::default(), + chunks_length: 1, + chunk_index: 0, + } + } + + /// Duration allowed for a request. + pub fn timeout_duration(&self) -> Duration { + Duration::from_secs(15) + } +} + +impl HttpMessage for LocalRequest { + fn headers(&self) -> &Headers { + &self.headers + } + + fn headers_mut(&mut self) -> &mut Headers { + &mut self.headers + } + + fn body(&self) -> &[u8] { + self.body.as_slice() + } + + fn body_mut(&mut self) -> &mut Vec { + &mut self.body + } + + fn into_body(self) -> Vec { + self.body + } + + fn chunks_len(&self) -> u32 { + self.chunks_length + } + + fn chunk_index(&self) -> u32 { + self.chunk_index + } + + fn into_chunks(self, limit: usize, chunk_size: usize) -> Vec { + if self.body.len() < limit { + vec![self] + } else { + let mut messages = Vec::new(); + let uri = self.uri.clone(); + let method = self.method.clone(); + let (headers, body) = self.into_parts(); + let len = if body.len() > chunk_size { + let mut len = body.len() / chunk_size; + if body.len() % chunk_size != 0 { + len += 1; + } + len + } else { + 1 + }; + for (index, window) in + body.as_slice().chunks(chunk_size).enumerate() + { + let message = Self { + uri: uri.clone(), + method: method.clone(), + body: window.to_owned(), + headers: headers.clone(), + chunks_length: len as u32, + chunk_index: index as u32, + }; + messages.push(message); + } + messages + } + } +} + +impl fmt::Debug for LocalRequest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("LocalRequest") + .field("method", &self.method.to_string()) + .field("uri", &self.uri.to_string()) + .field("headers", &format_args!("{:?}", self.headers)) + .field("body_length", &self.body.len().to_string()) + .finish() + } +} + +impl From>> for LocalRequest { + fn from(value: Request>) -> Self { + let (parts, body) = value.into_parts(); + + let mut headers = HashMap::new(); + for (key, value) in parts.headers.iter() { + let entry = headers.entry(key.to_string()).or_insert(vec![]); + entry.push(value.to_str().unwrap().to_owned()); + } + + Self { + method: parts.method, + uri: parts.uri, + headers, + body, + chunks_length: 1, + chunk_index: 0, + } + } +} + +impl TryFrom for Request> { + type Error = Error; + + fn try_from(value: LocalRequest) -> Result { + let mut request = + Request::builder().uri(&value.uri).method(&value.method); + for (k, values) in &value.headers { + for value in values { + request = request.header(k, value); + } + } + Ok(request.body(value.body)?) + } +} + +/// Response received from a local data source. +/// +/// Supports serde so this type is compatible with the +/// browser extension which transfers JSON via the +/// native messaging API. +/// +/// The body will usually be protobuf-encoded binary data. +#[typeshare] +#[serde_as] +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalResponse { + /// Response status code. + pub status: u16, + /// Response headers. + #[serde_as(as = "Vec<(_, _)>")] + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub headers: Headers, + /// Response body. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub body: Vec, + /// Number of chunks for this message. + pub chunks_length: u32, + /// Chunk index for this message. + pub chunk_index: u32, +} + +impl Default for LocalResponse { + fn default() -> Self { + Self { + status: StatusCode::OK.into(), + headers: Default::default(), + body: Default::default(), + chunks_length: 1, + chunk_index: 0, + } + } +} + +impl fmt::Debug for LocalResponse { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("LocalResponse") + .field("status", &self.status.to_string()) + .field("headers", &format_args!("{:?}", self.headers)) + .field("body_length", &self.body.len().to_string()) + .finish() + } +} + +impl From>> for LocalResponse { + fn from(value: Response>) -> Self { + let (parts, body) = value.into_parts(); + + let mut headers = HashMap::new(); + for (key, value) in parts.headers.iter() { + let entry = headers.entry(key.to_string()).or_insert(vec![]); + entry.push(value.to_str().unwrap().to_owned()); + } + + Self { + status: parts.status.into(), + headers, + body, + chunks_length: 1, + chunk_index: 0, + } + } +} + +impl From for LocalResponse { + fn from(status: StatusCode) -> Self { + Self { + status: status.into(), + ..Default::default() + } + } +} + +impl LocalResponse { + /// Create a response with a request id. + pub fn with_id(status: StatusCode, id: u64) -> Self { + let mut res = Self { + status: status.into(), + headers: Default::default(), + body: Default::default(), + chunks_length: 1, + chunk_index: 0, + }; + res.set_request_id(id); + res + } + + /// Status code. + pub fn status(&self) -> Result { + Ok(self.status.try_into()?) + } +} + +impl HttpMessage for LocalResponse { + fn headers(&self) -> &Headers { + &self.headers + } + + fn headers_mut(&mut self) -> &mut Headers { + &mut self.headers + } + + fn body(&self) -> &[u8] { + self.body.as_slice() + } + + fn body_mut(&mut self) -> &mut Vec { + &mut self.body + } + + fn into_body(self) -> Vec { + self.body + } + + fn chunks_len(&self) -> u32 { + self.chunks_length + } + + fn chunk_index(&self) -> u32 { + self.chunk_index + } + + fn into_chunks(self, limit: usize, chunk_size: usize) -> Vec { + if self.body.len() < limit { + vec![self] + } else { + let mut messages = Vec::new(); + let status = self.status.clone(); + let (headers, body) = self.into_parts(); + let len = if body.len() > chunk_size { + let mut len = body.len() / chunk_size; + if body.len() % chunk_size != 0 { + len += 1; + } + len + } else { + 1 + }; + for (index, window) in + body.as_slice().chunks(chunk_size).enumerate() + { + let message = Self { + status, + headers: headers.clone(), + body: window.to_owned(), + chunks_length: len as u32, + chunk_index: index as u32, + }; + messages.push(message); + } + messages + } + } +} diff --git a/crates/ipc/src/memory_server.rs b/crates/ipc/src/memory_server.rs new file mode 100644 index 0000000000..29813b55c5 --- /dev/null +++ b/crates/ipc/src/memory_server.rs @@ -0,0 +1,159 @@ +//! HTTP server that listens for connections +//! using in-memory duplex streams. +use crate::{ + local_transport::{LocalRequest, LocalResponse}, + LocalWebService, Result, ServiceAppInfo, WebAccounts, +}; +use bytes::Bytes; +use http::{header::CONNECTION, Request, Response}; +use http_body_util::{BodyExt, Full}; +use hyper::client::conn::http1::handshake; +use hyper::server::conn::http1::Builder; +use hyper_util::rt::tokio::TokioIo; +use sos_protocol::{Merge, SyncStorage}; +use sos_sdk::prelude::{Account, ErrorExt}; +use std::sync::Arc; +use tokio::{ + io::DuplexStream, + sync::{mpsc, oneshot}, +}; + +/// Client for the in-memory HTTP server. +#[derive(Clone)] +pub struct LocalMemoryClient { + connect_tx: mpsc::Sender>, +} + +impl LocalMemoryClient { + /// Send a request. + pub async fn send(&self, request: LocalRequest) -> Result { + let stream = self.connect().await?; + + let request: Request> = request.try_into()?; + let (mut header, body) = request.into_parts(); + header.headers.insert(CONNECTION, "close".parse().unwrap()); + let request = + Request::from_parts(header, Full::new(Bytes::from(body))); + + let response = self.send_http(stream, request).await?; + let (header, body) = response.into_parts(); + let bytes = body.collect().await.unwrap().to_bytes(); + let response = Response::from_parts(header, bytes.to_vec()); + Ok(response.into()) + } + + /// Connect to the server. + async fn connect(&self) -> Result { + let (tx, rx) = oneshot::channel::(); + self.connect_tx.send(tx).await.unwrap(); + Ok(rx.await.unwrap()) + } + + /// Send a HTTP request. + async fn send_http( + &self, + io: DuplexStream, + request: Request>, + ) -> Result>> { + let socket = TokioIo::new(io); + let (mut sender, conn) = handshake(socket).await?; + + tokio::task::spawn(async move { + if let Err(err) = conn.await { + tracing::error!(error = %err, "ipc::client::connection"); + } + }); + + let response = sender.send_request(request).await?; + let (header, body) = response.into_parts(); + let bytes = body.collect().await.unwrap().to_bytes(); + let response = Response::from_parts(header, Full::new(bytes)); + Ok(response) + } + + /* + /// Get application information. + pub async fn info(&mut self) -> Result { + let response = self.send_request(Default::default()).await?; + let status = response.status()?; + if status.is_success() { + let app_info: ServiceAppInfo = + serde_json::from_slice(&response.body)?; + Ok(app_info) + } else { + Err(NetworkError::ResponseCode(status).into()) + } + } + + /// List accounts. + pub async fn list_accounts(&mut self) -> Result> { + let request = LocalRequest::get("/accounts".parse()?); + let response = self.send_request(request).await?; + let status = response.status()?; + if status.is_success() { + let accounts: Vec = + serde_json::from_slice(&response.body)?; + Ok(accounts) + } else { + Err(NetworkError::ResponseCode(status).into()) + } + } + */ +} + +/// Server for in-memory communication. +pub struct LocalMemoryServer; + +impl LocalMemoryServer { + /// Listen to an in-memory stream. + pub async fn listen( + accounts: WebAccounts, + app_info: ServiceAppInfo, + ) -> Result + where + A: Account + + SyncStorage + + Merge + + Sync + + Send + + 'static, + R: 'static, + E: std::fmt::Debug + + std::error::Error + + ErrorExt + + From + + From + + 'static, + { + let service = LocalWebService::new(app_info, accounts); + let svc = Arc::new(service); + + let (conn_tx, mut conn_rx) = + mpsc::channel::>(64); + let client = LocalMemoryClient { + connect_tx: conn_tx, + }; + tokio::task::spawn(async move { + while let Some(notify) = conn_rx.recv().await { + let (client, server) = tokio::io::duplex(4096); + + notify.send(client).unwrap(); + + let socket = TokioIo::new(server); + let mut http = Builder::new(); + http.auto_date_header(false); + + tracing::debug!("memory_server::new_connection"); + let conn = http.serve_connection(socket, svc.clone()); + if let Err(err) = conn.await { + tracing::error!( + error = %err, + "memory_server::connection_error"); + } + tracing::debug!("memory_server::connection_close"); + } + }); + + Ok(client) + } +} diff --git a/crates/ipc/src/web_service/account.rs b/crates/ipc/src/web_service/account.rs new file mode 100644 index 0000000000..e98a6afa56 --- /dev/null +++ b/crates/ipc/src/web_service/account.rs @@ -0,0 +1,408 @@ +//! Account and folder routes. + +use http::{Request, Response, StatusCode}; +use secrecy::SecretString; +use serde::Deserialize; +use sos_protocol::{Merge, SyncStorage}; +use sos_sdk::prelude::{AccessKey, Account, Address, ErrorExt, Identity}; +use std::collections::HashMap; + +use crate::web_service::{ + internal_server_error, json, parse_account_id, parse_json_body, status, + Body, Incoming, WebAccounts, +}; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct SigninRequest { + password: String, + save_password: bool, +} + +/// List account public identities. +pub async fn list_accounts( + _req: Request, + accounts: WebAccounts, +) -> hyper::Result> +where + A: Account + + SyncStorage + + Merge + + Sync + + Send + + 'static, + R: 'static, + E: std::fmt::Debug + + ErrorExt + + std::error::Error + + From + + From + + 'static, +{ + let accounts = accounts.as_ref().read().await; + match Identity::list_accounts(accounts.paths()).await { + Ok(list) => json(StatusCode::OK, &list), + Err(e) => internal_server_error(e), + } +} + +/// List folders for authenticated accounts. +pub async fn list_folders( + _req: Request, + accounts: WebAccounts, +) -> hyper::Result> +where + A: Account + + SyncStorage + + Merge + + Sync + + Send + + 'static, + R: 'static, + E: std::fmt::Debug + + ErrorExt + + std::error::Error + + From + + From + + 'static, +{ + let accounts = accounts.as_ref().read().await; + let mut list = HashMap::new(); + for account in accounts.iter() { + let address = account.address().to_string(); + if account.is_authenticated().await { + match account.list_folders().await { + Ok(folders) => { + list.insert(address, folders); + } + Err(e) => { + return internal_server_error(e); + } + } + } + } + json(StatusCode::OK, &list) +} + +/// List account authenticated status. +pub async fn authenticated_accounts( + _req: Request, + accounts: WebAccounts, +) -> hyper::Result> +where + A: Account + + SyncStorage + + Merge + + Sync + + Send + + 'static, + R: 'static, + E: std::fmt::Debug + + ErrorExt + + std::error::Error + + From + + From + + 'static, +{ + let accounts = accounts.as_ref().read().await; + let mut list = HashMap::new(); + for account in accounts.iter() { + let address = account.address().to_string(); + list.insert(address.to_string(), account.is_authenticated().await); + } + json(StatusCode::OK, &list) +} + +/// Sign in to an account with a user-supplied password. +pub async fn sign_in_account( + req: Request, + accounts: WebAccounts, +) -> hyper::Result> +where + A: Account + + SyncStorage + + Merge + + Sync + + Send + + 'static, + R: 'static, + E: std::fmt::Debug + + std::error::Error + + ErrorExt + + From + + From + + 'static, +{ + let Some(account_id) = parse_account_id(&req) else { + return status(StatusCode::BAD_REQUEST); + }; + + let Ok(request) = parse_json_body::(req).await else { + return status(StatusCode::BAD_REQUEST); + }; + + tracing::debug!(account = %account_id, "sign_in"); + + let password = SecretString::new(request.password.into()); + sign_in_password(accounts, account_id, password, request.save_password) + .await +} + +/// Sign in to an account attempting to retrieve the account +/// password from the platform keyring. +/// +/// If a platform authenticator or platform keyring is not supported +/// this will return `StatusCode::UNAUTHORIZED` and the user will +/// need to supply their password and +pub async fn sign_in( + req: Request, + accounts: WebAccounts, +) -> hyper::Result> +where + A: Account + + SyncStorage + + Merge + + Sync + + Send + + 'static, + R: 'static, + E: std::fmt::Debug + + std::error::Error + + ErrorExt + + From + + From + + 'static, +{ + use sos_platform_authenticator::{ + find_account_credential, keyring_password, local_auth, + }; + + let Some(account_id) = parse_account_id(&req) else { + return status(StatusCode::BAD_REQUEST); + }; + + tracing::debug!( + account_id = %account_id, + local_auth_supported = %local_auth::supported(), + keyring_password_supported = %keyring_password::supported(), + ); + + match find_account_credential(&account_id.to_string()).await { + Ok(password) => { + sign_in_password(accounts, account_id, password, false).await + } + Err(e) => { + let code: StatusCode = (&e).into(); + match code { + StatusCode::INTERNAL_SERVER_ERROR => internal_server_error(e), + StatusCode::NOT_FOUND => status(StatusCode::UNAUTHORIZED), + _ => status(code), + } + } + } +} + +/// Sign in to an account +pub async fn sign_in_password( + accounts: WebAccounts, + account_id: Address, + password: SecretString, + save_password: bool, +) -> hyper::Result> +where + A: Account + + SyncStorage + + Merge + + Sync + + Send + + 'static, + R: 'static, + E: std::fmt::Debug + + std::error::Error + + ErrorExt + + From + + From + + 'static, +{ + use sos_platform_authenticator::keyring_password; + + let mut user_accounts = accounts.as_ref().write().await; + let Some(account) = user_accounts + .iter_mut() + .find(|a| a.address() == &account_id) + else { + return status(StatusCode::NOT_FOUND); + }; + + let key: AccessKey = password.clone().into(); + + let folder_ids = if let Ok(folders) = account.list_folders().await { + folders.into_iter().map(|f| *f.id()).collect::>() + } else { + vec![] + }; + + match account.sign_in(&key).await { + Ok(_) => { + if let Err(e) = + accounts.watch(account_id, account.paths(), folder_ids) + { + tracing::error!(error = ?e); + } + } + Err(e) => { + if e.is_permission_denied() { + return status(StatusCode::FORBIDDEN); + } else { + return internal_server_error(e); + } + } + } + + if let Err(e) = account.initialize_search_index().await { + return internal_server_error(e); + } + + if save_password && keyring_password::supported() { + if let Err(e) = keyring_password::save_account_password( + &account_id.to_string(), + password, + ) { + return internal_server_error(e); + } + } + + status(StatusCode::OK) +} + +/// Sign out of an account +pub async fn sign_out_account( + req: Request, + accounts: WebAccounts, +) -> hyper::Result> +where + A: Account + + SyncStorage + + Merge + + Sync + + Send + + 'static, + R: 'static, + E: std::fmt::Debug + + std::error::Error + + ErrorExt + + From + + From + + 'static, +{ + let Some(account_id) = parse_account_id(&req) else { + return status(StatusCode::BAD_REQUEST); + }; + + tracing::debug!(account = %account_id, "sign_out::account"); + + sign_out(accounts, Some(account_id)).await +} + +/// Sign out of all accounts +pub async fn sign_out_all( + _req: Request, + accounts: WebAccounts, +) -> hyper::Result> +where + A: Account + + SyncStorage + + Merge + + Sync + + Send + + 'static, + R: 'static, + E: std::fmt::Debug + + std::error::Error + + ErrorExt + + From + + From + + 'static, +{ + tracing::debug!("sign_out::all"); + sign_out(accounts, None).await +} + +/// Sign out of an account +pub async fn sign_out( + accounts: WebAccounts, + account_id: Option
, +) -> hyper::Result> +where + A: Account + + SyncStorage + + Merge + + Sync + + Send + + 'static, + R: 'static, + E: std::fmt::Debug + + std::error::Error + + ErrorExt + + From + + From + + 'static, +{ + let mut user_accounts = accounts.as_ref().write().await; + if let Some(account_id) = account_id { + let Some(account) = user_accounts + .iter_mut() + .find(|a| a.address() == &account_id) + else { + return status(StatusCode::NOT_FOUND); + }; + + let folder_ids = if let Ok(folders) = account.list_folders().await { + folders.into_iter().map(|f| *f.id()).collect::>() + } else { + vec![] + }; + + match account.sign_out().await { + Ok(_) => { + if let Err(e) = + accounts.unwatch(&account_id, account.paths(), folder_ids) + { + return internal_server_error(e); + } + status(StatusCode::OK) + } + Err(e) => internal_server_error(e), + } + } else { + let mut account_info = Vec::new(); + for account in user_accounts.iter() { + let folder_ids = if let Ok(folders) = account.list_folders().await + { + folders.into_iter().map(|f| *f.id()).collect::>() + } else { + vec![] + }; + + account_info.push(( + *account.address(), + account.paths(), + folder_ids, + )); + } + + match user_accounts.sign_out_all().await { + Ok(_) => { + for (account_id, paths, folder_ids) in account_info { + if let Err(e) = + accounts.unwatch(&account_id, paths, folder_ids) + { + return internal_server_error(e); + } + } + status(StatusCode::OK) + } + Err(e) => internal_server_error(e), + } + } +} diff --git a/crates/ipc/src/web_service/common.rs b/crates/ipc/src/web_service/common.rs new file mode 100644 index 0000000000..e0714d5786 --- /dev/null +++ b/crates/ipc/src/web_service/common.rs @@ -0,0 +1,94 @@ +use std::collections::HashMap; + +use bytes::Bytes; +use http::{header::CONTENT_TYPE, Request, Response, StatusCode, Uri}; +use http_body_util::{BodyExt, Full}; +use serde::{de::DeserializeOwned, Serialize}; +use sos_protocol::{ + constants::{MIME_TYPE_JSON, X_SOS_ACCOUNT_ID}, + ErrorReply, +}; +use sos_sdk::{prelude::Address, url::form_urlencoded}; + +use super::{Body, Incoming}; + +pub async fn parse_json_body( + req: Request, +) -> crate::Result { + let bytes = read_bytes(req).await?.to_vec(); + Ok(serde_json::from_slice::(&bytes)?) +} + +pub fn parse_query(uri: &Uri) -> HashMap { + let uri = uri.to_string(); + let parts = uri.splitn(2, "?"); + let Some(query) = parts.last() else { + return Default::default(); + }; + let it = form_urlencoded::parse(query.as_bytes()); + it.map(|(k, v)| (k.into_owned(), v.into_owned())) + .collect::>() +} + +pub async fn read_bytes(req: Request) -> hyper::Result { + Ok(req.collect().await?.to_bytes()) +} + +pub fn parse_account_id(req: &Request) -> Option
{ + let Some(Ok(account_id)) = + req.headers().get(X_SOS_ACCOUNT_ID).map(|v| v.to_str()) + else { + return None; + }; + let Ok(account_id) = account_id.parse::
() else { + return None; + }; + Some(account_id) +} + +pub fn status(status: StatusCode) -> hyper::Result> { + Ok(Response::builder() + .status(status) + .body(Body::default()) + .unwrap()) +} + +pub fn not_found() -> hyper::Result> { + status(StatusCode::NOT_FOUND) +} + +pub fn internal_server_error( + e: impl std::fmt::Display, +) -> hyper::Result> { + let error = ErrorReply::new_message(StatusCode::INTERNAL_SERVER_ERROR, e); + json(StatusCode::INTERNAL_SERVER_ERROR, &error) +} + +pub fn json( + status: StatusCode, + value: &S, +) -> hyper::Result> { + match serde_json::to_vec(value) { + Ok(body) => { + let response = Response::builder() + .status(status) + .header(CONTENT_TYPE, MIME_TYPE_JSON) + .body(Full::new(Bytes::from(body))) + .unwrap(); + Ok(response) + } + Err(e) => internal_server_error(e), + } +} + +pub fn text( + status: StatusCode, + body: String, +) -> hyper::Result> { + let response = Response::builder() + .status(status) + .header(CONTENT_TYPE, "text/plain") + .body(Full::new(Bytes::from(body.as_bytes().to_vec()))) + .unwrap(); + Ok(response) +} diff --git a/crates/ipc/src/web_service/helpers.rs b/crates/ipc/src/web_service/helpers.rs new file mode 100644 index 0000000000..9ba66ee554 --- /dev/null +++ b/crates/ipc/src/web_service/helpers.rs @@ -0,0 +1,53 @@ +//! Helper routes for utility functions. + +use crate::web_service::{parse_query, status, Body, Incoming}; +use http::{Request, Response, StatusCode}; + +/// Open a URL. +pub async fn open_url( + req: Request, +) -> hyper::Result> { + tracing::debug!(uri = %req.uri(), "open_url"); + + let query = parse_query(req.uri()); + + let Some(value) = query.get("url") else { + return status(StatusCode::BAD_REQUEST); + }; + + tracing::debug!(url = %value, "open_url"); + + #[cfg(debug_assertions)] + if let Some(app) = option_env!("SOS_DEBUG_APP") { + match open::with_detached(value, app) { + Ok(_) => status(StatusCode::OK), + Err(_) => status(StatusCode::BAD_GATEWAY), + } + } else { + match open::that_detached(value) { + Ok(_) => status(StatusCode::OK), + Err(_) => status(StatusCode::BAD_GATEWAY), + } + } + + #[cfg(not(debug_assertions))] + match open::that_detached(value) { + Ok(_) => status(StatusCode::OK), + Err(_) => status(StatusCode::BAD_GATEWAY), + } +} + +#[cfg(debug_assertions)] +pub async fn large_file( + _req: Request, +) -> hyper::Result> { + use bytes::Bytes; + use http_body_util::Full; + const MB: usize = 1024 * 1024; + let body = [255u8; MB].to_vec(); + + Ok(Response::builder() + .status(StatusCode::OK) + .body(Full::new(Bytes::from(body))) + .unwrap()) +} diff --git a/crates/ipc/src/web_service/mod.rs b/crates/ipc/src/web_service/mod.rs new file mode 100644 index 0000000000..9ab65878f8 --- /dev/null +++ b/crates/ipc/src/web_service/mod.rs @@ -0,0 +1,365 @@ +use bytes::Bytes; +use http::{Method, Request, Response, StatusCode}; +use http_body_util::Full; +use hyper::body::Incoming; +use hyper::service::Service; +use parking_lot::Mutex; +use sos_protocol::{Merge, SyncStorage}; +use sos_sdk::prelude::{Account, ErrorExt}; +use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc}; +use tower::service_fn; +use tower::util::BoxCloneService; +use tower::Service as _; + +use crate::ServiceAppInfo; + +type Body = Full; + +// Need the Mutex as BoxCloneService does not implement Sync +type MethodRoute = + Mutex, Response, hyper::Error>>; + +type Router = HashMap>; + +mod account; +mod common; +mod helpers; +mod search; +mod secret; +mod web_accounts; + +use account::*; +use common::*; +use helpers::*; +use search::*; +use secret::*; +pub use web_accounts::*; + +async fn index( + app_info: Arc, +) -> hyper::Result> { + json(StatusCode::OK, &*app_info) +} + +/// Local server handles sync requests from app integrations +/// running on the same device. +/// +/// We avoid using axum directly as we need the `Sync` bound +/// but `axum::Body` is `!Sync`. +#[derive(Clone)] +pub(crate) struct LocalWebService { + /// Service router. + router: Arc, +} + +impl LocalWebService { + /// Create a local server. + pub fn new( + app_info: ServiceAppInfo, + accounts: WebAccounts, + ) -> Self + where + A: Account + + SyncStorage + + Merge + + Sync + + Send + + 'static, + R: 'static, + E: std::fmt::Debug + + std::error::Error + + ErrorExt + + From + + From + + 'static, + { + let mut router = Router::new(); + let info = Arc::new(app_info); + + router + .entry(Method::GET) + .or_default() + .insert( + "/", + BoxCloneService::new(service_fn( + move |_req: Request| index(info.clone()), + )) + .into(), + ) + .unwrap(); + + router + .entry(Method::HEAD) + .or_default() + .insert( + "/", + BoxCloneService::new(service_fn( + move |_req: Request| async move { + status(StatusCode::OK) + }, + )) + .into(), + ) + .unwrap(); + + router + .entry(Method::GET) + .or_default() + .insert( + "/open", + BoxCloneService::new(service_fn( + move |req: Request| open_url(req), + )) + .into(), + ) + .unwrap(); + + // Route used to test chunking logic for responses + // that exceed the 1MB native messaging API limit + #[cfg(debug_assertions)] + router + .entry(Method::GET) + .or_default() + .insert( + "/large-file", + BoxCloneService::new(service_fn( + move |req: Request| large_file(req), + )) + .into(), + ) + .unwrap(); + + let state = accounts.clone(); + router + .entry(Method::GET) + .or_default() + .insert( + "/accounts", + BoxCloneService::new(service_fn( + move |req: Request| { + list_accounts(req, state.clone()) + }, + )) + .into(), + ) + .unwrap(); + + let state = accounts.clone(); + router + .entry(Method::GET) + .or_default() + .insert( + "/accounts/authenticated", + BoxCloneService::new(service_fn( + move |req: Request| { + authenticated_accounts(req, state.clone()) + }, + )) + .into(), + ) + .unwrap(); + + let state = accounts.clone(); + router + .entry(Method::GET) + .or_default() + .insert( + "/folders", + BoxCloneService::new(service_fn( + move |req: Request| { + list_folders(req, state.clone()) + }, + )) + .into(), + ) + .unwrap(); + + let state = accounts.clone(); + router + .entry(Method::POST) + .or_default() + .insert( + "/search", + BoxCloneService::new(service_fn( + move |req: Request| search(req, state.clone()), + )) + .into(), + ) + .unwrap(); + + let state = accounts.clone(); + router + .entry(Method::POST) + .or_default() + .insert( + "/search/view", + BoxCloneService::new(service_fn( + move |req: Request| { + query_view(req, state.clone()) + }, + )) + .into(), + ) + .unwrap(); + + { + let state = accounts.clone(); + router + .entry(Method::POST) + .or_default() + .insert( + "/signin", + BoxCloneService::new(service_fn( + move |req: Request| { + sign_in(req, state.clone()) + }, + )) + .into(), + ) + .unwrap(); + } + + let state = accounts.clone(); + router + .entry(Method::PUT) + .or_default() + .insert( + "/signin", + BoxCloneService::new(service_fn( + move |req: Request| { + sign_in_account(req, state.clone()) + }, + )) + .into(), + ) + .unwrap(); + + { + let state = accounts.clone(); + router + .entry(Method::GET) + .or_default() + .insert( + "/secret", + BoxCloneService::new(service_fn( + move |req: Request| { + read_secret(req, state.clone()) + }, + )) + .into(), + ) + .unwrap(); + } + + #[cfg(feature = "contacts")] + { + let state = accounts.clone(); + router + .entry(Method::GET) + .or_default() + .insert( + "/avatar", + BoxCloneService::new(service_fn( + move |req: Request| { + load_avatar(req, state.clone()) + }, + )) + .into(), + ) + .unwrap(); + } + + #[cfg(feature = "clipboard")] + { + let state = accounts.clone(); + router + .entry(Method::POST) + .or_default() + .insert( + "/secret/copy", + BoxCloneService::new(service_fn( + move |req: Request| { + copy_secret_clipboard(req, state.clone()) + }, + )) + .into(), + ) + .unwrap(); + } + + let state = accounts.clone(); + router + .entry(Method::POST) + .or_default() + .insert( + "/signout", + BoxCloneService::new(service_fn( + move |req: Request| { + sign_out_account(req, state.clone()) + }, + )) + .into(), + ) + .unwrap(); + + let state = accounts.clone(); + router + .entry(Method::PUT) + .or_default() + .insert( + "/signout", + BoxCloneService::new(service_fn( + move |req: Request| { + sign_out_all(req, state.clone()) + }, + )) + .into(), + ) + .unwrap(); + + Self { + router: Arc::new(router), + } + } + + async fn route( + router: Arc, + req: Request, + ) -> hyper::Result> { + let Some(router) = router.get(req.method()) else { + return Ok(Response::builder() + .status(StatusCode::METHOD_NOT_ALLOWED) + .body(Body::default()) + .unwrap()); + }; + + let Ok(found) = router.at(req.uri().path()) else { + return not_found(); + }; + + // lock the service for a very short time, + // just to clone the service + let mut service = found.value.lock().clone(); + match service.call(req).await { + Ok(result) => Ok(result), + Err(e) => internal_server_error(e), + } + } +} + +impl Service> for LocalWebService { + type Response = Response>; + type Error = hyper::Error; + type Future = Pin< + Box< + dyn Future< + Output = std::result::Result, + > + Send, + >, + >; + + fn call(&self, req: Request) -> Self::Future { + let router = self.router.clone(); + Box::pin(async move { LocalWebService::route(router, req).await }) + } +} diff --git a/crates/ipc/src/web_service/search.rs b/crates/ipc/src/web_service/search.rs new file mode 100644 index 0000000000..06d125066d --- /dev/null +++ b/crates/ipc/src/web_service/search.rs @@ -0,0 +1,104 @@ +//! Search index query routes. + +use http::{Request, Response, StatusCode}; +use serde::Deserialize; +use sos_protocol::{Merge, SyncStorage}; +use sos_sdk::prelude::{ + Account, ArchiveFilter, DocumentView, ErrorExt, QueryFilter, +}; +use std::collections::HashMap; + +use crate::web_service::{ + internal_server_error, json, parse_json_body, status, Body, Incoming, + WebAccounts, +}; + +#[derive(Deserialize)] +struct SearchRequest { + needle: String, + filter: QueryFilter, +} + +#[derive(Deserialize)] +struct QueryViewRequest { + views: Vec, + archive_filter: Option, +} + +/// Search authenticated accounts. +pub async fn search( + req: Request, + accounts: WebAccounts, +) -> hyper::Result> +where + A: Account + + SyncStorage + + Merge + + Sync + + Send + + 'static, + R: 'static, + E: std::fmt::Debug + + ErrorExt + + std::error::Error + + From + + From + + 'static, +{ + let Ok(request) = parse_json_body::(req).await else { + return status(StatusCode::BAD_REQUEST); + }; + + let accounts = accounts.as_ref().read().await; + match accounts.search(request.needle, request.filter).await { + Ok(results) => { + let list = results + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect::>(); + json(StatusCode::OK, &list) + } + Err(e) => internal_server_error(e), + } +} + +/// Query a search index view for authenticated accounts. +pub async fn query_view( + req: Request, + accounts: WebAccounts, +) -> hyper::Result> +where + A: Account + + SyncStorage + + Merge + + Sync + + Send + + 'static, + R: 'static, + E: std::fmt::Debug + + ErrorExt + + std::error::Error + + From + + From + + 'static, +{ + let Ok(request) = parse_json_body::(req).await else { + return status(StatusCode::BAD_REQUEST); + }; + + let accounts = accounts.as_ref().read().await; + match accounts + .query_view(request.views.as_slice(), request.archive_filter.as_ref()) + .await + { + Ok(results) => { + let list = results + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect::>(); + + json(StatusCode::OK, &list) + } + Err(e) => internal_server_error(e), + } +} diff --git a/crates/ipc/src/web_service/secret.rs b/crates/ipc/src/web_service/secret.rs new file mode 100644 index 0000000000..4e2735b4d6 --- /dev/null +++ b/crates/ipc/src/web_service/secret.rs @@ -0,0 +1,175 @@ +//! Server for the native messaging API bridge. + +use http::{Request, Response, StatusCode}; +use serde::Deserialize; +use sos_protocol::{Merge, SyncStorage}; +use sos_sdk::prelude::{Account, ClipboardCopyRequest, ErrorExt, SecretPath}; + +use crate::web_service::{ + internal_server_error, json, parse_account_id, parse_json_body, status, + Body, Incoming, WebAccounts, +}; + +#[derive(Deserialize)] +struct CopyRequest { + target: SecretPath, + request: Option, +} + +/// Copy a secret to the clipboard. +#[cfg(feature = "clipboard")] +pub async fn copy_secret_clipboard( + req: Request, + accounts: WebAccounts, +) -> hyper::Result> +where + A: Account + + SyncStorage + + Merge + + Sync + + Send + + 'static, + R: 'static, + E: std::fmt::Debug + + ErrorExt + + std::error::Error + + From + + From + + 'static, +{ + use crate::web_service::internal_server_error; + + let Some(account_id) = parse_account_id(&req) else { + return status(StatusCode::BAD_REQUEST); + }; + + let Ok(mut payload) = parse_json_body::(req).await else { + return status(StatusCode::BAD_REQUEST); + }; + + let request = payload.request.take().unwrap_or_default(); + let accounts = accounts.as_ref().read().await; + match accounts + .copy_clipboard(&account_id, &payload.target, &request) + .await + { + Ok(result) => json(StatusCode::OK, &result), + Err(e) => { + tracing::error!(error = %e, "copy_clipboard"); + internal_server_error(e) + } + } +} + +/// Read a secret. +pub async fn read_secret( + req: Request, + accounts: WebAccounts, +) -> hyper::Result> +where + A: Account + + SyncStorage + + Merge + + Sync + + Send + + 'static, + R: 'static, + E: std::fmt::Debug + + ErrorExt + + std::error::Error + + From + + From + + 'static, +{ + let Some(account_id) = parse_account_id(&req) else { + return status(StatusCode::BAD_REQUEST); + }; + + let accounts = accounts.as_ref().read().await; + let Some(account) = accounts.iter().find(|a| a.address() == &account_id) + else { + return status(StatusCode::NOT_FOUND); + }; + + let Ok(request) = parse_json_body::(req).await else { + return status(StatusCode::BAD_REQUEST); + }; + + let Some(folder) = account.find(|f| f.id() == request.folder_id()).await + else { + return status(StatusCode::NOT_FOUND); + }; + + match account.read_secret(request.secret_id(), Some(folder)).await { + Ok(result) => { + let mut secret_row = result.0; + let redacted = secret_row.secret_mut().redact(true, 12); + tracing::debug!( + kind = %secret_row.meta().kind(), + redacted = %redacted, + "read_secret"); + json(StatusCode::OK, &secret_row) + } + Err(e) => { + tracing::error!(error = %e, "read_secret"); + internal_server_error(e) + } + } +} + +#[cfg(feature = "contacts")] +pub async fn load_avatar( + req: Request, + accounts: WebAccounts, +) -> hyper::Result> +where + A: Account + + SyncStorage + + Merge + + Sync + + Send + + 'static, + R: 'static, + E: std::fmt::Debug + + ErrorExt + + std::error::Error + + From + + From + + 'static, +{ + use crate::web_service::text; + use base64::prelude::*; + + let Some(account_id) = parse_account_id(&req) else { + return status(StatusCode::BAD_REQUEST); + }; + + let accounts = accounts.as_ref().read().await; + let Some(account) = accounts.iter().find(|a| a.address() == &account_id) + else { + return status(StatusCode::NOT_FOUND); + }; + + let Ok(request) = parse_json_body::(req).await else { + return status(StatusCode::BAD_REQUEST); + }; + + let Some(folder) = account.find(|f| f.id() == request.folder_id()).await + else { + return status(StatusCode::NOT_FOUND); + }; + + match account.load_avatar(request.secret_id(), Some(folder)).await { + Ok(maybe_avatar) => { + let Some(png_bytes) = maybe_avatar else { + return status(StatusCode::NOT_FOUND); + }; + let encoded = BASE64_STANDARD.encode(&png_bytes); + text( + StatusCode::OK, + format!("data:image/jpeg;base64,{}", encoded), + ) + } + Err(e) => internal_server_error(e), + } +} diff --git a/crates/ipc/src/web_service/web_accounts.rs b/crates/ipc/src/web_service/web_accounts.rs new file mode 100644 index 0000000000..5cb2dab8e3 --- /dev/null +++ b/crates/ipc/src/web_service/web_accounts.rs @@ -0,0 +1,586 @@ +use notify::{ + recommended_watcher, Event, RecommendedWatcher, RecursiveMode, Watcher, +}; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use sos_protocol::{Merge, SyncStorage}; +use sos_sdk::{ + events::{AccountEvent, EventLogExt, WriteEvent}, + prelude::{ + Account, AccountSwitcher, Address, Error as SdkError, ErrorExt, Paths, + }, + vault::VaultId, +}; +use std::{collections::HashMap, sync::Arc}; +use tokio::sync::{broadcast, RwLock}; + +use crate::{Error, FileEventError, Result}; + +/// Event broadcast when an account changes on disc. +#[typeshare::typeshare] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountChangeEvent { + /// Account identifier. + pub account_id: Address, + /// Event records with information about the changes. + pub records: ChangeRecords, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ChangeRecords { + /// Account level events. + Account(Vec), + /// Folder level events. + Folder(VaultId, Vec), +} + +impl ChangeRecords { + /// Determine if the records are empty. + pub fn is_empty(&self) -> bool { + match self { + Self::Account(records) => records.is_empty(), + Self::Folder(_, records) => records.is_empty(), + } + } +} + +/// User accounts for the web service. +pub struct WebAccounts +where + A: Account + + SyncStorage + + Merge + + Sync + + Send + + 'static, + R: 'static, + E: std::fmt::Debug + + std::error::Error + + ErrorExt + + From + + From + + 'static, +{ + accounts: Arc>>, + watchers: Arc>>, + channel: broadcast::Sender, +} + +impl Clone for WebAccounts +where + A: Account + + SyncStorage + + Merge + + Sync + + Send + + 'static, + R: 'static, + E: std::fmt::Debug + + std::error::Error + + ErrorExt + + From + + From + + 'static, +{ + fn clone(&self) -> Self { + Self { + accounts: self.accounts.clone(), + watchers: self.watchers.clone(), + channel: self.channel.clone(), + } + } +} + +impl WebAccounts +where + A: Account + + SyncStorage + + Merge + + Sync + + Send + + 'static, + R: 'static, + E: std::fmt::Debug + + std::error::Error + + ErrorExt + + From + + From + + 'static, +{ + /// Create new accounts. + pub fn new(accounts: Arc>>) -> Self { + let (tx, _) = broadcast::channel::(64); + Self { + accounts, + watchers: Arc::new(Mutex::new(HashMap::new())), + channel: tx, + } + } + + /// Subscribe to change events. + pub fn subscribe(&self) -> broadcast::Receiver { + self.channel.subscribe() + } + + /// Start watching an account for changes. + pub fn watch( + &self, + account_id: Address, + paths: Arc, + folder_ids: Vec, + ) -> Result<()> { + let has_watcher = { + let watchers = self.watchers.lock(); + watchers.get(&account_id).is_some() + }; + + if !has_watcher { + let (tx, rx) = broadcast::channel::(32); + let channel = self.channel.clone(); + let task_id = account_id.clone(); + let task_paths = paths.clone(); + let task_accounts = self.accounts.clone(); + let task_watchers = self.watchers.clone(); + tokio::task::spawn(async move { + if let Err(e) = notify_listener( + task_id, + task_paths, + task_accounts, + task_watchers, + rx, + channel, + ) + .await + { + tracing::error!(error = %e, "notify_listener"); + } + }); + + let mut watcher = recommended_watcher( + move |res: notify::Result| match res { + Ok(event) => { + if let Err(e) = tx.send(event) { + tracing::error!(error = %e, "file_system_notify_channel::send"); + } + } + Err(e) => { + tracing::error!(error = %e, "notify::error"); + } + }, + )?; + + watcher.watch( + &paths.identity_events(), + RecursiveMode::NonRecursive, + )?; + + for id in &folder_ids { + watch_folder(id, &*paths, &mut watcher)?; + } + + let mut watchers = self.watchers.lock(); + watchers.insert(account_id, watcher); + } + Ok(()) + } + + /// Stop watching an account for changes. + pub fn unwatch( + &self, + account_id: &Address, + paths: Arc, + folder_ids: Vec, + ) -> Result { + let mut watchers = self.watchers.lock(); + if let Some(mut watcher) = watchers.remove(account_id) { + watcher.watch( + &paths.identity_events(), + RecursiveMode::NonRecursive, + )?; + for id in &folder_ids { + unwatch_folder(id, &*paths, &mut watcher)?; + } + Ok(true) + } else { + Ok(false) + } + } +} + +impl AsRef>>> + for WebAccounts +where + A: Account + + SyncStorage + + Merge + + Sync + + Send + + 'static, + R: 'static, + E: std::fmt::Debug + + std::error::Error + + ErrorExt + + From + + From + + 'static, +{ + fn as_ref(&self) -> &Arc>> { + &self.accounts + } +} + +/// Update the search index for an account. +async fn update_account_search_index( + account: &mut A, + records: &ChangeRecords, +) -> std::result::Result<(), E> +where + A: Account + + SyncStorage + + Merge + + Sync + + Send + + 'static, + R: 'static, + E: std::fmt::Debug + + std::error::Error + + ErrorExt + + From + + From + + 'static, +{ + let paths = account.paths(); + let index = account.index().await?; + + let folder_ids = match records { + ChangeRecords::Account(events) => { + let mut folder_ids = Vec::new(); + for event in events { + match event { + AccountEvent::CreateFolder(folder_id, _) => { + folder_ids.push(*folder_id); + } + AccountEvent::DeleteFolder(folder_id) => { + folder_ids.push(*folder_id) + } + _ => {} + } + } + folder_ids + } + ChangeRecords::Folder(folder_id, _) => vec![*folder_id], + }; + + for folder_id in folder_ids { + match records { + ChangeRecords::Account(events) => { + for event in events { + match event { + AccountEvent::CreateFolder(_, _) => { + // Find the folder password which should be available + // as the identity folder has been reloaded already + let key = account + .find_folder_password(&folder_id) + .await? + .ok_or(SdkError::NoFolderPassword( + folder_id, + ))?; + // Import the vault into the account + account + .import_folder( + paths.vault_path(&folder_id), + key, + true, + ) + .await?; + + // Now the storage should have the folder so + // we can access the gatekeeper and add it to + // the search index + let storage = account.storage().await.unwrap(); + let storage = storage.read().await; + if let Some(folder) = + storage.cache().get(&folder_id) + { + let keeper = folder.keeper(); + let mut index = index.write().await; + index.add_folder(keeper).await?; + } + } + AccountEvent::DeleteFolder(_) => { + let mut index = index.write().await; + index.remove_vault(&folder_id); + } + _ => {} + } + } + } + ChangeRecords::Folder(folder_id, events) => { + let storage = account.storage().await.unwrap(); + let mut storage = storage.write().await; + if let Some(folder) = storage.cache_mut().get_mut(&folder_id) + { + let keeper = folder.keeper_mut(); + + // Must reload the vault before updating the + // search index + let path = paths.vault_path(folder_id); + keeper.reload_vault(path).await?; + + for event in events { + match event { + WriteEvent::CreateSecret(secret_id, _) => { + if let Some((meta, secret, _)) = + keeper.read_secret(secret_id).await? + { + let mut index = index.write().await; + index.add( + folder_id, secret_id, &meta, &secret, + ); + } + } + WriteEvent::UpdateSecret(secret_id, _) => { + if let Some((meta, secret, _)) = + keeper.read_secret(secret_id).await? + { + let mut index = index.write().await; + index.update( + folder_id, secret_id, &meta, &secret, + ); + } + } + WriteEvent::DeleteSecret(secret_id) => { + let mut index = index.write().await; + index.remove(folder_id, secret_id); + } + _ => {} + } + } + } + } + } + } + + Ok(()) +} + +async fn notify_listener( + account_id: Address, + paths: Arc, + accounts: Arc>>, + watchers: Arc>>, + mut rx: broadcast::Receiver, + channel: broadcast::Sender, +) -> Result<()> +where + A: Account + + SyncStorage + + Merge + + Sync + + Send + + 'static, + R: 'static, + E: std::fmt::Debug + + std::error::Error + + ErrorExt + + From + + From + + 'static, +{ + let account_name = account_id.to_string(); + + while let Ok(event) = rx.recv().await { + let path = event.paths.get(0).ok_or(FileEventError::NoEventPath)?; + let name = path + .file_stem() + .ok_or(FileEventError::EventPathStem(path.to_owned()))? + .to_string_lossy() + .into_owned(); + + tracing::debug!( + file_stem = %name, + account_name = %account_name, + "notify_listener::change_event"); + + // Identity folder event log changes + let records = if name == account_name { + let mut accounts = accounts.write().await; + let account = accounts + .iter_mut() + .find(|a| a.address() == &account_id) + .ok_or(FileEventError::NoAccount(account_id))?; + + // Reload the identity folder + { + account.reload_identity_folder().await.map_err(|e| { + FileEventError::ReloadIdentityFolder(e.to_string()) + })?; + } + + let records = load_account_records(account).await?; + + // Check for folder create events and start watching new + // folder as they are created. + { + let mut watchers = watchers.lock(); + for record in &records { + if let ( + AccountEvent::CreateFolder(folder_id, _), + Some(watcher), + ) = (record, watchers.get_mut(&account_id)) + { + watch_folder(&folder_id, &*paths, watcher)?; + } + } + } + + // Update folders in memory + { + tracing::debug!("account_change::load_folders"); + if let Err(e) = account.load_folders().await { + tracing::error!(error = %e); + } + } + + ChangeRecords::Account(records) + } else { + let folder_id: VaultId = name.parse().map_err(SdkError::from)?; + + // Event log was removed so we can treat + // as a folder delete event, we should + // stop watching the folder. + if event.kind.is_remove() { + { + let mut watchers = watchers.lock(); + if let Some(watcher) = watchers.get_mut(&account_id) { + unwatch_folder(&folder_id, &*paths, watcher)?; + } + } + + { + let accounts = accounts.read().await; + let account = accounts + .iter() + .find(|a| a.address() == &account_id) + .ok_or(FileEventError::NoAccount(account_id))?; + + let storage = account.storage().await.unwrap(); + let mut storage = storage.write().await; + storage.remove_folder(&folder_id).await?; + } + ChangeRecords::Folder(folder_id, vec![]) + } else { + let accounts = accounts.read().await; + let account = accounts + .iter() + .find(|a| a.address() == &account_id) + .ok_or(FileEventError::NoAccount(account_id))?; + + let storage = account.storage().await.unwrap(); + let storage = storage.read().await; + let folder = storage + .cache() + .get(&folder_id) + .ok_or(FileEventError::NoFolder(folder_id))?; + + let event_log = folder.event_log(); + let mut event_log = event_log.write().await; + let commit = event_log.tree().last_commit(); + let patch = event_log.diff_events(commit.as_ref()).await?; + let records = patch.into_events::().await?; + + event_log.load_tree().await?; + + ChangeRecords::Folder(folder_id, records) + } + }; + + // Update the search index + { + let mut accounts = accounts.write().await; + + if let Some(account) = + accounts.iter_mut().find(|a| a.address() == &account_id) + { + update_account_search_index(account, &records) + .await + .map_err(|e| { + FileEventError::UpdateSearchIndex(e.to_string()) + })?; + } + } + + if !records.is_empty() { + // Dispatch the event + let evt = AccountChangeEvent { + account_id, + records, + }; + if let Err(e) = channel.send(evt) { + tracing::error!( + error = ?e, + "account_channel::send"); + } + } + } + + Ok::<_, Error>(()) +} + +async fn load_account_records( + account: &A, +) -> Result> +where + A: Account + + SyncStorage + + Merge + + Sync + + Send + + 'static, + R: 'static, + E: std::fmt::Debug + + std::error::Error + + ErrorExt + + From + + From + + 'static, +{ + let storage = account.storage().await.unwrap(); + let storage = storage.read().await; + + let mut event_log = storage.account_log.write().await; + let commit = event_log.tree().last_commit(); + + let patch = event_log.diff_events(commit.as_ref()).await?; + let records = patch.into_events::().await?; + + event_log.load_tree().await?; + Ok(records) +} + +/// Start watching folder event log. +fn watch_folder( + folder_id: &VaultId, + paths: &Paths, + watcher: &mut RecommendedWatcher, +) -> Result<()> { + tracing::debug!(folder_id = %folder_id, "watch::folder"); + watcher.watch( + &paths.event_log_path(folder_id), + RecursiveMode::NonRecursive, + )?; + Ok(()) +} + +/// Stop watching folder event log. +fn unwatch_folder( + folder_id: &VaultId, + paths: &Paths, + watcher: &mut RecommendedWatcher, +) -> Result<()> { + tracing::debug!(folder_id = %folder_id, "unwatch::folder"); + watcher.unwatch(&paths.event_log_path(folder_id))?; + Ok(()) +} diff --git a/crates/keychain_parser/Cargo.toml b/crates/keychain_parser/Cargo.toml index c1e0175451..5679e5502f 100644 --- a/crates/keychain_parser/Cargo.toml +++ b/crates/keychain_parser/Cargo.toml @@ -8,9 +8,9 @@ license = "MIT OR Apache-2.0" repository = "https://github.com/saveoursecrets/sdk" [dependencies] -thiserror = "1" -logos = { version = "0.14", features = ["export_derive"] } -plist = "1.6.1" +thiserror.workspace = true +logos = { version = "0.15", features = ["export_derive"] } +plist = "1.7" [dev-dependencies] anyhow = "1" diff --git a/crates/net/Cargo.toml b/crates/net/Cargo.toml index ce5b21e819..8238fec331 100644 --- a/crates/net/Cargo.toml +++ b/crates/net/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sos-net" -version = "0.15.1" +version = "0.16.1" edition = "2021" description = "Networking library for the Save Our Secrets SDK." homepage = "https://saveoursecrets.com" @@ -14,6 +14,7 @@ rustdoc-args = ["--cfg", "docsrs"] [features] default = [] full = [ + "account", "audit", "archive", "contacts", @@ -29,13 +30,16 @@ full = [ "system-messages", "clipboard", ] -listen = ["dep:tokio-tungstenite", "sos-protocol/listen"] -hashcheck = [] + +account = ["sos-sdk/account"] +listen = ["sos-protocol/listen"] +hashcheck = ["sos-protocol/hashcheck"] audit = ["sos-sdk/audit"] -archive = ["sos-sdk/archive"] +archive = ["sos-sdk/archive", "sos-protocol/archive"] files = ["sos-sdk/files", "sos-protocol/files"] -contacts = ["sos-sdk/contacts"] -migrate = ["sos-sdk/migrate"] +clipboard = ["sos-sdk/clipboard"] +contacts = ["sos-sdk/contacts", "sos-protocol/contacts"] +migrate = ["sos-sdk/migrate", "sos-protocol/migrate"] keychain-access = ["sos-sdk/keychain-access"] recovery = ["sos-sdk/recovery"] pairing = ["dep:snow", "sos-protocol/pairing"] @@ -44,7 +48,6 @@ search = ["sos-sdk/search", "sos-protocol/search"] preferences = ["sos-account-extras/preferences"] security-report = ["sos-account-extras/security-report"] system-messages = ["sos-account-extras/system-messages"] -clipboard = ["sos-account-extras/clipboard"] [dependencies] tokio-util.workspace = true @@ -64,11 +67,8 @@ serde_with.workspace = true rand.workspace = true url.workspace = true futures.workspace = true -bs58.workspace = true urlencoding.workspace = true indexmap.workspace = true -async-stream = "0.3" -colored = "2" binary-stream.workspace = true rs_merkle.workspace = true prost.workspace = true @@ -76,21 +76,18 @@ prost.workspace = true # pairing snow = { version = "0.9", optional = true } -reqwest = { version = "0.12.5", default-features = false, features = ["json", "rustls-tls", "stream"] } tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync"] } -tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] , optional = true} [dependencies.sos-sdk] -version = "0.15.1" -path = "../sdk" +workspace = true [dependencies.sos-protocol] -version = "0.15.1" +version = "0.16" path = "../protocol" -features = ["account"] +features = ["account", "network-client"] [dependencies.sos-account-extras] -version = "0.15.1" +version = "0.16" path = "../account_extras/" optional = true diff --git a/crates/net/src/account/file_transfers/inflight.rs b/crates/net/src/account/file_transfers/inflight.rs index 72bc3cb773..d2eddd8cfe 100644 --- a/crates/net/src/account/file_transfers/inflight.rs +++ b/crates/net/src/account/file_transfers/inflight.rs @@ -1,8 +1,10 @@ //! Tracks inflight file transfer requests. use crate::{ - protocol::{Origin, TransferOperation}, + protocol::{ + transfer::{CancelReason, TransferOperation}, + Origin, + }, sdk::storage::files::ExternalFile, - CancelReason, }; use std::{ diff --git a/crates/net/src/account/file_transfers/mod.rs b/crates/net/src/account/file_transfers/mod.rs index be4f076e2b..38ad3d6451 100644 --- a/crates/net/src/account/file_transfers/mod.rs +++ b/crates/net/src/account/file_transfers/mod.rs @@ -15,10 +15,16 @@ //! Requests are limited to the `concurrent_requests` setting guarded //! by a semaphore and notifications are sent via [InflightTransfers]. use crate::{ - net::NetworkRetry, - protocol::{FileOperation, Origin, TransferOperation}, + protocol::{ + network_client::NetworkRetry, + transfer::{ + CancelReason, FileOperation, FileSyncClient, + FileTransferQueueRequest, ProgressChannel, TransferOperation, + }, + Origin, SyncClient, + }, sdk::{storage::files::ExternalFile, vfs, Paths}, - CancelReason, Error, Result, SyncClient, + Error, Result, }; use futures::FutureExt; @@ -41,12 +47,6 @@ pub use inflight::{ }; use std::collections::{HashSet, VecDeque}; -/// Request to queue a file transfer. -pub type FileTransferQueueRequest = Vec; - -/// Channel for upload and download progress notifications. -pub type ProgressChannel = mpsc::Sender<(u64, Option)>; - /// Channel used to cancel uploads and downloads. /// /// The boolean flag indicates whether the cancellation was @@ -222,7 +222,13 @@ impl FileTransfersHandle { /// when each operation has been completed on every client. pub struct FileTransfers where - C: SyncClient + Clone + Send + Sync + PartialEq + 'static, + C: SyncClient + + FileSyncClient + + Clone + + Send + + Sync + + PartialEq + + 'static, { clients: Arc>>, settings: Arc, @@ -233,7 +239,13 @@ where impl FileTransfers where - C: SyncClient + Clone + Send + Sync + PartialEq + 'static, + C: SyncClient + + FileSyncClient + + Clone + + Send + + Sync + + PartialEq + + 'static, { /// Create new file transfers manager. pub fn new(clients: Vec, settings: FileTransferSettings) -> Self { diff --git a/crates/net/src/account/file_transfers/operations.rs b/crates/net/src/account/file_transfers/operations.rs index 193700692b..8ab1f2b587 100644 --- a/crates/net/src/account/file_transfers/operations.rs +++ b/crates/net/src/account/file_transfers/operations.rs @@ -3,13 +3,18 @@ //! Tasks that handle retry until exhaustion for //! download, upload, move and delete operations. use crate::{ - net::NetworkRetry, + protocol::{ + network_client::NetworkRetry, + transfer::{CancelReason, FileSyncClient}, + Error, SyncClient, + }, sdk::{storage::files::ExternalFile, vfs, Paths}, - CancelReason, Error, Result, SyncClient, + Result, }; use async_recursion::async_recursion; use http::StatusCode; +use sos_protocol::NetworkError; use std::{io::ErrorKind, sync::Arc}; use tokio::sync::watch; @@ -20,7 +25,12 @@ use super::{ pub struct UploadOperation where - C: SyncClient + Clone + Send + Sync + 'static, + C: SyncClient + + FileSyncClient + + Clone + + Send + + Sync + + 'static, { client: C, paths: Arc, @@ -33,7 +43,12 @@ where impl UploadOperation where - C: SyncClient + Clone + Send + Sync + 'static, + C: SyncClient + + FileSyncClient + + Clone + + Send + + Sync + + 'static, { pub fn new( client: C, @@ -105,10 +120,10 @@ where Ok(res) => res, Err(e) => { match e { - Error::RetryCanceled(user_canceled) => { + sos_protocol::Error::RetryCanceled(user_canceled) => { Ok(TransferResult::Fatal(TransferError::Canceled(user_canceled))) } - _ => Err(e), + _ => Err(e.into()), } } } @@ -120,7 +135,12 @@ where impl TransferTask for UploadOperation where - C: SyncClient + Clone + Send + Sync + 'static, + C: SyncClient + + FileSyncClient + + Clone + + Send + + Sync + + 'static, { fn request_id(&self) -> u64 { self.request_id @@ -146,7 +166,12 @@ where pub struct DownloadOperation where - C: SyncClient + Clone + Send + Sync + 'static, + C: SyncClient + + FileSyncClient + + Clone + + Send + + Sync + + 'static, { client: C, paths: Arc, @@ -159,7 +184,12 @@ where impl DownloadOperation where - C: SyncClient + Clone + Send + Sync + 'static, + C: SyncClient + + FileSyncClient + + Clone + + Send + + Sync + + 'static, { pub fn new( client: C, @@ -247,10 +277,10 @@ where Ok(res) => res, Err(e) => { match e { - Error::RetryCanceled(user_canceled) => { + sos_protocol::Error::RetryCanceled(user_canceled) => { Ok(TransferResult::Fatal(TransferError::Canceled(user_canceled))) } - _ => Err(e), + _ => Err(e.into()), } } } @@ -262,7 +292,12 @@ where impl TransferTask for DownloadOperation where - C: SyncClient + Clone + Send + Sync + 'static, + C: SyncClient + + FileSyncClient + + Clone + + Send + + Sync + + 'static, { fn request_id(&self) -> u64 { self.request_id @@ -300,7 +335,12 @@ where impl DeleteOperation where - C: SyncClient + Clone + Send + Sync + 'static, + C: SyncClient + + FileSyncClient + + Clone + + Send + + Sync + + 'static, { pub fn new( client: C, @@ -359,12 +399,12 @@ where { Ok(res) => res, Err(e) => match e { - Error::RetryCanceled(user_canceled) => { + sos_protocol::Error::RetryCanceled(user_canceled) => { Ok(TransferResult::Fatal(TransferError::Canceled( user_canceled, ))) } - _ => Err(e), + _ => Err(e.into()), }, } } else { @@ -375,7 +415,12 @@ where impl TransferTask for DeleteOperation where - C: SyncClient + Clone + Send + Sync + 'static, + C: SyncClient + + FileSyncClient + + Clone + + Send + + Sync + + 'static, { fn request_id(&self) -> u64 { self.request_id @@ -401,7 +446,12 @@ where pub struct MoveOperation where - C: SyncClient + Clone + Send + Sync + 'static, + C: SyncClient + + FileSyncClient + + Clone + + Send + + Sync + + 'static, { client: C, transfer_id: u64, @@ -413,7 +463,12 @@ where impl MoveOperation where - C: SyncClient + Clone + Send + Sync + 'static, + C: SyncClient + + FileSyncClient + + Clone + + Send + + Sync + + 'static, { pub fn new( client: C, @@ -476,12 +531,12 @@ where { Ok(res) => res, Err(e) => match e { - Error::RetryCanceled(user_canceled) => { + sos_protocol::Error::RetryCanceled(user_canceled) => { Ok(TransferResult::Fatal(TransferError::Canceled( user_canceled, ))) } - _ => Err(e), + _ => Err(e.into()), }, } } else { @@ -492,7 +547,12 @@ where impl TransferTask for MoveOperation where - C: SyncClient + Clone + Send + Sync + 'static, + C: SyncClient + + FileSyncClient + + Clone + + Send + + Sync + + 'static, { fn request_id(&self) -> u64 { self.request_id @@ -513,9 +573,10 @@ where fn on_error(&self, error: Error) -> TransferResult { tracing::warn!(error = ?error, "move_file::error"); match error { - Error::ResponseJson(StatusCode::NOT_FOUND, _) => { - TransferResult::Fatal(TransferError::MovedMissing) - } + Error::Network(NetworkError::ResponseJson( + StatusCode::NOT_FOUND, + _, + )) => TransferResult::Fatal(TransferError::MovedMissing), _ => on_error(error), } } diff --git a/crates/net/src/account/listen.rs b/crates/net/src/account/listen.rs index c72b8f2c16..63f92a3b0c 100644 --- a/crates/net/src/account/listen.rs +++ b/crates/net/src/account/listen.rs @@ -1,9 +1,11 @@ //! Adds functions for listening to change notifications using //! a websocket connection. use crate::{ - protocol::{ChangeNotification, Origin, SyncStorage}, - sync::RemoteSync, - Error, ListenOptions, NetworkAccount, RemoteResult, Result, + protocol::{ + network_client::ListenOptions, ChangeNotification, Origin, + RemoteResult, RemoteSync, SyncStorage, + }, + Error, NetworkAccount, Result, }; use std::sync::Arc; use tokio::sync::mpsc; @@ -37,7 +39,9 @@ impl NetworkAccount { &self, origin: &Origin, options: ListenOptions, - listener: Option>, + listener: Option< + mpsc::Sender<(ChangeNotification, RemoteResult)>, + >, ) -> Result<()> { let remotes = self.remotes.read().await; if let Some(remote) = remotes.get(origin) { diff --git a/crates/net/src/account/mod.rs b/crates/net/src/account/mod.rs index ada8b2597d..6a604ad881 100644 --- a/crates/net/src/account/mod.rs +++ b/crates/net/src/account/mod.rs @@ -1,7 +1,7 @@ //! Network aware account storage. use crate::sdk::prelude::{Account, AccountSwitcher}; -mod auto_merge; +// mod auto_merge; #[cfg(feature = "files")] mod file_transfers; #[cfg(feature = "listen")] @@ -12,34 +12,15 @@ mod sync; /// Account switcher for network-enabled accounts. pub type NetworkAccountSwitcher = AccountSwitcher< - ::Error, - ::NetworkResult, NetworkAccount, + ::NetworkResult, + ::Error, >; -/// Information about a cancellation. -#[derive(Default, Debug, Clone, Hash, Eq, PartialEq)] -pub enum CancelReason { - /// Unknown reason. - #[default] - Unknown, - /// Event loop is being shutdown. - Shutdown, - /// Websocket connection was closed. - Closed, - /// Cancellation was from a user interaction. - UserCanceled, - /// Aborted due to conflict with a subsequent operation. - /// - /// For example, a move or delete transfer operation must abort - /// any existing upload or download. - Aborted, -} - #[cfg(feature = "files")] pub use file_transfers::{ CancelChannel, FileTransferSettings, InflightNotification, - InflightRequest, InflightTransfers, ProgressChannel, TransferError, + InflightRequest, InflightTransfers, TransferError, }; pub use network_account::{NetworkAccount, NetworkAccountOptions}; pub use remote::RemoteBridge; diff --git a/crates/net/src/account/network_account.rs b/crates/net/src/account/network_account.rs index a505ca60a8..578bcc247a 100644 --- a/crates/net/src/account/network_account.rs +++ b/crates/net/src/account/network_account.rs @@ -1,19 +1,22 @@ //! Network aware account. use crate::{ - protocol::{Origin, SyncOptions, UpdateSet}, + protocol::{ + AccountSync, DiffRequest, EventLogType, Origin, RemoteSync, + RemoteSyncHandler, SyncClient, SyncOptions, SyncResult, UpdateSet, + }, sdk::{ account::{ Account, AccountBuilder, AccountChange, AccountData, - CipherComparison, DetachedView, FolderChange, FolderCreate, - FolderDelete, LocalAccount, SecretChange, SecretDelete, - SecretInsert, SecretMove, SigninOptions, + CipherComparison, FolderChange, FolderCreate, FolderDelete, + LocalAccount, SecretChange, SecretDelete, SecretInsert, + SecretMove, }, commit::{CommitHash, CommitState}, crypto::{AccessKey, Cipher, KeyDerivation}, device::{ DeviceManager, DevicePublicKey, DeviceSigner, TrustedDevice, }, - events::{AccountEvent, EventLogExt, ReadEvent}, + events::{AccountEvent, EventLogExt, EventRecord, ReadEvent}, identity::{AccountRef, PublicIdentity}, sha2::{Digest, Sha256}, signer::ecdsa::{Address, BoxedEcdsaSigner}, @@ -22,42 +25,50 @@ use crate::{ }, vault::{ secret::{Secret, SecretId, SecretMeta, SecretRow}, - Summary, Vault, VaultId, + Summary, Vault, VaultCommit, VaultFlags, VaultId, }, vfs, Paths, }, - SyncClient, SyncResult, + Error, RemoteBridge, Result, }; use async_trait::async_trait; use secrecy::SecretString; -use sos_protocol::{DiffRequest, EventLogType}; -use sos_sdk::{events::EventRecord, vault::VaultFlags}; +use sos_sdk::events::{AccountPatch, DevicePatch, FolderPatch}; use std::{ collections::{HashMap, HashSet}, path::{Path, PathBuf}, sync::Arc, }; -use tokio::{ - io::{AsyncRead, AsyncSeek}, - sync::{Mutex, RwLock}, +use tokio::sync::{Mutex, RwLock}; + +#[cfg(feature = "clipboard")] +use sos_sdk::{ + prelude::{ClipboardCopyRequest, SecretPath}, + xclipboard::Clipboard, }; #[cfg(feature = "search")] -use crate::sdk::storage::search::{ +use crate::sdk::prelude::{ AccountStatistics, ArchiveFilter, Document, DocumentCount, DocumentView, QueryFilter, SearchIndex, }; #[cfg(feature = "archive")] -use crate::sdk::account::archive::{Inventory, RestoreOptions}; +use crate::sdk::prelude::{Inventory, RestoreOptions}; use indexmap::IndexSet; -#[cfg(feature = "listen")] -use crate::WebSocketHandle; - #[cfg(feature = "contacts")] -use crate::sdk::account::ContactImportProgress; +use crate::sdk::prelude::ContactImportProgress; + +#[cfg(feature = "archive")] +use tokio::io::{AsyncRead, AsyncSeek}; + +#[cfg(feature = "migrate")] +use crate::sdk::prelude::ImportTarget; + +#[cfg(feature = "listen")] +use sos_protocol::network_client::WebSocketHandle; /* #[cfg(feature = "security-report")] @@ -66,11 +77,7 @@ use crate::sdk::account::security_report::{ }; */ -#[cfg(feature = "migrate")] -use crate::sdk::migrate::import::ImportTarget; - use super::remote::Remotes; -use crate::{AccountSync, Error, RemoteBridge, RemoteSync, Result}; #[cfg(feature = "files")] use crate::{ @@ -78,9 +85,8 @@ use crate::{ FileTransferSettings, FileTransfers, FileTransfersHandle, InflightTransfers, }, - protocol::FileOperation, - sdk::storage::files::FileMutationEvent, - HttpClient, + protocol::{network_client::HttpClient, transfer::FileOperation}, + sdk::{prelude::FilePatch, storage::files::FileMutationEvent}, }; /// Options for network account creation. @@ -134,18 +140,15 @@ pub struct NetworkAccount { pub(crate) offline: bool, /// Options for the network account. + #[allow(dead_code)] options: NetworkAccountOptions, } impl NetworkAccount { - async fn login( - &mut self, - key: &AccessKey, - options: SigninOptions, - ) -> Result> { + async fn login(&mut self, key: &AccessKey) -> Result> { let folders = { let mut account = self.account.lock().await; - let folders = account.sign_in_with_options(key, options).await?; + let folders = account.sign_in(key).await?; self.paths = account.paths(); self.address = account.address().clone(); folders @@ -158,21 +161,72 @@ impl NetworkAccount { } // Load origins from disc and create remote definitions - let mut clients = Vec::new(); if let Some(origins) = self.load_servers().await? { let mut remotes: Remotes = Default::default(); for origin in origins { let remote = self.remote_bridge(&origin).await?; - clients.push(remote.client().clone()); remotes.insert(origin, remote); } self.remotes = Arc::new(RwLock::new(remotes)); } + self.activate().await?; + + Ok(folders) + } + + /* + /// Copy of the HTTP client for a remote. + /// + /// This is an internal function used for testing + /// only, do not use. + #[doc(hidden)] + pub async fn remote_client(&self, origin: &Origin) -> Option { + let remotes = self.remotes.read().await; + remotes.get(origin).map(|r| r.client().clone()) + } + */ + + /// Deactive this account by closing down long-running tasks. + /// + /// Does not sign out of the account so is similar to moving + /// this account to the background so the data is still accessible. + /// + /// This can be used when implementing quick account switching + /// to shutdown the websocket and file transfers. + /// + /// Server remotes are left intact so that making changes + /// will still sync with server(s). + pub async fn deactivate(&mut self) { + #[cfg(feature = "listen")] + { + tracing::debug!("net_sign_out::shutdown_websockets"); + self.shutdown_websockets().await; + } + #[cfg(feature = "files")] { + tracing::debug!("net_sign_out::stop_file_transfers"); + self.stop_file_transfers().await; + } + } + + /// Activate this account by resuming websocket connections + /// and file transfers. + pub async fn activate(&mut self) -> Result<()> { + #[cfg(feature = "files")] + { + let clients = { + let mut clients = Vec::new(); + let remotes = self.remotes.read().await; + for (_, remote) in &*remotes { + clients.push(remote.client().clone()); + } + clients + }; + let file_transfers = FileTransfers::new( clients, self.options.file_transfer_settings.clone(), @@ -181,7 +235,7 @@ impl NetworkAccount { self.start_file_transfers().await?; } - Ok(folders) + Ok(()) } /// Revoke a device. @@ -197,7 +251,8 @@ impl NetworkAccount { // Update the local device event log { let account = self.account.lock().await; - let storage = account.storage().await?; + let storage = + account.storage().await.ok_or(sos_sdk::Error::NoStorage)?; let mut storage = storage.write().await; storage.revoke_device(device_key).await?; } @@ -264,15 +319,14 @@ impl NetworkAccount { &mut self, origin: Origin, ) -> Result> { - let remote = self.remote_bridge(&origin).await?; - - #[cfg(feature = "files")] - if let Some(file_transfers) = self.file_transfers.as_mut() { - file_transfers.add_client(remote.client().clone()).await; - }; - #[cfg(feature = "files")] { + let remote = self.remote_bridge(&origin).await?; + + if let Some(file_transfers) = self.file_transfers.as_mut() { + file_transfers.add_client(remote.client().clone()).await; + }; + let mut remotes = self.remotes.write().await; if let Some(handle) = &self.file_transfer_handle { self.proxy_remote_file_queue(handle, &remote).await; @@ -329,6 +383,7 @@ impl NetworkAccount { let remote = { let mut remotes = self.remotes.write().await; let remote = remotes.remove(origin); + #[allow(unused_variables)] if let Some(remote) = &remote { #[cfg(feature = "files")] if let Some(file_transfers) = self.file_transfers.as_mut() { @@ -412,7 +467,7 @@ impl NetworkAccount { log_type: EventLogType::Folder(*folder_id), from_hash: None, }; - let response = remote.client().diff(request).await?; + let response = remote.client().diff(self.address(), request).await?; self.restore_folder(folder_id, response.patch).await } @@ -433,7 +488,7 @@ impl NetworkAccount { let origins = remotes.keys().collect::>(); let data = serde_json::to_vec_pretty(&origins)?; let file = self.paths().remote_origins(); - vfs::write(file, data).await?; + vfs::write_exclusive(file, data).await?; Ok(()) } @@ -639,7 +694,7 @@ impl NetworkAccount { #[async_trait] impl Account for NetworkAccount { type Error = Error; - type NetworkResult = SyncResult; + type NetworkResult = SyncResult; fn address(&self) -> &Address { &self.address @@ -659,6 +714,27 @@ impl Account for NetworkAccount { Ok(account.account_signer().await?) } + async fn import_account_events( + &mut self, + identity: FolderPatch, + account: AccountPatch, + device: DevicePatch, + folders: HashMap, + #[cfg(feature = "files")] files: FilePatch, + ) -> Result<()> { + let mut inner = self.account.lock().await; + Ok(inner + .import_account_events( + identity, + account, + device, + folders, + #[cfg(feature = "files")] + files, + ) + .await?) + } + async fn new_device_vault( &mut self, ) -> Result<(DeviceSigner, DeviceManager)> { @@ -747,6 +823,11 @@ impl Account for NetworkAccount { Ok(account.identity_folder_summary().await?) } + async fn reload_identity_folder(&mut self) -> Result<()> { + let mut account = self.account.lock().await; + Ok(account.reload_identity_folder().await?) + } + async fn change_cipher( &mut self, account_key: &AccessKey, @@ -843,15 +924,7 @@ impl Account for NetworkAccount { } async fn sign_in(&mut self, key: &AccessKey) -> Result> { - self.login(key, Default::default()).await - } - - async fn sign_in_with_options( - &mut self, - key: &AccessKey, - options: SigninOptions, - ) -> Result> { - self.login(key, options).await + self.login(key).await } async fn verify(&self, key: &AccessKey) -> bool { @@ -859,27 +932,25 @@ impl Account for NetworkAccount { account.verify(key).await } - async fn open_folder(&mut self, summary: &Summary) -> Result<()> { - let mut account = self.account.lock().await; + async fn open_folder(&self, summary: &Summary) -> Result<()> { + let account = self.account.lock().await; Ok(account.open_folder(summary).await?) } + async fn current_folder(&self) -> Result> { + let account = self.account.lock().await; + Ok(account.current_folder().await?) + } + async fn sign_out(&mut self) -> Result<()> { - #[cfg(feature = "listen")] - { - tracing::debug!("net_sign_out::shutdown_websockets"); - self.shutdown_websockets().await; - } + self.deactivate().await; + self.remotes = Default::default(); #[cfg(feature = "files")] { - tracing::debug!("net_sign_out::stop_file_transfers"); - self.stop_file_transfers().await; self.file_transfers.take(); } - self.remotes = Default::default(); - let mut account = self.account.lock().await; Ok(account.sign_out().await?) } @@ -891,8 +962,7 @@ impl Account for NetworkAccount { let _ = self.sync_lock.lock().await; let result = { let mut account = self.account.lock().await; - let result = account.rename_account(account_name).await?; - result + account.rename_account(account_name).await? }; let result = AccountChange { @@ -929,9 +999,17 @@ impl Account for NetworkAccount { account.find(predicate).await } - async fn storage(&self) -> Result>> { + async fn storage(&self) -> Option>> { let account = self.account.lock().await; - Ok(account.storage().await?) + account.storage().await + } + + async fn set_storage( + &mut self, + storage: Option>>, + ) { + let mut account = self.account.lock().await; + account.set_storage(storage).await } async fn load_folders(&mut self) -> Result> { @@ -1133,7 +1211,7 @@ impl Account for NetworkAccount { &self, summary: &Summary, commit: CommitHash, - ) -> Result { + ) -> Result { let account = self.account.lock().await; Ok(account.detached_view(summary, commit).await?) } @@ -1161,8 +1239,8 @@ impl Account for NetworkAccount { #[cfg(feature = "search")] async fn query_view( &self, - views: Vec, - archive: Option, + views: &[DocumentView], + archive: Option<&ArchiveFilter>, ) -> Result> { let account = self.account.lock().await; Ok(account.query_view(views, archive).await?) @@ -1218,8 +1296,7 @@ impl Account for NetworkAccount { let result = { let mut account = self.account.lock().await; - let result = account.create_secret(meta, secret, options).await?; - result + account.create_secret(meta, secret, options).await? }; let result = SecretChange { @@ -1256,7 +1333,7 @@ impl Account for NetworkAccount { results: result .results .into_iter() - .map(|mut result| { + .map(|#[allow(unused_mut)] mut result| { #[cfg(feature = "files")] file_events.append(&mut result.file_events); SecretChange { @@ -1291,10 +1368,9 @@ impl Account for NetworkAccount { let result = { let mut account = self.account.lock().await; - let result = account + account .update_secret(secret_id, meta, secret, options, destination) - .await?; - result + .await? }; let result = SecretChange { @@ -1342,14 +1418,24 @@ impl Account for NetworkAccount { } async fn read_secret( - &mut self, + &self, secret_id: &SecretId, folder: Option, ) -> Result<(SecretRow, ReadEvent)> { - let mut account = self.account.lock().await; + let account = self.account.lock().await; Ok(account.read_secret(secret_id, folder).await?) } + async fn raw_secret( + &self, + folder_id: &VaultId, + secret_id: &SecretId, + ) -> std::result::Result<(Option, ReadEvent), Self::Error> + { + let account = self.account.lock().await; + Ok(account.raw_secret(folder_id, secret_id).await?) + } + async fn delete_secret( &mut self, secret_id: &SecretId, @@ -1613,31 +1699,31 @@ impl Account for NetworkAccount { #[cfg(feature = "contacts")] async fn load_avatar( - &mut self, + &self, secret_id: &SecretId, folder: Option, ) -> Result>> { - let mut account = self.account.lock().await; + let account = self.account.lock().await; Ok(account.load_avatar(secret_id, folder).await?) } #[cfg(feature = "contacts")] async fn export_contact( - &mut self, + &self, path: impl AsRef + Send + Sync, secret_id: &SecretId, folder: Option, ) -> Result<()> { - let mut account = self.account.lock().await; + let account = self.account.lock().await; Ok(account.export_contact(path, secret_id, folder).await?) } #[cfg(feature = "contacts")] async fn export_all_contacts( - &mut self, + &self, path: impl AsRef + Send + Sync, ) -> Result<()> { - let mut account = self.account.lock().await; + let account = self.account.lock().await; Ok(account.export_all_contacts(path).await?) } @@ -1738,4 +1824,15 @@ impl Account for NetworkAccount { .restore_backup_archive(path, password, options, data_dir) .await?) } + + #[cfg(feature = "clipboard")] + async fn copy_clipboard( + &self, + clipboard: &Clipboard, + target: &SecretPath, + request: &ClipboardCopyRequest, + ) -> Result { + let account = self.account.lock().await; + Ok(account.copy_clipboard(clipboard, target, request).await?) + } } diff --git a/crates/net/src/account/remote.rs b/crates/net/src/account/remote.rs index 8cd8cbee69..4602f39bcb 100644 --- a/crates/net/src/account/remote.rs +++ b/crates/net/src/account/remote.rs @@ -1,26 +1,25 @@ -//! Bridge between local storage and a remote server. +//! Connect a remote data source with a local account. use crate::{ - net::HttpClient, protocol::{ - MaybeDiff, Merge, MergeOutcome, Origin, SyncOptions, SyncPacket, - SyncStatus, SyncStorage, UpdateSet, + network_client::HttpClient, AutoMerge, Origin, RemoteResult, + RemoteSync, SyncClient, SyncDirection, SyncOptions, UpdateSet, }, sdk::{ - account::{Account, LocalAccount}, + account::LocalAccount, + prelude::Address, signer::{ecdsa::BoxedEcdsaSigner, ed25519::BoxedEd25519Signer}, - storage::StorageEventLogs, - vfs, }, - Error, RemoteResult, RemoteSync, Result, SyncClient, + Result, }; use async_trait::async_trait; +use sos_protocol::RemoteSyncHandler; use std::{collections::HashMap, sync::Arc}; -use tokio::sync::{broadcast, Mutex}; +use tokio::sync::Mutex; #[cfg(feature = "files")] -use crate::{ - account::file_transfers::FileTransferQueueRequest, - protocol::{FileOperation, FileSet, TransferOperation}, +use crate::protocol::transfer::{ + FileOperation, FileSet, FileSyncClient, FileTransferQueueRequest, + FileTransferQueueSender, TransferOperation, }; /// Collection of remote targets for synchronization. @@ -29,8 +28,8 @@ pub(crate) type Remotes = HashMap; /// Bridge between a local account and a remote. #[derive(Clone)] pub struct RemoteBridge { - /// Origin for this remote. - origin: Origin, + /// Address of the account. + address: Address, /// Account so we can replay events /// when a remote diff is merged. pub(super) account: Arc>, @@ -38,8 +37,7 @@ pub struct RemoteBridge { pub(crate) client: HttpClient, // File transfers. #[cfg(feature = "files")] - pub(crate) file_transfer_queue: - broadcast::Sender, + pub(crate) file_transfer_queue: FileTransferQueueSender, } impl RemoteBridge { @@ -52,199 +50,65 @@ impl RemoteBridge { device: BoxedEd25519Signer, connection_id: String, ) -> Result { - let client = - HttpClient::new(origin.clone(), signer, device, connection_id)?; + let address = signer.address()?; + let client = HttpClient::new(origin, signer, device, connection_id)?; #[cfg(feature = "files")] let (file_transfer_queue, _) = - broadcast::channel::(32); + tokio::sync::broadcast::channel::(32); Ok(Self { account, - origin, client, + address, #[cfg(feature = "files")] file_transfer_queue, }) } +} - /// Client implementation. - pub fn client(&self) -> &HttpClient { - &self.client - } - - /// Create an account on the remote. - async fn create_remote_account(&self) -> Result<()> { - { - let account = self.account.lock().await; - let public_account = account.change_set().await?; - self.client.create_account(public_account).await?; - } +#[async_trait] +impl RemoteSyncHandler for RemoteBridge { + type Client = HttpClient; + type Account = LocalAccount; + type Error = crate::Error; - #[cfg(feature = "files")] - self.execute_sync_file_transfers().await?; - Ok(()) + fn direction(&self) -> SyncDirection { + SyncDirection::Push } - async fn sync_account( - &self, - remote_status: SyncStatus, - ) -> Result { - let mut account = self.account.lock().await; - - tracing::debug!("merge_client"); - - let (needs_sync, local_status, local_changes) = - sos_protocol::diff(&*account, remote_status).await?; - - tracing::debug!(needs_sync = %needs_sync, "merge_client"); - - let mut outcome = MergeOutcome::default(); - - if needs_sync { - let packet = SyncPacket { - status: local_status, - diff: local_changes, - compare: None, - }; - let remote_changes = self.client.sync(packet.clone()).await?; - - let maybe_conflict = remote_changes - .compare - .as_ref() - .map(|c| c.maybe_conflict()) - .unwrap_or_default(); - let has_conflicts = maybe_conflict.has_conflicts(); - - if !has_conflicts { - account.merge(remote_changes.diff, &mut outcome).await?; - - // Compute which external files need to be downloaded - // and add to the transfers queue - - #[cfg(feature = "files")] - if !outcome.external_files.is_empty() { - let paths = account.paths(); - // let mut writer = self.transfers.write().await; - - for file in outcome.external_files.drain(..) { - let file_path = paths.file_location( - file.vault_id(), - file.secret_id(), - file.file_name().to_string(), - ); - if !vfs::try_exists(file_path).await? { - tracing::debug!( - file = ?file, - "add file download to transfers", - ); - if self.file_transfer_queue.receiver_count() > 0 { - let _ = self.file_transfer_queue.send(vec![ - FileOperation( - file, - TransferOperation::Download, - ), - ]); - } - } - } - } - - // self.compare(&mut *account, remote_changes).await?; - } else { - // Some parts of the remote patch may not - // be in conflict and must still be merged - if !maybe_conflict.identity { - if let Some(MaybeDiff::Diff(diff)) = - remote_changes.diff.identity - { - account.merge_identity(diff, &mut outcome).await?; - } - } - if !maybe_conflict.account { - if let Some(MaybeDiff::Diff(diff)) = - remote_changes.diff.account - { - account.merge_account(diff, &mut outcome).await?; - } - } - if !maybe_conflict.device { - if let Some(MaybeDiff::Diff(diff)) = - remote_changes.diff.device - { - account.merge_device(diff, &mut outcome).await?; - } - } - #[cfg(feature = "files")] - if !maybe_conflict.files { - if let Some(MaybeDiff::Diff(diff)) = - remote_changes.diff.files - { - account.merge_files(diff, &mut outcome).await?; - } - } + fn client(&self) -> &Self::Client { + &self.client + } - let merge_folders = remote_changes - .diff - .folders - .into_iter() - .filter(|(k, _)| maybe_conflict.folders.get(k).is_none()) - .collect::>(); - for (id, maybe_diff) in merge_folders { - if let MaybeDiff::Diff(diff) = maybe_diff { - account.merge_folder(&id, diff, &mut outcome).await?; - } - } + fn origin(&self) -> &Origin { + self.client.origin() + } - return Err(Error::SoftConflict { - conflict: maybe_conflict, - local: packet.status, - remote: remote_changes.status, - }); - } - } + fn address(&self) -> &Address { + &self.address + } - Ok(outcome) + fn account(&self) -> Arc> { + self.account.clone() } - async fn execute_sync( - &self, - options: &SyncOptions, - ) -> Result> { - let exists = self.client.account_exists().await?; - if exists { - let sync_status = self.client.sync_status().await?; - match self.sync_account(sync_status).await { - Ok(outcome) => Ok(Some(outcome)), - Err(e) => match e { - Error::SoftConflict { - conflict, - local, - remote, - } => { - let outcome = self - .auto_merge(options, conflict, local, remote) - .await?; - Ok(Some(outcome)) - } - _ => Err(e), - }, - } - } else { - self.create_remote_account().await?; - Ok(None) - } + #[cfg(feature = "files")] + fn file_transfer_queue(&self) -> &FileTransferQueueSender { + &self.file_transfer_queue } #[cfg(feature = "files")] async fn execute_sync_file_transfers(&self) -> Result<()> { + use sos_sdk::storage::StorageEventLogs; let external_files = { - let account = self.account.lock().await; + let account = self.account(); + let account = account.lock().await; account.canonical_files().await? }; let file_set = FileSet(external_files); - let file_transfers = self.client.compare_files(file_set).await?; + let file_transfers = self.client().compare_files(file_set).await?; let mut ops = Vec::new(); for file in file_transfers.uploads.0 { @@ -263,47 +127,62 @@ impl RemoteBridge { } } +#[async_trait] +impl AutoMerge for RemoteBridge {} + #[async_trait] impl RemoteSync for RemoteBridge { - async fn sync(&self) -> RemoteResult { + type Error = crate::Error; + + async fn sync(&self) -> RemoteResult { self.sync_with_options(&Default::default()).await } - async fn sync_with_options(&self, options: &SyncOptions) -> RemoteResult { + async fn sync_with_options( + &self, + options: &SyncOptions, + ) -> RemoteResult { match self.execute_sync(options).await { Ok(outcome) => RemoteResult { - origin: self.origin.clone(), + origin: self.origin().clone(), result: Ok(outcome), }, Err(e) => RemoteResult { - origin: self.origin.clone(), + origin: self.origin().clone(), result: Err(e), }, } } - #[cfg(feature = "files")] - async fn sync_file_transfers(&self) -> RemoteResult { - match self.execute_sync_file_transfers().await { + async fn force_update( + &self, + account_data: UpdateSet, + ) -> RemoteResult { + match self + .client + .update_account(&self.address, account_data) + .await + { Ok(_) => RemoteResult { - origin: self.origin.clone(), + origin: self.origin().clone(), result: Ok(None), }, Err(e) => RemoteResult { - origin: self.origin.clone(), - result: Err(e), + origin: self.origin().clone(), + result: Err(e.into()), }, } } - async fn force_update(&self, account_data: UpdateSet) -> RemoteResult { - match self.client.update_account(account_data).await { + #[cfg(feature = "files")] + async fn sync_file_transfers(&self) -> RemoteResult { + match self.execute_sync_file_transfers().await { Ok(_) => RemoteResult { - origin: self.origin.clone(), + origin: self.origin().clone(), result: Ok(None), }, Err(e) => RemoteResult { - origin: self.origin.clone(), + origin: self.origin().clone(), result: Err(e), }, } @@ -313,8 +192,11 @@ impl RemoteSync for RemoteBridge { #[cfg(feature = "listen")] mod listen { use crate::{ - protocol::ChangeNotification, ListenOptions, RemoteBridge, - WebSocketHandle, + protocol::{ + network_client::{ListenOptions, WebSocketHandle}, + ChangeNotification, + }, + RemoteBridge, }; use tokio::sync::mpsc; diff --git a/crates/net/src/account/sync.rs b/crates/net/src/account/sync.rs index 4fcce4daf4..b7bd843ad9 100644 --- a/crates/net/src/account/sync.rs +++ b/crates/net/src/account/sync.rs @@ -1,33 +1,48 @@ //! Adds sync capability to network account. use crate::{ - protocol::{Origin, SyncOptions, SyncStatus, SyncStorage, UpdateSet}, + protocol::{ + AccountSync, Merge, Origin, RemoteSync, SyncClient, SyncOptions, + SyncResult, SyncStatus, SyncStorage, UpdateSet, + }, sdk::{ events::{AccountEventLog, FolderEventLog}, + prelude::{Account, CommitState}, storage::StorageEventLogs, vault::{Summary, VaultId}, Result, }, - AccountSync, NetworkAccount, RemoteSync, SyncClient, SyncResult, + NetworkAccount, }; use async_trait::async_trait; use indexmap::IndexSet; -use std::{collections::HashMap, sync::Arc}; +use sos_protocol::MergeOutcome; +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; use tokio::sync::RwLock; -use sos_sdk::events::DeviceEventLog; +use sos_sdk::{ + commit::Comparison, + events::{ + AccountDiff, CheckedPatch, DeviceDiff, DeviceEventLog, FolderDiff, + WriteEvent, + }, +}; #[cfg(feature = "files")] use crate::{ - protocol::{FileSet, FileTransfersSet}, - sdk::events::FileEventLog, + protocol::transfer::{FileSet, FileSyncClient, FileTransfersSet}, + sdk::events::{FileDiff, FileEventLog}, }; /// Server status for all remote origins. -pub type ServerStatus = HashMap>; +pub type ServerStatus = HashMap>; /// Transfer status for all remote origins. #[cfg(feature = "files")] -pub type TransferStatus = HashMap>; +pub type TransferStatus = + HashMap>; impl NetworkAccount { /// Sync status for remote servers. @@ -44,7 +59,7 @@ impl NetworkAccount { || options.origins.contains(origin); if sync_remote { - match remote.client.sync_status().await { + match remote.client.sync_status(self.address()).await { Ok(status) => { server_status.insert(origin.clone(), Ok(status)); } @@ -89,11 +104,16 @@ impl NetworkAccount { #[async_trait] impl AccountSync for NetworkAccount { - async fn sync(&self) -> SyncResult { + type Error = crate::Error; + + async fn sync(&self) -> SyncResult { self.sync_with_options(&Default::default()).await } - async fn sync_with_options(&self, options: &SyncOptions) -> SyncResult { + async fn sync_with_options( + &self, + options: &SyncOptions, + ) -> SyncResult { let mut result = SyncResult::default(); if self.offline { tracing::warn!("offline mode active, ignoring sync"); @@ -119,7 +139,10 @@ impl AccountSync for NetworkAccount { } #[cfg(feature = "files")] - async fn sync_file_transfers(&self, options: &SyncOptions) -> SyncResult { + async fn sync_file_transfers( + &self, + options: &SyncOptions, + ) -> SyncResult { let mut result = SyncResult::default(); if self.offline { tracing::warn!( @@ -150,7 +173,7 @@ impl AccountSync for NetworkAccount { &self, account_data: UpdateSet, options: &SyncOptions, - ) -> SyncResult { + ) -> SyncResult { let mut result = SyncResult::default(); if self.offline { tracing::warn!("offline mode active, ignoring force update"); @@ -200,11 +223,6 @@ impl StorageEventLogs for NetworkAccount { account.file_log().await } - async fn folder_identifiers(&self) -> Result> { - let account = self.account.lock().await; - account.folder_identifiers().await - } - async fn folder_details(&self) -> Result> { let account = self.account.lock().await; account.folder_details().await @@ -230,3 +248,92 @@ impl SyncStorage for NetworkAccount { account.sync_status().await } } + +#[async_trait] +impl Merge for NetworkAccount { + async fn merge_identity( + &mut self, + diff: FolderDiff, + outcome: &mut MergeOutcome, + ) -> Result { + let mut account = self.account.lock().await; + Ok(account.merge_identity(diff, outcome).await?) + } + + async fn compare_identity( + &self, + state: &CommitState, + ) -> Result { + let account = self.account.lock().await; + Ok(account.compare_identity(state).await?) + } + + async fn merge_account( + &mut self, + diff: AccountDiff, + outcome: &mut MergeOutcome, + ) -> Result<(CheckedPatch, HashSet)> { + let mut account = self.account.lock().await; + Ok(account.merge_account(diff, outcome).await?) + } + + async fn compare_account( + &self, + state: &CommitState, + ) -> Result { + let account = self.account.lock().await; + Ok(account.compare_account(state).await?) + } + + async fn merge_device( + &mut self, + diff: DeviceDiff, + outcome: &mut MergeOutcome, + ) -> Result { + let mut account = self.account.lock().await; + Ok(account.merge_device(diff, outcome).await?) + } + + async fn compare_device( + &self, + state: &CommitState, + ) -> Result { + let account = self.account.lock().await; + Ok(account.compare_device(state).await?) + } + + #[cfg(feature = "files")] + async fn merge_files( + &mut self, + diff: FileDiff, + outcome: &mut MergeOutcome, + ) -> Result { + let mut account = self.account.lock().await; + Ok(account.merge_files(diff, outcome).await?) + } + + #[cfg(feature = "files")] + async fn compare_files(&self, state: &CommitState) -> Result { + let account = self.account.lock().await; + Ok(account.compare_files(state).await?) + } + + async fn merge_folder( + &mut self, + folder_id: &VaultId, + diff: FolderDiff, + outcome: &mut MergeOutcome, + ) -> Result<(CheckedPatch, Vec)> { + let mut account = self.account.lock().await; + Ok(account.merge_folder(folder_id, diff, outcome).await?) + } + + async fn compare_folder( + &self, + folder_id: &VaultId, + state: &CommitState, + ) -> Result { + let account = self.account.lock().await; + Ok(account.compare_folder(folder_id, state).await?) + } +} diff --git a/crates/net/src/error.rs b/crates/net/src/error.rs index 7d1ef3675b..f61a9ed30e 100644 --- a/crates/net/src/error.rs +++ b/crates/net/src/error.rs @@ -1,9 +1,8 @@ //! Error type for the client module. -use crate::CancelReason; -use http::StatusCode; -use serde_json::Value; -use sos_protocol::{MaybeConflict, Origin, SyncStatus}; -use sos_sdk::vault::VaultId; +use sos_protocol::{ + transfer::CancelReason, AsConflict, ConflictError, Origin, +}; +use sos_sdk::{prelude::ErrorExt, vault::VaultId}; use std::error::Error as StdError; use std::path::PathBuf; use thiserror::Error; @@ -27,18 +26,6 @@ pub enum Error { #[error("folder {0} already exists")] FolderExists(VaultId), - /// Error generated when an unexpected response code is received. - #[error("unexpected response status code {0}")] - ResponseCode(StatusCode), - - /// Error generated when an unexpected response code is received. - #[error("unexpected response {1} (code: {0})")] - ResponseJson(StatusCode, Value), - - /// Error generated when an unexpected content type is returend. - #[error("unexpected content type {0}, expected: {1}")] - ContentType(String, String), - /// Error generated when a return value is expected from a RPC call /// but the response did not have a result. #[error("method did not return a value")] @@ -48,10 +35,11 @@ pub enum Error { #[error("origin '{0}' not found")] OriginNotFound(Origin), + /* /// Error generated when a websocket message is not binary. #[error("not binary message type on websocket")] NotBinaryWebsocketMessageType, - + */ /// Error generated attempting to revoke the current device. #[error("cannot revoke access to this device")] RevokeDeviceSelf, @@ -69,6 +57,7 @@ pub enum Error { #[error("invalid share url for device enrollment")] InvalidShareUrl, + /* /// Error generated when a downloaded file checksum does not /// match the expected checksum. #[error("file download checksum mismatch; expected '{0}' but got '{1}'")] @@ -78,7 +67,6 @@ pub enum Error { /// /// The boolean flag indicates whether the cancellation was /// triggered by the user. - #[error("file transfer canceled")] TransferCanceled(CancelReason), @@ -89,38 +77,15 @@ pub enum Error { /// Network retry was canceled possibly by the user. #[error("network retry was canceled")] RetryCanceled(CancelReason), - - /// Error generated when a soft conflict was detected. - /// - /// A soft conflict may be resolved by searching for a - /// common ancestor commit and merging changes since - /// the common ancestor commit. - #[error("soft conflict")] - SoftConflict { - /// Conflict information. - conflict: MaybeConflict, - /// Local information sent to the remote. - local: SyncStatus, - /// Remote information in the server reply. - remote: SyncStatus, - }, - - /// Error generated when a hard conflict was detected. - /// - /// A hard conflict is triggered after a soft conflict - /// attempted to scan proofs on a remote and was unable - /// to find a common ancestor commit. - #[error("hard conflict")] - HardConflict, + */ + /// Error generated when a conflict is detected. + #[error(transparent)] + Conflict(#[from] ConflictError), /// Error generated parsing to an integer. #[error(transparent)] ParseInt(#[from] std::num::ParseIntError), - /// Error generated converting a header to a string. - #[error(transparent)] - ToStr(#[from] reqwest::header::ToStrError), - /// Error generated by the io module. #[error(transparent)] Io(#[from] std::io::Error), @@ -137,10 +102,6 @@ pub enum Error { #[error(transparent)] Sdk(#[from] sos_sdk::Error), - /// Error generated by the HTTP request library. - #[error(transparent)] - Http(#[from] reqwest::Error), - /// Error generated attempting to parse a URL. #[error(transparent)] UrlParse(#[from] url::ParseError), @@ -149,19 +110,15 @@ pub enum Error { #[error(transparent)] Utf8(#[from] std::str::Utf8Error), + /* /// Error generated decoding a base58 string. #[error(transparent)] Base58Decode(#[from] bs58::decode::Error), - + */ /// Error generated converting an HTTP status code. #[error(transparent)] HttpStatus(#[from] http::status::InvalidStatusCode), - /// Error generated by the websocket client. - #[cfg(feature = "listen")] - #[error(transparent)] - WebSocket(#[from] tokio_tungstenite::tungstenite::Error), - /// Error generated when converting to a UUID. #[error(transparent)] Uuid(#[from] uuid::Error), @@ -178,25 +135,32 @@ pub enum Error { #[error(transparent)] #[cfg(feature = "migrate")] Migrate(#[from] sos_sdk::migrate::Error), + + /// Error generated by network communication. + #[error(transparent)] + Network(#[from] sos_protocol::NetworkError), } -impl Error { - /// Determine if this error is a secret not found. - pub fn is_secret_not_found(&self) -> bool { +impl ErrorExt for Error { + fn is_secret_not_found(&self) -> bool { matches!(self, Error::Sdk(crate::sdk::Error::SecretNotFound(_))) } - /// Determine if this error is a hard conflict. - pub fn is_hard_conflict(&self) -> bool { - matches!(self, Error::HardConflict) + fn is_permission_denied(&self) -> bool { + matches!(self, Error::Sdk(crate::sdk::Error::PassphraseVerification)) } +} +impl Error { /// Determine if this is a canceled error and /// whether the cancellation was triggered by the user. pub fn cancellation_reason(&self) -> Option<&CancelReason> { let source = source_error(self); if let Some(err) = source.downcast_ref::() { - if let Error::TransferCanceled(reason) = err { + if let Error::Protocol(sos_protocol::Error::TransferCanceled( + reason, + )) = err + { Some(reason) } else { None @@ -216,3 +180,20 @@ pub(crate) fn source_error<'a>( } source } + +impl AsConflict for Error { + fn is_conflict(&self) -> bool { + matches!(self, Error::Conflict(_)) + } + + fn is_hard_conflict(&self) -> bool { + matches!(self, Error::Conflict(ConflictError::Hard)) + } + + fn take_conflict(self) -> Option { + match self { + Self::Conflict(err) => Some(err), + _ => None, + } + } +} diff --git a/crates/net/src/lib.rs b/crates/net/src/lib.rs index ac21fe93c3..097162df70 100644 --- a/crates/net/src/lib.rs +++ b/crates/net/src/lib.rs @@ -11,26 +11,23 @@ mod account; mod error; -#[cfg(feature = "hashcheck")] -pub mod hashcheck; -mod net; #[cfg(feature = "pairing")] pub mod pairing; -mod sync; -pub use reqwest; -pub use sos_sdk as sdk; -// FIXME: remove this pub use sos_protocol as protocol; +pub use sos_sdk as sdk; pub use account::*; pub use error::Error; -#[cfg(feature = "listen")] -pub use net::{changes, connect, ListenOptions, WebSocketHandle}; -pub use net::{HttpClient, NetworkRetry}; -pub use sync::{ - AccountSync, RemoteResult, RemoteSync, SyncClient, SyncResult, -}; + +#[cfg(feature = "hashcheck")] +pub use sos_protocol::hashcheck; + +/// Remote result. +pub type RemoteResult = protocol::RemoteResult; + +/// Sync result. +pub type SyncResult = protocol::SyncResult; #[cfg(any( feature = "preferences", @@ -42,81 +39,4 @@ pub use sos_account_extras as extras; /// Result type for the client module. pub type Result = std::result::Result; -/// Determine if the offline environment variable is set. -pub fn is_offline() -> bool { - use crate::sdk::constants::SOS_OFFLINE; - std::env::var(SOS_OFFLINE).ok().is_some() -} - -#[cfg(any(feature = "listen", feature = "pairing"))] -mod websocket_request { - use super::Result; - use sos_sdk::url::Url; - use tokio_tungstenite::tungstenite::{ - self, client::IntoClientRequest, handshake::client::generate_key, - }; - - pub(crate) struct WebSocketRequest { - pub(crate) uri: Url, - host: String, - bearer: Option, - origin: url::Origin, - } - - impl WebSocketRequest { - /// Create a new websocket request. - pub fn new(url: &Url, path: &str) -> Result { - let origin = url.origin(); - let host = url.host_str().unwrap().to_string(); - - let mut uri = url.join(path)?; - let scheme = if uri.scheme() == "http" { - "ws" - } else if uri.scheme() == "https" { - "wss" - } else { - panic!("bad url scheme for websocket, requires http(s)"); - }; - - uri.set_scheme(scheme) - .expect("failed to set websocket scheme"); - - Ok(Self { - host, - uri, - origin, - bearer: None, - }) - } - - /// Set bearer authorization. - pub fn set_bearer(&mut self, bearer: String) { - self.bearer = Some(bearer); - } - } - - impl IntoClientRequest for WebSocketRequest { - fn into_client_request( - self, - ) -> std::result::Result, tungstenite::Error> - { - let origin = self.origin.unicode_serialization(); - let mut request = - http::Request::builder().uri(self.uri.to_string()); - if let Some(bearer) = self.bearer { - request = request.header("authorization", bearer); - } - request = request - .header("sec-websocket-key", generate_key()) - .header("sec-websocket-version", "13") - .header("host", self.host) - .header("origin", origin) - .header("connection", "keep-alive, Upgrade") - .header("upgrade", "websocket"); - Ok(request.body(())?) - } - } -} - -#[cfg(any(feature = "listen", feature = "pairing"))] -pub(crate) use websocket_request::WebSocketRequest; +pub use sos_protocol::is_offline; diff --git a/crates/net/src/pairing/enrollment.rs b/crates/net/src/pairing/enrollment.rs index 7d54971f18..7904e71b89 100644 --- a/crates/net/src/pairing/enrollment.rs +++ b/crates/net/src/pairing/enrollment.rs @@ -1,7 +1,7 @@ //! Enroll a device to an account on a remote server. use crate::{ pairing::{Error, Result}, - protocol::Origin, + protocol::{network_client::HttpClient, Origin, SyncClient}, sdk::{ account::Account, crypto::AccessKey, @@ -19,7 +19,7 @@ use crate::{ vault::{VaultAccess, VaultId, VaultWriter}, vfs, Paths, }, - HttpClient, NetworkAccount, SyncClient, + NetworkAccount, }; use std::{ collections::{HashMap, HashSet}, @@ -119,7 +119,7 @@ impl DeviceEnrollment { Paths::scaffold(self.data_dir.clone()).await?; self.paths.ensure().await?; - let change_set = self.client.fetch_account().await?; + let change_set = self.client.fetch_account(self.address()).await?; self.create_folders(change_set.folders).await?; self.create_account(change_set.account).await?; self.create_device(change_set.device).await?; @@ -135,7 +135,8 @@ impl DeviceEnrollment { } // Write the vault containing the device signing key - vfs::write(self.paths.device_file(), &self.device_vault).await?; + vfs::write_exclusive(self.paths.device_file(), &self.device_vault) + .await?; // Add origin servers early so that they will be registered // as remotes when the enrollment is finished and the account @@ -168,7 +169,7 @@ impl DeviceEnrollment { async fn add_origin_servers(&self) -> Result<()> { let remotes_file = self.paths.remote_origins(); let data = serde_json::to_vec_pretty(&self.servers)?; - vfs::write(remotes_file, data).await?; + vfs::write_exclusive(remotes_file, data).await?; Ok(()) } @@ -239,7 +240,7 @@ impl DeviceEnrollment { .await?; let buffer = encode(&vault).await?; - vfs::write(vault_path.as_ref(), buffer).await?; + vfs::write_exclusive(vault_path.as_ref(), buffer).await?; Ok(()) } diff --git a/crates/net/src/pairing/error.rs b/crates/net/src/pairing/error.rs index e1efc7d8e1..5abbc24e4a 100644 --- a/crates/net/src/pairing/error.rs +++ b/crates/net/src/pairing/error.rs @@ -76,7 +76,7 @@ pub enum Error { /// Error generated by the websocket client. #[error(transparent)] - WebSocket(#[from] tokio_tungstenite::tungstenite::Error), + WebSocket(#[from] sos_protocol::tokio_tungstenite::tungstenite::Error), /// Error generated by the protocol module. #[error(transparent)] diff --git a/crates/net/src/pairing/websocket.rs b/crates/net/src/pairing/websocket.rs index 82d7d1aca7..3a5a26ed51 100644 --- a/crates/net/src/pairing/websocket.rs +++ b/crates/net/src/pairing/websocket.rs @@ -2,9 +2,18 @@ use super::{DeviceEnrollment, Error, Result, ServerPairUrl}; use crate::{ protocol::{ - pairing_message, Origin, PairingConfirm, PairingMessage, - PairingReady, PairingRequest, ProtoMessage, RelayHeader, RelayPacket, - RelayPayload, SyncOptions, + network_client::WebSocketRequest, + pairing_message, + tokio_tungstenite::{ + connect_async, + tungstenite::protocol::{ + frame::coding::CloseCode, CloseFrame, Message, + }, + MaybeTlsStream, WebSocketStream, + }, + AccountSync, Origin, PairingConfirm, PairingMessage, PairingReady, + PairingRequest, ProtoMessage, RelayHeader, RelayPacket, RelayPayload, + SyncOptions, }, sdk::{ account::Account, @@ -13,8 +22,7 @@ use crate::{ signer::ecdsa::SingleParty, url::Url, }, - sync::AccountSync, - NetworkAccount, WebSocketRequest, + NetworkAccount, }; use futures::{ select, @@ -26,11 +34,6 @@ use snow::{Builder, HandshakeState, Keypair, TransportState}; use std::collections::HashSet; use std::{borrow::Cow, path::PathBuf}; use tokio::{net::TcpStream, sync::mpsc}; -use tokio_tungstenite::{ - connect_async, - tungstenite::protocol::{frame::coding::CloseCode, CloseFrame, Message}, - MaybeTlsStream, WebSocketStream, -}; const PATTERN: &str = "Noise_XXpsk3_25519_ChaChaPoly_BLAKE2s"; const RELAY_PATH: &str = "api/v1/relay"; @@ -440,7 +443,11 @@ impl<'a> OfferPairing<'a> { let events: Vec = vec![DeviceEvent::Trust(trusted_device)]; { - let storage = self.account.storage().await?; + let storage = self + .account + .storage() + .await + .ok_or(sos_sdk::Error::NoStorage)?; let mut writer = storage.write().await; writer.patch_devices_unchecked(events).await?; } diff --git a/crates/platform_authenticator/Cargo.toml b/crates/platform_authenticator/Cargo.toml new file mode 100644 index 0000000000..1fb809ee6a --- /dev/null +++ b/crates/platform_authenticator/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "sos-platform-authenticator" +version = "0.1.0" +edition = "2021" +description = "Platform authenticator and keyring suppport for the Save Our Secrets SDK." +homepage = "https://saveoursecrets.com" +license = "MIT OR Apache-2.0" +repository = "https://github.com/saveoursecrets/sdk" + +[dependencies] +thiserror.workspace = true +tracing.workspace = true +secrecy.workspace = true +http.workspace = true + +[target.'cfg(all(not(target_os = "android"), not(target_os = "macos")))'.dependencies] +keyring.workspace = true + +[target.'cfg(not(target_os = "linux"))'.dependencies] +robius-authentication = "0.1" + +[target.'cfg(target_os = "macos")'.dependencies.security-framework] +version = "3.1" +# path = "../../../../forks/rust-security-framework/security-framework" + +[build-dependencies] +rustc_version = "0.4.1" diff --git a/crates/platform_authenticator/build.rs b/crates/platform_authenticator/build.rs new file mode 100644 index 0000000000..5976a1c6d5 --- /dev/null +++ b/crates/platform_authenticator/build.rs @@ -0,0 +1,14 @@ +use rustc_version::{version_meta, Channel}; + +fn main() { + println!("cargo::rustc-check-cfg=cfg(CHANNEL_NIGHTLY)"); + + // Set cfg flags depending on release channel + let channel = match version_meta().unwrap().channel { + Channel::Stable => "CHANNEL_STABLE", + Channel::Beta => "CHANNEL_BETA", + Channel::Nightly => "CHANNEL_NIGHTLY", + Channel::Dev => "CHANNEL_DEV", + }; + println!("cargo:rustc-cfg={}", channel); +} diff --git a/crates/platform_authenticator/examples/local_auth.rs b/crates/platform_authenticator/examples/local_auth.rs new file mode 100644 index 0000000000..3553ad0fba --- /dev/null +++ b/crates/platform_authenticator/examples/local_auth.rs @@ -0,0 +1,13 @@ +use sos_platform_authenticator::local_auth; +fn main() { + if local_auth::supported() { + let verified = local_auth::authenticate(Default::default()); + if verified { + println!("Authorized"); + } else { + println!("Unauthorized"); + } + } else { + println!("Unsupported platform"); + } +} diff --git a/crates/platform_authenticator/src/error.rs b/crates/platform_authenticator/src/error.rs new file mode 100644 index 0000000000..2023469de3 --- /dev/null +++ b/crates/platform_authenticator/src/error.rs @@ -0,0 +1,45 @@ +use http::StatusCode; +use thiserror::Error; + +/// Errors generated by the platform authenticator library. +#[derive(Debug, Error)] +pub enum Error { + /// Error generated when a keyring entry could not be found. + #[error("keyring entry not found")] + NoKeyringEntry, + + /// Error generated when it is not possible to authenticate + /// using the platform authenticator and keyring; authentication + /// may still succeed with a password supplied by the user. + #[error("unauthorized")] + Unauthorized, + + /// Error generated when an attempt to authenticate failed. + #[error("forbidden")] + Forbidden, + + /// Error generated converting to UTF-8. + #[cfg(all(not(target_os = "android"), not(target_os = "macos")))] + #[error(transparent)] + Keyring(#[from] keyring::Error), + + /// Error generated converting to UTF-8. + #[error(transparent)] + Utf8(#[from] std::str::Utf8Error), + + /// Error generated by the security framework. + #[cfg(target_os = "macos")] + #[error(transparent)] + SecurityFramework(#[from] security_framework::base::Error), +} + +impl From<&Error> for StatusCode { + fn from(value: &Error) -> Self { + match value { + Error::NoKeyringEntry => StatusCode::NOT_FOUND, + Error::Unauthorized => StatusCode::UNAUTHORIZED, + Error::Forbidden => StatusCode::FORBIDDEN, + _ => StatusCode::INTERNAL_SERVER_ERROR, + } + } +} diff --git a/crates/platform_authenticator/src/keyring_password.rs b/crates/platform_authenticator/src/keyring_password.rs new file mode 100644 index 0000000000..98641ea0bc --- /dev/null +++ b/crates/platform_authenticator/src/keyring_password.rs @@ -0,0 +1,144 @@ +//! Interface to the platform keyring. + +/// Service name used to store keyring passwords. +#[cfg(not(debug_assertions))] +pub const SERVICE_NAME: &str = "com.saveoursecrets.accounts.local"; + +/// Service name used to store keyring passwords. +#[cfg(debug_assertions)] +pub const SERVICE_NAME: &str = "com.saveoursecrets.accounts.local.debug"; + +/// Legacy service name used to store keyring passwords. +/// +/// This was used for the account passwords stored +/// by the GUI app before sharing the platform keyring +/// between applications. +pub const LEGACY_SERVICE_NAME: &str = "com.saveoursecrets"; + +// MacOS implementation uses the security framework +// directly instead of the `keyring` crate. +#[cfg(target_os = "macos")] +mod macos { + use super::SERVICE_NAME; + use crate::{Error, Result}; + use secrecy::{ExposeSecret, SecretString}; + use security_framework::{ + passwords::{ + get_generic_password, set_generic_password, + set_generic_password_options, + }, + passwords_options::PasswordOptions, + }; + + const ERR_SEC_ITEM_NOT_FOUND: i32 = -25300; + + /// Find the password for an account. + pub fn find_account_password(account_id: &str) -> Result { + match get_generic_password(SERVICE_NAME, account_id) { + Ok(bytes) => Ok(std::str::from_utf8(&bytes)?.to_owned()), + Err(e) => { + let code = e.code(); + if code == ERR_SEC_ITEM_NOT_FOUND { + Err(Error::NoKeyringEntry) + } else { + Err(e.into()) + } + } + } + } + + /// Save the password for an account in the platform keyring. + pub fn save_account_password( + account_id: &str, + password: SecretString, + ) -> Result<()> { + if let Some(access_group) = option_env!("SOS_ACCESS_GROUP") { + let mut options = PasswordOptions::new_generic_password( + SERVICE_NAME, + account_id, + ); + + options.set_access_group(access_group); + + set_generic_password_options( + password.expose_secret().as_bytes(), + options, + )?; + } else { + set_generic_password( + SERVICE_NAME, + account_id, + password.expose_secret().as_bytes(), + )?; + } + + Ok(()) + } + + /// Whether platform keyring storage is supported. + pub fn supported() -> bool { + true + } +} + +// Other platforms use the `keyring` crate. +#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] +mod platform_keyring { + use super::SERVICE_NAME; + use crate::{Error, Result}; + use keyring::{Entry, Error as KeyringError}; + use secrecy::{ExposeSecret, SecretString}; + + /// Find the password for an account. + pub fn find_account_password(account_id: &str) -> Result { + let entry = Entry::new(SERVICE_NAME, account_id)?; + match entry.get_password() { + Ok(password) => Ok(password), + Err(e) => match e { + KeyringError::NoEntry => Err(Error::NoKeyringEntry), + _ => Err(e.into()), + }, + } + } + + /// Save the password for an account in the platform keyring. + pub fn save_account_password( + account_id: &str, + password: SecretString, + ) -> Result<()> { + let entry = Entry::new(SERVICE_NAME, account_id)?; + entry.set_password(password.expose_secret())?; + Ok(()) + } + + /// Whether platform keyring storage is supported. + pub fn supported() -> bool { + true + } +} + +#[cfg(target_os = "macos")] +pub use macos::*; + +#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] +pub use platform_keyring::*; + +// Android is not currently supported. +// SEE: https://github.com/hwchen/keyring-rs/issues/127 +#[cfg(target_os = "android")] +mod unsupported { + use crate::Result; + + /// Find the password for an account. + pub fn find_account_password(account_id: &str) -> Result { + unimplemented!(); + } + + /// Whether platform keyring storage is supported. + pub fn supported() -> bool { + false + } +} + +#[cfg(target_os = "android")] +pub use unsupported::*; diff --git a/crates/platform_authenticator/src/lib.rs b/crates/platform_authenticator/src/lib.rs new file mode 100644 index 0000000000..b71093aa13 --- /dev/null +++ b/crates/platform_authenticator/src/lib.rs @@ -0,0 +1,42 @@ +//! Platform authenticator and keyring support for the +//! [Save Our Secrets SDK](https://saveoursecrets.com). +#![deny(missing_docs)] +#![forbid(unsafe_code)] +#![cfg_attr(all(doc, CHANNEL_NIGHTLY), feature(doc_auto_cfg))] +use secrecy::SecretString; + +mod error; + +pub mod keyring_password; +pub mod local_auth; + +pub use error::Error; + +/// Result type for the library. +pub type Result = std::result::Result; + +/// Attempt to find authentication credentials for an account. +/// +/// First verify user presence via the platform +/// authenticator and then attempt to retrieve the account +/// password from the platform keyring. +pub async fn find_account_credential( + account_id: &str, +) -> Result { + if local_auth::supported() { + if local_auth::authenticate(Default::default()) { + if keyring_password::supported() { + match keyring_password::find_account_password(account_id) { + Ok(password) => Ok(SecretString::new(password.into())), + Err(e) => Err(e), + } + } else { + Err(Error::Unauthorized) + } + } else { + Err(Error::Forbidden) + } + } else { + Err(Error::Unauthorized) + } +} diff --git a/crates/platform_authenticator/src/local_auth.rs b/crates/platform_authenticator/src/local_auth.rs new file mode 100644 index 0000000000..2944ee9a78 --- /dev/null +++ b/crates/platform_authenticator/src/local_auth.rs @@ -0,0 +1,89 @@ +//! Interface to the platform authenticator. + +#[cfg(not(target_os = "linux"))] +mod supported { + use robius_authentication::{ + AndroidText, BiometricStrength, Context, Policy, PolicyBuilder, Text, + WindowsText, + }; + + /// Options for platform authentication. + pub struct AuthenticateOptions { + /// Biometrics strength. + pub biometrics: BiometricStrength, + /// Password fallback. + pub password: bool, + /// Text for android. + pub android: AndroidText<'static, 'static, 'static>, + /// Text for apple. + pub apple: &'static str, + /// Text for windows. + pub windows: WindowsText<'static, 'static>, + } + + impl Default for AuthenticateOptions { + fn default() -> Self { + Self { + biometrics: BiometricStrength::Strong, + password: true, + android: AndroidText { + title: "Authenticate", + subtitle: None, + description: None, + }, + apple: "authenticate", + windows: WindowsText::new( + "Save Our Secrets", + "Verify your identity to authenticate", + ) + .unwrap(), + } + } + } + + /// Authenticate using the platform authenticator. + pub fn authenticate(options: AuthenticateOptions) -> bool { + let policy: Policy = PolicyBuilder::new() + .biometrics(Some(options.biometrics)) + .password(options.password) + .watch(true) + .build() + .unwrap(); + + let text: Text = Text { + android: options.android, + apple: options.apple, + windows: options.windows, + }; + + let context = Context::new(()); + context.blocking_authenticate(text, &policy).is_ok() + } + + /// Determine if local platform authentication is supported. + pub fn supported() -> bool { + true + } +} +#[cfg(not(target_os = "linux"))] +pub use supported::*; + +#[cfg(target_os = "linux")] +mod unsupported { + /// Options for platform authentication. + #[derive(Default)] + pub struct AuthenticateOptions {} + + /// Authenticate using the platform authenticator. + pub fn authenticate(_options: AuthenticateOptions) -> bool { + false + } + + /// Determine if local platform authentication is supported. + pub fn supported() -> bool { + false + } +} + +#[cfg(target_os = "linux")] +pub use unsupported::*; diff --git a/crates/protocol/Cargo.toml b/crates/protocol/Cargo.toml index d80679f064..8046266908 100644 --- a/crates/protocol/Cargo.toml +++ b/crates/protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sos-protocol" -version = "0.15.1" +version = "0.16.3" edition = "2021" description = "Networking and sync protocol types for the Save Our Secrets SDK." homepage = "https://saveoursecrets.com" @@ -12,11 +12,15 @@ all-features = true rustdoc-args = ["--cfg", "docsrs"] [features] -default = [] -listen = [] +listen = ["tokio-tungstenite"] +hashcheck = ["reqwest"] +network-client = ["reqwest", "bs58", "async-stream", "tokio-util"] pairing = [] account = ["sos-sdk/account"] +archive = ["sos-sdk/archive"] +contacts = ["sos-sdk/contacts"] files = ["sos-sdk/files"] +migrate = ["sos-sdk/migrate"] search = ["sos-sdk/search"] [dependencies] @@ -24,18 +28,42 @@ thiserror.workspace = true tracing.workspace = true async-trait.workspace = true serde.workspace = true +serde_json.workspace = true uuid.workspace = true url.workspace = true futures.workspace = true indexmap.workspace = true rs_merkle.workspace = true prost.workspace = true +http.workspace = true +bytes.workspace = true +binary-stream.workspace = true +typeshare.workspace = true tokio = { version = "1", features = ["rt", "macros"] } +# network-client +# reqwest = { workspace = true, optional = true } +tokio-tungstenite = { workspace = true, optional = true } +bs58 = { workspace = true, optional = true } +async-stream = { workspace = true, optional = true } +tokio-util = { workspace = true, optional = true } + [dependencies.sos-sdk] -version = "0.15.1" +version = "0.16" path = "../sdk" +[target.'cfg(not(target_arch = "wasm32"))'.dependencies.reqwest] +version = "0.12.5" +default-features = false +features = ["json", "rustls-tls", "stream"] +optional = true + +[target.'cfg(target_arch = "wasm32")'.dependencies.reqwest] +version = "0.12.5" +default-features = false +features = ["json", "stream"] +optional = true + [dev-dependencies] anyhow = "1" diff --git a/crates/protocol/src/bindings/files.rs b/crates/protocol/src/bindings/files.rs index 49f1da1a10..99cb5be65c 100644 --- a/crates/protocol/src/bindings/files.rs +++ b/crates/protocol/src/bindings/files.rs @@ -9,7 +9,8 @@ mod files { storage::files::{ExternalFile, ExternalFileName}, vault::secret::SecretPath, }, - Error, FileSet, FileTransfersSet, ProtoBinding, Result, + transfer::{FileSet, FileTransfersSet}, + Error, ProtoBinding, Result, }; use indexmap::IndexSet; diff --git a/crates/protocol/src/constants.rs b/crates/protocol/src/constants.rs new file mode 100644 index 0000000000..52fc4a620e --- /dev/null +++ b/crates/protocol/src/constants.rs @@ -0,0 +1,47 @@ +//! Constants for the networking protocols. + +/// Name for GUI IPC sockets. +pub const IPC_GUI_SOCKET_NAME: &str = "com.saveoursecrets.gui.sock"; + +/// Name for CLI IPC sockets. +pub const IPC_CLI_SOCKET_NAME: &str = "com.saveoursecrets.cli.sock"; + +/// Constants for MIME types. +mod mime { + /// Mime type for protocol buffers. + pub const MIME_TYPE_PROTOBUF: &str = "application/x-protobuf"; + + /// Mime type for JSON. + pub const MIME_TYPE_JSON: &str = "application/json"; +} + +/// Constants for header names or values. +mod header { + /// Header name used to specify an account address. + pub const X_SOS_ACCOUNT_ID: &str = "x-sos-account-id"; + + /// Header name used to specify a request id. + pub const X_SOS_REQUEST_ID: &str = "x-sos-request-id"; +} + +/// Route paths. +pub mod routes { + /// Routes for v1. + pub mod v1 { + + /// List accounts; local IPC server only. + pub const ACCOUNTS_LIST: &str = "/api/v1/accounts"; + + /// Route for syncing account data. + pub const SYNC_ACCOUNT: &str = "/api/v1/sync/account"; + + /// Route for sync account status. + pub const SYNC_ACCOUNT_STATUS: &str = "/api/v1/sync/account/status"; + + /// Route for syncing account events. + pub const SYNC_ACCOUNT_EVENTS: &str = "/api/v1/sync/account/events"; + } +} + +pub use header::*; +pub use mime::*; diff --git a/crates/protocol/src/error.rs b/crates/protocol/src/error.rs index 34b959f3f8..b042ef9ede 100644 --- a/crates/protocol/src/error.rs +++ b/crates/protocol/src/error.rs @@ -1,7 +1,25 @@ //! Error type for the wire protocol. +use crate::{MaybeConflict, SyncStatus}; +use http::StatusCode; +use serde::{Deserialize, Serialize}; +use serde_json::Value; use sos_sdk::time; +use std::error::Error as StdError; use thiserror::Error; +/// Trait for error implementations that +/// support a conflict error. +pub trait AsConflict { + /// Determine if this is a conflict error. + fn is_conflict(&self) -> bool; + + /// Determine if this is a hard conflict error. + fn is_hard_conflict(&self) -> bool; + + /// Take an underlying conflict error. + fn take_conflict(self) -> Option; +} + /// Errors generated by the wire protocol. #[derive(Debug, Error)] pub enum Error { @@ -9,6 +27,14 @@ pub enum Error { #[error("relay packet end of file")] EndOfFile, + /// Error generated when a conflict is detected. + #[error(transparent)] + Conflict(#[from] ConflictError), + + /// Error generated by the IO module. + #[error(transparent)] + Io(#[from] std::io::Error), + /// Error generated converting from a slice. #[error(transparent)] TryFromSlice(#[from] std::array::TryFromSliceError), @@ -44,4 +70,207 @@ pub enum Error { /// Error generated parsing URLs. #[error(transparent)] UrlParse(#[from] crate::sdk::url::ParseError), + + /// Error generated by the HTTP library. + #[error(transparent)] + Http(#[from] http::Error), + + /// Error generated by the HTTP library. + #[error(transparent)] + StatusCode(#[from] http::status::InvalidStatusCode), + + /// Error generated by the JSON library. + #[error(transparent)] + Json(#[from] serde_json::Error), + + /// Error generated by network communication. + #[error(transparent)] + Network(#[from] NetworkError), + + #[cfg(feature = "network-client")] + /// Error generated converting a header to a string. + #[error(transparent)] + ToStr(#[from] reqwest::header::ToStrError), + + #[cfg(feature = "network-client")] + /// Error generated by the HTTP request library. + #[error(transparent)] + Request(#[from] reqwest::Error), + + #[cfg(feature = "network-client")] + /// Error generated decoding a base58 string. + #[error(transparent)] + Base58Decode(#[from] bs58::decode::Error), + + #[cfg(feature = "network-client")] + /// Error generated when a downloaded file checksum does not + /// match the expected checksum. + #[error("file download checksum mismatch; expected '{0}' but got '{1}'")] + FileChecksumMismatch(String, String), + + #[cfg(feature = "network-client")] + /// Error generated when a file transfer is canceled. + /// + /// The boolean flag indicates whether the cancellation was + /// triggered by the user. + #[error("file transfer canceled")] + TransferCanceled(crate::transfer::CancelReason), + + #[cfg(feature = "network-client")] + /// Overflow error calculating the retry exponential factor. + #[error("retry overflow")] + RetryOverflow, + + #[cfg(feature = "network-client")] + /// Network retry was canceled possibly by the user. + #[error("network retry was canceled")] + RetryCanceled(crate::transfer::CancelReason), + + #[cfg(feature = "listen")] + /// Error generated when a websocket message is not binary. + #[error("not binary message type on websocket")] + NotBinaryWebsocketMessageType, + + /// Error generated by the websocket client. + #[cfg(feature = "listen")] + #[error(transparent)] + WebSocket(#[from] tokio_tungstenite::tungstenite::Error), +} + +#[cfg(feature = "network-client")] +impl Error { + /// Determine if this is a canceled error and + /// whether the cancellation was triggered by the user. + pub fn cancellation_reason( + &self, + ) -> Option<&crate::transfer::CancelReason> { + let source = source_error(self); + if let Some(err) = source.downcast_ref::() { + if let Error::TransferCanceled(reason) = err { + Some(reason) + } else { + None + } + } else { + None + } + } +} + +pub(crate) fn source_error<'a>( + error: &'a (dyn StdError + 'static), +) -> &'a (dyn StdError + 'static) { + let mut source = error; + while let Some(next_source) = source.source() { + source = next_source; + } + source +} + +/// Error created communicating over the network. +#[derive(Debug, Error)] +pub enum NetworkError { + /// Error generated when an unexpected response code is received. + #[error("unexpected response status code {0}")] + ResponseCode(StatusCode), + + /// Error generated when an unexpected response code is received. + #[error("unexpected response {1} (code: {0})")] + ResponseJson(StatusCode, Value), + + /// Error generated when an unexpected content type is returend. + #[error("unexpected content type {0}, expected: {1}")] + ContentType(String, String), +} + +/// Error reply. +#[derive(Default, Serialize, Deserialize)] +#[serde(default)] +pub struct ErrorReply { + /// Status code. + code: u16, + /// Data value. + #[serde(skip_serializing_if = "Option::is_none")] + value: Option, + /// Error message. + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, +} + +impl ErrorReply { + /// New error reply with a message. + pub fn new_message( + status: StatusCode, + message: impl std::fmt::Display, + ) -> Self { + Self { + code: status.into(), + message: Some(message.to_string()), + ..Default::default() + } + } +} + +impl From for ErrorReply { + fn from(value: NetworkError) -> Self { + match value { + NetworkError::ResponseCode(status) => ErrorReply { + code: status.into(), + ..Default::default() + }, + NetworkError::ResponseJson(status, value) => ErrorReply { + code: status.into(), + value: Some(value), + ..Default::default() + }, + NetworkError::ContentType(_, _) => ErrorReply { + code: StatusCode::BAD_REQUEST.into(), + ..Default::default() + }, + } + } +} + +/// Error created whan a conflict is detected. +#[derive(Debug, Error)] +pub enum ConflictError { + /// Error generated when a soft conflict was detected. + /// + /// A soft conflict may be resolved by searching for a + /// common ancestor commit and merging changes since + /// the common ancestor commit. + #[error("soft conflict")] + Soft { + /// Conflict information. + conflict: MaybeConflict, + /// Local information sent to the remote. + local: SyncStatus, + /// Remote information in the server reply. + remote: SyncStatus, + }, + + /// Error generated when a hard conflict was detected. + /// + /// A hard conflict is triggered after a soft conflict + /// attempted to scan proofs on a remote and was unable + /// to find a common ancestor commit. + #[error("hard conflict")] + Hard, +} + +impl AsConflict for Error { + fn is_conflict(&self) -> bool { + matches!(self, Error::Conflict(_)) + } + + fn is_hard_conflict(&self) -> bool { + matches!(self, Error::Conflict(ConflictError::Hard)) + } + + fn take_conflict(self) -> Option { + match self { + Self::Conflict(err) => Some(err), + _ => None, + } + } } diff --git a/crates/net/src/hashcheck.rs b/crates/protocol/src/hashcheck.rs similarity index 100% rename from crates/net/src/hashcheck.rs rename to crates/protocol/src/hashcheck.rs diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 9ff6837bd2..6d7362ba97 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -2,6 +2,9 @@ #![forbid(unsafe_code)] #![cfg_attr(all(doc, CHANNEL_NIGHTLY), feature(doc_auto_cfg))] //! Networking and sync protocol types for [Save Our Secrets](https://saveoursecrets.com). +//! +//! When the `account` feature is enabled [SyncStorage] will be +//! implemented for `LocalAccount`. // There are two layers to the types in this module; the wire // types which are defined in the protobuf files are prefixed @@ -21,15 +24,37 @@ // for convenience, the code may panic on 32-bit machines. mod bindings; +pub mod constants; mod error; +#[cfg(feature = "network-client")] +pub mod network_client; +pub mod server_helpers; mod sync; +mod traits; + +#[cfg(any( + feature = "files", + feature = "listen", + feature = "network-client" +))] +pub mod transfer; + +#[cfg(feature = "hashcheck")] +pub mod hashcheck; pub use bindings::*; -pub use error::Error; +pub use error::{AsConflict, ConflictError, Error, ErrorReply, NetworkError}; pub use sync::*; +pub use traits::*; use prost::{bytes::Buf, Message}; +#[cfg(feature = "network-client")] +pub use reqwest; + +#[cfg(any(feature = "listen", feature = "pairing"))] +pub use tokio_tungstenite; + #[cfg(test)] mod tests; @@ -106,6 +131,7 @@ where ::Inner: From + 'static, T: TryFrom<::Inner, Error = Error>, { + #[cfg(not(target_arch = "wasm32"))] async fn encode(self) -> Result> { tokio::task::spawn_blocking(move || { let value: ::Inner = self.into(); @@ -117,6 +143,7 @@ where .await? } + #[cfg(not(target_arch = "wasm32"))] async fn decode(buffer: B) -> Result where B: Buf + Send + 'static, @@ -128,6 +155,25 @@ where }) .await? } + + #[cfg(target_arch = "wasm32")] + async fn encode(self) -> Result> { + let value: ::Inner = self.into(); + let mut buf = Vec::new(); + buf.reserve(value.encoded_len()); + value.encode(&mut buf)?; + Ok(buf) + } + + #[cfg(target_arch = "wasm32")] + async fn decode(buffer: B) -> Result + where + B: Buf + Send + 'static, + Self: Sized, + { + let result = <::Inner>::decode(buffer)?; + Ok(result.try_into()?) + } } fn decode_uuid(id: &[u8]) -> Result { @@ -138,3 +184,9 @@ fn decode_uuid(id: &[u8]) -> Result { fn encode_uuid(id: &uuid::Uuid) -> Vec { id.as_bytes().to_vec() } + +/// Determine if the offline environment variable is set. +pub fn is_offline() -> bool { + use crate::sdk::constants::SOS_OFFLINE; + std::env::var(SOS_OFFLINE).ok().is_some() +} diff --git a/crates/net/src/net/http.rs b/crates/protocol/src/network_client/http.rs similarity index 81% rename from crates/net/src/net/http.rs rename to crates/protocol/src/network_client/http.rs index 4f83ad1a7e..f8f2a7888f 100644 --- a/crates/net/src/net/http.rs +++ b/crates/protocol/src/network_client/http.rs @@ -1,42 +1,49 @@ //! HTTP client implementation. use async_trait::async_trait; -use futures::{Future, StreamExt}; use http::StatusCode; use reqwest::header::{AUTHORIZATION, CONTENT_TYPE}; use serde_json::Value; use tracing::instrument; use crate::{ - protocol::{ - CreateSet, DiffRequest, DiffResponse, Origin, PatchRequest, - PatchResponse, ScanRequest, ScanResponse, SyncPacket, SyncStatus, - UpdateSet, WireEncodeDecode, + constants::{ + routes::v1::{ + SYNC_ACCOUNT, SYNC_ACCOUNT_EVENTS, SYNC_ACCOUNT_STATUS, + }, + MIME_TYPE_JSON, MIME_TYPE_PROTOBUF, X_SOS_ACCOUNT_ID, }, - sdk::{ - constants::MIME_TYPE_PROTOBUF, - sha2::{Digest, Sha256}, - signer::{ecdsa::BoxedEcdsaSigner, ed25519::BoxedEd25519Signer}, - }, - CancelReason, Error, Result, SyncClient, + CreateSet, DiffRequest, DiffResponse, Error, NetworkError, Origin, + PatchRequest, PatchResponse, Result, ScanRequest, ScanResponse, + SyncClient, SyncPacket, SyncStatus, UpdateSet, WireEncodeDecode, +}; + +use sos_sdk::{ + prelude::Address, + signer::{ecdsa::BoxedEcdsaSigner, ed25519::BoxedEd25519Signer}, }; -use std::{fmt, path::Path, time::Duration}; + +use std::{fmt, time::Duration}; use url::Url; +#[cfg(feature = "listen")] +use futures::Future; + use super::{ bearer_prefix, encode_account_signature, encode_device_signature, }; #[cfg(feature = "listen")] use crate::{ - net::websocket::WebSocketChangeListener, protocol::ChangeNotification, - ListenOptions, WebSocketHandle, + network_client::websocket::{ + ListenOptions, WebSocketChangeListener, WebSocketHandle, + }, + ChangeNotification, }; #[cfg(feature = "files")] use crate::{ - protocol::{FileSet, FileTransfersSet}, sdk::storage::files::ExternalFile, - ProgressChannel, + transfer::{FileSet, FileSyncClient, FileTransfersSet, ProgressChannel}, }; /// Client that can synchronize with a server over HTTP(S). @@ -75,11 +82,15 @@ impl HttpClient { device_signer: BoxedEd25519Signer, connection_id: String, ) -> Result { + #[cfg(not(target_arch = "wasm32"))] let client = reqwest::ClientBuilder::new() .read_timeout(Duration::from_millis(15000)) .connect_timeout(Duration::from_millis(5000)) .build()?; + #[cfg(target_arch = "wasm32")] + let client = reqwest::ClientBuilder::new().build()?; + Ok(Self { origin, account_signer, @@ -103,7 +114,7 @@ impl HttpClient { /// from the remote server using a websocket /// that performs automatic re-connection. #[cfg(feature = "listen")] - pub(crate) fn listen( + pub fn listen( &self, options: ListenOptions, handler: impl Fn(ChangeNotification) -> F + Send + Sync + 'static, @@ -154,10 +165,11 @@ impl HttpClient { if content_type == &protobuf_type { Ok(response) } else { - Err(Error::ContentType( + Err(NetworkError::ContentType( content_type.to_str()?.to_owned(), MIME_TYPE_PROTOBUF.to_string(), - )) + ) + .into()) } } // Otherwise exit out early @@ -174,15 +186,15 @@ impl HttpClient { use reqwest::header::{self, HeaderValue}; let status = response.status(); - let json_type = HeaderValue::from_static("application/json"); + let json_type = HeaderValue::from_static(MIME_TYPE_JSON); let content_type = response.headers().get(&header::CONTENT_TYPE); if !status.is_success() { if let Some(content_type) = content_type { if content_type == json_type { let value: Value = response.json().await?; - Err(Error::ResponseJson(status, value)) + Err(NetworkError::ResponseJson(status, value).into()) } else { - Err(Error::ResponseCode(status)) + Err(NetworkError::ResponseCode(status).into()) } } else { Ok(response) @@ -193,15 +205,18 @@ impl HttpClient { } } -#[async_trait] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] impl SyncClient for HttpClient { + type Error = crate::Error; + fn origin(&self) -> &Origin { &self.origin } - #[instrument(skip_all)] - async fn account_exists(&self) -> Result { - let url = self.build_url("api/v1/sync/account")?; + #[cfg_attr(not(target_arch = "wasm32"), instrument(skip_all))] + async fn account_exists(&self, address: &Address) -> Result { + let url = self.build_url(SYNC_ACCOUNT)?; let sign_url = url.path(); let account_signature = encode_account_signature( @@ -214,6 +229,7 @@ impl SyncClient for HttpClient { let response = self .client .head(url) + .header(X_SOS_ACCOUNT_ID, address.to_string()) .header(AUTHORIZATION, auth) .send() .await?; @@ -223,40 +239,20 @@ impl SyncClient for HttpClient { StatusCode::OK => true, StatusCode::NOT_FOUND => false, _ => { - return Err(Error::ResponseCode(status)); + return Err(NetworkError::ResponseCode(status).into()); } }; Ok(exists) } - #[instrument(skip_all)] - async fn delete_account(&self) -> Result<()> { - let url = self.build_url("api/v1/sync/account")?; - - let sign_url = url.path(); - let account_signature = encode_account_signature( - self.account_signer.sign(sign_url.as_bytes()).await?, - ) - .await?; - let auth = bearer_prefix(&account_signature, None); - - tracing::debug!(url = %url, "http::delete_account"); - let response = self - .client - .delete(url) - .header(AUTHORIZATION, auth) - .send() - .await?; - let status = response.status(); - tracing::debug!(status = %status, "http::delete_account"); - self.error_json(response).await?; - Ok(()) - } - - #[instrument(skip_all)] - async fn create_account(&self, account: CreateSet) -> Result<()> { + #[cfg_attr(not(target_arch = "wasm32"), instrument(skip_all))] + async fn create_account( + &self, + address: &Address, + account: CreateSet, + ) -> Result<()> { let body = account.encode().await?; - let url = self.build_url("api/v1/sync/account")?; + let url = self.build_url(SYNC_ACCOUNT)?; tracing::debug!(url = %url, "http::create_account"); @@ -267,6 +263,7 @@ impl SyncClient for HttpClient { let response = self .client .put(url) + .header(X_SOS_ACCOUNT_ID, address.to_string()) .header(CONTENT_TYPE, MIME_TYPE_PROTOBUF) .header(AUTHORIZATION, auth) .body(body) @@ -278,10 +275,14 @@ impl SyncClient for HttpClient { Ok(()) } - #[instrument(skip_all)] - async fn update_account(&self, account: UpdateSet) -> Result<()> { + #[cfg_attr(not(target_arch = "wasm32"), instrument(skip_all))] + async fn update_account( + &self, + address: &Address, + account: UpdateSet, + ) -> Result<()> { let body = account.encode().await?; - let url = self.build_url("api/v1/sync/account")?; + let url = self.build_url(SYNC_ACCOUNT)?; tracing::debug!(url = %url, "http::update_account"); @@ -295,6 +296,7 @@ impl SyncClient for HttpClient { let response = self .client .post(url) + .header(X_SOS_ACCOUNT_ID, address.to_string()) .header(CONTENT_TYPE, MIME_TYPE_PROTOBUF) .header(AUTHORIZATION, auth) .body(body) @@ -306,9 +308,9 @@ impl SyncClient for HttpClient { Ok(()) } - #[instrument(skip_all)] - async fn fetch_account(&self) -> Result { - let url = self.build_url("api/v1/sync/account")?; + #[cfg_attr(not(target_arch = "wasm32"), instrument(skip_all))] + async fn fetch_account(&self, address: &Address) -> Result { + let url = self.build_url(SYNC_ACCOUNT)?; tracing::debug!(url = %url, "http::fetch_account"); @@ -325,6 +327,7 @@ impl SyncClient for HttpClient { let response = self .client .get(url) + .header(X_SOS_ACCOUNT_ID, address.to_string()) .header(AUTHORIZATION, auth) .send() .await?; @@ -335,9 +338,34 @@ impl SyncClient for HttpClient { Ok(CreateSet::decode(buffer).await?) } - #[instrument(skip_all)] - async fn sync_status(&self) -> Result { - let url = self.build_url("api/v1/sync/account/status")?; + #[cfg_attr(not(target_arch = "wasm32"), instrument(skip_all))] + async fn delete_account(&self, address: &Address) -> Result<()> { + let url = self.build_url(SYNC_ACCOUNT)?; + + let sign_url = url.path(); + let account_signature = encode_account_signature( + self.account_signer.sign(sign_url.as_bytes()).await?, + ) + .await?; + let auth = bearer_prefix(&account_signature, None); + + tracing::debug!(url = %url, "http::delete_account"); + let response = self + .client + .delete(url) + .header(X_SOS_ACCOUNT_ID, address.to_string()) + .header(AUTHORIZATION, auth) + .send() + .await?; + let status = response.status(); + tracing::debug!(status = %status, "http::delete_account"); + self.error_json(response).await?; + Ok(()) + } + + #[cfg_attr(not(target_arch = "wasm32"), instrument(skip_all))] + async fn sync_status(&self, address: &Address) -> Result { + let url = self.build_url(SYNC_ACCOUNT_STATUS)?; tracing::debug!(url = %url, "http::sync_status"); @@ -354,6 +382,7 @@ impl SyncClient for HttpClient { let response = self .client .get(url) + .header(X_SOS_ACCOUNT_ID, address.to_string()) .header(AUTHORIZATION, auth) .send() .await?; @@ -364,10 +393,15 @@ impl SyncClient for HttpClient { Ok(SyncStatus::decode(buffer).await?) } - #[instrument(skip_all)] - async fn sync(&self, packet: SyncPacket) -> Result { + #[cfg_attr(not(target_arch = "wasm32"), instrument(skip_all))] + async fn sync( + &self, + + address: &Address, + packet: SyncPacket, + ) -> Result { let body = packet.encode().await?; - let url = self.build_url("api/v1/sync/account")?; + let url = self.build_url(SYNC_ACCOUNT)?; tracing::debug!(url = %url, "http::sync"); @@ -381,6 +415,7 @@ impl SyncClient for HttpClient { let response = self .client .patch(url) + .header(X_SOS_ACCOUNT_ID, address.to_string()) .header(CONTENT_TYPE, MIME_TYPE_PROTOBUF) .header(AUTHORIZATION, auth) .body(body) @@ -393,10 +428,14 @@ impl SyncClient for HttpClient { Ok(SyncPacket::decode(buffer).await?) } - #[instrument(skip_all)] - async fn scan(&self, request: ScanRequest) -> Result { + #[cfg_attr(not(target_arch = "wasm32"), instrument(skip_all))] + async fn scan( + &self, + address: &Address, + request: ScanRequest, + ) -> Result { let body = request.encode().await?; - let url = self.build_url("api/v1/sync/account/events")?; + let url = self.build_url(SYNC_ACCOUNT_EVENTS)?; tracing::debug!(url = %url, "http::scan"); @@ -410,6 +449,7 @@ impl SyncClient for HttpClient { let response = self .client .get(url) + .header(X_SOS_ACCOUNT_ID, address.to_string()) .header(CONTENT_TYPE, MIME_TYPE_PROTOBUF) .header(AUTHORIZATION, auth) .body(body) @@ -422,10 +462,14 @@ impl SyncClient for HttpClient { Ok(ScanResponse::decode(buffer).await?) } - #[instrument(skip_all)] - async fn diff(&self, request: DiffRequest) -> Result { + #[cfg_attr(not(target_arch = "wasm32"), instrument(skip_all))] + async fn diff( + &self, + address: &Address, + request: DiffRequest, + ) -> Result { let body = request.encode().await?; - let url = self.build_url("api/v1/sync/account/events")?; + let url = self.build_url(SYNC_ACCOUNT_EVENTS)?; tracing::debug!(url = %url, "http::diff"); @@ -439,6 +483,7 @@ impl SyncClient for HttpClient { let response = self .client .post(url) + .header(X_SOS_ACCOUNT_ID, address.to_string()) .header(CONTENT_TYPE, MIME_TYPE_PROTOBUF) .header(AUTHORIZATION, auth) .body(body) @@ -451,10 +496,14 @@ impl SyncClient for HttpClient { Ok(DiffResponse::decode(buffer).await?) } - #[instrument(skip_all)] - async fn patch(&self, request: PatchRequest) -> Result { + #[cfg_attr(not(target_arch = "wasm32"), instrument(skip_all))] + async fn patch( + &self, + address: &Address, + request: PatchRequest, + ) -> Result { let body = request.encode().await?; - let url = self.build_url("api/v1/sync/account/events")?; + let url = self.build_url(SYNC_ACCOUNT_EVENTS)?; tracing::debug!(url = %url, "http::patch"); @@ -468,6 +517,7 @@ impl SyncClient for HttpClient { let response = self .client .patch(url) + .header(X_SOS_ACCOUNT_ID, address.to_string()) .header(CONTENT_TYPE, MIME_TYPE_PROTOBUF) .header(AUTHORIZATION, auth) .body(body) @@ -479,17 +529,29 @@ impl SyncClient for HttpClient { let buffer = response.bytes().await?; Ok(PatchResponse::decode(buffer).await?) } +} - #[cfg(feature = "files")] - #[instrument(skip(self, path, progress, cancel))] +#[cfg(feature = "files")] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl FileSyncClient for HttpClient { + type Error = crate::Error; + + #[cfg_attr( + not(target_arch = "wasm32"), + instrument(skip(self, path, progress, cancel)) + )] async fn upload_file( &self, file_info: &ExternalFile, - path: &Path, + path: &std::path::Path, progress: ProgressChannel, - mut cancel: tokio::sync::watch::Receiver, + mut cancel: tokio::sync::watch::Receiver< + crate::transfer::CancelReason, + >, ) -> Result { use crate::sdk::vfs; + use futures::StreamExt; use reqwest::{ header::{CONTENT_LENGTH, CONTENT_TYPE}, Body, @@ -566,16 +628,23 @@ impl SyncClient for HttpClient { Ok(status) } - #[cfg(feature = "files")] - #[instrument(skip(self, path, progress, cancel))] + #[cfg_attr( + not(target_arch = "wasm32"), + instrument(skip(self, path, progress, cancel)) + )] async fn download_file( &self, file_info: &ExternalFile, - path: &Path, + path: &std::path::Path, progress: ProgressChannel, - mut cancel: tokio::sync::watch::Receiver, + mut cancel: tokio::sync::watch::Receiver< + crate::transfer::CancelReason, + >, ) -> Result { - use crate::sdk::vfs; + use crate::sdk::{ + sha2::{Digest, Sha256}, + vfs, + }; use tokio::io::AsyncWriteExt; let url_path = format!("api/v1/sync/file/{}", file_info); @@ -645,10 +714,10 @@ impl SyncClient for HttpClient { let digest_valid = digest.as_slice() == file_info.file_name().as_ref(); if !digest_valid { - tokio::fs::remove_file(download_path).await?; + vfs::remove_file(download_path).await?; return Err(Error::FileChecksumMismatch( file_info.file_name().to_string(), - hex::encode(digest.as_slice()), + sos_sdk::hex::encode(digest.as_slice()), )); } @@ -665,8 +734,7 @@ impl SyncClient for HttpClient { Ok(status) } - #[cfg(feature = "files")] - #[instrument(skip(self))] + #[cfg_attr(not(target_arch = "wasm32"), instrument(skip(self)))] async fn delete_file( &self, file_info: &ExternalFile, @@ -701,8 +769,7 @@ impl SyncClient for HttpClient { Ok(status) } - #[cfg(feature = "files")] - #[instrument(skip(self))] + #[cfg_attr(not(target_arch = "wasm32"), instrument(skip(self)))] async fn move_file( &self, from: &ExternalFile, @@ -742,8 +809,7 @@ impl SyncClient for HttpClient { Ok(status) } - #[cfg(feature = "files")] - #[instrument(skip_all)] + #[cfg_attr(not(target_arch = "wasm32"), instrument(skip_all))] async fn compare_files( &self, local_files: FileSet, diff --git a/crates/net/src/net/mod.rs b/crates/protocol/src/network_client/mod.rs similarity index 59% rename from crates/net/src/net/mod.rs rename to crates/protocol/src/network_client/mod.rs index fdbd0358ff..d9eead61b7 100644 --- a/crates/net/src/net/mod.rs +++ b/crates/protocol/src/network_client/mod.rs @@ -1,6 +1,6 @@ //! HTTP transport trait and implementations. use super::{Error, Result}; -use crate::CancelReason; +use crate::transfer::CancelReason; use sos_sdk::{ encode, signer::{ @@ -17,7 +17,7 @@ use std::{ }, time::Duration, }; -use tokio::{sync::watch, time::sleep}; +use tokio::sync::watch; mod http; #[cfg(feature = "listen")] @@ -29,19 +29,24 @@ pub use self::http::HttpClient; pub use websocket::{changes, connect, ListenOptions, WebSocketHandle}; /// Network retry state and logic for exponential backoff. +#[cfg(not(target_arch = "wasm32"))] #[derive(Debug, Clone)] pub struct NetworkRetry { retries: Arc, - pub(crate) reconnect_interval: u16, - pub(crate) maximum_retries: u32, + /// Reconnect interval. + pub reconnect_interval: u16, + /// Maximum number of retries. + pub maximum_retries: u32, } +#[cfg(not(target_arch = "wasm32"))] impl Default for NetworkRetry { fn default() -> Self { Self::new(4, 1000) } } +#[cfg(not(target_arch = "wasm32"))] impl NetworkRetry { /// Create a new network retry. /// @@ -116,6 +121,7 @@ impl NetworkRetry { maximum_retries = %self.maximum_retries, "retry", ); + loop { tokio::select! { _ = cancel.changed() => { @@ -123,7 +129,7 @@ impl NetworkRetry { tracing::debug!(id = %id, "retry::canceled"); return Err(Error::RetryCanceled(reason.clone())); } - _ = sleep(Duration::from_millis(delay)) => { + _ = tokio::time::sleep(Duration::from_millis(delay)) => { return Ok(callback.await) } }; @@ -155,3 +161,81 @@ pub(crate) fn bearer_prefix( format!("Bearer {}", account_signature) } } + +#[cfg(any(feature = "listen", feature = "pairing"))] +mod websocket_request { + use super::Result; + use sos_sdk::url::Url; + use tokio_tungstenite::tungstenite::{ + self, client::IntoClientRequest, handshake::client::generate_key, + }; + + /// Build a websocket connection request. + pub struct WebSocketRequest { + /// Remote URI. + pub uri: Url, + /// Remote host. + pub host: String, + /// Bearer authentication. + pub bearer: Option, + /// URL origin. + pub origin: url::Origin, + } + + impl WebSocketRequest { + /// Create a new websocket request. + pub fn new(url: &Url, path: &str) -> Result { + let origin = url.origin(); + let host = url.host_str().unwrap().to_string(); + + let mut uri = url.join(path)?; + let scheme = if uri.scheme() == "http" { + "ws" + } else if uri.scheme() == "https" { + "wss" + } else { + panic!("bad url scheme for websocket, requires http(s)"); + }; + + uri.set_scheme(scheme) + .expect("failed to set websocket scheme"); + + Ok(Self { + host, + uri, + origin, + bearer: None, + }) + } + + /// Set bearer authorization. + pub fn set_bearer(&mut self, bearer: String) { + self.bearer = Some(bearer); + } + } + + impl IntoClientRequest for WebSocketRequest { + fn into_client_request( + self, + ) -> std::result::Result, tungstenite::Error> + { + let origin = self.origin.unicode_serialization(); + let mut request = + http::Request::builder().uri(self.uri.to_string()); + if let Some(bearer) = self.bearer { + request = request.header("authorization", bearer); + } + request = request + .header("sec-websocket-key", generate_key()) + .header("sec-websocket-version", "13") + .header("host", self.host) + .header("origin", origin) + .header("connection", "keep-alive, Upgrade") + .header("upgrade", "websocket"); + Ok(request.body(())?) + } + } +} + +#[cfg(any(feature = "listen", feature = "pairing"))] +pub use websocket_request::WebSocketRequest; diff --git a/crates/net/src/net/websocket.rs b/crates/protocol/src/network_client/websocket.rs similarity index 98% rename from crates/net/src/net/websocket.rs rename to crates/protocol/src/network_client/websocket.rs index 4ee2d0b441..8690e5b4fe 100644 --- a/crates/net/src/net/websocket.rs +++ b/crates/protocol/src/network_client/websocket.rs @@ -19,9 +19,9 @@ use tokio::{net::TcpStream, sync::watch, time::Duration}; use sos_sdk::signer::{ecdsa::BoxedEcdsaSigner, ed25519::BoxedEd25519Signer}; use crate::{ - net::NetworkRetry, - protocol::{ChangeNotification, Origin, WireEncodeDecode}, - CancelReason, Error, Result, WebSocketRequest, + network_client::{NetworkRetry, WebSocketRequest}, + transfer::CancelReason, + ChangeNotification, Error, Origin, Result, WireEncodeDecode, }; use super::{ diff --git a/crates/protocol/src/server_helpers.rs b/crates/protocol/src/server_helpers.rs new file mode 100644 index 0000000000..e53f51c615 --- /dev/null +++ b/crates/protocol/src/server_helpers.rs @@ -0,0 +1,389 @@ +//! Helper functions for server implementations. +use crate::{ + DiffRequest, DiffResponse, EventLogType, Merge, MergeOutcome, + PatchRequest, PatchResponse, Result, ScanRequest, ScanResponse, + SyncPacket, SyncStorage, +}; +use binary_stream::futures::{Decodable, Encodable}; +use sos_sdk::{ + events::{ + AccountDiff, AccountEvent, CheckedPatch, DeviceDiff, DeviceEvent, + DiscEventLog, EventRecord, FolderDiff, Patch, WriteEvent, + }, + prelude::EventLogExt, + storage::StorageEventLogs, +}; + +#[cfg(feature = "files")] +use sos_sdk::events::{FileDiff, FileEvent}; + +/// Sync an account. +pub async fn sync_account( + packet: SyncPacket, + storage: &mut (impl SyncStorage + Merge + Send + Sync + 'static), +) -> Result<(SyncPacket, MergeOutcome)> { + let (remote_status, mut diff) = (packet.status, packet.diff); + + // Apply the diff to the storage + let mut outcome = MergeOutcome::default(); + let compare = { + tracing::debug!("merge_server"); + + // Only try to merge folders that exist in storage + // otherwise after folder deletion sync will fail + let folders = storage + .folder_details() + .await? + .into_iter() + .map(|s| *s.id()) + .collect::>(); + diff.folders.retain(|k, _| folders.contains(k)); + + storage.merge(diff, &mut outcome).await? + }; + + // Generate a new diff so the client can apply changes + // that exist in remote but not in the local + let (local_status, diff) = { + // let reader = account.read().await; + let (_, local_status, diff) = + crate::diff(storage, remote_status).await?; + (local_status, diff) + }; + + let packet = SyncPacket { + status: local_status, + diff, + compare: Some(compare), + }; + + Ok((packet, outcome)) +} + +/// Read a diff of events from a event log. +pub async fn event_diff( + req: &DiffRequest, + storage: &impl StorageEventLogs, +) -> Result { + match &req.log_type { + EventLogType::Identity => { + let log = storage.identity_log().await?; + let event_log = log.read().await; + diff_log(&req, &*event_log).await + } + EventLogType::Account => { + let log = storage.account_log().await?; + let event_log = log.read().await; + diff_log(&req, &*event_log).await + } + EventLogType::Device => { + let log = storage.device_log().await?; + let event_log = log.read().await; + diff_log(&req, &*event_log).await + } + #[cfg(feature = "files")] + EventLogType::Files => { + let log = storage.file_log().await?; + let event_log = log.read().await; + diff_log(&req, &*event_log).await + } + EventLogType::Folder(id) => { + let log = storage.folder_log(id).await?; + let event_log = log.read().await; + diff_log(&req, &*event_log).await + } + } +} + +/// Create a diff response from a request and target event log. +async fn diff_log( + req: &DiffRequest, + event_log: &DiscEventLog, +) -> Result +where + T: Default + Encodable + Decodable + Send + Sync + 'static, +{ + Ok(DiffResponse { + patch: event_log.diff_records(req.from_hash.as_ref()).await?, + checkpoint: event_log.tree().head()?, + }) +} + +/// Scan event proofs. +pub async fn event_scan( + req: &ScanRequest, + storage: &impl StorageEventLogs, +) -> Result { + let response = match &req.log_type { + EventLogType::Identity => { + let log = storage.identity_log().await?; + let event_log = log.read().await; + scan_log(&req, &*event_log).await? + } + EventLogType::Account => { + let log = storage.account_log().await?; + let event_log = log.read().await; + scan_log(&req, &*event_log).await? + } + EventLogType::Device => { + let log = storage.device_log().await?; + let event_log = log.read().await; + scan_log(&req, &*event_log).await? + } + #[cfg(feature = "files")] + EventLogType::Files => { + let log = storage.file_log().await?; + let event_log = log.read().await; + scan_log(&req, &*event_log).await? + } + EventLogType::Folder(id) => { + let log = storage.folder_log(&id).await?; + let event_log = log.read().await; + scan_log(&req, &*event_log).await? + } + }; + + Ok(response) +} + +/// Scan an event log. +async fn scan_log( + req: &ScanRequest, + event_log: &DiscEventLog, +) -> Result +where + T: Default + Encodable + Decodable + Send + Sync + 'static, +{ + let mut res = ScanResponse { + first_proof: None, + proofs: vec![], + offset: 0, + }; + let offset = req.offset; + let num_commits = event_log.tree().len() as u64; + + let mut index = if event_log.tree().len() > 0 { + event_log.tree().len() - 1 + } else { + 0 + }; + + if event_log.tree().len() > 0 { + res.first_proof = Some(event_log.tree().proof(&[0])?); + } + + // Short circuit if the offset is clearly out of bounds + if offset >= num_commits { + res.offset = num_commits; + return Ok(res); + } + + let mut it = event_log.iter(true).await?; + let mut skip = 0; + + loop { + let event = it.next().await?; + if offset > 0 && skip < offset { + if index > 0 { + index -= 1; + } + skip += 1; + continue; + } + if let Some(_event) = event { + let proof = event_log.tree().proof(&[index])?; + res.proofs.insert(0, proof); + res.offset = offset + res.proofs.len() as u64; + + if index > 0 { + index -= 1; + } + + if res.proofs.len() == req.limit as usize { + break; + } + } else { + break; + } + } + Ok(res) +} + +/// Apply a patch of events rewinding to an optional checkpoint commit +/// before applying the patch. +pub async fn event_patch( + req: PatchRequest, + storage: &mut (impl StorageEventLogs + Merge), +) -> Result<(PatchResponse, MergeOutcome)> { + let (checked_patch, outcome, records) = match &req.log_type { + EventLogType::Identity => { + let patch = Patch::::new(req.patch); + let (last_commit, records) = if let Some(commit) = &req.commit { + let log = storage.identity_log().await?; + let mut event_log = log.write().await; + let records = event_log.rewind(commit).await?; + (Some(*commit), records) + } else { + (None, vec![]) + }; + + let diff = FolderDiff { + last_commit, + checkpoint: req.proof, + patch, + }; + + let mut outcome = MergeOutcome::default(); + ( + storage.merge_identity(diff, &mut outcome).await?, + outcome, + records, + ) + } + EventLogType::Account => { + let patch = Patch::::new(req.patch); + let (last_commit, records) = if let Some(commit) = &req.commit { + let log = storage.account_log().await?; + let mut event_log = log.write().await; + let records = event_log.rewind(commit).await?; + (Some(*commit), records) + } else { + (None, vec![]) + }; + + let diff = AccountDiff { + last_commit, + checkpoint: req.proof, + patch, + }; + + let mut outcome = MergeOutcome::default(); + ( + storage.merge_account(diff, &mut outcome).await?.0, + outcome, + records, + ) + } + EventLogType::Device => { + let patch = Patch::::new(req.patch); + let (last_commit, records) = if let Some(commit) = &req.commit { + let log = storage.device_log().await?; + let mut event_log = log.write().await; + let records = event_log.rewind(commit).await?; + (Some(*commit), records) + } else { + (None, vec![]) + }; + + let diff = DeviceDiff { + last_commit, + checkpoint: req.proof, + patch, + }; + + let mut outcome = MergeOutcome::default(); + ( + storage.merge_device(diff, &mut outcome).await?, + outcome, + records, + ) + } + #[cfg(feature = "files")] + EventLogType::Files => { + let patch = Patch::::new(req.patch); + let (last_commit, records) = if let Some(commit) = &req.commit { + let log = storage.file_log().await?; + let mut event_log = log.write().await; + let records = event_log.rewind(commit).await?; + (Some(*commit), records) + } else { + (None, vec![]) + }; + + let diff = FileDiff { + last_commit, + checkpoint: req.proof, + patch, + }; + + let mut outcome = MergeOutcome::default(); + ( + storage.merge_files(diff, &mut outcome).await?, + outcome, + records, + ) + } + EventLogType::Folder(id) => { + let patch = Patch::::new(req.patch); + let (last_commit, records) = if let Some(commit) = &req.commit { + let log = storage.folder_log(&id).await?; + let mut event_log = log.write().await; + let records = event_log.rewind(commit).await?; + (Some(*commit), records) + } else { + (None, vec![]) + }; + + let diff = FolderDiff { + last_commit, + checkpoint: req.proof, + patch, + }; + + let mut outcome = MergeOutcome::default(); + ( + storage.merge_folder(&id, diff, &mut outcome).await?.0, + outcome, + records, + ) + } + }; + + // Rollback the rewind if the merge failed + if let CheckedPatch::Conflict { head, .. } = &checked_patch { + tracing::warn!( + head = ?head, + num_records = ?records.len(), + "events_patch::rollback_rewind"); + rollback_rewind(&req.log_type, storage, records).await?; + } + + Ok((PatchResponse { checked_patch }, outcome)) +} + +async fn rollback_rewind( + log_type: &EventLogType, + storage: &mut impl StorageEventLogs, + records: Vec, +) -> Result<()> { + match log_type { + EventLogType::Identity => { + let log = storage.identity_log().await?; + let mut event_log = log.write().await; + event_log.apply_records(records).await?; + } + EventLogType::Account => { + let log = storage.account_log().await?; + let mut event_log = log.write().await; + event_log.apply_records(records).await?; + } + EventLogType::Device => { + let log = storage.device_log().await?; + let mut event_log = log.write().await; + event_log.apply_records(records).await?; + } + #[cfg(feature = "files")] + EventLogType::Files => { + let log = storage.file_log().await?; + let mut event_log = log.write().await; + event_log.apply_records(records).await?; + } + EventLogType::Folder(id) => { + let log = storage.folder_log(id).await?; + let mut event_log = log.write().await; + event_log.apply_records(records).await?; + } + } + + Ok(()) +} diff --git a/crates/net/src/account/auto_merge.rs b/crates/protocol/src/sync/auto_merge.rs similarity index 61% rename from crates/net/src/account/auto_merge.rs rename to crates/protocol/src/sync/auto_merge.rs index 89d44be38a..7bd3b8e4c3 100644 --- a/crates/net/src/account/auto_merge.rs +++ b/crates/protocol/src/sync/auto_merge.rs @@ -1,21 +1,21 @@ //! Implements auto merge logic for a remote. -use crate::sdk::{ +use crate::{ + AsConflict, ConflictError, DiffRequest, PatchRequest, ScanRequest, + SyncClient, SyncDirection, +}; +use async_trait::async_trait; +use sos_sdk::{ account::Account, commit::{CommitHash, CommitProof, CommitTree}, events::{ - AccountDiff, AccountEvent, CheckedPatch, EventLogExt, EventRecord, - FolderDiff, Patch, WriteEvent, + AccountDiff, AccountEvent, CheckedPatch, Diff, EventLogExt, + EventRecord, FolderDiff, Patch, WriteEvent, }, storage::StorageEventLogs, vault::VaultId, }; -use crate::{ - protocol::{DiffRequest, PatchRequest, ScanRequest}, - Error, RemoteBridge, Result, SyncClient, -}; -use async_recursion::async_recursion; -use sos_protocol::{ +use crate::{ EventLogType, ForceMerge, HardConflictResolver, MaybeConflict, Merge, MergeOutcome, SyncOptions, SyncStatus, }; @@ -29,84 +29,308 @@ use sos_sdk::events::{DeviceDiff, DeviceEvent}; #[cfg(feature = "files")] use sos_sdk::events::{FileDiff, FileEvent}; -/// Implements the auto merge logic for an event log type. -macro_rules! auto_merge_impl { - ($log_id:expr, $fn_name:ident, $log_type:expr, $event_type:ident, $conflict_fn:ident) => { - async fn $fn_name( - &self, - options: &SyncOptions, - outcome: &mut MergeOutcome, - ) -> Result { - tracing::debug!($log_id); - - let req = ScanRequest { - log_type: $log_type, - offset: 0, - limit: PROOF_SCAN_LIMIT, - }; - match self.scan_proofs(req).await { - Ok(Some((ancestor_commit, proof))) => { - self.try_merge_from_ancestor::<$event_type>( - EventLogType::Identity, - ancestor_commit, - proof, - ) - .await?; - Ok(false) - } - Err(e) => match e { - Error::HardConflict => { - self.$conflict_fn(options, outcome).await?; - Ok(true) - } - _ => Err(e), - }, - _ => Err(Error::HardConflict), - } - } - }; -} +use super::RemoteSyncHandler; -/// Implements the hard conflict resolution logic for an event log type. -macro_rules! auto_merge_conflict_impl { - ($log_id:expr, $fn_name:ident, $log_type:expr, $event_type:ident, $diff_type:ident, $merge_fn:ident) => { - async fn $fn_name( - &self, - options: &SyncOptions, - outcome: &mut MergeOutcome, - ) -> Result<()> { - match &options.hard_conflict_resolver { - HardConflictResolver::AutomaticFetch => { - tracing::debug!($log_id); - - let request = DiffRequest { - log_type: $log_type, - from_hash: None, - }; - let response = self.client.diff(request).await?; - let patch = Patch::<$event_type>::new(response.patch); - let diff = $diff_type { - patch, - checkpoint: response.checkpoint, - last_commit: None, - }; - let mut account = self.account.lock().await; - Ok(account.$merge_fn(diff, outcome).await?) - } - } - } - }; +/// State used while scanning commit proofs on a remote data source. +#[doc(hidden)] +pub enum ScanState { + Result((CommitHash, CommitProof)), + Continue(ScanRequest), + Exhausted, } /// Whether to apply an auto merge to local or remote. -enum AutoMerge { +#[doc(hidden)] +pub enum AutoMergeStatus { /// Apply the events to the local event log. RewindLocal(Vec), /// Push events to the remote. PushRemote(Vec), } -impl RemoteBridge { +/// Support for auto merge on sync. +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +pub trait AutoMerge: RemoteSyncHandler { + /// Execute the sync operation. + /// + /// If the account does not exist it is created + /// on the remote, otherwise the account is synced. + #[doc(hidden)] + async fn execute_sync( + &self, + options: &SyncOptions, + ) -> Result, Self::Error> { + match self.direction() { + SyncDirection::Push => { + let exists = + self.client().account_exists(self.address()).await?; + if exists { + self.perform_sync(options).await + } else { + self.create_account().await?; + Ok(None) + } + } + SyncDirection::Pull => { + let exists = { + let account = self.account(); + let account = account.lock().await; + account.storage().await.is_some() + }; + if exists { + self.perform_sync(options).await + } else { + self.create_account().await?; + Ok(None) + } + } + } + } + + #[doc(hidden)] + async fn perform_sync( + &self, + options: &SyncOptions, + ) -> Result, Self::Error> { + let sync_status = self.client().sync_status(self.address()).await?; + match self.sync_account(sync_status).await { + Ok(outcome) => Ok(Some(outcome)), + Err(e) => { + if e.is_conflict() { + let conflict = e.take_conflict().unwrap(); + match conflict { + ConflictError::Soft { + conflict, + local, + remote, + } => { + let outcome = self + .auto_merge(options, conflict, local, remote) + .await?; + Ok(Some(outcome)) + } + _ => Err(conflict.into()), + } + } else { + Err(e) + } + } + } + } + + #[doc(hidden)] + async fn auto_merge_scan( + &self, + log_id: &'static str, + log_type: EventLogType, + ) -> Result::Error> + where + T: Default + Send + Sync, + { + tracing::debug!(log_id); + + let req = ScanRequest { + log_type, + offset: 0, + limit: PROOF_SCAN_LIMIT, + }; + match self.scan_proofs(req).await { + Ok(Some((ancestor_commit, proof))) => { + self.try_merge_from_ancestor::( + EventLogType::Identity, + ancestor_commit, + proof, + ) + .await?; + Ok(false) + } + Err(e) => { + if e.is_hard_conflict() { + Ok(true) + } else { + Err(e) + } + } + _ => Err(ConflictError::Hard.into()), + } + } + + /// Auto merge identity folders. + async fn auto_merge_identity( + &self, + options: &SyncOptions, + outcome: &mut MergeOutcome, + ) -> Result::Error> { + let handle_conflict = self + .auto_merge_scan::( + "auto_merge::identity", + EventLogType::Identity, + ) + .await?; + if handle_conflict { + self.identity_hard_conflict(options, outcome).await?; + } + Ok(handle_conflict) + } + + /// Auto merge account events. + async fn auto_merge_account( + &self, + options: &SyncOptions, + outcome: &mut MergeOutcome, + ) -> Result::Error> { + let handle_conflict = self + .auto_merge_scan::( + "auto_merge::account", + EventLogType::Account, + ) + .await?; + if handle_conflict { + self.account_hard_conflict(options, outcome).await?; + } + Ok(handle_conflict) + } + + /// Auto merge device events. + async fn auto_merge_device( + &self, + options: &SyncOptions, + outcome: &mut MergeOutcome, + ) -> Result::Error> { + let handle_conflict = self + .auto_merge_scan::( + "auto_merge::device", + EventLogType::Device, + ) + .await?; + if handle_conflict { + self.device_hard_conflict(options, outcome).await?; + } + Ok(handle_conflict) + } + + /// Auto merge file events. + #[cfg(feature = "files")] + async fn auto_merge_files( + &self, + options: &SyncOptions, + outcome: &mut MergeOutcome, + ) -> Result::Error> { + let handle_conflict = self + .auto_merge_scan::( + "auto_merge::files", + EventLogType::Files, + ) + .await?; + if handle_conflict { + self.files_hard_conflict(options, outcome).await?; + } + Ok(handle_conflict) + } + + #[doc(hidden)] + async fn hard_conflict_diff( + &self, + log_id: &'static str, + log_type: EventLogType, + options: &SyncOptions, + ) -> Result, ::Error> { + match &options.hard_conflict_resolver { + HardConflictResolver::AutomaticFetch => { + tracing::debug!(log_id); + + let request = DiffRequest { + log_type, + from_hash: None, + }; + let response = + self.client().diff(self.address(), request).await?; + let patch = Patch::::new(response.patch); + let diff = + Diff::::new(patch, response.checkpoint, None); + Ok(diff) + } + } + } + + #[doc(hidden)] + async fn identity_hard_conflict( + &self, + options: &SyncOptions, + outcome: &mut MergeOutcome, + ) -> Result<(), ::Error> { + let diff = self + .hard_conflict_diff::( + "hard_conflict::force_merge::identity", + EventLogType::Identity, + options, + ) + .await?; + + let account = self.account(); + let mut account = account.lock().await; + Ok(account.force_merge_identity(diff, outcome).await?) + } + + #[doc(hidden)] + async fn account_hard_conflict( + &self, + options: &SyncOptions, + outcome: &mut MergeOutcome, + ) -> Result<(), ::Error> { + let diff = self + .hard_conflict_diff::( + "hard_conflict::force_merge::account", + EventLogType::Account, + options, + ) + .await?; + + let account = self.account(); + let mut account = account.lock().await; + Ok(account.force_merge_account(diff, outcome).await?) + } + + #[doc(hidden)] + async fn device_hard_conflict( + &self, + options: &SyncOptions, + outcome: &mut MergeOutcome, + ) -> Result<(), ::Error> { + let diff = self + .hard_conflict_diff::( + "hard_conflict::force_merge::device", + EventLogType::Device, + options, + ) + .await?; + + let account = self.account(); + let mut account = account.lock().await; + Ok(account.force_merge_device(diff, outcome).await?) + } + + #[doc(hidden)] + #[cfg(feature = "files")] + async fn files_hard_conflict( + &self, + options: &SyncOptions, + outcome: &mut MergeOutcome, + ) -> Result<(), ::Error> { + let diff = self + .hard_conflict_diff::( + "hard_conflict::force_merge::files", + EventLogType::Files, + options, + ) + .await?; + + let account = self.account(); + let mut account = account.lock().await; + Ok(account.force_merge_files(diff, outcome).await?) + } + /// Try to auto merge on conflict. /// /// Searches the remote event log for a common ancestor and @@ -116,13 +340,13 @@ impl RemoteBridge { /// Once the changes have been merged a force update of the /// server is necessary. #[instrument(skip_all)] - pub(crate) async fn auto_merge( + async fn auto_merge( &self, options: &SyncOptions, conflict: MaybeConflict, local: SyncStatus, _remote: SyncStatus, - ) -> Result { + ) -> Result::Error> { let mut force_merge_outcome = MergeOutcome::default(); let mut has_hard_conflict = false; @@ -171,90 +395,22 @@ impl RemoteBridge { tracing::debug!( outcome = ?force_merge_outcome, "hard_conflict::sign_out"); - let mut account = self.account.lock().await; + let account = self.account(); + let mut account = account.lock().await; account.sign_out().await?; } Ok(force_merge_outcome) } - auto_merge_impl!( - "auto_merge::identity", - auto_merge_identity, - EventLogType::Identity, - WriteEvent, - identity_hard_conflict - ); - - auto_merge_conflict_impl!( - "hard_conflict::force_merge::identity", - identity_hard_conflict, - EventLogType::Identity, - WriteEvent, - FolderDiff, - force_merge_identity - ); - - auto_merge_impl!( - "auto_merge::account", - auto_merge_account, - EventLogType::Account, - AccountEvent, - account_hard_conflict - ); - - auto_merge_conflict_impl!( - "hard_conflict::force_merge::account", - account_hard_conflict, - EventLogType::Account, - AccountEvent, - AccountDiff, - force_merge_account - ); - - auto_merge_impl!( - "auto_merge::device", - auto_merge_device, - EventLogType::Device, - DeviceEvent, - device_hard_conflict - ); - - auto_merge_conflict_impl!( - "hard_conflict::force_merge::device", - device_hard_conflict, - EventLogType::Device, - DeviceEvent, - DeviceDiff, - force_merge_device - ); - - #[cfg(feature = "files")] - auto_merge_impl!( - "auto_merge::files", - auto_merge_files, - EventLogType::Files, - FileEvent, - files_hard_conflict - ); - - #[cfg(feature = "files")] - auto_merge_conflict_impl!( - "hard_conflict::force_merge::files", - files_hard_conflict, - EventLogType::Files, - FileEvent, - FileDiff, - force_merge_files - ); - + /// Auto merge a folder. async fn auto_merge_folder( &self, options: &SyncOptions, _local_status: &SyncStatus, folder_id: &VaultId, outcome: &mut MergeOutcome, - ) -> Result { + ) -> Result::Error> { tracing::debug!(folder_id = %folder_id, "auto_merge::folder"); let req = ScanRequest { @@ -272,38 +428,42 @@ impl RemoteBridge { .await?; Ok(false) } - Err(e) => match e { - Error::HardConflict => { + Err(e) => { + if e.is_hard_conflict() { self.folder_hard_conflict(folder_id, options, outcome) .await?; Ok(true) + } else { + Err(e) } - _ => Err(e), - }, - _ => Err(Error::HardConflict), + } + _ => Err(ConflictError::Hard.into()), } } + #[doc(hidden)] async fn folder_hard_conflict( &self, folder_id: &VaultId, options: &SyncOptions, outcome: &mut MergeOutcome, - ) -> Result<()> { + ) -> Result<(), ::Error> { match &options.hard_conflict_resolver { HardConflictResolver::AutomaticFetch => { let request = DiffRequest { log_type: EventLogType::Folder(*folder_id), from_hash: None, }; - let response = self.client.diff(request).await?; + let response = + self.client().diff(self.address(), request).await?; let patch = Patch::::new(response.patch); let diff = FolderDiff { patch, checkpoint: response.checkpoint, last_commit: None, }; - let mut account = self.account.lock().await; + let account = self.account(); + let mut account = account.lock().await; Ok(account .force_merge_folder(folder_id, diff, outcome) .await?) @@ -312,12 +472,13 @@ impl RemoteBridge { } /// Try to merge from a shared ancestor commit. + #[doc(hidden)] async fn try_merge_from_ancestor( &self, log_type: EventLogType, commit: CommitHash, proof: CommitProof, - ) -> Result<()> + ) -> Result<(), ::Error> where T: Default + Send + Sync, { @@ -325,7 +486,8 @@ impl RemoteBridge { // Get the patch from local let local_patch = { - let account = self.account.lock().await; + let account = self.account(); + let account = account.lock().await; match &log_type { EventLogType::Identity => { let log = account.identity_log().await?; @@ -361,12 +523,13 @@ impl RemoteBridge { log_type, from_hash: Some(commit), }; - let remote_patch = self.client.diff(request).await?.patch; + let remote_patch = + self.client().diff(self.address(), request).await?.patch; let result = self.merge_patches(local_patch, remote_patch).await?; match result { - AutoMerge::RewindLocal(events) => { + AutoMergeStatus::RewindLocal(events) => { let local_patch = self .rewind_local(&log_type, commit, proof, events) .await?; @@ -377,7 +540,7 @@ impl RemoteBridge { tracing::info!("auto_merge::rewind_local::success"); } } - AutoMerge::PushRemote(events) => { + AutoMergeStatus::PushRemote(events) => { let (remote_patch, local_patch) = self .push_remote::(&log_type, commit, proof, events) .await?; @@ -398,11 +561,12 @@ impl RemoteBridge { Ok(()) } + #[doc(hidden)] async fn merge_patches( &self, mut local: Vec, remote: Vec, - ) -> Result { + ) -> Result::Error> { tracing::info!( local_len = local.len(), remote_len = remote.len(), @@ -421,7 +585,7 @@ impl RemoteBridge { // If we didn't do this then automerge could go on // ad infinitum. if local_commits.is_subset(&remote_commits) { - return Ok(AutoMerge::RewindLocal(remote)); + return Ok(AutoMergeStatus::RewindLocal(remote)); } // Combine the event records @@ -430,17 +594,18 @@ impl RemoteBridge { // Sort by time so the more recent changes will win (LWW) local.sort_by(|a, b| a.time().cmp(b.time())); - Ok(AutoMerge::PushRemote(local)) + Ok(AutoMergeStatus::PushRemote(local)) } /// Rewind a local event log and apply the events. + #[doc(hidden)] async fn rewind_local( &self, log_type: &EventLogType, commit: CommitHash, proof: CommitProof, events: Vec, - ) -> Result { + ) -> Result::Error> { tracing::debug!( log_type = ?log_type, commit = %commit, @@ -455,7 +620,8 @@ impl RemoteBridge { // Merge the events after rewinding let checked_patch = { - let mut account = self.account.lock().await; + let account = self.account(); + let mut account = account.lock().await; match &log_type { EventLogType::Identity => { let patch = Patch::::new(events); @@ -518,12 +684,14 @@ impl RemoteBridge { Ok(checked_patch) } + #[doc(hidden)] async fn rollback_rewind( &self, log_type: &EventLogType, records: Vec, - ) -> Result<()> { - let account = self.account.lock().await; + ) -> Result<(), ::Error> { + let account = self.account(); + let account = account.lock().await; match log_type { EventLogType::Identity => { let log = account.identity_log().await?; @@ -557,13 +725,17 @@ impl RemoteBridge { } /// Push the events to a remote and rewind local. + #[doc(hidden)] async fn push_remote( &self, log_type: &EventLogType, commit: CommitHash, proof: CommitProof, events: Vec, - ) -> Result<(CheckedPatch, Option)> + ) -> Result< + (CheckedPatch, Option), + ::Error, + > where T: Default + Send + Sync, { @@ -581,7 +753,11 @@ impl RemoteBridge { patch: events.clone(), }; - let remote_patch = self.client.patch(req).await?.checked_patch; + let remote_patch = self + .client() + .patch(self.address(), req) + .await? + .checked_patch; let local_patch = match &remote_patch { CheckedPatch::Success(_) => { let local_patch = self @@ -603,18 +779,20 @@ impl RemoteBridge { } /// Rewind an event log to a specific commit. + #[doc(hidden)] async fn rewind_event_log( &self, log_type: &EventLogType, commit: &CommitHash, - ) -> Result> { + ) -> Result, ::Error> { tracing::debug!( log_type = ?log_type, commit = %commit, "automerge::rewind_event_log", ); // Rewind the event log - let account = self.account.lock().await; + let account = self.account(); + let account = account.lock().await; Ok(match &log_type { EventLogType::Identity => { let log = account.identity_log().await?; @@ -646,14 +824,19 @@ impl RemoteBridge { } /// Scan the remote for proofs that match this client. + #[doc(hidden)] async fn scan_proofs( &self, request: ScanRequest, - ) -> Result> { + ) -> Result< + Option<(CommitHash, CommitProof)>, + ::Error, + > { tracing::debug!(request = ?request, "auto_merge::scan_proofs"); let leaves = { - let account = self.account.lock().await; + let account = self.account(); + let account = account.lock().await; match &request.log_type { EventLogType::Identity => { let log = account.identity_log().await?; @@ -684,19 +867,29 @@ impl RemoteBridge { } }; - self.iterate_scan_proofs(request, &leaves).await + let mut req = request.clone(); + loop { + match self.iterate_scan_proofs(req.clone(), &leaves).await? { + ScanState::Result(value) => return Ok(Some(value)), + ScanState::Continue(scan) => req = scan, + ScanState::Exhausted => return Ok(None), + } + } } /// Scan the remote for proofs that match this client. - #[async_recursion] + #[doc(hidden)] async fn iterate_scan_proofs( &self, request: ScanRequest, leaves: &[[u8; 32]], - ) -> Result> { - tracing::debug!(request = ?request, "auto_merge::iterate_scan_proofs"); + ) -> Result::Error> { + tracing::debug!( + request = ?request, + "auto_merge::iterate_scan_proofs"); - let response = self.client.scan(request.clone()).await?; + let response = + self.client().scan(self.address(), request.clone()).await?; // If the server gave us a first proof and we don't // have it in our event log then there is no point scanning @@ -704,7 +897,7 @@ impl RemoteBridge { if let Some(first_proof) = &response.first_proof { let (verified, _) = first_proof.verify_leaves(leaves); if !verified { - return Err(Error::HardConflict); + return Err(ConflictError::Hard.into()); } } @@ -726,21 +919,26 @@ impl RemoteBridge { new_tree.commit(); let checkpoint_proof = new_tree.head()?; - return Ok(Some((commit_hash, checkpoint_proof))); + return Ok(ScanState::Result(( + commit_hash, + checkpoint_proof, + ))); } } // Try to scan more proofs let mut req = request; req.offset = response.offset; - self.iterate_scan_proofs(req, leaves).await + + Ok(ScanState::Continue(req)) } else { - Err(Error::HardConflict) + Ok(ScanState::Exhausted) } } /// Determine if a local event log contains a proof /// received from the server. + #[doc(hidden)] fn compare_proof( &self, proof: &CommitProof, diff --git a/crates/protocol/src/sync/folder.rs b/crates/protocol/src/sync/folder.rs index ab35a33a57..cd5640bc1d 100644 --- a/crates/protocol/src/sync/folder.rs +++ b/crates/protocol/src/sync/folder.rs @@ -54,7 +54,7 @@ impl IdentityFolderMerge for IdentityFolder where T: EventLogExt + Send + Sync, R: AsyncRead + AsyncSeek + Unpin + Send + Sync, - W: AsyncWrite + Unpin + Send + Sync, + W: AsyncWrite + AsyncSeek + Unpin + Send + Sync, D: Clone + Send + Sync, { async fn merge( @@ -79,7 +79,7 @@ impl FolderMerge for Folder where T: EventLogExt + Send + Sync, R: AsyncRead + AsyncSeek + Unpin + Send + Sync, - W: AsyncWrite + Unpin + Send + Sync, + W: AsyncWrite + AsyncSeek + Unpin + Send + Sync, D: Clone + Send + Sync, { async fn merge<'a>( diff --git a/crates/protocol/src/sync/local_account.rs b/crates/protocol/src/sync/local_account.rs index 29fd90272c..fab4a3b56c 100644 --- a/crates/protocol/src/sync/local_account.rs +++ b/crates/protocol/src/sync/local_account.rs @@ -30,7 +30,8 @@ use crate::sdk::events::{DeviceDiff, DeviceReducer}; #[cfg(feature = "files")] use crate::sdk::events::FileDiff; -#[async_trait] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] impl ForceMerge for LocalAccount { async fn force_merge_identity( &mut self, @@ -141,7 +142,8 @@ impl ForceMerge for LocalAccount { "force_merge::folder", ); - let storage = self.storage().await?; + let storage = + self.storage().await.ok_or(sos_sdk::Error::NoStorage)?; let mut storage = storage.write().await; let folder = storage @@ -160,7 +162,8 @@ impl ForceMerge for LocalAccount { } } -#[async_trait] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] impl Merge for LocalAccount { async fn merge_identity( &mut self, @@ -257,7 +260,10 @@ impl Merge for LocalAccount { // Must operate on the storage level otherwise // we would duplicate identity events for folder // password - let storage = self.storage().await?; + let storage = self + .storage() + .await + .ok_or(sos_sdk::Error::NoStorage)?; let mut storage = storage.write().await; storage .import_folder( @@ -272,7 +278,10 @@ impl Merge for LocalAccount { AccountEvent::RenameFolder(id, name) => { let summary = self.find(|s| s.id() == id).await; if let Some(summary) = &summary { - let storage = self.storage().await?; + let storage = self + .storage() + .await + .ok_or(sos_sdk::Error::NoStorage)?; let mut storage = storage.write().await; // Note that this event is recorded at both // the account level and the folder level so @@ -285,7 +294,10 @@ impl Merge for LocalAccount { AccountEvent::DeleteFolder(id) => { let summary = self.find(|s| s.id() == id).await; if let Some(summary) = &summary { - let storage = self.storage().await?; + let storage = self + .storage() + .await + .ok_or(sos_sdk::Error::NoStorage)?; let mut storage = storage.write().await; storage.delete_folder(summary, false).await?; deleted_folders.insert(*id); @@ -324,7 +336,8 @@ impl Merge for LocalAccount { ); let checked_patch = { - let storage = self.storage().await?; + let storage = + self.storage().await.ok_or(sos_sdk::Error::NoStorage)?; let storage = storage.read().await; let mut event_log = storage.device_log.write().await; event_log @@ -334,14 +347,16 @@ impl Merge for LocalAccount { if let CheckedPatch::Success(_) = &checked_patch { let devices = { - let storage = self.storage().await?; + let storage = + self.storage().await.ok_or(sos_sdk::Error::NoStorage)?; let storage = storage.read().await; let event_log = storage.device_log.read().await; let reducer = DeviceReducer::new(&*event_log); reducer.reduce().await? }; - let storage = self.storage().await?; + let storage = + self.storage().await.ok_or(sos_sdk::Error::NoStorage)?; let mut storage = storage.write().await; storage.devices = devices; @@ -375,7 +390,8 @@ impl Merge for LocalAccount { "files", ); - let storage = self.storage().await?; + let storage = + self.storage().await.ok_or(sos_sdk::Error::NoStorage)?; let storage = storage.read().await; let mut event_log = storage.file_log.write().await; @@ -426,7 +442,8 @@ impl Merge for LocalAccount { let len = diff.patch.len() as u64; let (checked_patch, events) = { - let storage = self.storage().await?; + let storage = + self.storage().await.ok_or(sos_sdk::Error::NoStorage)?; let mut storage = storage.write().await; #[cfg(feature = "search")] @@ -515,7 +532,8 @@ impl Merge for LocalAccount { folder_id: &VaultId, state: &CommitState, ) -> Result { - let storage = self.storage().await?; + let storage = + self.storage().await.ok_or(sos_sdk::Error::NoStorage)?; let storage = storage.read().await; let folder = storage @@ -528,7 +546,8 @@ impl Merge for LocalAccount { } } -#[async_trait] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] impl SyncStorage for LocalAccount { fn is_client_storage(&self) -> bool { true @@ -541,7 +560,8 @@ impl SyncStorage for LocalAccount { // NOTE: collection must be sorted so that the folders // NOTE: root hash is deterministic - let storage = self.storage().await?; + let storage = + self.storage().await.ok_or(sos_sdk::Error::NoStorage)?; let storage = storage.read().await; let summaries = storage.list_folders().to_vec(); diff --git a/crates/protocol/src/sync/mod.rs b/crates/protocol/src/sync/mod.rs index 04b5085c30..f9b6f89057 100644 --- a/crates/protocol/src/sync/mod.rs +++ b/crates/protocol/src/sync/mod.rs @@ -1,19 +1,32 @@ //! Sync types, traits and merge implementations //! for local account and folders. +mod auto_merge; mod folder; #[cfg(feature = "account")] mod local_account; mod primitives; +mod remote; mod transport; -#[cfg(feature = "files")] -mod transfer; - -#[cfg(feature = "files")] -pub use transfer::*; - +pub use auto_merge::*; pub use primitives::*; +pub use remote::*; pub use transport::*; +/// Direction of a sync. +#[derive(Debug, Clone, Copy)] +pub enum SyncDirection { + /// Create accounts on remote from the local. + /// + /// Used when a local account is pushing data to + /// a server for syncing with other devices. + Push, + /// Create accounts on local from the remote. + /// + /// Used by local replicas for app integrations + /// such as the browser extension. + Pull, +} + pub(crate) use folder::{FolderMerge, IdentityFolderMerge}; diff --git a/crates/protocol/src/sync/primitives.rs b/crates/protocol/src/sync/primitives.rs index c3eaefa92c..f16d28bfcf 100644 --- a/crates/protocol/src/sync/primitives.rs +++ b/crates/protocol/src/sync/primitives.rs @@ -428,7 +428,8 @@ impl SyncComparison { } /// Storage implementations that can synchronize. -#[async_trait] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] pub trait SyncStorage: StorageEventLogs { /// Determine if this is client-side storage. fn is_client_storage(&self) -> bool; @@ -473,7 +474,7 @@ pub trait SyncStorage: StorageEventLogs { if folder.flags().is_sync_disabled() { tracing::debug!( folder_id = %folder.id(), - "create_set::ignore::no_sync_flag"); + "change_set::ignore::no_sync_flag"); continue; } let event_log = self.folder_log(folder.id()).await?; @@ -499,7 +500,8 @@ pub trait SyncStorage: StorageEventLogs { /// /// Use this when event logs have completely diverged /// and need to be rewritten. -#[async_trait] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] pub trait ForceMerge { /// Force merge changes to the identity folder. async fn force_merge_identity( @@ -540,7 +542,8 @@ pub trait ForceMerge { } /// Types that can merge diffs. -#[async_trait] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] pub trait Merge { /// Merge changes to the identity folder. async fn merge_identity( diff --git a/crates/protocol/src/sync/remote.rs b/crates/protocol/src/sync/remote.rs new file mode 100644 index 0000000000..73247c244b --- /dev/null +++ b/crates/protocol/src/sync/remote.rs @@ -0,0 +1,260 @@ +//! Handler that can synchronize account data between a +//! remote data source and local account. +use crate::{ + AsConflict, ConflictError, MaybeDiff, Merge, MergeOutcome, Origin, + SyncClient, SyncDirection, SyncPacket, SyncStatus, SyncStorage, +}; +use async_trait::async_trait; +use sos_sdk::prelude::{Account, Address}; +use std::{collections::HashMap, sync::Arc}; +use tokio::sync::Mutex; + +#[cfg(feature = "files")] +use crate::transfer::{ + FileOperation, FileTransferQueueSender, TransferOperation, +}; + +use super::ForceMerge; + +/// Trait for types that bridge between a remote data source +/// and a local account. +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +pub trait RemoteSyncHandler { + /// Client used to fetch data from the data source. + type Client: SyncClient + Send + Sync + 'static; + + /// Local account. + type Account: Account + + SyncStorage + + Merge + + ForceMerge + + Send + + Sync + + 'static; + + /// Error implementation. + type Error: std::error::Error + + std::fmt::Debug + + AsConflict + + From + + From + + From + + From<::Error> + + From<::Error> + + Send + + Sync + + 'static; + + /// Client implementation. + fn client(&self) -> &Self::Client; + + /// Remote origin. + fn origin(&self) -> &Origin; + + /// Account address. + fn address(&self) -> &Address; + + /// Local account. + fn account(&self) -> Arc>; + + /// Direction for account creation and auto merge. + fn direction(&self) -> SyncDirection; + + /// Queue for file transfers. + #[cfg(feature = "files")] + fn file_transfer_queue(&self) -> &FileTransferQueueSender; + + /// Sync file transfers. + #[cfg(feature = "files")] + async fn execute_sync_file_transfers(&self) -> Result<(), Self::Error>; + + /// Push an account to the remote. + #[doc(hidden)] + async fn create_push_account(&self) -> Result<(), Self::Error> { + { + let account = self.account(); + let account = account.lock().await; + let public_account = account.change_set().await?; + self.client() + .create_account(self.address(), public_account) + .await?; + } + + #[cfg(feature = "files")] + self.execute_sync_file_transfers().await?; + + Ok(()) + } + + /// Pull an account from the remote. + #[doc(hidden)] + async fn create_pull_account(&self) -> Result<(), Self::Error> { + tracing::info!("create_pull_account"); + + // Get account data from the remote. + let public_account = + self.client().fetch_account(self.address()).await?; + + tracing::info!("create_pull_account::fetch_completed"); + + { + let account = self.account(); + let mut account = account.lock().await; + account + .import_account_events( + public_account.identity, + public_account.account, + public_account.device, + public_account.folders, + #[cfg(feature = "files")] + public_account.files, + ) + .await?; + } + + /* + #[cfg(feature = "files")] + self.execute_sync_file_transfers().await?; + */ + + Ok(()) + } + + /// Create an account on local or remote depending + /// on the sync direction. + async fn create_account(&self) -> Result<(), Self::Error> { + match self.direction() { + SyncDirection::Push => self.create_push_account().await, + SyncDirection::Pull => self.create_pull_account().await, + } + } + + /// Sync the account. + async fn sync_account( + &self, + remote_status: SyncStatus, + ) -> Result { + let account = self.account(); + let mut account = account.lock().await; + + tracing::debug!("merge_client"); + + let (needs_sync, local_status, local_changes) = + crate::diff(&*account, remote_status).await?; + + tracing::debug!(needs_sync = %needs_sync, "merge_client"); + + let mut outcome = MergeOutcome::default(); + + if needs_sync { + let packet = SyncPacket { + status: local_status, + diff: local_changes, + compare: None, + }; + let remote_changes = + self.client().sync(self.address(), packet.clone()).await?; + + let maybe_conflict = remote_changes + .compare + .as_ref() + .map(|c| c.maybe_conflict()) + .unwrap_or_default(); + let has_conflicts = maybe_conflict.has_conflicts(); + + if !has_conflicts { + account.merge(remote_changes.diff, &mut outcome).await?; + + // Compute which external files need to be downloaded + // and add to the transfers queue + + #[cfg(feature = "files")] + if !outcome.external_files.is_empty() { + use sos_sdk::account::Account; + let paths = account.paths(); + // let mut writer = self.transfers.write().await; + + for file in outcome.external_files.drain(..) { + let file_path = paths.file_location( + file.vault_id(), + file.secret_id(), + file.file_name().to_string(), + ); + if !sos_sdk::vfs::try_exists(file_path).await? { + tracing::debug!( + file = ?file, + "add file download to transfers", + ); + + if self.file_transfer_queue().receiver_count() > 0 + { + let _ = + self.file_transfer_queue().send(vec![ + FileOperation( + file, + TransferOperation::Download, + ), + ]); + } + } + } + } + + // self.compare(&mut *account, remote_changes).await?; + } else { + // Some parts of the remote patch may not + // be in conflict and must still be merged + if !maybe_conflict.identity { + if let Some(MaybeDiff::Diff(diff)) = + remote_changes.diff.identity + { + account.merge_identity(diff, &mut outcome).await?; + } + } + if !maybe_conflict.account { + if let Some(MaybeDiff::Diff(diff)) = + remote_changes.diff.account + { + account.merge_account(diff, &mut outcome).await?; + } + } + if !maybe_conflict.device { + if let Some(MaybeDiff::Diff(diff)) = + remote_changes.diff.device + { + account.merge_device(diff, &mut outcome).await?; + } + } + #[cfg(feature = "files")] + if !maybe_conflict.files { + if let Some(MaybeDiff::Diff(diff)) = + remote_changes.diff.files + { + account.merge_files(diff, &mut outcome).await?; + } + } + + let merge_folders = remote_changes + .diff + .folders + .into_iter() + .filter(|(k, _)| maybe_conflict.folders.get(k).is_none()) + .collect::>(); + for (id, maybe_diff) in merge_folders { + if let MaybeDiff::Diff(diff) = maybe_diff { + account.merge_folder(&id, diff, &mut outcome).await?; + } + } + return Err(ConflictError::Soft { + conflict: maybe_conflict, + local: packet.status, + remote: remote_changes.status, + } + .into()); + } + } + + Ok(outcome) + } +} diff --git a/crates/protocol/src/sync/transfer.rs b/crates/protocol/src/sync/transfer.rs deleted file mode 100644 index f67a7efa58..0000000000 --- a/crates/protocol/src/sync/transfer.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! Manage pending file transfer operations. -use crate::sdk::{ - events::FileEvent, - storage::files::{ExternalFile, FileMutationEvent}, -}; -use indexmap::IndexSet; - -/// Set of files built from the state on disc. -#[derive(Debug, Default, Clone, PartialEq, Eq)] -pub struct FileSet(pub IndexSet); - -/// Sets of files that should be uploaded and -/// downloaded from a remote server. -#[derive(Debug, Default, Clone, PartialEq, Eq)] -pub struct FileTransfersSet { - /// Files that exist on local but not on remote. - pub uploads: FileSet, - /// Files that exist on remote but not on local. - pub downloads: FileSet, -} - -/// Operations for file transfers. -#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] -pub enum TransferOperation { - /// Upload a file. - Upload, - /// Download a file. - Download, - /// Delete a file. - Delete, - /// Move a file. - Move(ExternalFile), -} - -/// File and transfer information. -#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] -pub struct FileOperation(pub ExternalFile, pub TransferOperation); - -impl From<&FileMutationEvent> for FileOperation { - fn from(value: &FileMutationEvent) -> Self { - match value { - FileMutationEvent::Create { event, .. } => event.into(), - FileMutationEvent::Move(event) => event.into(), - FileMutationEvent::Delete(event) => event.into(), - } - } -} - -impl From<&FileEvent> for FileOperation { - fn from(value: &FileEvent) -> Self { - match value { - FileEvent::CreateFile(owner, file_name) => FileOperation( - ExternalFile::new(*owner, *file_name), - TransferOperation::Upload, - ), - FileEvent::DeleteFile(owner, file_name) => FileOperation( - ExternalFile::new(*owner, *file_name), - TransferOperation::Delete, - ), - FileEvent::MoveFile { name, from, dest } => FileOperation( - ExternalFile::new(*from, *name), - TransferOperation::Move(ExternalFile::new(*dest, *name)), - ), - _ => panic!("attempt to convert noop file event"), - } - } -} diff --git a/crates/protocol/src/tests/protocol.rs b/crates/protocol/src/tests/protocol.rs index 607e044a6c..cf29751e72 100644 --- a/crates/protocol/src/tests/protocol.rs +++ b/crates/protocol/src/tests/protocol.rs @@ -358,7 +358,7 @@ async fn encode_decode_update_set() -> Result<()> { async fn encode_decode_change_files() -> Result<()> { use crate::{ sdk::{storage::files::ExternalFile, vault::secret::SecretId}, - sync::{FileSet, FileTransfersSet}, + transfer::{FileSet, FileTransfersSet}, }; use indexmap::IndexSet; diff --git a/crates/net/src/sync.rs b/crates/protocol/src/traits.rs similarity index 53% rename from crates/net/src/sync.rs rename to crates/protocol/src/traits.rs index 3bc1267c68..faa77cdbf5 100644 --- a/crates/net/src/sync.rs +++ b/crates/protocol/src/traits.rs @@ -1,34 +1,38 @@ use crate::{ - protocol::{ - CreateSet, DiffRequest, DiffResponse, MergeOutcome, Origin, - PatchRequest, PatchResponse, ScanRequest, ScanResponse, SyncOptions, - SyncPacket, SyncStatus, UpdateSet, - }, - CancelReason, Result, + CreateSet, DiffRequest, DiffResponse, MergeOutcome, Origin, PatchRequest, + PatchResponse, ScanRequest, ScanResponse, SyncOptions, SyncPacket, + SyncStatus, UpdateSet, }; use async_trait::async_trait; -use sos_sdk::storage; -use std::path::Path; +use sos_sdk::prelude::Address; /// Result of a sync operation with a single remote. #[derive(Debug)] -pub struct RemoteResult { +pub struct RemoteResult { /// Origin of the remote. pub origin: Origin, /// Result of the sync operation. - pub result: Result>, + pub result: Result, E>, } /// Result of a sync operation. -#[derive(Debug, Default)] -pub struct SyncResult { - /// Result of syncing with remote servers. - pub remotes: Vec, +#[derive(Debug)] +pub struct SyncResult { + /// Result of syncing with remote data sources. + pub remotes: Vec>, +} + +impl Default for SyncResult { + fn default() -> Self { + Self { + remotes: Vec::new(), + } + } } -impl SyncResult { +impl SyncResult { /// Find the first sync error. - pub fn first_error(self) -> Option { + pub fn first_error(self) -> Option { self.remotes.into_iter().find_map(|res| { if res.result.is_err() { res.result.err() @@ -39,7 +43,7 @@ impl SyncResult { } /// Find the first sync error by reference. - pub fn first_error_ref(&self) -> Option<&crate::Error> { + pub fn first_error_ref(&self) -> Option<&E> { self.remotes.iter().find_map(|res| { if let Err(e) = &res.result { Some(e) @@ -56,8 +60,12 @@ impl SyncResult { } /// Trait for types that can sync with a single remote. -#[async_trait] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] pub trait RemoteSync { + /// Error type for remote sync. + type Error: std::error::Error + std::fmt::Debug; + /// Perform a full sync of the account using /// the default options. /// @@ -65,21 +73,16 @@ pub trait RemoteSync { /// server the account will be created and /// [RemoteSync::sync_file_transfers] will be called /// to ensure the transfers queue is synced. - async fn sync(&self) -> RemoteResult; + async fn sync(&self) -> RemoteResult; /// Perform a full sync of the account /// using the given options. /// /// See the documentation for [RemoteSync::sync] for more details. - async fn sync_with_options(&self, options: &SyncOptions) -> RemoteResult; - - /// Sync file transfers. - /// - /// Updates the file transfers queue with any pending - /// uploads or downloads by comparing the local file - /// state with the file state on remote server(s). - #[cfg(feature = "files")] - async fn sync_file_transfers(&self) -> RemoteResult; + async fn sync_with_options( + &self, + options: &SyncOptions, + ) -> RemoteResult; /// Force update an account on remote servers. /// @@ -87,12 +90,26 @@ pub trait RemoteSync { /// changes to an account's folders. For example, if /// the encryption cipher has been changed, a folder /// password was changed or folder(s) were compacted. - async fn force_update(&self, account_data: UpdateSet) -> RemoteResult; + async fn force_update( + &self, + account_data: UpdateSet, + ) -> RemoteResult; + + /// Sync file transfers. + /// + /// Updates the file transfers queue with any pending + /// uploads or downloads by comparing the local file + /// state with the file state on remote server(s). + #[cfg(feature = "files")] + async fn sync_file_transfers(&self) -> RemoteResult; } /// Trait for types that can sync with multiple remotes. #[async_trait] pub trait AccountSync { + /// Error type for account sync. + type Error: std::error::Error + std::fmt::Debug; + /// Perform a full sync of the account using /// the default options. /// @@ -100,13 +117,16 @@ pub trait AccountSync { /// server the account will be created and /// [RemoteSync::sync_file_transfers] will be called /// to ensure the transfers queue is synced. - async fn sync(&self) -> SyncResult; + async fn sync(&self) -> SyncResult; /// Perform a full sync of the account /// using the given options. /// /// See the documentation for [RemoteSync::sync] for more details. - async fn sync_with_options(&self, options: &SyncOptions) -> SyncResult; + async fn sync_with_options( + &self, + options: &SyncOptions, + ) -> SyncResult; /// Sync file transfers. /// @@ -114,7 +134,10 @@ pub trait AccountSync { /// uploads or downloads by comparing the local file /// state with the file state on remote server(s). #[cfg(feature = "files")] - async fn sync_file_transfers(&self, options: &SyncOptions) -> SyncResult; + async fn sync_file_transfers( + &self, + options: &SyncOptions, + ) -> SyncResult; /// Force update an account on remote servers. /// @@ -126,94 +149,85 @@ pub trait AccountSync { &self, account_data: UpdateSet, options: &SyncOptions, - ) -> SyncResult; + ) -> SyncResult; } -/// Client that can synchronize with a remote server. -#[async_trait] +/// Client that can communicate with a remote data source. +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] pub trait SyncClient { + /// Error type for sync client. + type Error: std::error::Error + std::fmt::Debug; + /// Origin of the remote server. fn origin(&self) -> &Origin; /// Check if an account already exists. - async fn account_exists(&self) -> Result; + async fn account_exists( + &self, + address: &Address, + ) -> Result; /// Create a new account. - async fn create_account(&self, account: CreateSet) -> Result<()>; + async fn create_account( + &self, + address: &Address, + account: CreateSet, + ) -> Result<(), Self::Error>; /// Update an account. - async fn update_account(&self, account: UpdateSet) -> Result<()>; + async fn update_account( + &self, + address: &Address, + account: UpdateSet, + ) -> Result<(), Self::Error>; /// Fetch an account from a remote server. - async fn fetch_account(&self) -> Result; + async fn fetch_account( + &self, + address: &Address, + ) -> Result; /// Delete the account on the server. - async fn delete_account(&self) -> Result<()>; + async fn delete_account( + &self, + address: &Address, + ) -> Result<(), Self::Error>; /// Sync status on the server. - async fn sync_status(&self) -> Result; + async fn sync_status( + &self, + address: &Address, + ) -> Result; /// Sync with a remote. - async fn sync(&self, packet: SyncPacket) -> Result; + async fn sync( + &self, + address: &Address, + packet: SyncPacket, + ) -> Result; /// Scan commits in an event log. - async fn scan(&self, request: ScanRequest) -> Result; + async fn scan( + &self, + address: &Address, + request: ScanRequest, + ) -> Result; /// Fetch a collection of event records since a given commit hash. - async fn diff(&self, request: DiffRequest) -> Result; + async fn diff( + &self, + address: &Address, + request: DiffRequest, + ) -> Result; /// Patch an event log. /// /// If the request contains a commit hash then the remote will /// attempt to rewind to the commit before applying the patch. - async fn patch(&self, request: PatchRequest) -> Result; - - /// Send a file. - #[cfg(feature = "files")] - async fn upload_file( - &self, - file_info: &storage::files::ExternalFile, - path: &Path, - progress: crate::ProgressChannel, - cancel: tokio::sync::watch::Receiver, - ) -> Result; - - /// Receive a file. - #[cfg(feature = "files")] - async fn download_file( - &self, - file_info: &storage::files::ExternalFile, - path: &Path, - progress: crate::ProgressChannel, - cancel: tokio::sync::watch::Receiver, - ) -> Result; - - /// Delete a file on the remote server. - #[cfg(feature = "files")] - async fn delete_file( - &self, - file_info: &storage::files::ExternalFile, - ) -> Result; - - /// Move a file on the remote server. - #[cfg(feature = "files")] - async fn move_file( - &self, - from: &storage::files::ExternalFile, - to: &storage::files::ExternalFile, - ) -> Result; - - /// Compare local files with a remote server. - /// - /// Used to build a transfer queue that will eventually ensure - /// external files are in sync. - /// - /// Comparing sets of files is expensive as both local and remote - /// need to read the external files state from disc so only use this - /// when necessary. - #[cfg(feature = "files")] - async fn compare_files( + async fn patch( &self, - local_files: crate::protocol::FileSet, - ) -> Result; + address: &Address, + request: PatchRequest, + ) -> Result; } diff --git a/crates/protocol/src/transfer.rs b/crates/protocol/src/transfer.rs new file mode 100644 index 0000000000..4a84168ea0 --- /dev/null +++ b/crates/protocol/src/transfer.rs @@ -0,0 +1,162 @@ +//! Types for file transfers. + +/// Information about a cancellation. +#[derive(Default, Debug, Clone, Hash, Eq, PartialEq)] +pub enum CancelReason { + /// Unknown reason. + #[default] + Unknown, + /// Event loop is being shutdown. + Shutdown, + /// Websocket connection was closed. + Closed, + /// Cancellation was from a user interaction. + UserCanceled, + /// Aborted due to conflict with a subsequent operation. + /// + /// For example, a move or delete transfer operation must abort + /// any existing upload or download. + Aborted, +} + +#[cfg(feature = "files")] +mod files { + //! Manage pending file transfer operations. + use crate::sdk::{ + events::FileEvent, + storage::files::{ExternalFile, FileMutationEvent}, + }; + + use crate::transfer::CancelReason; + use async_trait::async_trait; + use http::StatusCode; + use indexmap::IndexSet; + use std::path::Path; + use tokio::sync::watch; + + /// Channel for upload and download progress notifications. + pub type ProgressChannel = tokio::sync::mpsc::Sender<(u64, Option)>; + + /// Request to queue a file transfer. + pub type FileTransferQueueRequest = Vec; + + /// Sender to queue a file transfer. + pub type FileTransferQueueSender = + tokio::sync::broadcast::Sender; + + /// Set of files built from the state on disc. + #[derive(Debug, Default, Clone, PartialEq, Eq)] + pub struct FileSet(pub IndexSet); + + /// Sets of files that should be uploaded and + /// downloaded from a remote server. + #[derive(Debug, Default, Clone, PartialEq, Eq)] + pub struct FileTransfersSet { + /// Files that exist on local but not on remote. + pub uploads: FileSet, + /// Files that exist on remote but not on local. + pub downloads: FileSet, + } + + /// Operations for file transfers. + #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] + pub enum TransferOperation { + /// Upload a file. + Upload, + /// Download a file. + Download, + /// Delete a file. + Delete, + /// Move a file. + Move(ExternalFile), + } + + /// File and transfer information. + #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] + pub struct FileOperation(pub ExternalFile, pub TransferOperation); + + impl From<&FileMutationEvent> for FileOperation { + fn from(value: &FileMutationEvent) -> Self { + match value { + FileMutationEvent::Create { event, .. } => event.into(), + FileMutationEvent::Move(event) => event.into(), + FileMutationEvent::Delete(event) => event.into(), + } + } + } + + impl From<&FileEvent> for FileOperation { + fn from(value: &FileEvent) -> Self { + match value { + FileEvent::CreateFile(owner, file_name) => FileOperation( + ExternalFile::new(*owner, *file_name), + TransferOperation::Upload, + ), + FileEvent::DeleteFile(owner, file_name) => FileOperation( + ExternalFile::new(*owner, *file_name), + TransferOperation::Delete, + ), + FileEvent::MoveFile { name, from, dest } => FileOperation( + ExternalFile::new(*from, *name), + TransferOperation::Move(ExternalFile::new(*dest, *name)), + ), + _ => panic!("attempt to convert noop file event"), + } + } + } + + /// Client that can synchronize files. + #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] + #[cfg_attr(not(target_arch = "wasm32"), async_trait)] + pub trait FileSyncClient { + /// Error type for file sync client. + type Error: std::error::Error + std::fmt::Debug; + + /// Send a file. + async fn upload_file( + &self, + file_info: &ExternalFile, + path: &Path, + progress: ProgressChannel, + cancel: watch::Receiver, + ) -> Result; + + /// Receive a file. + async fn download_file( + &self, + file_info: &ExternalFile, + path: &Path, + progress: ProgressChannel, + cancel: watch::Receiver, + ) -> Result; + + /// Delete a file on the remote server. + async fn delete_file( + &self, + file_info: &ExternalFile, + ) -> Result; + + /// Move a file on the remote server. + async fn move_file( + &self, + from: &ExternalFile, + to: &ExternalFile, + ) -> Result; + + /// Compare local files with a remote server. + /// + /// Used to build a transfer queue that will eventually ensure + /// external files are in sync. + /// + /// Comparing sets of files is expensive as both local and remote + /// need to read the external files state from disc so only use this + /// when necessary. + async fn compare_files( + &self, + local_files: FileSet, + ) -> Result; + } +} + +#[cfg(feature = "files")] +pub use files::*; diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml index b603bbf516..d75dc5f13e 100644 --- a/crates/sdk/Cargo.toml +++ b/crates/sdk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sos-sdk" -version = "0.15.1" +version = "0.16.2" edition = "2021" description = "Distributed, encrypted database for private secrets." homepage = "https://saveoursecrets.com" @@ -20,6 +20,7 @@ default = ["account", "audit", "logs"] account = [] archive = ["dep:async_zip"] audit = [] +clipboard = ["dep:xclipboard", "dep:serde_json_path"] contacts = [] files = [] interactive-keychain-tests = [] @@ -29,7 +30,6 @@ migrate = ["dep:async_zip", "dep:csv-async"] keychain-access = ["dep:security-framework", "dep:keychain_parser"] recovery = ["dep:vsss-rs"] search = ["dep:probly-search"] -test-utils = ["dep:anyhow"] [dependencies] binary-stream.workspace = true @@ -38,8 +38,6 @@ tokio-util.workspace = true tracing.workspace = true thiserror.workspace = true async-trait.workspace = true -async-recursion.workspace = true -http.workspace = true serde.workspace = true serde_json.workspace = true uuid.workspace = true @@ -53,6 +51,7 @@ once_cell.workspace = true rand.workspace = true url.workspace = true time.workspace = true +time-tz.workspace = true futures.workspace = true parking_lot.workspace = true bs58.workspace = true @@ -61,33 +60,35 @@ bitflags.workspace = true enum-iterator.workspace = true tempfile.workspace = true rs_merkle.workspace = true +typeshare.workspace = true +sos-vfs.workspace = true +xclipboard = { workspace = true, optional = true } +serde_json_path = { workspace = true, optional = true } + subtle = { version = "2.5" } ethereum-types = "0.15" async_zip = { version = "0.0.17", default-features = false, features = ["deflate", "tokio"], optional = true } csv-async = { version = "1", features = ["tokio", "with_serde"], optional = true } -sos-vfs = { version = "0.2.2" } aes-gcm = { version = "0.10.1", features = ["std"] } chacha20poly1305 = { version = "0.10.1", features = ["std"] } filetime = "0.2" argon2 = { version = "0.5", features = ["std"]} balloon-hash = { version = "0.4", features = ["std"]} -etcetera = "0.8" pem = { version = "3", features = ["serde"] } zxcvbn = { version = "3.0.1", features = ["ser"] } totp-rs = { version = "5.5", features = ["qr", "serde_support", "zeroize"] } -vcard4 = { version = "0.5", features = ["serde"] } +vcard4 = { version = "0.7.1", features = ["serde"] } async-once-cell = "0.5" -age = { version = "0.10", features = ["async"], git = "https://github.com/tmpfs/rage", branch = "secrecy-0.10" } +age = { version = "0.11", features = ["async"] } ed25519-dalek = { version = "2.1.1", features = ["rand_core"] } urn = { version = "0.7", features = ["serde"] } walkdir = "2" unicode-segmentation = "1.10" mime_guess = { version = "2" } -sanitize-filename = "0.5" +sanitize-filename = "0.6" futures-util = "0.3" async-stream = "0.3" whoami = { version = "1.5" } -anyhow = {version = "1", optional = true } vsss-rs = {version = "3", optional = true } tracing-appender = { version = "0.2", optional = true } tracing-subscriber = { version = "0.3.16", features = ["env-filter", "json"], optional = true } @@ -98,12 +99,12 @@ version = "2.0.1" optional = true [target.'cfg(target_os = "macos")'.dependencies] -security-framework = { version = "2.8", optional = true } +security-framework = { version = "3.1", optional = true } keychain_parser = { version = "0.1", path = "../keychain_parser", optional = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tokio = { version = "1", default-features = false, features = ["rt", "fs", "io-util", "sync"] } -file-guard.workspace = true +etcetera = "0.8" [target.'cfg(target_arch = "wasm32")'.dependencies] getrandom = {version = "0.2", features = ["js"]} @@ -116,7 +117,7 @@ version = "0.1.1" [dev-dependencies] anyhow = "1" tokio = { version = "1", features = ["full"] } -sos_test_utils = { path = "../test_utils" } +sos-test-utils = { path = "../test_utils" } [build-dependencies] rustc_version = "0.4.1" diff --git a/crates/sdk/src/account/account.rs b/crates/sdk/src/account/account.rs index 63fa83a846..7ccc23b5c5 100644 --- a/crates/sdk/src/account/account.rs +++ b/crates/sdk/src/account/account.rs @@ -12,8 +12,9 @@ use crate::{ crypto::{AccessKey, Cipher, KeyDerivation}, decode, encode, events::{ - AccountEvent, AccountEventLog, Event, EventKind, EventLogExt, - EventRecord, FolderEventLog, FolderReducer, ReadEvent, WriteEvent, + AccountEvent, AccountEventLog, AccountPatch, DevicePatch, Event, + EventKind, EventLogExt, EventRecord, FolderEventLog, FolderPatch, + FolderReducer, ReadEvent, WriteEvent, }, identity::{AccountRef, FolderKeys, Identity, PublicIdentity}, signer::ecdsa::{Address, BoxedEcdsaSigner}, @@ -22,16 +23,15 @@ use crate::{ StorageEventLogs, }, vault::{ - secret::{Secret, SecretId, SecretMeta, SecretRow, SecretType}, + secret::{ + Secret, SecretId, SecretMeta, SecretPath, SecretRow, SecretType, + }, BuilderCredentials, Gatekeeper, Header, Summary, Vault, VaultBuilder, - VaultFlags, VaultId, + VaultCommit, VaultFlags, VaultId, }, vfs, Error, Paths, Result, UtcDateTime, }; -#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] -use crate::storage::paths::FileLock; - #[cfg(feature = "search")] use crate::storage::search::{DocumentCount, SearchIndex}; @@ -49,7 +49,10 @@ use crate::{ use indexmap::IndexSet; #[cfg(feature = "files")] -use crate::{events::FileEventLog, storage::files::FileMutationEvent}; +use crate::{ + events::{FileEventLog, FilePatch}, + storage::files::FileMutationEvent, +}; #[cfg(feature = "search")] use crate::storage::search::*; @@ -71,27 +74,50 @@ use crate::migrate::{ use async_trait::async_trait; use secrecy::SecretString; use serde::{Deserialize, Serialize}; -use tokio::{ - io::{AsyncRead, AsyncSeek, BufReader}, - sync::{mpsc, RwLock}, -}; +use tokio::sync::RwLock; -/// Determine how to handle a locked account. -#[derive(Default, Clone)] -pub enum AccountLocked { - /// Error on sign in when the account - /// is already locked. - #[default] - Error, - /// Send a notification over a channel. - Notify(mpsc::Sender<()>), +#[cfg(feature = "archive")] +use tokio::io::{AsyncRead, AsyncSeek, BufReader}; + +#[cfg(feature = "clipboard")] +use serde_json_path::JsonPath; +#[cfg(feature = "clipboard")] +use xclipboard::Clipboard; + +/// Clipboard text formatter. +#[cfg(feature = "clipboard")] +#[typeshare::typeshare] +#[derive(Debug, Serialize, Deserialize)] +#[serde( + rename_all = "camelCase", + rename_all_fields = "camelCase", + tag = "kind", + content = "body" +)] +pub enum ClipboardTextFormat { + /// Parse as a RFC3339 date string and + /// format according to the given format string. + Date { + /// Format string. + + // Typeshare doesn't respect rename_all_fields + #[serde(rename = "formatDescription")] + format_description: String, + }, } -/// Options for sign in. -#[derive(Default, Clone)] -pub struct SigninOptions { - /// How to handle a locked account. - pub locked: AccountLocked, +/// Request a clipboard copy operation. +#[cfg(feature = "clipboard")] +#[typeshare::typeshare] +#[derive(Default, Debug, Serialize, Deserialize)] +#[serde(default)] +pub struct ClipboardCopyRequest { + /// Target paths. + #[serde(skip_serializing_if = "Option::is_none")] + pub paths: Option>, + /// Format option. + #[serde(skip_serializing_if = "Option::is_none")] + pub format: Option, } /// Result information for a change to an account. @@ -207,10 +233,11 @@ pub enum ContactImportProgress { } /// Trait for account implementations. -#[async_trait] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] pub trait Account { /// Errors for this account. - type Error: std::error::Error + std::fmt::Debug; + type Error: std::error::Error + std::fmt::Debug + From; /// Result type for network-aware implementations. type NetworkResult: std::fmt::Debug; @@ -229,6 +256,16 @@ pub trait Account { &self, ) -> std::result::Result; + /// Import encrypted account events into the client storage. + async fn import_account_events( + &mut self, + identity: FolderPatch, + account: AccountPatch, + device: DevicePatch, + folders: HashMap, + #[cfg(feature = "files")] files: FilePatch, + ) -> std::result::Result<(), Self::Error>; + /// Create a new in-memory device vault. /// /// The password for the vault is saved to the identity folder. @@ -308,6 +345,14 @@ pub trait Account { &self, ) -> std::result::Result; + /// Reload the identity folder into memory. + /// + /// Can be used when changes to the identity folder + /// have been made by external processes. + async fn reload_identity_folder( + &mut self, + ) -> std::result::Result<(), Self::Error>; + /// Change the cipher for an account. async fn change_cipher( &mut self, @@ -330,15 +375,6 @@ pub trait Account { key: &AccessKey, ) -> std::result::Result, Self::Error>; - /// Access an account by signing in with the given options. - /// - /// If a default folder exists for the account it is opened. - async fn sign_in_with_options( - &mut self, - key: &AccessKey, - options: SigninOptions, - ) -> std::result::Result, Self::Error>; - /// Verify an access key for this account. /// /// If the account is not authenticated this returns false. @@ -346,10 +382,15 @@ pub trait Account { /// Open a folder. async fn open_folder( - &mut self, + &self, summary: &Summary, ) -> std::result::Result<(), Self::Error>; + /// Current open folder. + async fn current_folder( + &self, + ) -> std::result::Result, Self::Error>; + /// Try to find a folder using a predicate. async fn find

(&self, predicate: P) -> Option

where @@ -390,9 +431,13 @@ pub trait Account { ) -> std::result::Result<(), Self::Error>; /// Storage provider. - async fn storage( - &self, - ) -> std::result::Result>, Self::Error>; + async fn storage(&self) -> Option>>; + + /// Set the storage provider. + async fn set_storage( + &mut self, + storage: Option>>, + ) -> (); /// Read the secret identifiers in a vault. async fn secret_ids( @@ -510,8 +555,8 @@ pub trait Account { #[cfg(feature = "search")] async fn query_view( &self, - views: Vec, - archive: Option, + views: &[DocumentView], + archive: Option<&ArchiveFilter>, ) -> std::result::Result, Self::Error>; /// Query the search index. @@ -584,11 +629,21 @@ pub trait Account { /// Read a secret in the current open folder. async fn read_secret( - &mut self, + &self, secret_id: &SecretId, folder: Option, ) -> std::result::Result<(SecretRow, ReadEvent), Self::Error>; + /// Read the encrypted contents of a secret. + /// + /// Does not affect the currently open folder and + /// does not append any audit logs. + async fn raw_secret( + &self, + folder_id: &VaultId, + secret_id: &SecretId, + ) -> std::result::Result<(Option, ReadEvent), Self::Error>; + /// Delete a secret and remove any external files. async fn delete_secret( &mut self, @@ -716,7 +771,7 @@ pub trait Account { /// Looks in the current open folder if no specified folder is given. #[cfg(feature = "contacts")] async fn load_avatar( - &mut self, + &self, secret_id: &SecretId, folder: Option, ) -> std::result::Result>, Self::Error>; @@ -724,7 +779,7 @@ pub trait Account { /// Export a contact secret to a vCard file. #[cfg(feature = "contacts")] async fn export_contact( - &mut self, + &self, path: impl AsRef + Send + Sync, secret_id: &SecretId, folder: Option, @@ -733,7 +788,7 @@ pub trait Account { /// Export all contacts to a single vCard. #[cfg(feature = "contacts")] async fn export_all_contacts( - &mut self, + &self, path: impl AsRef + Send + Sync, ) -> std::result::Result<(), Self::Error>; @@ -798,6 +853,15 @@ pub trait Account { mut options: RestoreOptions, data_dir: Option, ) -> std::result::Result; + + /// Copy a secret to the clipboard. + #[cfg(feature = "clipboard")] + async fn copy_clipboard( + &self, + clipboard: &Clipboard, + target: &SecretPath, + request: &ClipboardCopyRequest, + ) -> std::result::Result; } /// Read-only view created from a specific event log commit. @@ -834,15 +898,6 @@ pub struct AccountData { pub device_id: String, } -/// Account information when signed in. -pub(super) struct Authenticated { - /// Authenticated user. - pub(super) user: Identity, - - /// Storage provider. - storage: Arc>, -} - /// User account backed by the filesystem. /// /// Many functions require that the account is authenticated and will @@ -854,28 +909,19 @@ pub struct LocalAccount { /// Account information after a successful /// sign in. - pub(super) authenticated: Option, + pub(super) authenticated: Option, + + /// Storage provider. + storage: Option>>, /// Storage paths. paths: Arc, - - /// Lock for the account. - /// - /// Prevents multiple client implementations trying to - /// access the same account simultaneously which could - /// lead to data corruption. - #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] - account_lock: Option, } impl LocalAccount { /// Private login implementation so we can support the backwards /// compatible sign_in() and also the newer sign_in_with_options(). - async fn login( - &mut self, - key: &AccessKey, - options: SigninOptions, - ) -> Result> { + async fn login(&mut self, key: &AccessKey) -> Result> { let address = &self.address; let data_dir = self.paths().documents_dir().clone(); @@ -896,34 +942,16 @@ impl LocalAccount { let identity_log = user.identity().as_ref().unwrap().event_log(); - let mut storage = ClientStorage::new( + #[allow(unused_mut)] + let mut storage = ClientStorage::new_authenticated( signer.address()?, Some(data_dir), identity_log, user.identity()?.devices()?.current_device(None), ) .await?; - self.paths = storage.paths(); - #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] - { - self.account_lock = Some( - self.paths - .acquire_account_lock(|| async { - let locked = options.locked.clone(); - match locked { - AccountLocked::Error => { - return Err(Error::AccountLocked); - } - AccountLocked::Notify(tx) => { - tx.send(()).await?; - Ok(()) - } - } - }) - .await?, - ); - } + self.paths = storage.paths(); #[cfg(feature = "files")] { @@ -937,10 +965,8 @@ impl LocalAccount { ) .await?; - self.authenticated = Some(Authenticated { - user, - storage: Arc::new(RwLock::new(storage)), - }); + self.authenticated = Some(user); + self.storage = Some(Arc::new(RwLock::new(storage))); // Load vaults into memory and initialize folder // event log commit trees @@ -949,7 +975,7 @@ impl LocalAccount { // Unlock all the storage vaults { let folder_keys = self.folder_keys().await?; - let storage = self.storage().await?; + let storage = self.storage.as_mut().ok_or(Error::NoStorage)?; let mut storage = storage.write().await; storage.unlock(&folder_keys).await?; } @@ -964,19 +990,13 @@ impl LocalAccount { /// Authenticated user information. #[doc(hidden)] pub fn user(&self) -> Result<&Identity> { - self.authenticated - .as_ref() - .map(|a| &a.user) - .ok_or(Error::NotAuthenticated) + self.authenticated.as_ref().ok_or(Error::NotAuthenticated) } /// Mutable authenticated user information. #[doc(hidden)] pub fn user_mut(&mut self) -> Result<&mut Identity> { - self.authenticated - .as_mut() - .map(|a| &mut a.user) - .ok_or(Error::NotAuthenticated) + self.authenticated.as_mut().ok_or(Error::NotAuthenticated) } async fn initialize_account_log( @@ -1051,13 +1071,13 @@ impl LocalAccount { } pub(crate) async fn open_vault( - &mut self, + &self, summary: &Summary, audit: bool, ) -> Result<()> { // Bail early if the folder is already open { - let storage = self.storage().await?; + let storage = self.storage.as_ref().ok_or(Error::NoStorage)?; let reader = storage.read().await; if let Some(current) = reader.current_folder() { if current.id() == summary.id() { @@ -1067,7 +1087,7 @@ impl LocalAccount { } let event = { - let storage = self.storage().await?; + let storage = self.storage().await.ok_or(Error::NoStorage)?; let mut writer = storage.write().await; writer.open_folder(summary).await? }; @@ -1091,7 +1111,7 @@ impl LocalAccount { options: &AccessOptions, ) -> Result<(Summary, CommitState)> { let (folder, commit_state) = { - let storage = self.storage().await?; + let storage = self.storage.as_ref().ok_or(Error::NoStorage)?; let reader = storage.read().await; let folder = options .folder @@ -1130,7 +1150,7 @@ impl LocalAccount { // Update the identity vault let buffer = encode(&vault).await?; let identity_vault_path = self.paths().identity_vault(); - vfs::write(&identity_vault_path, &buffer).await?; + vfs::write_exclusive(&identity_vault_path, &buffer).await?; // Update the events for the identity vault let user = self.user()?; @@ -1154,7 +1174,7 @@ impl LocalAccount { #[cfg(feature = "files")] file_events: &mut Vec, ) -> Result<(SecretId, Event, Summary)> { let folder = { - let storage = self.storage().await?; + let storage = self.storage.as_ref().ok_or(Error::NoStorage)?; let reader = storage.read().await; options .folder @@ -1173,8 +1193,10 @@ impl LocalAccount { let id = SecretId::new_v4(); let secret_data = SecretRow::new(id, meta, secret); + + #[allow(unused_mut)] let mut result = { - let storage = self.storage().await?; + let storage = self.storage.as_mut().ok_or(Error::NoStorage)?; let mut writer = storage.write().await; writer.create_secret(secret_data, options).await? }; @@ -1197,13 +1219,13 @@ impl LocalAccount { /// Some internal operations needn't generate extra /// audit log records. pub(crate) async fn get_secret( - &mut self, + &self, secret_id: &SecretId, folder: Option, audit: bool, ) -> Result<(SecretRow, ReadEvent)> { let folder = { - let storage = self.storage().await?; + let storage = self.storage.as_ref().ok_or(Error::NoStorage)?; let reader = storage.read().await; folder .or_else(|| reader.current_folder()) @@ -1213,7 +1235,7 @@ impl LocalAccount { self.open_folder(&folder).await?; let (meta, secret, read_event) = { - let storage = self.storage().await?; + let storage = self.storage.as_ref().ok_or(Error::NoStorage)?; let reader = storage.read().await; reader.read_secret(secret_id).await? }; @@ -1232,11 +1254,13 @@ impl LocalAccount { secret_id: &SecretId, from: &Summary, to: &Summary, - mut options: AccessOptions, + #[allow(unused_mut, unused_variables)] mut options: AccessOptions, ) -> Result::NetworkResult>> { self.open_vault(from, false).await?; let (secret_data, read_event) = self.get_secret(secret_id, None, false).await?; + + #[cfg(feature = "files")] let move_secret_data = secret_data.clone(); #[cfg(feature = "files")] @@ -1259,14 +1283,14 @@ impl LocalAccount { // as we need the original external files for the // move_files operation. let delete_event = { - let storage = self.storage().await?; + let storage = self.storage.as_mut().ok_or(Error::NoStorage)?; let mut writer = storage.write().await; writer.remove_secret(secret_id).await? }; #[cfg(feature = "files")] { - let storage = self.storage().await?; + let storage = self.storage.as_mut().ok_or(Error::NoStorage)?; let mut writer = storage.write().await; let mut move_file_events = writer .move_files( @@ -1313,18 +1337,19 @@ impl LocalAccount { /// Build the search index for all folders. #[cfg(feature = "search")] + #[allow(dead_code)] pub(crate) async fn build_search_index( &mut self, ) -> Result { let keys = self.folder_keys().await?; - let storage = self.storage().await?; + let storage = self.storage.as_mut().ok_or(Error::NoStorage)?; let mut writer = storage.write().await; writer.build_search_index(&keys).await } /// Access keys for all folders. pub(super) async fn folder_keys(&self) -> Result { - let storage = self.storage().await?; + let storage = self.storage.as_ref().ok_or(Error::NoStorage)?; let reader = storage.read().await; let folders = reader.list_folders(); let mut keys = HashMap::new(); @@ -1449,14 +1474,25 @@ impl LocalAccount { Paths::data_dir()? }; - let paths = Paths::new_global(data_dir); + let paths = Paths::new(data_dir, address.to_string()); + + let storage = if paths.is_usable().await? { + Some(Arc::new(RwLock::new( + ClientStorage::new_unauthenticated( + address, + Arc::new(paths.clone()), + ) + .await?, + ))) + } else { + None + }; Ok(Self { address, + storage, paths: Arc::new(paths), authenticated: None, - #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] - account_lock: None, }) } @@ -1504,7 +1540,7 @@ impl LocalAccount { let address = new_account.address; let identity_log = new_account.user.identity()?.event_log(); - let mut storage = ClientStorage::new( + let mut storage = ClientStorage::new_authenticated( address, data_dir.clone(), identity_log, @@ -1523,16 +1559,16 @@ impl LocalAccount { let account = Self { address, paths: storage.paths(), + storage: Some(Arc::new(RwLock::new(storage))), authenticated: None, - #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] - account_lock: None, }; Ok(account) } } -#[async_trait] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] impl Account for LocalAccount { type Error = Error; type NetworkResult = (); @@ -1557,6 +1593,76 @@ impl Account for LocalAccount { Ok(self.user()?.identity()?.device().clone()) } + async fn import_account_events( + &mut self, + identity: FolderPatch, + account: AccountPatch, + device: DevicePatch, + folders: HashMap, + #[cfg(feature = "files")] files: FilePatch, + ) -> Result<()> { + let address = *self.address(); + let paths = self.paths(); + + let mut storage = + ClientStorage::new_unauthenticated(address, paths.clone()) + .await?; + + { + let mut identity_log = storage.identity_log.write().await; + let records: Vec = identity.into(); + identity_log.apply_records(records).await?; + let vault = FolderReducer::new() + .reduce(&*identity_log) + .await? + .build(true) + .await?; + let buffer = encode(&vault).await?; + let identity_vault = paths.identity_vault(); + vfs::write_exclusive(identity_vault, &buffer).await?; + + tracing::info!( + root = ?identity_log.tree().root().map(|c| c.to_string()), + "import_account_events::identity"); + } + + { + let mut account_log = storage.account_log.write().await; + let records: Vec = account.into(); + account_log.apply_records(records).await?; + + tracing::info!( + root = ?account_log.tree().root().map(|c| c.to_string()), + "import_account_events::account"); + } + + { + let mut device_log = storage.device_log.write().await; + let records: Vec = device.into(); + device_log.apply_records(records).await?; + tracing::info!( + root = ?device_log.tree().root().map(|c| c.to_string()), + "import_account_events::device"); + } + + storage.import_folder_patches(folders).await?; + + #[cfg(feature = "files")] + { + tracing::info!("import_account_events::files"); + let mut file_log = storage.file_log.write().await; + let records: Vec = files.into(); + file_log.apply_records(records).await?; + tracing::info!( + root = ?file_log.tree().root().map(|c| c.to_string()), + "import_account_events::files"); + } + + self.set_storage(Some(Arc::new(RwLock::new(storage)))).await; + + Ok(()) + } + async fn new_device_vault( &mut self, ) -> Result<(DeviceSigner, DeviceManager)> { @@ -1579,13 +1685,12 @@ impl Account for LocalAccount { .authenticated .as_ref() .ok_or(Error::NotAuthenticated)? - .user .devices()? .current_device(None)) } async fn trusted_devices(&self) -> Result> { - let storage = self.storage().await?; + let storage = self.storage.as_ref().ok_or(Error::NoStorage)?; let reader = storage.read().await; Ok(reader.devices().clone()) } @@ -1604,7 +1709,7 @@ impl Account for LocalAccount { ) -> Result { self.authenticated.as_ref().ok_or(Error::NotAuthenticated)?; self.open_folder(folder).await?; - let storage = self.storage().await?; + let storage = self.storage.as_ref().ok_or(Error::NoStorage)?; let reader = storage.read().await; Ok(reader.description().await?) } @@ -1625,7 +1730,7 @@ impl Account for LocalAccount { let (_, commit_state) = self.compute_folder_state(&options).await?; let event = { - let storage = self.storage().await?; + let storage = self.storage.as_mut().ok_or(Error::NoStorage)?; let mut writer = storage.write().await; writer.set_description(description).await? }; @@ -1651,7 +1756,7 @@ impl Account for LocalAccount { } async fn identity_vault_buffer(&self) -> Result> { - let storage = self.storage().await?; + let storage = self.storage.as_ref().ok_or(Error::NoStorage)?; let reader = storage.read().await; let identity_path = reader.paths().identity_vault(); Ok(vfs::read(identity_path).await?) @@ -1662,6 +1767,33 @@ impl Account for LocalAccount { Ok(self.user()?.identity()?.vault().summary().clone()) } + async fn reload_identity_folder(&mut self) -> Result<()> { + self.authenticated.as_ref().ok_or(Error::NotAuthenticated)?; + + // Reload the vault on disc + let path = self.paths.identity_vault(); + self.user_mut()? + .identity_mut()? + .folder + .keeper_mut() + .reload_vault(path) + .await?; + + // Reload the event log merkle tree + // TODO: we could only load commits from HEAD here + let event_log = self.user_mut()?.identity_mut()?.folder.event_log(); + let mut event_log = event_log.write().await; + event_log.load_tree().await?; + + // Rebuild the folder password lookup index + self.user_mut()? + .identity_mut()? + .rebuild_lookup_index() + .await?; + + Ok(()) + } + async fn change_cipher( &mut self, account_key: &AccessKey, @@ -1734,48 +1866,43 @@ impl Account for LocalAccount { } async fn sign_in(&mut self, key: &AccessKey) -> Result> { - self.login(key, Default::default()).await - } - - async fn sign_in_with_options( - &mut self, - key: &AccessKey, - options: SigninOptions, - ) -> Result> { - self.login(key, options).await + self.login(key).await } async fn verify(&self, key: &AccessKey) -> bool { if let Some(auth) = &self.authenticated { - auth.user.verify(key).await + auth.verify(key).await } else { false } } - async fn open_folder(&mut self, summary: &Summary) -> Result<()> { + async fn open_folder(&self, summary: &Summary) -> Result<()> { self.open_vault(summary, true).await } + async fn current_folder(&self) -> Result> { + let storage = self.storage.as_ref().ok_or(Error::NoStorage)?; + let storage = storage.read().await; + Ok(storage.current_folder()) + } + async fn sign_out(&mut self) -> Result<()> { tracing::debug!(address = %self.address(), "sign_out"); - #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] - { - self.account_lock.take(); - } - tracing::debug!("lock storage vaults"); // Lock all the storage vaults - let storage = self.storage().await?; - let mut writer = storage.write().await; - writer.lock().await; - - #[cfg(feature = "search")] { - tracing::debug!("clear search index"); - // Remove the search index - writer.index_mut()?.clear().await; + let storage = self.storage.as_mut().ok_or(Error::NoStorage)?; + let mut writer = storage.write().await; + writer.lock().await; + + #[cfg(feature = "search")] + { + tracing::debug!("clear search index"); + // Remove the search index + writer.index_mut()?.clear().await; + } } tracing::debug!("sign out user identity"); @@ -1829,42 +1956,50 @@ impl Account for LocalAccount { where P: FnMut(&&Summary) -> bool + Send, { - if let Some(auth) = &self.authenticated { - let reader = auth.storage.read().await; + if let Some(_) = &self.authenticated { + let reader = self.storage.as_ref().unwrap().read().await; reader.find(predicate).cloned() } else { None } } - async fn storage(&self) -> Result>> { - let auth = - self.authenticated.as_ref().ok_or(Error::NotAuthenticated)?; - Ok(Arc::clone(&auth.storage)) + async fn storage(&self) -> Option>> { + self.storage.as_ref().map(Arc::clone) + } + + async fn set_storage( + &mut self, + storage: Option>>, + ) { + self.storage = storage; } async fn secret_ids(&self, summary: &Summary) -> Result> { - let storage = self.storage().await?; + let storage = self.storage.as_ref().ok_or(Error::NoStorage)?; let reader = storage.read().await; let vault: Vault = reader.read_vault(summary.id()).await?; Ok(vault.keys().cloned().collect()) } async fn load_folders(&mut self) -> Result> { - tracing::debug!("load folders"); - let storage = self.storage().await?; + let storage = self.storage.as_mut().ok_or(Error::NoStorage)?; let mut writer = storage.write().await; - Ok(writer.load_folders().await?.to_vec()) + let mut folders = writer.load_folders().await?.to_vec(); + folders.sort_by(|a, b| a.name().cmp(b.name())); + Ok(folders) } async fn list_folders(&self) -> Result> { - let storage = self.storage().await?; + let storage = self.storage.as_ref().ok_or(Error::NoStorage)?; let reader = storage.read().await; - Ok(reader.list_folders().to_vec()) + let mut folders = reader.list_folders().to_vec(); + folders.sort_by(|a, b| a.name().cmp(b.name())); + Ok(folders) } async fn account_data(&self) -> Result { - let storage = self.storage().await?; + let storage = self.storage.as_ref().ok_or(Error::NoStorage)?; let reader = storage.read().await; let user = self.user()?; Ok(AccountData { @@ -1880,7 +2015,7 @@ impl Account for LocalAccount { } async fn root_commit(&self, summary: &Summary) -> Result { - let storage = self.storage().await?; + let storage = self.storage.as_ref().ok_or(Error::NoStorage)?; let reader = storage.read().await; let cache = reader.cache(); let folder = cache @@ -1892,13 +2027,13 @@ impl Account for LocalAccount { } async fn identity_state(&self) -> Result { - let storage = self.storage().await?; + let storage = self.storage.as_ref().ok_or(Error::NoStorage)?; let reader = storage.read().await; Ok(reader.identity_state().await?) } async fn commit_state(&self, summary: &Summary) -> Result { - let storage = self.storage().await?; + let storage = self.storage.as_ref().ok_or(Error::NoStorage)?; let reader = storage.read().await; Ok(reader.commit_state(summary).await?) } @@ -1959,7 +2094,7 @@ impl Account for LocalAccount { .ok_or(Error::NoFolderPassword(*summary.id()))?; let (event, old_size, new_size) = { - let storage = self.storage().await?; + let storage = self.storage.as_mut().ok_or(Error::NoStorage)?; let mut writer = storage.write().await; writer.compact_folder(summary, &key).await? }; @@ -1978,7 +2113,7 @@ impl Account for LocalAccount { .await? .ok_or(Error::NoFolderPassword(*folder_id))?; - let storage = self.storage().await?; + let storage = self.storage.as_mut().ok_or(Error::NoStorage)?; let mut writer = storage.write().await; Ok(writer.restore_folder(folder_id, records, &key).await?) } @@ -1997,13 +2132,13 @@ impl Account for LocalAccount { .ok_or(Error::NoFolderPassword(*folder.id()))?; let vault = { - let storage = self.storage().await?; + let storage = self.storage.as_ref().ok_or(Error::NoStorage)?; let reader = storage.read().await; reader.read_vault(folder.id()).await? }; { - let storage = self.storage().await?; + let storage = self.storage.as_mut().ok_or(Error::NoStorage)?; let mut writer = storage.write().await; writer .change_password(&vault, current_key, new_key.clone()) @@ -2026,7 +2161,7 @@ impl Account for LocalAccount { ) -> Result { let search_index = Arc::new(RwLock::new(SearchIndex::new())); - let storage = self.storage().await?; + let storage = self.storage.as_ref().ok_or(Error::NoStorage)?; let reader = storage.read().await; let cache = reader.cache(); let folder = cache @@ -2066,9 +2201,12 @@ impl Account for LocalAccount { &mut self, ) -> Result<(DocumentCount, Vec)> { let keys = self.folder_keys().await?; - let storage = self.storage().await?; + let storage = self.storage.as_mut().ok_or(Error::NoStorage)?; let mut writer = storage.write().await; - writer.initialize_search_index(&keys).await + let (count, mut folders) = + writer.initialize_search_index(&keys).await?; + folders.sort_by(|a, b| a.name().cmp(b.name())); + Ok((count, folders)) } #[cfg(feature = "search")] @@ -2115,7 +2253,7 @@ impl Account for LocalAccount { #[cfg(feature = "search")] async fn index(&self) -> Result>> { - let storage = self.storage().await?; + let storage = self.storage.as_ref().ok_or(Error::NoStorage)?; let reader = storage.read().await; Ok(reader.index()?.search()) } @@ -2123,10 +2261,10 @@ impl Account for LocalAccount { #[cfg(feature = "search")] async fn query_view( &self, - views: Vec, - archive: Option, + views: &[DocumentView], + archive: Option<&ArchiveFilter>, ) -> Result> { - let storage = self.storage().await?; + let storage = self.storage.as_ref().ok_or(Error::NoStorage)?; let reader = storage.read().await; reader.index()?.query_view(views, archive).await } @@ -2137,14 +2275,14 @@ impl Account for LocalAccount { query: &str, filter: QueryFilter, ) -> Result> { - let storage = self.storage().await?; + let storage = self.storage.as_ref().ok_or(Error::NoStorage)?; let reader = storage.read().await; reader.index()?.query_map(query, filter).await } #[cfg(feature = "search")] async fn document_count(&self) -> Result { - let storage = self.storage().await?; + let storage = self.storage.as_ref().ok_or(Error::NoStorage)?; let reader = storage.read().await; let search = reader.index()?.search(); let index = search.read().await; @@ -2158,7 +2296,7 @@ impl Account for LocalAccount { label: &str, id: Option<&SecretId>, ) -> Result { - let storage = self.storage().await?; + let storage = self.storage.as_ref().ok_or(Error::NoStorage)?; let reader = storage.read().await; let search = reader.index()?.search(); let index = search.read().await; @@ -2172,7 +2310,7 @@ impl Account for LocalAccount { secret_id: &SecretId, file_name: &str, ) -> Result> { - let storage = self.storage().await?; + let storage = self.storage.as_ref().ok_or(Error::NoStorage)?; let reader = storage.read().await; reader.download_file(vault_id, secret_id, file_name).await } @@ -2247,7 +2385,7 @@ impl Account for LocalAccount { } let result = { - let storage = self.storage().await?; + let storage = self.storage.as_mut().ok_or(Error::NoStorage)?; let mut writer = storage.write().await; writer .update_secret(secret_id, meta, secret, options.clone()) @@ -2299,13 +2437,27 @@ impl Account for LocalAccount { } async fn read_secret( - &mut self, + &self, secret_id: &SecretId, folder: Option, ) -> Result<(SecretRow, ReadEvent)> { self.get_secret(secret_id, folder, true).await } + async fn raw_secret( + &self, + folder_id: &VaultId, + secret_id: &SecretId, + ) -> std::result::Result<(Option, ReadEvent), Self::Error> + { + let storage = self.storage.as_ref().ok_or(Error::NoStorage)?; + let reader = storage.read().await; + Ok(match reader.raw_secret(folder_id, secret_id).await? { + (Some(commit), event) => (Some(commit.into_owned()), event), + (None, event) => (None, event), + }) + } + async fn delete_secret( &mut self, secret_id: &SecretId, @@ -2317,7 +2469,7 @@ impl Account for LocalAccount { self.open_folder(&folder).await?; let result = { - let storage = self.storage().await?; + let storage = self.storage.as_mut().ok_or(Error::NoStorage)?; let mut writer = storage.write().await; writer.delete_secret(secret_id, options).await? }; @@ -2436,7 +2588,7 @@ impl Account for LocalAccount { options.kdf = Some(kdf); let (buffer, _, summary, account_event) = { - let storage = self.storage().await?; + let storage = self.storage.as_mut().ok_or(Error::NoStorage)?; let mut writer = storage.write().await; writer.create_folder(name, options).await? }; @@ -2478,7 +2630,7 @@ impl Account for LocalAccount { // Update the provider let event = { - let storage = self.storage().await?; + let storage = self.storage.as_mut().ok_or(Error::NoStorage)?; let mut writer = storage.write().await; writer.rename_folder(&summary, &name).await? }; @@ -2504,7 +2656,7 @@ impl Account for LocalAccount { // Update the provider let event = { - let storage = self.storage().await?; + let storage = self.storage.as_mut().ok_or(Error::NoStorage)?; let mut writer = storage.write().await; writer.update_folder_flags(&summary, flags).await? }; @@ -2595,7 +2747,7 @@ impl Account for LocalAccount { // Import the vault let (event, summary) = { - let storage = self.storage().await?; + let storage = self.storage.as_mut().ok_or(Error::NoStorage)?; let mut writer = storage.write().await; writer .import_folder(buffer.as_ref(), Some(&key), true, None) @@ -2621,7 +2773,8 @@ impl Account for LocalAccount { // is loaded into memory we must close it so // the UI does not show stale in-memory data { - let storage = self.storage().await?; + let storage = + self.storage.as_mut().ok_or(Error::NoStorage)?; let mut writer = storage.write().await; let is_current = if let Some(current) = writer.current_folder() { @@ -2672,7 +2825,7 @@ impl Account for LocalAccount { let buffer = self .export_folder_buffer(summary, new_key, save_key) .await?; - vfs::write(path, buffer).await?; + vfs::write_exclusive(path, buffer).await?; Ok(()) } @@ -2750,7 +2903,7 @@ impl Account for LocalAccount { self.compute_folder_state(&options).await?; let events = { - let storage = self.storage().await?; + let storage = self.storage.as_mut().ok_or(Error::NoStorage)?; let mut writer = storage.write().await; writer.delete_folder(&summary, true).await? }; @@ -2767,7 +2920,7 @@ impl Account for LocalAccount { #[cfg(feature = "contacts")] async fn load_avatar( - &mut self, + &self, secret_id: &SecretId, folder: Option, ) -> Result>> { @@ -2789,13 +2942,13 @@ impl Account for LocalAccount { #[cfg(feature = "contacts")] async fn export_contact( - &mut self, + &self, path: impl AsRef + Send + Sync, secret_id: &SecretId, folder: Option, ) -> Result<()> { let current_folder = { - let storage = self.storage().await?; + let storage = self.storage.as_ref().ok_or(Error::NoStorage)?; let reader = storage.read().await; folder .clone() @@ -2806,7 +2959,7 @@ impl Account for LocalAccount { let (data, _) = self.get_secret(secret_id, folder, false).await?; if let Secret::Contact { vcard, .. } = &data.secret { let content = vcard.to_string(); - vfs::write(&path, content).await?; + vfs::write_exclusive(&path, content).await?; } else { return Err(Error::NotContact); } @@ -2826,7 +2979,7 @@ impl Account for LocalAccount { #[cfg(feature = "contacts")] async fn export_all_contacts( - &mut self, + &self, path: impl AsRef + Send + Sync, ) -> Result<()> { let contacts = self @@ -2854,7 +3007,7 @@ impl Account for LocalAccount { vcf.push_str(&vcard.to_string()); } } - vfs::write(path, vcf.as_bytes()).await?; + vfs::write_exclusive(path, vcf.as_bytes()).await?; #[cfg(feature = "audit")] { @@ -2879,7 +3032,7 @@ impl Account for LocalAccount { let mut ids = Vec::new(); let current = { - let storage = self.storage().await?; + let storage = self.storage.as_ref().ok_or(Error::NoStorage)?; let reader = storage.read().await; reader.current_folder() }; @@ -2973,7 +3126,7 @@ impl Account for LocalAccount { migration.append_files(files).await?; migration.finish().await?; - vfs::write(path.as_ref(), &archive).await?; + vfs::write_exclusive(path.as_ref(), &archive).await?; #[cfg(feature = "audit")] { @@ -3128,11 +3281,7 @@ impl Account for LocalAccount { ) -> Result { use super::archive::{AccountBackup, ExtractFilesLocation}; - let current_folder = { - let storage = self.storage().await?; - let reader = storage.read().await; - reader.current_folder() - }; + let current_folder = self.current_folder().await?; let files_dir = ExtractFilesLocation::Path(self.paths().files_dir().clone()); @@ -3150,7 +3299,7 @@ impl Account for LocalAccount { { let keys = self.folder_keys().await?; - let storage = self.storage().await?; + let storage = self.storage.as_mut().ok_or(Error::NoStorage)?; let mut writer = storage.write().await; writer.restore_archive(&targets, &keys).await?; } @@ -3173,52 +3322,140 @@ impl Account for LocalAccount { Ok(account) } + + /// Copy a secret to the clipboard. + #[cfg(feature = "clipboard")] + async fn copy_clipboard( + &self, + clipboard: &Clipboard, + target: &SecretPath, + request: &ClipboardCopyRequest, + ) -> Result { + use serde_json::Value; + let target_folder = self.find(|f| f.id() == target.folder_id()).await; + if let Some(folder) = target_folder { + let current_folder = self.current_folder().await?; + let (data, _) = + self.read_secret(target.secret_id(), Some(folder)).await?; + if let Some(current) = ¤t_folder { + self.open_folder(current).await?; + } + let secret = data.secret(); + let text = if let Some(paths) = &request.paths { + fn value_to_string(node: &Value) -> String { + match node { + Value::Null => node.to_string(), + Value::Bool(val) => val.to_string(), + Value::Number(num) => num.to_string(), + Value::String(s) => s.to_string(), + Value::Array(list) => { + let mut s = String::new(); + for node in list { + s.push_str(&value_to_string(node)); + } + s + } + Value::Object(map) => { + let mut s = String::new(); + for (k, v) in map { + s.push_str(&k); + s.push('='); + s.push_str(&value_to_string(v)); + } + s + } + } + } + + let value: Value = serde_json::to_value(&secret)?; + let mut s = String::new(); + let mut nodes = Vec::new(); + for path in paths { + let mut matches = path.query(&value).all(); + nodes.append(&mut matches); + } + + if nodes.is_empty() { + return Err(Error::JsonPathQueryEmpty( + paths.iter().map(|p| p.to_string()).collect(), + )); + } + + let len = nodes.len(); + for (index, node) in nodes.into_iter().enumerate() { + s.push_str(&value_to_string(node)); + if index < len - 1 { + s.push('\n'); + } + } + s + } else { + secret.copy_value_unsafe().unwrap_or_default() + }; + + let text = if let Some(format) = &request.format { + match format { + ClipboardTextFormat::Date { format_description } => { + let dt = UtcDateTime::parse_rfc3339(&text)?; + let tz = time_tz::system::get_timezone()?; + let dt = dt.to_timezone(tz); + dt.format(format_description)? + } + } + } else { + text + }; + + clipboard + .set_text_timeout(text) + .await + .map_err(Error::from)?; + return Ok(true); + } + Ok(false) + } } -#[async_trait] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] impl StorageEventLogs for LocalAccount { async fn identity_log(&self) -> Result>> { - let storage = self.storage().await?; + let storage = self.storage.as_ref().ok_or(Error::NoStorage)?; let storage = storage.read().await; Ok(Arc::clone(&storage.identity_log)) } async fn account_log(&self) -> Result>> { - let storage = self.storage().await?; + let storage = self.storage.as_ref().ok_or(Error::NoStorage)?; let storage = storage.read().await; Ok(Arc::clone(&storage.account_log)) } async fn device_log(&self) -> Result>> { - let storage = self.storage().await?; + let storage = self.storage.as_ref().ok_or(Error::NoStorage)?; let storage = storage.read().await; Ok(Arc::clone(&storage.device_log)) } #[cfg(feature = "files")] async fn file_log(&self) -> Result>> { - let storage = self.storage().await?; + let storage = self.storage.as_ref().ok_or(Error::NoStorage)?; let storage = storage.read().await; Ok(Arc::clone(&storage.file_log)) } - async fn folder_identifiers(&self) -> Result> { - let storage = self.storage().await?; - let storage = storage.read().await; - let summaries = storage.list_folders().to_vec(); - Ok(summaries.iter().map(|s| *s.id()).collect()) - } - async fn folder_details(&self) -> Result> { - let folders = self.list_folders().await?; - Ok(folders.into_iter().collect()) + let storage = self.storage.as_ref().ok_or(Error::NoStorage)?; + let storage = storage.read().await; + let folders = storage.list_folders(); + Ok(folders.into_iter().cloned().collect()) } async fn folder_log( &self, id: &VaultId, ) -> Result>> { - let storage = self.storage().await?; + let storage = self.storage.as_ref().ok_or(Error::NoStorage)?; let storage = storage.read().await; let folder = storage .cache() diff --git a/crates/sdk/src/account/account_switcher.rs b/crates/sdk/src/account/account_switcher.rs index 19fbb1417a..40e94ea201 100644 --- a/crates/sdk/src/account/account_switcher.rs +++ b/crates/sdk/src/account/account_switcher.rs @@ -1,41 +1,133 @@ +use std::path::PathBuf; +use std::pin::Pin; +use std::{collections::HashMap, future::Future}; + use crate::{ account::{Account, LocalAccount}, - prelude::Address, + identity::Identity, + prelude::{Address, PublicIdentity}, + Error, Paths, Result, }; +#[cfg(feature = "search")] +use crate::prelude::{ArchiveFilter, Document, DocumentView, QueryFilter}; + +#[cfg(feature = "clipboard")] +use xclipboard::Clipboard; + +#[cfg(feature = "clipboard")] +use crate::prelude::{ClipboardCopyRequest, SecretPath}; + /// Account switcher for local accounts. pub type LocalAccountSwitcher = AccountSwitcher< - ::Error, - ::NetworkResult, LocalAccount, + ::NetworkResult, + ::Error, >; +/// Options for an account switcher. +#[derive(Default)] +pub struct AccountSwitcherOptions { + /// Paths for data storage. + pub paths: Option, + /// Clipboard backend. + #[cfg(feature = "clipboard")] + pub clipboard: Option, +} + /// Collection of accounts with a currently selected account. /// /// Allows multiple accounts to be authenticated concurrently /// so that integrations are able to operate on multiple accounts /// provided they are authenticated. -pub struct AccountSwitcher> { - accounts: Vec, +pub struct AccountSwitcher +where + A: Account + Sync + Send + 'static, + E: From + std::error::Error + std::fmt::Debug, +{ + #[doc(hidden)] + pub accounts: Vec, selected: Option
, + paths: Option, + #[cfg(feature = "clipboard")] + clipboard: Option, } -impl> - AccountSwitcher +impl AccountSwitcher +where + A: Account + Sync + Send + 'static, + E: From + std::error::Error + std::fmt::Debug, { /// Create an account switcher. pub fn new() -> Self { Self { accounts: Default::default(), selected: None, + paths: None, + #[cfg(feature = "clipboard")] + clipboard: None, } } + /// Create an account switcher with a data directory. + pub fn new_with_options(options: AccountSwitcherOptions) -> Self { + Self { + accounts: Default::default(), + selected: None, + paths: options.paths, + #[cfg(feature = "clipboard")] + clipboard: options.clipboard, + } + } + + /// Data directory. + pub fn paths(&self) -> Option<&Paths> { + self.paths.as_ref() + } + + /// Accounts iterator. + pub fn iter<'a>(&'a self) -> std::slice::Iter<'a, A> { + self.accounts.iter() + } + + /// Mutable accounts iterator. + pub fn iter_mut<'a>(&'a mut self) -> std::slice::IterMut<'a, A> { + self.accounts.iter_mut() + } + /// Number of accounts. pub fn len(&self) -> usize { self.accounts.len() } + /// Load accounts from disc and add them. + pub async fn load_accounts( + &mut self, + builder: B, + paths: Option, + ) -> Result<()> + where + B: Fn( + PublicIdentity, + ) + -> Pin>>>, + { + Paths::scaffold(paths.clone()).await?; + + let identities = Identity::list_accounts(self.paths()).await?; + + for identity in identities { + tracing::info!(address = %identity.address(), "add_account"); + let account = builder(identity).await.unwrap(); + + let paths = account.paths(); + // tracing::info!(paths = ?paths); + paths.ensure().await?; + self.add_account(account); + } + Ok(()) + } + /// Add an account if it does not already exist and make /// it the selected account. /// @@ -113,7 +205,87 @@ impl> } } + /// Search all authenticated accounts. + #[cfg(feature = "search")] + pub async fn search( + &self, + needle: String, + filter: QueryFilter, + ) -> std::result::Result>, E> { + let mut out = HashMap::new(); + for account in self.iter() { + if account.is_authenticated().await { + let results = + account.query_map(&needle, filter.clone()).await?; + out.insert(*account.address(), results); + } + } + Ok(out) + } + + /// Query a search index view for all authenticated accounts. + #[cfg(feature = "search")] + pub async fn query_view( + &self, + views: &[DocumentView], + archive_filter: Option<&ArchiveFilter>, + ) -> std::result::Result>, E> { + let mut out = HashMap::new(); + for account in self.iter() { + if account.is_authenticated().await { + let results = + account.query_view(views, archive_filter).await?; + out.insert(*account.address(), results); + } + } + Ok(out) + } + + /// Sign out of all authenticated accounts. + pub async fn sign_out_all(&mut self) -> std::result::Result<(), E> { + for account in &mut self.accounts { + if account.is_authenticated().await { + tracing::info!(account = %account.address(), "sign out"); + account.sign_out().await?; + } + } + Ok(()) + } + + /// Copy a secret to the clipboard. + #[cfg(feature = "clipboard")] + pub async fn copy_clipboard( + &self, + account_id: &Address, + target: &SecretPath, + request: &ClipboardCopyRequest, + ) -> std::result::Result { + let Some(clipboard) = self.clipboard.clone() else { + return Err(Error::NoClipboard.into()); + }; + + let account = self.iter().find(|a| a.address() == account_id); + if let Some(account) = account { + account.copy_clipboard(&clipboard, target, request).await + } else { + Ok(false) + } + } + fn position(&self, address: &Address) -> Option { self.accounts.iter().position(|a| a.address() == address) } } + +impl From for AccountSwitcher +where + A: Account + Sync + Send + 'static, + E: From + std::error::Error + std::fmt::Debug, +{ + fn from(paths: Paths) -> Self { + Self::new_with_options(AccountSwitcherOptions { + paths: Some(paths), + ..Default::default() + }) + } +} diff --git a/crates/sdk/src/account/archive/backup.rs b/crates/sdk/src/account/archive/backup.rs index 422150d4ee..64a651555b 100644 --- a/crates/sdk/src/account/archive/backup.rs +++ b/crates/sdk/src/account/archive/backup.rs @@ -453,7 +453,7 @@ impl AccountBackup { event_log_path.set_extension(EVENT_LOG_EXT); // Write out the vault buffer - vfs::write(&vault_path, buffer).await?; + vfs::write_exclusive(&vault_path, buffer).await?; let (_, events) = FolderReducer::split(vault.clone()).await?; diff --git a/crates/sdk/src/account/archive/zip.rs b/crates/sdk/src/account/archive/zip.rs index 79149c65e1..d9b504f736 100644 --- a/crates/sdk/src/account/archive/zip.rs +++ b/crates/sdk/src/account/archive/zip.rs @@ -199,7 +199,7 @@ impl Reader { self.archive_entry(&entry_name, checksum).await?; vaults.push(summary); } - vaults.sort_by(|a, b| a.name().partial_cmp(b.name()).unwrap()); + vaults.sort_by(|a, b| a.name().cmp(b.name())); Ok(Inventory { manifest, identity, diff --git a/crates/sdk/src/account/builder.rs b/crates/sdk/src/account/builder.rs index 2500eddc8d..913e0aa8e5 100644 --- a/crates/sdk/src/account/builder.rs +++ b/crates/sdk/src/account/builder.rs @@ -165,11 +165,13 @@ impl AccountBuilder { create_archive, create_authenticator, create_contacts, + #[cfg(feature = "files")] create_file_password, mut default_folder_name, archive_folder_name, authenticator_folder_name, contacts_folder_name, + .. } = self; Paths::scaffold(data_dir.clone()).await?; diff --git a/crates/sdk/src/account/mod.rs b/crates/sdk/src/account/mod.rs index 075943616a..0fdf7b9ac4 100644 --- a/crates/sdk/src/account/mod.rs +++ b/crates/sdk/src/account/mod.rs @@ -7,13 +7,18 @@ mod builder; mod convert; pub use account::{ - Account, AccountChange, AccountData, AccountLocked, DetachedView, - FolderChange, FolderCreate, FolderDelete, LocalAccount, SecretChange, - SecretDelete, SecretInsert, SecretMove, SigninOptions, + Account, AccountChange, AccountData, DetachedView, FolderChange, + FolderCreate, FolderDelete, LocalAccount, SecretChange, SecretDelete, + SecretInsert, SecretMove, +}; +pub use account_switcher::{ + AccountSwitcher, AccountSwitcherOptions, LocalAccountSwitcher, }; -pub use account_switcher::{AccountSwitcher, LocalAccountSwitcher}; pub use builder::{AccountBuilder, PrivateNewAccount}; pub use convert::CipherComparison; #[cfg(feature = "contacts")] pub use account::ContactImportProgress; + +#[cfg(feature = "clipboard")] +pub use account::ClipboardCopyRequest; diff --git a/crates/sdk/src/audit/log_file.rs b/crates/sdk/src/audit/log_file.rs index e858e35ca7..f3e4f42ccf 100644 --- a/crates/sdk/src/audit/log_file.rs +++ b/crates/sdk/src/audit/log_file.rs @@ -4,11 +4,9 @@ use std::{ path::{Path, PathBuf}, }; -use futures::io::{ - AsyncWriteExt as FuturesAsyncWriteExt, BufReader, BufWriter, Cursor, -}; +use futures::io::{BufReader, BufWriter, Cursor}; use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; -use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt}; +use tokio_util::compat::Compat; use crate::{ constants::AUDIT_IDENTITY, @@ -24,7 +22,6 @@ use binary_stream::futures::{BinaryReader, BinaryWriter}; /// Represents an audit log file. pub struct AuditLogFile { - file: Compat, file_path: PathBuf, } @@ -32,8 +29,7 @@ impl AuditLogFile { /// Create an audit log file. pub async fn new>(path: P) -> Result { let file_path = path.as_ref().to_path_buf(); - let file = AuditLogFile::create(path.as_ref()).await?.compat_write(); - Ok(Self { file, file_path }) + Ok(Self { file_path }) } /// Log file path. @@ -61,6 +57,7 @@ impl AuditLogFile { let size = file.metadata().await?.len(); if size == 0 { file.write_all(&AUDIT_IDENTITY).await?; + file.flush().await?; } Ok(file) @@ -92,6 +89,7 @@ impl AuditProvider for AuditLogFile { &mut self, events: Vec, ) -> Result<()> { + // Make a single buffer of all audit events let buffer: Vec = { let mut buffer = Vec::new(); let mut stream = BufWriter::new(Cursor::new(&mut buffer)); @@ -103,8 +101,12 @@ impl AuditProvider for AuditLogFile { } buffer }; - self.file.write_all(&buffer).await?; - self.file.flush().await?; + + let file = Self::create(&self.file_path).await?; + let mut guard = vfs::lock_write(file).await?; + guard.write_all(&buffer).await?; + guard.flush().await?; + Ok(()) } } diff --git a/crates/sdk/src/constants.rs b/crates/sdk/src/constants.rs index 2e4ba743eb..5ab16517ac 100644 --- a/crates/sdk/src/constants.rs +++ b/crates/sdk/src/constants.rs @@ -61,6 +61,9 @@ mod vault { } mod urn { + /// URN namespace identifier. + pub const URN_NID: &str = "sos"; + /// Login vault signing key name. pub const LOGIN_SIGNING_KEY_URN: &str = "urn:sos:identity:signer"; @@ -84,12 +87,6 @@ mod urn { pub const DEVICE_KEY_URN: &str = "urn:sos:device:key"; } -/// Constants for MIME types. -mod mime { - /// Mime type for protocol buffers. - pub const MIME_TYPE_PROTOBUF: &str = "application/x-protobuf"; -} - /// Constants for directory names. mod folders { /// Directory to store vaults. @@ -133,9 +130,6 @@ mod folders { /// Name of the vault file that stores the device /// signing key. pub const DEVICE_FILE: &str = "device"; - - /// Lock file for an account. - pub const LOCK_FILE: &str = "account.lock"; } /// File names. @@ -154,11 +148,13 @@ mod env_vars { pub const SOS_PROMPT: &str = "SOS_PROMPT"; } +/// Service name prefix for platform keyring entries. +pub const KEYRING_SERVICE: &str = "Save Our Secrets"; + pub use self::urn::*; pub use env_vars::*; pub use extensions::*; pub use files::*; pub use folders::*; pub use identity::*; -pub use mime::*; pub use vault::*; diff --git a/crates/sdk/src/date_time.rs b/crates/sdk/src/date_time.rs index 2cc0bbe9a4..150a946307 100644 --- a/crates/sdk/src/date_time.rs +++ b/crates/sdk/src/date_time.rs @@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize}; use std::fmt; +use time_tz::{OffsetDateTimeExt, TimeZone}; use filetime::FileTime; @@ -39,6 +40,11 @@ impl UtcDateTime { Default::default() } + /// Convert this date time to the given timezone. + pub fn to_timezone(&self, tz: &T) -> Self { + Self(self.clone().0.to_timezone(tz)) + } + /// Create from a calendar date. pub fn from_calendar_date( year: i32, @@ -69,6 +75,12 @@ impl UtcDateTime { Ok(self.0.format(&format)?) } + /// Format according to a format description. + pub fn format(&self, description: &str) -> Result { + let format = format_description::parse(description)?; + Ok(self.0.format(&format)?) + } + /// Parse as RFC3339. pub fn parse_rfc3339(value: &str) -> Result { Ok(Self(OffsetDateTime::parse(value, &Rfc3339)?)) @@ -93,7 +105,7 @@ impl UtcDateTime { Ok(datetime.format(&Rfc2822)?) } - /// Convert this timestamp to a RFC3339 formatted string. + /// Convert this date and time to a RFC3339 formatted string. pub fn to_rfc3339(&self) -> Result { UtcDateTime::rfc3339(&self.0) } diff --git a/crates/sdk/src/encoding/v1/timestamp.rs b/crates/sdk/src/encoding/v1/timestamp.rs index d7cf2e1a06..d341d52dc0 100644 --- a/crates/sdk/src/encoding/v1/timestamp.rs +++ b/crates/sdk/src/encoding/v1/timestamp.rs @@ -29,7 +29,6 @@ impl Decodable for UtcDateTime { ) -> Result<()> { let seconds = reader.read_i64().await?; let nanos = reader.read_u32().await?; - self.0 = OffsetDateTime::from_unix_timestamp(seconds) .map_err(encoding_error)? + Duration::nanoseconds(nanos as i64); diff --git a/crates/sdk/src/error.rs b/crates/sdk/src/error.rs index 422770e702..2c518452d3 100644 --- a/crates/sdk/src/error.rs +++ b/crates/sdk/src/error.rs @@ -34,6 +34,12 @@ pub enum Error { #[error("account not authenticated, sign in required")] NotAuthenticated, + /// Error generated when no storage is configured. + #[error( + "account is empty, you may need to initialize the account or sign in" + )] + NoStorage, + /// Error generated if we could not determine a cache directory. #[error("could not determine cache directory")] NoCache, @@ -153,6 +159,24 @@ pub enum Error { #[error("unknown shared access kind {0}")] UnknownSharedAccessKind(u8), + /// Error generated when the namespace identifier for a URN is wrong. + #[error( + "invalid URN namespace identifier, expected '{0}' but got '{1}'" + )] + InvalidUrnNid(String, String), + + /// Error generated when a URN expects an account address. + #[error("account address expected in URN '{0}'")] + NoUrnAddress(String), + + /// Error generated when a URN expects a folder identifier. + #[error("folder identifier expected in URN '{0}'")] + NoUrnFolderId(String), + + /// Error generated when a URN expects a secret identifier. + #[error("secret identifier expected in URN '{0}'")] + NoUrnSecretId(String), + /// Error generated when an AeadPack contains a nonce that /// is invalid for the decryption cipher. #[error("invalid nonce")] @@ -454,10 +478,6 @@ pub enum Error { #[error("recovery identifier is expected")] RecoveryId, - /// Account is already locked. - #[error("account locked")] - AccountLocked, - /// Error generated when replacing events in an event log /// does not compute the same root hash as the expected /// checkpoint. @@ -480,6 +500,16 @@ pub enum Error { #[error("attempt to add an event in the past, this can happen if your clocks are out of sync, to fix this ensure that your device clock is using the correct date and time")] EventTimeBehind, + #[cfg(feature = "clipboard")] + /// Error when no clipboard is configured. + #[error("clipboard is not configured")] + NoClipboard, + + /// Error generated by the JSON path library when no nodes matched. + #[cfg(feature = "clipboard")] + #[error("paths '{0:?}' did not match any nodes")] + JsonPathQueryEmpty(Vec), + /// Generic boxed error. #[error(transparent)] Boxed(#[from] Box), @@ -524,6 +554,10 @@ pub enum Error { #[error(transparent)] Merkle(#[from] rs_merkle::Error), + /// Error generated attempting to detect the system time zone. + #[error(transparent)] + TimeZone(#[from] time_tz::system::Error), + /// Error generated converting time types. #[error(transparent)] Time(#[from] time::error::ComponentRange), @@ -640,6 +674,30 @@ pub enum Error { /// Error generated by the TOTP library. #[error(transparent)] TotpUrl(#[from] totp_rs::TotpUrlError), + + /// Error generated by the clipboard library. + #[cfg(feature = "clipboard")] + #[error(transparent)] + Clipboard(#[from] xclipboard::Error), +} + +/// Extension functions for error types. +pub trait ErrorExt { + /// Whether this is a secret not found error. + fn is_secret_not_found(&self) -> bool; + + /// Whether this is a permission denied error. + fn is_permission_denied(&self) -> bool; +} + +impl ErrorExt for Error { + fn is_secret_not_found(&self) -> bool { + matches!(self, Error::SecretNotFound(_)) + } + + fn is_permission_denied(&self) -> bool { + matches!(self, Error::PassphraseVerification) + } } impl From for Error { diff --git a/crates/sdk/src/events/account.rs b/crates/sdk/src/events/account.rs index 66519b8268..63ec334782 100644 --- a/crates/sdk/src/events/account.rs +++ b/crates/sdk/src/events/account.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; /// Events generated in the context of an account. #[derive(Default, Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub enum AccountEvent { #[default] #[doc(hidden)] @@ -23,12 +24,12 @@ pub enum AccountEvent { /// the folder event log. /// /// Buffer is a vault. - UpdateIdentity(Vec), + UpdateIdentity(#[serde(skip)] Vec), /// Create folder. /// /// Buffer is a head-only vault. - CreateFolder(VaultId, Vec), + CreateFolder(VaultId, #[serde(skip)] Vec), /// Rename a folder. RenameFolder(VaultId, String), @@ -42,7 +43,7 @@ pub enum AccountEvent { /// the folder event log. /// /// Buffer is a vault. - UpdateFolder(VaultId, Vec), + UpdateFolder(VaultId, #[serde(skip)] Vec), /// Folder events were compacted. /// @@ -50,7 +51,7 @@ pub enum AccountEvent { /// the folder event log. /// /// Buffer is a vault. - CompactFolder(VaultId, Vec), + CompactFolder(VaultId, #[serde(skip)] Vec), /// Change folder password. /// @@ -58,7 +59,7 @@ pub enum AccountEvent { /// the folder event log. /// /// Buffer is a vault. - ChangeFolderPassword(VaultId, Vec), + ChangeFolderPassword(VaultId, #[serde(skip)] Vec), /// Delete folder. DeleteFolder(VaultId), diff --git a/crates/sdk/src/events/log/file.rs b/crates/sdk/src/events/log/file.rs index b522856bb3..f3768650e6 100644 --- a/crates/sdk/src/events/log/file.rs +++ b/crates/sdk/src/events/log/file.rs @@ -125,7 +125,7 @@ pub trait EventLogExt: Send + Sync where E: Default + Encodable + Decodable + Send + Sync + 'static, R: AsyncRead + AsyncSeek + Unpin + Send + Sync + 'static, - W: AsyncWrite + Unpin + Send + Sync + 'static, + W: AsyncWrite + AsyncSeek + Unpin + Send + Sync + 'static, D: Clone, { /// Commit tree contains the in-memory merkle tree. @@ -547,19 +547,66 @@ where commits.push(*record.commit()); } - let rw = self.file(); - let mut file = MutexGuard::map(rw.lock().await, |f| &mut f.1); - match file.write_all(&buffer).await { - Ok(_) => { - file.flush().await?; - let mut hashes = - commits.iter().map(|c| *c.as_ref()).collect::>(); - let tree = self.tree_mut(); - tree.append(&mut hashes); - tree.commit(); - Ok(()) + // File based implementations should attempt to + // acquire an advisory lock + if let Some(path) = self.data_any().downcast_ref::() { + use tokio::io::AsyncWriteExt as TokioAsyncWriteExt; + + let rw = self.file(); + let _lock = rw.lock().await; + + let file = OpenOptions::new() + .write(true) + .append(true) + .open(path) + .await?; + + let mut guard = vfs::lock_write(file).await?; + + #[cfg(target_arch = "wasm32")] + { + file.seek(SeekFrom::End(0)).await?; + } + match guard.write_all(&buffer).await { + Ok(_) => { + guard.flush().await?; + let mut hashes = commits + .iter() + .map(|c| *c.as_ref()) + .collect::>(); + let tree = self.tree_mut(); + tree.append(&mut hashes); + tree.commit(); + Ok(()) + } + Err(e) => Err(e.into()), + } + // In-memory implementations can just write + // without acquiring an advisory lock + } else { + let rw = self.file(); + let mut file = MutexGuard::map(rw.lock().await, |f| &mut f.1); + // Workaround for bug in the vfs implementation on wasm32 + // that is overwriting the file identity bytes when + // applying records. + #[cfg(target_arch = "wasm32")] + { + file.seek(SeekFrom::End(0)).await?; + } + match file.write_all(&buffer).await { + Ok(_) => { + file.flush().await?; + let mut hashes = commits + .iter() + .map(|c| *c.as_ref()) + .collect::>(); + let tree = self.tree_mut(); + tree.append(&mut hashes); + tree.commit(); + Ok(()) + } + Err(e) => Err(e.into()), } - Err(e) => Err(e.into()), } } @@ -654,6 +701,10 @@ where } async fn truncate(&mut self) -> Result<()> { + use tokio::io::{ + AsyncSeekExt as TokioAsyncSeekExt, + AsyncWriteExt as TokioAsyncWriteExt, + }; let _ = self.file.lock().await; // Workaround for set_len(0) failing with "Access Denied" on Windows @@ -662,15 +713,17 @@ where .write(true) .truncate(true) .open(&self.data) - .await? - .compat_write(); + .await?; file.seek(SeekFrom::Start(0)).await?; - file.write_all(self.identity).await?; + + let mut guard = vfs::lock_write(file).await?; + guard.write_all(self.identity).await?; if let Some(version) = self.version() { - file.write_all(&version.to_le_bytes()).await?; + guard.write_all(&version.to_le_bytes()).await?; } - file.flush().await?; + guard.flush().await?; + Ok(()) } @@ -712,6 +765,11 @@ where OpenOptions::new().write(true).open(&self.data).await?; file.set_len(length).await?; + /* + let mut guard = vfs::lock_write(file).await?; + guard.inner_mut().set_len(length).await?; + */ + return Ok(records); } @@ -749,23 +807,30 @@ where identity: &'static [u8], encoding_version: Option, ) -> Result { - let mut file = OpenOptions::new() + let file = OpenOptions::new() .create(true) .append(true) .open(path.as_ref()) - .await? - .compat_write(); + .await?; let size = vfs::metadata(path.as_ref()).await?.len(); if size == 0 { + use tokio::io::AsyncWriteExt as TokioAsyncWriteExt; + let file = OpenOptions::new() + .create(true) + .append(true) + .open(path.as_ref()) + .await?; + let mut guard = vfs::lock_write(file).await?; let mut header = identity.to_vec(); if let Some(version) = encoding_version { header.extend_from_slice(&version.to_le_bytes()); } - file.write_all(&header).await?; - file.flush().await?; + guard.write_all(&header).await?; + guard.flush().await?; } - Ok(file) + + Ok(file.compat_write()) } /// Create the reader for an event log file. diff --git a/crates/sdk/src/events/log/patch.rs b/crates/sdk/src/events/log/patch.rs index 4ad3836023..908a902f1c 100644 --- a/crates/sdk/src/events/log/patch.rs +++ b/crates/sdk/src/events/log/patch.rs @@ -90,16 +90,6 @@ pub enum CheckedPatch { /// Diff between local and remote. #[derive(Default, Debug, Clone, PartialEq, Eq)] pub struct Diff { - /// Last commit hash before the patch was created. - /// - /// This can be used to determine if the patch is to - /// be used to initialize a new set of events when - /// no last commit is available. - /// - /// For example, for file event logs which are - /// lazily instantiated once external files are created. - pub last_commit: Option, - /// Contents of the patch. pub patch: Patch, /// Checkpoint for the diff patch. @@ -111,6 +101,30 @@ pub struct Diff { /// references the commit proof of HEAD after /// applying the patch. pub checkpoint: CommitProof, + /// Last commit hash before the patch was created. + /// + /// This can be used to determine if the patch is to + /// be used to initialize a new set of events when + /// no last commit is available. + /// + /// For example, for file event logs which are + /// lazily instantiated once external files are created. + pub last_commit: Option, +} + +impl Diff { + /// Create a diff. + pub fn new( + patch: Patch, + checkpoint: CommitProof, + last_commit: Option, + ) -> Self { + Self { + patch, + checkpoint, + last_commit, + } + } } /// Diff between account events logs. diff --git a/crates/sdk/src/events/log/reducer.rs b/crates/sdk/src/events/log/reducer.rs index ec62901aef..f24ed57d71 100644 --- a/crates/sdk/src/events/log/reducer.rs +++ b/crates/sdk/src/events/log/reducer.rs @@ -68,10 +68,11 @@ impl FolderReducer { where T: EventLogExt + Send + Sync + 'static, R: AsyncRead + AsyncSeek + Unpin + Send + Sync + 'static, - W: AsyncWrite + Unpin + Send + Sync + 'static, + W: AsyncWrite + AsyncSeek + Unpin + Send + Sync + 'static, D: Clone + Send + Sync, { // TODO: use event_log.stream() ! + // let mut it = event_log.iter(false).await?; if let Some(log) = it.next().await? { diff --git a/crates/sdk/src/events/write.rs b/crates/sdk/src/events/write.rs index 77d47014bc..e01bd28f2c 100644 --- a/crates/sdk/src/events/write.rs +++ b/crates/sdk/src/events/write.rs @@ -3,11 +3,13 @@ use crate::{ crypto::AeadPack, vault::{secret::SecretId, VaultCommit, VaultFlags}, }; +use serde::{Deserialize, Serialize}; use super::{EventKind, LogEvent}; /// Write operations. -#[derive(Default, Debug, Clone, Eq, PartialEq)] +#[derive(Default, Serialize, Deserialize, Debug, Clone, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] pub enum WriteEvent { /// Default variant, should never be used. /// @@ -23,7 +25,7 @@ pub enum WriteEvent { /// if the vault contains secrets they should be /// separated using an [FolderReducer::split]() beforehand /// and appended to the event log as create secret events. - CreateVault(Vec), + CreateVault(#[serde(skip)] Vec), /// Event used to indicate the vault name was set. SetVaultName(String), @@ -32,13 +34,13 @@ pub enum WriteEvent { SetVaultFlags(VaultFlags), /// Event used to indicate the vault meta data was set. - SetVaultMeta(AeadPack), + SetVaultMeta(#[serde(skip)] AeadPack), /// Event used to indicate a secret was created. - CreateSecret(SecretId, VaultCommit), + CreateSecret(SecretId, #[serde(skip)] VaultCommit), /// Event used to indicate a secret was updated. - UpdateSecret(SecretId, VaultCommit), + UpdateSecret(SecretId, #[serde(skip)] VaultCommit), /// Event used to indicate a secret was deleted. DeleteSecret(SecretId), diff --git a/crates/sdk/src/formats/file_identity.rs b/crates/sdk/src/formats/file_identity.rs index 5bb4e45ad3..67170556ca 100644 --- a/crates/sdk/src/formats/file_identity.rs +++ b/crates/sdk/src/formats/file_identity.rs @@ -8,6 +8,10 @@ use crate::{vfs::File, Error, Result}; /// String of formatted identity bytes for error messages. fn format_identity_bytes(identity: &[u8]) -> String { + let c = + std::str::from_utf8(identity).expect("identity bytes to be UTF-8"); + format!("{:#04x?} ({})", identity, c) + /* let c = std::str::from_utf8(identity).expect("identity bytes to be UTF-8"); let mut s = String::new(); @@ -19,6 +23,7 @@ fn format_identity_bytes(identity: &[u8]) -> String { } s.push_str(&format!(" ({})", c)); s + */ } /// Read and write the identity bytes for a file. diff --git a/crates/sdk/src/identity/identity_folder.rs b/crates/sdk/src/identity/identity_folder.rs index 59ff2b202b..9d6cf63ae3 100644 --- a/crates/sdk/src/identity/identity_folder.rs +++ b/crates/sdk/src/identity/identity_folder.rs @@ -7,10 +7,7 @@ //! This enables user interfaces to protect both the signing //! key and folder passwords using a single primary password. use crate::{ - constants::{ - FILE_PASSWORD_URN, LOGIN_AGE_KEY_URN, LOGIN_SIGNING_KEY_URN, - VAULT_NSS, - }, + constants::{LOGIN_AGE_KEY_URN, LOGIN_SIGNING_KEY_URN}, crypto::{AccessKey, KeyDerivation}, decode, encode, events::{ @@ -25,9 +22,7 @@ use crate::{ }, storage::{DiscFolder, Folder, MemoryFolder}, vault::{ - secret::{ - Secret, SecretId, SecretMeta, SecretRow, SecretSigner, UserData, - }, + secret::{Secret, SecretId, SecretMeta, SecretRow, SecretSigner}, BuilderCredentials, Gatekeeper, Vault, VaultBuilder, VaultFlags, VaultId, VaultWriter, }, @@ -36,7 +31,6 @@ use crate::{ use futures::io::{AsyncRead, AsyncSeek, AsyncWrite}; use secrecy::{ExposeSecret, SecretBox, SecretString}; use std::{ - collections::HashMap, path::{Path, PathBuf}, sync::Arc, }; @@ -62,7 +56,7 @@ pub struct IdentityFolder where T: EventLogExt + Send + Sync + 'static, R: AsyncRead + AsyncSeek + Unpin + Send + Sync + 'static, - W: AsyncWrite + Unpin + Send + Sync + 'static, + W: AsyncWrite + AsyncSeek + Unpin + Send + Sync + 'static, D: Clone + Send + Sync, { /// Folder storage. @@ -80,7 +74,7 @@ impl IdentityFolder where T: EventLogExt + Send + Sync + 'static, R: AsyncRead + AsyncSeek + Unpin + Send + Sync + 'static, - W: AsyncWrite + Unpin + Send + Sync + 'static, + W: AsyncWrite + AsyncSeek + Unpin + Send + Sync + 'static, D: Clone + Send + Sync, { /// Private identity. @@ -257,7 +251,7 @@ where let key: AccessKey = device_password.into(); let mut device_keeper = if mirror { let buffer = encode(&vault).await?; - vfs::write(&device_vault_path, &buffer).await?; + vfs::write_exclusive(&device_vault_path, &buffer).await?; let vault_file = VaultWriter::open(&device_vault_path).await?; let mirror = VaultWriter::new(&device_vault_path, vault_file)?; Gatekeeper::new_mirror(vault, mirror) @@ -393,10 +387,19 @@ where Ok(()) } + //// Rebuild the index lookup for folder passwords. + pub(crate) async fn rebuild_lookup_index(&mut self) -> Result<()> { + let keeper = self.folder.keeper(); + let (index, _, _) = Self::lookup_identity_secrets(keeper).await?; + self.index = index; + Ok(()) + } + #[cfg(feature = "files")] pub(crate) async fn create_file_encryption_password( &mut self, ) -> Result<()> { + use crate::{constants::FILE_PASSWORD_URN, vault::secret::UserData}; let file_passphrase = self.generate_folder_password()?; let secret = Secret::Password { password: file_passphrase, @@ -421,6 +424,7 @@ where pub(crate) async fn find_file_encryption_password( &self, ) -> Result { + use crate::constants::FILE_PASSWORD_URN; let urn: Urn = FILE_PASSWORD_URN.parse()?; let id = self @@ -455,9 +459,12 @@ where Ok(()) } - async fn login_private_identity( + /// Lookup secrets in the identity folder and prepare + /// the URN lookup index which maps URNs to the + /// corresponding secret identifiers. + async fn lookup_identity_secrets( keeper: &Gatekeeper, - ) -> Result<(UrnLookup, PrivateIdentity)> { + ) -> Result<(UrnLookup, Option, Option)> { let mut index: UrnLookup = Default::default(); let signer_urn: Urn = LOGIN_SIGNING_KEY_URN.parse()?; @@ -465,22 +472,12 @@ where let mut signer_secret: Option = None; let mut identity_secret: Option = None; - let mut folder_secrets = HashMap::new(); - for id in keeper.vault().keys() { - if let Some((meta, secret, _)) = keeper.read_secret(id).await? { + for secret_id in keeper.vault().keys() { + if let Some((meta, secret, _)) = + keeper.read_secret(secret_id).await? + { if let Some(urn) = meta.urn() { - if urn.nss().starts_with(VAULT_NSS) { - let id: VaultId = urn - .nss() - .trim_start_matches(VAULT_NSS) - .parse()?; - if let Secret::Password { password, .. } = &secret { - let key: AccessKey = password.clone().into(); - folder_secrets.insert(id, key); - } - } - if urn == &signer_urn { signer_secret = Some(secret); } else if urn == &identity_urn { @@ -488,10 +485,18 @@ where } // Add to the URN lookup index - index.insert((*keeper.id(), urn.clone()), *id); + index.insert((*keeper.id(), urn.clone()), *secret_id); } } } + Ok((index, signer_secret, identity_secret)) + } + + async fn login_private_identity( + keeper: &Gatekeeper, + ) -> Result<(UrnLookup, PrivateIdentity)> { + let (index, signer_secret, identity_secret) = + Self::lookup_identity_secrets(keeper).await?; let signer = signer_secret.ok_or(Error::NoSigningKey)?; let identity = identity_secret.ok_or(Error::NoIdentityKey)?; @@ -574,7 +579,7 @@ impl IdentityFolder { .await?; let buffer = encode(&vault).await?; - vfs::write(paths.identity_vault(), buffer).await?; + vfs::write_exclusive(paths.identity_vault(), buffer).await?; let mut folder = DiscFolder::new(paths.identity_vault()).await?; let key: AccessKey = password.into(); diff --git a/crates/sdk/src/identity/public_identity.rs b/crates/sdk/src/identity/public_identity.rs index 24be1ef8b6..32704f776f 100644 --- a/crates/sdk/src/identity/public_identity.rs +++ b/crates/sdk/src/identity/public_identity.rs @@ -14,7 +14,8 @@ use std::{ }; /// Public account identity information. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[typeshare::typeshare] +#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Hash)] pub struct PublicIdentity { /// Address identifier for the account. /// @@ -51,7 +52,7 @@ impl PublicIdentity { pub async fn list_accounts( paths: Option<&Paths>, ) -> Result> { - let mut keys = Vec::new(); + let mut identities = Vec::new(); let paths = if let Some(paths) = paths { paths.clone() } else { @@ -63,11 +64,11 @@ impl PublicIdentity { if let Some(ident) = Self::read_public_identity(entry.path()).await? { - keys.push(ident); + identities.push(ident); } } - keys.sort_by(|a, b| a.label().cmp(b.label())); - Ok(keys) + identities.sort_by(|a, b| a.label().cmp(b.label())); + Ok(identities) } /// Read the public identity from an identity vault file. diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs index ec1087348a..6511b5f06a 100644 --- a/crates/sdk/src/lib.rs +++ b/crates/sdk/src/lib.rs @@ -106,5 +106,10 @@ pub use uuid; pub use vcard4; pub use zxcvbn; +#[cfg(feature = "clipboard")] +pub use serde_json_path as json_path; +#[cfg(feature = "clipboard")] +pub use xclipboard; + /// Result type for the core library. pub type Result = std::result::Result; diff --git a/crates/sdk/src/logs.rs b/crates/sdk/src/logs.rs index 1e0f084551..e542878ab3 100644 --- a/crates/sdk/src/logs.rs +++ b/crates/sdk/src/logs.rs @@ -72,6 +72,7 @@ impl Logger { default_log_level: Option, ) -> Result<()> { let logs_dir = &self.logs_dir; + let logfile = RollingFileAppender::new(Rotation::DAILY, logs_dir, &self.name); let default_log_level = @@ -90,6 +91,7 @@ impl Logger { let fmt_layer = tracing_subscriber::fmt::layer() .with_file(false) .with_line_number(false) + .with_writer(std::io::stderr) .with_target(false); // NOTE: drop the error if already set so hot reload @@ -103,9 +105,8 @@ impl Logger { Ok(()) } - /// Initialize the tracing subscriber. - #[cfg(not(debug_assertions))] - pub fn init_subscriber( + /// Initialize a subscriber that writes to a file. + pub fn init_file_subscriber( &self, default_log_level: Option, ) -> Result<()> { @@ -137,6 +138,15 @@ impl Logger { Ok(()) } + /// Initialize the tracing subscriber. + #[cfg(not(debug_assertions))] + pub fn init_subscriber( + &self, + default_log_level: Option, + ) -> Result<()> { + self.init_file_subscriber(default_log_level) + } + /// Log file status. pub fn status(&self) -> Result { let current = self.current_log_file()?; diff --git a/crates/sdk/src/migrate/error.rs b/crates/sdk/src/migrate/error.rs index ceb2cc9f11..2159744380 100644 --- a/crates/sdk/src/migrate/error.rs +++ b/crates/sdk/src/migrate/error.rs @@ -13,7 +13,7 @@ pub enum Error { NoAuthenticatorUrls(String), /// Error generated by the keychain access integration. - #[cfg(target_os = "macos")] + #[cfg(all(target_os = "macos", feature = "keychain-access"))] #[error(transparent)] Keychain(#[from] crate::migrate::import::keychain::Error), diff --git a/crates/sdk/src/migrate/import/csv/dashlane.rs b/crates/sdk/src/migrate/import/csv/dashlane.rs index d7d92290af..4807fa2688 100644 --- a/crates/sdk/src/migrate/import/csv/dashlane.rs +++ b/crates/sdk/src/migrate/import/csv/dashlane.rs @@ -15,7 +15,7 @@ use std::{ }; use time::{Date, Month}; use url::Url; -use vcard4::{property::DeliveryAddress, uriparse::URI as Uri, VcardBuilder}; +use vcard4::{property::DeliveryAddress, Uri, VcardBuilder}; use async_zip::tokio::read::seek::ZipFileReader; use tokio::io::{AsyncBufRead, AsyncSeek, BufReader}; @@ -456,8 +456,8 @@ impl From for GenericContactRecord { None }; - let url: Option> = if !value.url.is_empty() { - Uri::try_from(&value.url[..]).ok().map(|u| u.into_owned()) + let url: Option = if !value.url.is_empty() { + value.url.parse().ok() } else { None }; @@ -533,7 +533,7 @@ impl From for GenericContactRecord { builder = builder.title(value.job_title); } if let Some(date) = date_of_birth { - builder = builder.birthday(date); + builder = builder.birthday(date.into()); } let vcard = builder.finish(); Self { diff --git a/crates/sdk/src/prelude.rs b/crates/sdk/src/prelude.rs index e0b54c784c..206a33b589 100644 --- a/crates/sdk/src/prelude.rs +++ b/crates/sdk/src/prelude.rs @@ -37,4 +37,4 @@ pub use crate::{ pub use crate::recovery::*; // Ensure top-level versions take precedence -pub use crate::{Error, Result}; +pub use crate::{error::ErrorExt, Error, Result}; diff --git a/crates/sdk/src/signer/address.rs b/crates/sdk/src/signer/address.rs index b209bef0d9..f9f7f08574 100644 --- a/crates/sdk/src/signer/address.rs +++ b/crates/sdk/src/signer/address.rs @@ -8,6 +8,7 @@ use k256::{ AffinePoint, EncodedPoint, FieldBytes, Scalar, Secp256k1, }; +use rand::Rng; use serde::{Deserialize, Serialize}; use sha3::{Digest, Keccak256}; use std::{fmt, str::FromStr}; @@ -21,6 +22,14 @@ use subtle::Choice; #[serde(try_from = "String", into = "String")] pub struct Address([u8; 20]); +impl Address { + /// Create a random address. + pub fn random() -> Self { + let rng = &mut rand::rngs::OsRng; + Self(rng.gen()) + } +} + impl fmt::Display for Address { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "0x{}", hex::encode(self.0)) diff --git a/crates/sdk/src/storage/client.rs b/crates/sdk/src/storage/client.rs index 4e744b476b..56fa8e7610 100644 --- a/crates/sdk/src/storage/client.rs +++ b/crates/sdk/src/storage/client.rs @@ -6,7 +6,8 @@ use crate::{ decode, encode, events::{ AccountEvent, AccountEventLog, Event, EventLogExt, EventRecord, - FolderEventLog, FolderReducer, IntoRecord, ReadEvent, WriteEvent, + FolderEventLog, FolderPatch, FolderReducer, IntoRecord, ReadEvent, + WriteEvent, }, identity::FolderKeys, passwd::diceware::generate_passphrase, @@ -16,15 +17,14 @@ use crate::{ vault::{ secret::{Secret, SecretId, SecretMeta, SecretRow}, BuilderCredentials, ChangePassword, FolderRef, Header, Summary, - Vault, VaultBuilder, VaultId, + Vault, VaultBuilder, VaultCommit, VaultId, }, vfs, Error, Paths, Result, UtcDateTime, }; use futures::{pin_mut, StreamExt}; use indexmap::IndexSet; -use secrecy::SecretString; -use std::{collections::HashMap, path::PathBuf, sync::Arc}; +use std::{borrow::Cow, collections::HashMap, path::PathBuf, sync::Arc}; use tokio::sync::RwLock; #[cfg(feature = "archive")] @@ -101,12 +101,59 @@ pub struct ClientStorage { /// Password for file encryption. #[cfg(feature = "files")] - pub(super) file_password: Option, + pub(super) file_password: Option, } impl ClientStorage { + /// Create unauthenticated folder storage for client-side access. + pub async fn new_unauthenticated( + address: Address, + paths: Arc, + ) -> Result { + paths.ensure().await?; + + let identity_log = Arc::new(RwLock::new( + FolderEventLog::new(paths.identity_events()).await?, + )); + + let account_log = Arc::new(RwLock::new( + AccountEventLog::new_account(paths.account_events()).await?, + )); + + let device_log = Arc::new(RwLock::new( + DeviceEventLog::new_device(paths.device_events()).await?, + )); + + #[cfg(feature = "files")] + let file_log = Arc::new(RwLock::new( + FileEventLog::new_file(paths.file_events()).await?, + )); + + let mut storage = Self { + address, + summaries: Vec::new(), + current: None, + cache: Default::default(), + paths, + identity_log, + account_log, + #[cfg(feature = "search")] + index: None, + device_log, + devices: Default::default(), + #[cfg(feature = "files")] + file_log, + #[cfg(feature = "files")] + file_password: None, + }; + + storage.load_folders().await?; + + Ok(storage) + } + /// Create folder storage for client-side access. - pub async fn new( + pub async fn new_authenticated( address: Address, data_dir: Option, identity_log: Arc>, @@ -207,7 +254,10 @@ impl ClientStorage { /// Set the password for file encryption. #[cfg(feature = "files")] - pub fn set_file_password(&mut self, file_password: Option) { + pub fn set_file_password( + &mut self, + file_password: Option, + ) { self.file_password = file_password; } @@ -385,13 +435,43 @@ impl ClientStorage { Ok(events) } - /// Restore a folder from an event log. - pub async fn restore_folder( + /// Create folders from a collection of folder patches. + /// + /// If the folders already exist they will be overwritten. + pub async fn import_folder_patches( + &mut self, + patches: HashMap, + ) -> Result<()> { + for (folder_id, patch) in patches { + let records: Vec = patch.into(); + let (folder, vault) = + self.initialize_folder(&folder_id, records).await?; + + { + let event_log = folder.event_log(); + let event_log = event_log.read().await; + tracing::info!( + folder_id = %folder_id, + root = ?event_log.tree().root().map(|c| c.to_string()), + "import_folder_patch"); + } + + self.cache.insert(folder_id, folder); + let summary = vault.summary().to_owned(); + self.add_summary(summary.clone()); + } + Ok(()) + } + + /// Initialize a folder from an event log. + /// + /// If an event log exists for the folder identifer + /// it is replaced with the new event records. + async fn initialize_folder( &mut self, folder_id: &VaultId, records: Vec, - key: &AccessKey, - ) -> Result { + ) -> Result<(DiscFolder, Vault)> { let vault_path = self.paths.vault_path(folder_id); // Prepare the vault file on disc @@ -403,7 +483,6 @@ impl ClientStorage { self.write_vault_file(folder_id, buffer).await?; let folder = DiscFolder::new(&vault_path).await?; - let event_log = folder.event_log(); let mut event_log = event_log.write().await; event_log.clear().await?; @@ -423,12 +502,25 @@ impl ClientStorage { // Setup the folder access to the latest vault information // and load the merkle tree - let mut folder = DiscFolder::new(&vault_path).await?; + let folder = DiscFolder::new(&vault_path).await?; let event_log = folder.event_log(); let mut event_log = event_log.write().await; event_log.load_tree().await?; - // Unlock the folder and create the in-memory reference + Ok((folder, vault)) + } + + /// Restore a folder from an event log. + pub async fn restore_folder( + &mut self, + folder_id: &VaultId, + records: Vec, + key: &AccessKey, + ) -> Result { + let (mut folder, vault) = + self.initialize_folder(folder_id, records).await?; + + // Unlock the folder folder.unlock(key).await?; self.cache.insert(*folder_id, folder); @@ -653,7 +745,7 @@ impl ClientStorage { buffer: impl AsRef<[u8]>, ) -> Result<()> { let vault_path = self.paths.vault_path(vault_id); - vfs::write(vault_path, buffer.as_ref()).await?; + vfs::write_exclusive(vault_path, buffer.as_ref()).await?; Ok(()) } @@ -664,7 +756,7 @@ impl ClientStorage { buffer: impl AsRef<[u8]>, ) -> Result<()> { let vault_path = self.paths.pending_vault_path(vault_id); - vfs::write(vault_path, buffer.as_ref()).await?; + vfs::write_exclusive(vault_path, buffer.as_ref()).await?; Ok(()) } @@ -1059,10 +1151,8 @@ impl ClientStorage { .await } - /// Load folders from the local disc. - /// - /// Creates the in-memory event logs for each folder on disc. - pub async fn load_folders(&mut self) -> Result<&[Summary]> { + /// Read folders from the local disc. + async fn read_folders(&self) -> Result> { let storage = self.paths.vaults_dir(); let mut summaries = Vec::new(); let mut contents = vfs::read_dir(&storage).await?; @@ -1078,7 +1168,13 @@ impl ClientStorage { } } } + Ok(summaries) + } + /// Read folders from the local disc and create the in-memory + /// event logs for each folder on disc. + pub async fn load_folders(&mut self) -> Result<&[Summary]> { + let summaries = self.read_folders().await?; self.load_caches(&summaries).await?; self.summaries = summaries; Ok(self.list_folders()) @@ -1134,6 +1230,20 @@ impl ClientStorage { Ok(events) } + /// Remove a folder from the cache. + pub async fn remove_folder( + &mut self, + folder_id: &VaultId, + ) -> Result { + let summary = self.find(|s| s.id() == folder_id).cloned(); + if let Some(summary) = summary { + self.remove_local_cache(&summary)?; + Ok(true) + } else { + Ok(false) + } + } + /// Update the in-memory name for a folder. pub fn set_folder_name( &mut self, @@ -1350,7 +1460,7 @@ impl ClientStorage { pub(crate) async fn create_secret( &mut self, secret_data: SecretRow, - mut options: AccessOptions, + #[allow(unused_mut, unused_variables)] mut options: AccessOptions, ) -> Result { let summary = self.current_folder().ok_or(Error::NoOpenVault)?; @@ -1402,6 +1512,19 @@ impl ClientStorage { Ok(result) } + /// Read the encrypted contents of a secret. + pub(crate) async fn raw_secret( + &self, + folder_id: &VaultId, + secret_id: &SecretId, + ) -> Result<(Option>, ReadEvent)> { + let folder = self + .cache + .get(folder_id) + .ok_or(Error::CacheNotAvailable(*folder_id))?; + folder.raw_secret(secret_id).await + } + /// Read a secret in the currently open folder. pub(crate) async fn read_secret( &self, @@ -1425,7 +1548,7 @@ impl ClientStorage { secret_id: &SecretId, meta: SecretMeta, secret: Option, - mut options: AccessOptions, + #[allow(unused_mut, unused_variables)] mut options: AccessOptions, ) -> Result { let (old_meta, old_secret, _) = self.read_secret(secret_id).await?; let old_secret_data = @@ -1478,7 +1601,7 @@ impl ClientStorage { &mut self, id: &SecretId, mut secret_data: SecretRow, - is_update: bool, + #[allow(unused_variables)] is_update: bool, ) -> Result { let summary = self.current_folder().ok_or(Error::NoOpenVault)?; @@ -1508,7 +1631,6 @@ impl ClientStorage { }; let event = { - let summary = self.current_folder().ok_or(Error::NoOpenVault)?; let folder = self .cache .get_mut(summary.id()) @@ -1533,10 +1655,13 @@ impl ClientStorage { pub(crate) async fn delete_secret( &mut self, secret_id: &SecretId, - mut options: AccessOptions, + #[allow(unused_mut, unused_variables)] mut options: AccessOptions, ) -> Result { - let (meta, secret, _) = self.read_secret(secret_id).await?; - let secret_data = SecretRow::new(*secret_id, meta, secret); + #[cfg(feature = "files")] + let secret_data = { + let (meta, secret, _) = self.read_secret(secret_id).await?; + SecretRow::new(*secret_id, meta, secret) + }; let event = self.remove_secret(secret_id).await?; @@ -1574,7 +1699,6 @@ impl ClientStorage { let summary = self.current_folder().ok_or(Error::NoOpenVault)?; let event = { - let summary = self.current_folder().ok_or(Error::NoOpenVault)?; let folder = self .cache .get_mut(summary.id()) diff --git a/crates/sdk/src/storage/files/external_files.rs b/crates/sdk/src/storage/files/external_files.rs index f5760af920..3f71a7956a 100644 --- a/crates/sdk/src/storage/files/external_files.rs +++ b/crates/sdk/src/storage/files/external_files.rs @@ -54,7 +54,7 @@ impl FileStorage { let dest = PathBuf::from(target.as_ref()).join(file_name); let size = encrypted.len() as u64; - vfs::write(dest, encrypted).await?; + vfs::write_exclusive(dest, encrypted).await?; Ok((digest.to_vec(), size)) } diff --git a/crates/sdk/src/storage/folder.rs b/crates/sdk/src/storage/folder.rs index c6f542ebe9..7fe824966e 100644 --- a/crates/sdk/src/storage/folder.rs +++ b/crates/sdk/src/storage/folder.rs @@ -12,12 +12,12 @@ use crate::{ prelude::VaultFlags, vault::{ secret::{Secret, SecretId, SecretMeta, SecretRow}, - Gatekeeper, Vault, VaultId, VaultMeta, VaultWriter, + Gatekeeper, Vault, VaultCommit, VaultId, VaultMeta, VaultWriter, }, vfs, Error, Paths, Result, }; -use std::{path::Path, sync::Arc}; +use std::{borrow::Cow, path::Path, sync::Arc}; use tokio::sync::RwLock; use futures::io::{AsyncRead, AsyncSeek, AsyncWrite}; @@ -34,7 +34,7 @@ pub struct Folder where T: EventLogExt + Send + Sync + 'static, R: AsyncRead + AsyncSeek + Unpin + Send + Sync + 'static, - W: AsyncWrite + Unpin + Send + Sync + 'static, + W: AsyncWrite + AsyncSeek + Unpin + Send + Sync + 'static, D: Clone + Send + Sync, { pub(crate) keeper: Gatekeeper, @@ -46,7 +46,7 @@ impl Folder where T: EventLogExt + Send + Sync + 'static, R: AsyncRead + AsyncSeek + Unpin + Send + Sync + 'static, - W: AsyncWrite + Unpin + Send + Sync + 'static, + W: AsyncWrite + AsyncSeek + Unpin + Send + Sync + 'static, D: Clone + Send + Sync, { /// Create a new folder. @@ -107,6 +107,14 @@ where self.keeper.read_secret(id).await } + /// Read the encrypted contents of a secret. + pub async fn raw_secret( + &self, + id: &SecretId, + ) -> Result<(Option>, ReadEvent)> { + self.keeper.raw_secret(id).await + } + /// Update a secret. pub async fn update_secret( &mut self, diff --git a/crates/sdk/src/storage/mod.rs b/crates/sdk/src/storage/mod.rs index ba54df14ab..c541db0efd 100644 --- a/crates/sdk/src/storage/mod.rs +++ b/crates/sdk/src/storage/mod.rs @@ -9,7 +9,7 @@ use crate::{ use async_trait::async_trait; use indexmap::IndexSet; use std::{path::Path, sync::Arc}; -use tokio::sync::{mpsc, RwLock}; +use tokio::sync::RwLock; mod client; #[cfg(feature = "files")] @@ -22,9 +22,6 @@ pub mod search; pub use client::ClientStorage; pub use folder::{DiscFolder, Folder, MemoryFolder}; -#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] -pub use paths::FileLock; - use crate::events::DeviceEventLog; #[cfg(feature = "files")] @@ -66,7 +63,7 @@ pub struct AccessOptions { pub folder: Option, /// Channel for file progress operations. #[cfg(feature = "files")] - pub file_progress: Option>, + pub file_progress: Option>, } impl From for AccessOptions { @@ -116,7 +113,8 @@ pub fn guess_mime(path: impl AsRef) -> Result { } /// References to the storage event logs. -#[async_trait] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] pub trait StorageEventLogs { /// Clone of the identity log. async fn identity_log(&self) -> Result>>; @@ -143,10 +141,9 @@ pub trait StorageEventLogs { Ok(reducer.reduce(None).await?) } - /// Folder identifiers managed by this storage. - async fn folder_identifiers(&self) -> Result>; - - /// Folder information managed by this storage. + /// Folders managed by this storage. + /// + /// Built from the in-memory list of folders. async fn folder_details(&self) -> Result>; /// Folder event log. diff --git a/crates/sdk/src/storage/paths.rs b/crates/sdk/src/storage/paths.rs index 93601ae23e..5745d41876 100644 --- a/crates/sdk/src/storage/paths.rs +++ b/crates/sdk/src/storage/paths.rs @@ -3,29 +3,25 @@ use crate::Result; #[cfg(feature = "audit")] use async_once_cell::OnceCell; + +#[cfg(not(target_arch = "wasm32"))] use etcetera::{ app_strategy::choose_native_strategy, AppStrategy, AppStrategyArgs, }; -#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] -use file_guard::{try_lock, FileGuard, Lock}; - use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; use std::{ - fs::{File, OpenOptions}, - future::Future, - io::ErrorKind, path::{Path, PathBuf}, - sync::{Arc, RwLock}, + sync::RwLock, }; use crate::{ constants::{ ACCOUNT_EVENTS, APP_AUTHOR, APP_NAME, AUDIT_FILE_NAME, DEVICE_EVENTS, DEVICE_FILE, EVENT_LOG_EXT, FILES_DIR, FILE_EVENTS, IDENTITY_DIR, - JSON_EXT, LOCAL_DIR, LOCK_FILE, LOGS_DIR, PENDING_DIR, REMOTES_FILE, - REMOTE_DIR, VAULTS_DIR, VAULT_EXT, + JSON_EXT, LOCAL_DIR, LOGS_DIR, PENDING_DIR, REMOTES_FILE, REMOTE_DIR, + VAULTS_DIR, VAULT_EXT, }, vault::{secret::SecretId, VaultId}, vfs, @@ -165,6 +161,24 @@ impl Paths { Ok(()) } + /// Try to determine if the account is ready to be used + /// by checking for the presence of required files on disc. + pub async fn is_usable(&self) -> Result { + if self.is_global() { + panic!("is_usable is not accessible for global paths"); + } + + let identity_vault = self.identity_vault(); + let identity_events = self.identity_events(); + let account_events = self.account_events(); + let device_events = self.device_events(); + + Ok(vfs::try_exists(identity_vault).await? + && vfs::try_exists(identity_events).await? + && vfs::try_exists(account_events).await? + && vfs::try_exists(device_events).await?) + } + /// User identifier. pub fn user_id(&self) -> &str { &self.user_id @@ -496,25 +510,14 @@ impl Paths { writer.append_audit_events(events).await?; Ok(()) } +} - /// Attempt to acquire an account lock. - #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] - pub(crate) async fn acquire_account_lock( - &self, - on_message: impl Fn() -> F, - ) -> Result - where - F: Future>, - { - if self.is_global() { - panic!("account lock is not accessible for global paths"); - } - let lock_path = self.user_dir.join(LOCK_FILE); - FileLock::acquire(&lock_path, on_message).await - } +#[cfg(target_os = "android")] +fn default_storage_dir() -> Result { + Ok(PathBuf::from("")) } -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))] fn default_storage_dir() -> Result { let strategy = choose_native_strategy(AppStrategyArgs { top_level_domain: "com".to_string(), @@ -539,60 +542,5 @@ fn default_storage_dir() -> Result { #[cfg(target_arch = "wasm32")] fn default_storage_dir() -> Result { - Ok(PathBuf::from("")) -} - -/// Exclusive file lock. -/// -/// Used to prevent multiple applications from accessing -/// the same account simultaneously which could lead to -/// data corruption. -#[doc(hidden)] -#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] -pub struct FileLock { - #[allow(dead_code)] - guard: FileGuard>, -} - -#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] -impl FileLock { - /// Try to acquire a file lock for a path. - pub async fn acquire( - path: impl AsRef, - on_message: impl Fn() -> F, - ) -> Result - where - F: Future>, - { - let file = Arc::new( - OpenOptions::new() - .read(true) - .write(true) - .create(true) - .open(path.as_ref())?, - ); - - let mut message_printed = false; - - loop { - match try_lock(file.clone(), Lock::Exclusive, 0, 1) { - Ok(guard) => { - return Ok(Self { guard }); - } - Err(e) => match e.kind() { - ErrorKind::WouldBlock => { - if !message_printed { - on_message().await?; - message_printed = true; - } - std::thread::sleep(std::time::Duration::from_millis( - 50, - )); - continue; - } - _ => return Err(e.into()), - }, - } - } - } + Ok(PathBuf::from("/")) } diff --git a/crates/sdk/src/storage/search.rs b/crates/sdk/src/storage/search.rs index 0b09adc040..7fda4c0007 100644 --- a/crates/sdk/src/storage/search.rs +++ b/crates/sdk/src/storage/search.rs @@ -16,6 +16,7 @@ use std::{ }; use tokio::sync::RwLock; use unicode_segmentation::UnicodeSegmentation; +use url::Url; /// Create a set of ngrams of the given size. fn ngram_slice(s: &str, n: usize) -> HashSet<&str> { @@ -91,6 +92,16 @@ fn comment_extract(d: &Document) -> Vec<&str> { } } +// Website +fn website_extract(d: &Document) -> Vec<&str> { + if let Some(websites) = d.extra().websites() { + websites + // vec![] + } else { + vec![] + } +} + /// Count of documents by vault identitier and secret kind. #[derive(Default, Debug, Clone)] pub struct DocumentCount { @@ -149,9 +160,9 @@ impl DocumentCount { /// Determine if a document vault identifier matches /// an archive vault. - fn is_archived(&self, vault_id: &VaultId) -> bool { + fn is_archived(&self, folder_id: &VaultId) -> bool { if let Some(archive) = &self.archive { - return vault_id == archive; + return folder_id == archive; } false } @@ -159,11 +170,11 @@ impl DocumentCount { /// Document was removed, update the count. fn remove( &mut self, - vault_id: VaultId, + folder_id: VaultId, mut options: Option<(u8, HashSet, bool)>, ) { self.vaults - .entry(vault_id) + .entry(folder_id) .and_modify(|counter| { if *counter > 0 { *counter -= 1; @@ -172,7 +183,7 @@ impl DocumentCount { .or_insert(0); if let Some((kind, tags, favorite)) = options.take() { - if !self.is_archived(&vault_id) { + if !self.is_archived(&folder_id) { self.kinds .entry(kind) .and_modify(|counter| { @@ -209,17 +220,17 @@ impl DocumentCount { /// Document was added, update the count. fn add( &mut self, - vault_id: VaultId, + folder_id: VaultId, kind: u8, tags: &HashSet, favorite: bool, ) { self.vaults - .entry(vault_id) + .entry(folder_id) .and_modify(|counter| *counter += 1) .or_insert(1); - if !self.is_archived(&vault_id) { + if !self.is_archived(&folder_id) { self.kinds .entry(kind) .and_modify(|counter| *counter += 1) @@ -267,18 +278,25 @@ impl IndexStatistics { /// Additional fields that can exposed via search results /// that are extracted from the secret data but safe to /// be exposed. -#[derive(Default, Debug, Serialize, Clone)] +#[typeshare::typeshare] +#[derive(Default, Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] pub struct ExtraFields { /// Comment about a secret. pub comment: Option, /// Contact type for contact secrets. pub contact_type: Option, + /// Collection of websites. + pub websites: Option>, } impl From<&Secret> for ExtraFields { fn from(value: &Secret) -> Self { let mut extra = ExtraFields { comment: value.user_data().comment().map(|c| c.to_owned()), + websites: value + .websites() + .map(|w| w.into_iter().map(|u| u.to_string()).collect()), ..Default::default() }; if let Secret::Contact { vcard, .. } = value { @@ -297,25 +315,34 @@ impl ExtraFields { pub fn comment(&self) -> Option<&str> { self.comment.as_ref().map(|c| &c[..]) } + + /// Optional websites. + pub fn websites(&self) -> Option> { + self.websites + .as_ref() + .map(|u| u.into_iter().map(|u| &u[..]).collect()) + } } /// Document that can be indexed. -#[derive(Debug, Serialize, Clone)] +#[typeshare::typeshare] +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] pub struct Document { - /// The vault identifier. - pub vault_id: VaultId, - /// The secret identifier. + /// Folder identifier. + pub folder_id: VaultId, + /// Secret identifier. pub secret_id: SecretId, - /// The secret meta data. + /// Secret meta data. pub meta: SecretMeta, - /// The extra fields for the document. + /// Extra fields for the document. pub extra: ExtraFields, } impl Document { /// Get the vault identifier. - pub fn vault_id(&self) -> &VaultId { - &self.vault_id + pub fn folder_id(&self) -> &VaultId { + &self.folder_id } /// Get the secret identifier. @@ -351,7 +378,7 @@ impl SearchIndex { /// Create a new search index. pub fn new() -> Self { // Create index with N fields - let index = Index::<(VaultId, SecretId)>::new(3); + let index = Index::<(VaultId, SecretId)>::new(4); Self { index, documents: Default::default(), @@ -399,7 +426,7 @@ impl SearchIndex { // FIXME: use _name suffix to be consistent with attachment handling pub fn find_by_label<'a>( &'a self, - vault_id: &VaultId, + folder_id: &VaultId, label: &str, id: Option<&SecretId>, ) -> Option<&'a Document> { @@ -412,7 +439,7 @@ impl SearchIndex { true } }) - .find(|d| d.vault_id() == vault_id && d.meta().label() == label) + .find(|d| d.folder_id() == folder_id && d.meta().label() == label) } /// Find document by label in any vault. @@ -469,12 +496,12 @@ impl SearchIndex { /// Find document by id. pub fn find_by_id<'a>( &'a self, - vault_id: &VaultId, + folder_id: &VaultId, id: &SecretId, ) -> Option<&'a Document> { self.documents .values() - .find(|d| d.vault_id() == vault_id && d.id() == id) + .find(|d| d.folder_id() == folder_id && d.id() == id) } /// Find secret meta by uuid or label. @@ -482,12 +509,14 @@ impl SearchIndex { // FIXME: use _name suffix to be consistent with attachment handling pub fn find_by_uuid_or_label<'a>( &'a self, - vault_id: &VaultId, + folder_id: &VaultId, target: &SecretRef, ) -> Option<&'a Document> { match target { - SecretRef::Id(id) => self.find_by_id(vault_id, id), - SecretRef::Name(name) => self.find_by_label(vault_id, name, None), + SecretRef::Id(id) => self.find_by_id(folder_id, id), + SecretRef::Name(name) => { + self.find_by_label(folder_id, name, None) + } } } @@ -497,15 +526,15 @@ impl SearchIndex { /// then no document is created. pub fn prepare( &self, - vault_id: &VaultId, + folder_id: &VaultId, id: &SecretId, meta: &SecretMeta, secret: &Secret, ) -> Option<(DocumentKey, Document)> { // Prevent duplicates - if self.find_by_id(vault_id, id).is_none() { + if self.find_by_id(folder_id, id).is_none() { let doc = Document { - vault_id: *vault_id, + folder_id: *folder_id, secret_id: *id, meta: meta.clone(), extra: secret.into(), @@ -515,7 +544,7 @@ impl SearchIndex { // secrets with the same label do not overwrite each other let key = DocumentKey( doc.meta().label().to_lowercase(), - *vault_id, + *folder_id, *id, ); @@ -533,14 +562,19 @@ impl SearchIndex { let doc = self.documents.entry(key).or_insert(doc); if !exists { self.index.add_document( - &[label_extract, tags_extract, comment_extract], + &[ + label_extract, + tags_extract, + comment_extract, + website_extract, + ], tokenizer, - (doc.vault_id, doc.secret_id), + (doc.folder_id, doc.secret_id), doc, ); self.statistics.count.add( - doc.vault_id, + doc.folder_id, doc.meta().kind().into(), doc.meta().tags(), doc.meta().favorite(), @@ -552,24 +586,24 @@ impl SearchIndex { /// Add a document to the index. pub fn add( &mut self, - vault_id: &VaultId, + folder_id: &VaultId, id: &SecretId, meta: &SecretMeta, secret: &Secret, ) { - self.commit(self.prepare(vault_id, id, meta, secret)); + self.commit(self.prepare(folder_id, id, meta, secret)); } /// Update a document in the index. pub fn update( &mut self, - vault_id: &VaultId, + folder_id: &VaultId, id: &SecretId, meta: &SecretMeta, secret: &Secret, ) { - self.remove(vault_id, id); - self.add(vault_id, id, meta, secret); + self.remove(folder_id, id); + self.add(folder_id, id, meta, secret); } /// Add the meta data from the entries in a folder @@ -596,11 +630,11 @@ impl SearchIndex { } /// Remove and vacuum a document from the index. - pub fn remove(&mut self, vault_id: &VaultId, id: &SecretId) { + pub fn remove(&mut self, folder_id: &VaultId, id: &SecretId) { let key = self .documents .keys() - .find(|key| &key.1 == vault_id && &key.2 == id) + .find(|key| &key.1 == folder_id && &key.2 == id) .cloned(); let doc_info = if let Some(key) = &key { let doc = self.documents.remove(key); @@ -612,19 +646,19 @@ impl SearchIndex { None }; - self.index.remove_document((*vault_id, *id)); + self.index.remove_document((*folder_id, *id)); // Vacuum to remove completely self.index.vacuum(); - self.statistics.count.remove(*vault_id, doc_info); + self.statistics.count.remove(*folder_id, doc_info); } /// Remove all the documents for a given vault identifier from the index. - pub fn remove_vault(&mut self, vault_id: &VaultId) { + pub fn remove_vault(&mut self, folder_id: &VaultId) { let keys: Vec = self .documents .keys() - .filter(|k| &k.1 == vault_id) + .filter(|k| &k.1 == folder_id) .cloned() .collect(); for key in keys { @@ -654,7 +688,7 @@ impl SearchIndex { needle, &mut bm25::new(), query_tokenizer, - &[1., 1., 1.], + &[1., 1., 1., 1.], ) } @@ -732,10 +766,10 @@ impl AccountSearch { } /// Remove a folder from the search index. - pub async fn remove_folder(&self, vault_id: &VaultId) { + pub async fn remove_folder(&self, folder_id: &VaultId) { // Clean entries from the search index let mut writer = self.search_index.write().await; - writer.remove_vault(vault_id); + writer.remove_vault(folder_id); } /// Add a vault to the search index. @@ -761,25 +795,25 @@ impl AccountSearch { /// Determine if a document exists in a folder. pub async fn document_exists( &self, - vault_id: &VaultId, + folder_id: &VaultId, label: &str, id: Option<&SecretId>, ) -> bool { let reader = self.search_index.read().await; - reader.find_by_label(vault_id, label, id).is_some() + reader.find_by_label(folder_id, label, id).is_some() } /// Query with document views. pub async fn query_view( &self, - views: Vec, - archive: Option, + views: &[DocumentView], + archive: Option<&ArchiveFilter>, ) -> Result> { let index_reader = self.search_index.read().await; let mut docs = Vec::with_capacity(index_reader.len()); for doc in index_reader.values_iter() { - for view in &views { - if view.test(doc, archive.as_ref()) { + for view in views { + if view.test(doc, archive) { docs.push(doc.clone()); } } @@ -824,9 +858,9 @@ impl AccountSearch { .is_empty() }; - let vault_id = doc.vault_id(); + let folder_id = doc.folder_id(); let folder_match = filter.folders.is_empty() - || filter.folders.contains(vault_id); + || filter.folders.contains(folder_id); let type_match = filter.types.is_empty() || filter.types.contains(doc.meta().kind()); @@ -837,11 +871,14 @@ impl AccountSearch { } /// View of documents in the search index. -#[derive(Debug)] +#[typeshare::typeshare] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind", content = "body")] pub enum DocumentView { /// View all documents in the search index. All { /// List of secret types to ignore. + #[serde(rename = "ignoredTypes")] ignored_types: Option>, }, /// View all the documents for a folder. @@ -862,10 +899,20 @@ pub enum DocumentView { /// Documents with the specific identifiers. Documents { /// Vault identifier. - vault_id: VaultId, + #[serde(rename = "folderId")] + folder_id: VaultId, /// Secret identifiers. identifiers: Vec, }, + /// Secrets with the associated websites. + Websites { + /// Secrets that match the given target URLs. + matches: Option>, + /// Exact match requires that the match targets and + /// websites are exactly equal. Otherwise, comparison + /// is performed using the URL origin. + exact: bool, + }, } impl Default for DocumentView { @@ -884,11 +931,10 @@ impl DocumentView { archive: Option<&ArchiveFilter>, ) -> bool { if let Some(filter) = archive { - if !filter.include_documents && doc.vault_id() == &filter.id { + if !filter.include_documents && doc.folder_id() == &filter.id { return false; } } - match self { DocumentView::All { ignored_types } => { if let Some(ignored_types) = ignored_types { @@ -896,7 +942,7 @@ impl DocumentView { } true } - DocumentView::Vault(vault_id) => doc.vault_id() == vault_id, + DocumentView::Vault(folder_id) => doc.folder_id() == folder_id, DocumentView::TypeId(type_id) => doc.meta().kind() == type_id, DocumentView::Favorites => doc.meta().favorite(), DocumentView::Tags(tags) => { @@ -923,15 +969,66 @@ impl DocumentView { false } DocumentView::Documents { - vault_id, + folder_id, identifiers, - } => doc.vault_id() == vault_id && identifiers.contains(doc.id()), + } => { + doc.folder_id() == folder_id && identifiers.contains(doc.id()) + } + DocumentView::Websites { matches, exact } => { + if let Some(sites) = doc.extra().websites() { + if sites.is_empty() { + false + } else { + if let Some(targets) = matches { + // Search index stores as string but + // we need to compare as URLs + let mut urls: Vec = + Vec::with_capacity(sites.len()); + for site in sites { + match site.parse() { + Ok(url) => urls.push(url), + Err(e) => { + tracing::warn!( + error = %e, + "search::url_parse"); + } + } + } + + if *exact { + for url in targets { + if urls.contains(url) { + return true; + } + } + false + } else { + for url in targets { + for site in &urls { + if url.origin() == site.origin() { + return true; + } + } + } + false + } + } else { + // No target matches but has some + // associated websites so include in the view + true + } + } + } else { + false + } + } } } } /// Filter for a search query. -#[derive(Default, Debug)] +#[typeshare::typeshare] +#[derive(Default, Debug, Clone, Serialize, Deserialize)] pub struct QueryFilter { /// List of tags. pub tags: Vec, @@ -942,7 +1039,9 @@ pub struct QueryFilter { } /// Filter for archived documents. -#[derive(Debug)] +#[typeshare::typeshare] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct ArchiveFilter { /// Identifier of the archive vault. pub id: VaultId, @@ -969,7 +1068,7 @@ mod test { #[test] fn search_index() { - let vault_id = Uuid::new_v4(); + let folder_id = Uuid::new_v4(); let mut idx = SearchIndex::new(); @@ -995,12 +1094,12 @@ mod test { user_data: Default::default(), }; - idx.add(&vault_id, &id1, &meta1, &secret1); + idx.add(&folder_id, &id1, &meta1, &secret1); assert_eq!(1, idx.documents().len()); - idx.add(&vault_id, &id2, &meta2, &secret2); + idx.add(&folder_id, &id2, &meta2, &secret2); assert_eq!(2, idx.documents().len()); - assert_eq!(2, *idx.statistics.count.vaults.get(&vault_id).unwrap()); + assert_eq!(2, *idx.statistics.count.vaults.get(&folder_id).unwrap()); assert_eq!( 2, *idx.statistics @@ -1016,9 +1115,9 @@ mod test { let docs = idx.query("secret"); assert_eq!(2, docs.len()); - idx.remove(&vault_id, &id1); + idx.remove(&folder_id, &id1); - assert_eq!(1, *idx.statistics.count.vaults.get(&vault_id).unwrap()); + assert_eq!(1, *idx.statistics.count.vaults.get(&folder_id).unwrap()); assert_eq!( 1, *idx.statistics @@ -1038,10 +1137,10 @@ mod test { assert_eq!(1, docs.len()); assert_eq!(&id2, docs.get(0).unwrap().id()); - idx.remove(&vault_id, &id2); + idx.remove(&folder_id, &id2); assert_eq!(0, idx.documents.len()); - assert_eq!(0, *idx.statistics.count.vaults.get(&vault_id).unwrap()); + assert_eq!(0, *idx.statistics.count.vaults.get(&folder_id).unwrap()); assert_eq!( 0, *idx.statistics @@ -1053,6 +1152,6 @@ mod test { // Duplicate removal when no more documents // to ensure it does not panic - idx.remove(&vault_id, &id2); + idx.remove(&folder_id, &id2); } } diff --git a/crates/sdk/src/vault/file_writer.rs b/crates/sdk/src/vault/file_writer.rs index 2ca354d743..810ac47639 100644 --- a/crates/sdk/src/vault/file_writer.rs +++ b/crates/sdk/src/vault/file_writer.rs @@ -33,7 +33,7 @@ use crate::{ secret::SecretId, Contents, Header, Summary, VaultAccess, VaultCommit, VaultEntry, VaultFlags, }, - vfs::{File, OpenOptions}, + vfs::{self, File, OpenOptions}, Result, }; @@ -96,13 +96,15 @@ impl VaultWriter { file.rewind().await?; file.set_len(0).await?; + let mut guard = vfs::lock_write(file).await?; + // Write out the header - file.write_all(&head).await?; + guard.write_all(&head).await?; // Write out the content - file.write_all(&content).await?; + guard.write_all(&content).await?; - file.flush().await?; + guard.flush().await?; Ok(()) } @@ -137,15 +139,17 @@ impl VaultWriter { // Must seek to the end before writing out the content or tail file.seek(SeekFrom::End(0)).await?; + let mut guard = vfs::lock_write(file).await?; + // Inject the content if necessary if let Some(content) = content { - file.write_all(content).await?; + guard.write_all(content).await?; } // Write out the end portion - file.write_all(&end).await?; + guard.write_all(&end).await?; - file.flush().await?; + guard.flush().await?; Ok(()) } @@ -248,18 +252,27 @@ impl VaultAccess secret: VaultEntry, ) -> Result { let _summary = self.summary().await?; - let mut stream = self.stream.lock().await; + let _stream = self.stream.lock().await; - let mut writer = BinaryWriter::new(&mut *stream, encoding_options()); + // Encode the row into a buffer + let mut buffer = Vec::new(); + let mut writer = + BinaryWriter::new(Cursor::new(&mut buffer), encoding_options()); let row = VaultCommit(commit, secret); - - // Seek to the end of the file and append the row - writer.seek(SeekFrom::End(0)).await?; - Contents::encode_row(&mut writer, &id, &row).await?; - writer.flush().await?; + // Append to the file + let file = OpenOptions::new() + .read(true) + .write(true) + .append(true) + .open(&self.file_path) + .await?; + let mut guard = vfs::lock_write(file).await?; + guard.write_all(&buffer).await?; + guard.flush().await?; + Ok(WriteEvent::CreateSecret(id, row)) } diff --git a/crates/sdk/src/vault/gatekeeper.rs b/crates/sdk/src/vault/gatekeeper.rs index 5c13077be0..d4665c7de6 100644 --- a/crates/sdk/src/vault/gatekeeper.rs +++ b/crates/sdk/src/vault/gatekeeper.rs @@ -10,6 +10,7 @@ use crate::{ }, vfs, Error, Result, }; +use std::{borrow::Cow, path::Path}; use super::VaultFlags; @@ -88,12 +89,37 @@ impl Gatekeeper { ) -> Result<()> { if let (true, Some(mirror)) = (write_disc, &self.mirror) { let buffer = encode(&vault).await?; - vfs::write(&mirror.file_path, &buffer).await?; + vfs::write_exclusive(&mirror.file_path, &buffer).await?; } self.vault = vault; Ok(()) } + /// Reload the vault from disc. + /// + /// Replaces the in-memory vault and updates the vault writer + /// mirror when mirroring to disc is enabled. + /// + /// Use this to update the in-memory representation when a vault + /// has been modified in a different process. + /// + /// Assumes the private key for the folder has not changed. + pub async fn reload_vault( + &mut self, + path: impl AsRef, + ) -> Result<()> { + let buffer = vfs::read(path.as_ref()).await?; + self.vault = decode(&buffer).await?; + + if self.mirror.is_some() { + let vault_file = VaultWriter::open(path.as_ref()).await?; + let mirror = VaultWriter::new(path.as_ref(), vault_file)?; + self.mirror = Some(mirror); + } + + Ok(()) + } + /// Set the vault. pub fn set_vault(&mut self, vault: Vault) { self.vault = vault; @@ -248,13 +274,20 @@ impl Gatekeeper { Ok(result) } + /// Read the encrypted contents of a secret. + pub async fn raw_secret( + &self, + id: &SecretId, + ) -> Result<(Option>, ReadEvent)> { + self.vault.read_secret(id).await + } + /// Get a secret and it's meta data. pub async fn read_secret( &self, id: &SecretId, ) -> Result> { - let event = ReadEvent::ReadSecret(*id); - if let (Some(value), _payload) = self.vault.read_secret(id).await? { + if let (Some(value), event) = self.raw_secret(id).await? { let (meta, secret) = self .decrypt_secret(value.as_ref(), self.private_key.as_ref()) .await?; diff --git a/crates/sdk/src/vault/secret.rs b/crates/sdk/src/vault/secret.rs index b86d435d12..fbaa6cfc55 100644 --- a/crates/sdk/src/vault/secret.rs +++ b/crates/sdk/src/vault/secret.rs @@ -5,7 +5,6 @@ use ed25519_dalek::SECRET_KEY_LENGTH; use pem::Pem; use secrecy::{ExposeSecret, SecretBox, SecretString}; use serde::{ - de::{self, Deserializer, Visitor}, ser::{SerializeMap, SerializeSeq}, Deserialize, Serialize, Serializer, }; @@ -35,9 +34,21 @@ use crate::{ use std::path::PathBuf; /// Path to a secret. -#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)] +#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] pub struct SecretPath(pub VaultId, pub SecretId); +impl SecretPath { + /// Folder identifier. + pub fn folder_id(&self) -> &VaultId { + &self.0 + } + + /// Secret identifier. + pub fn secret_id(&self) -> &SecretId { + &self.1 + } +} + bitflags! { /// Bit flags for a secret. #[derive(Default, Serialize, Deserialize, Debug, Clone)] @@ -164,6 +175,7 @@ impl FromStr for SecretRef { /// Matches the enum variants for a secret and is used /// so we can know the type of secret from the meta data /// before secret data has been decrypted. +#[typeshare::typeshare] #[derive( Default, Clone, Debug, Copy, Serialize, Deserialize, Eq, PartialEq, Hash, )] @@ -305,6 +317,7 @@ impl TryFrom for SecretType { } /// Encapsulates the meta data for a secret. +#[typeshare::typeshare] #[derive(Debug, Serialize, Deserialize, Default, Clone)] #[serde(rename_all = "camelCase")] pub struct SecretMeta { @@ -402,7 +415,7 @@ impl SecretMeta { } } - /// The label for the secret. + /// Label for the secret. pub fn label(&self) -> &str { &self.label } @@ -412,27 +425,37 @@ impl SecretMeta { self.label = label; } - /// The kind of the secret. + /// Kind of the secret. pub fn kind(&self) -> &SecretType { &self.kind } - /// The created date and time. + /// Created date and time. pub fn date_created(&self) -> &UtcDateTime { &self.date_created } + /// Set the created date and time. + pub fn set_date_created(&mut self, date_created: UtcDateTime) { + self.date_created = date_created; + } + /// Update the last updated timestamp to now. pub fn touch(&mut self) { self.last_updated = Default::default(); } - /// The last updated date and time. + /// Last updated date and time. pub fn last_updated(&self) -> &UtcDateTime { &self.last_updated } - /// Get the tags. + /// Set the updated date and time. + pub fn set_last_updated(&mut self, last_updated: UtcDateTime) { + self.last_updated = last_updated; + } + + /// Secret tags. pub fn tags(&self) -> &HashSet { &self.tags } @@ -577,6 +600,7 @@ impl PartialEq for SecretSigner { } /// Secret with it's associated meta data and identifier. +#[typeshare::typeshare] #[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct SecretRow { /// Identifier for the secret. @@ -638,9 +662,11 @@ impl From for Secret { } /// Collection of custom user data. +#[typeshare::typeshare] #[derive(Default, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] pub struct UserData { - /// Collection of custom user_data. + /// Collection of custom user fields. #[serde(skip_serializing_if = "Vec::is_empty")] pub(crate) fields: Vec, /// Comment for the secret. @@ -718,7 +744,9 @@ impl UserData { } /// Enumeration of types of identification. -#[derive(PartialEq, Eq, Clone)] +#[typeshare::typeshare] +#[derive(PartialEq, Eq, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub enum IdentityKind { /// Personal identification number (PIN). PersonalIdNumber, @@ -791,48 +819,6 @@ impl TryFrom for IdentityKind { } } -impl Serialize for IdentityKind { - fn serialize( - &self, - serializer: S, - ) -> std::result::Result - where - S: Serializer, - { - serializer.serialize_u8(self.into()) - } -} - -impl<'de> Deserialize<'de> for IdentityKind { - fn deserialize( - deserializer: D, - ) -> std::result::Result - where - D: Deserializer<'de>, - { - deserializer.deserialize_u8(IdentityKindVisitor) - } -} - -struct IdentityKindVisitor; - -impl<'de> Visitor<'de> for IdentityKindVisitor { - type Value = IdentityKind; - - fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter - .write_str("an integer between 0 and 255 for identification kind") - } - - fn visit_u8(self, value: u8) -> std::result::Result - where - E: de::Error, - { - let value: IdentityKind = value.try_into().unwrap(); - Ok(value) - } -} - /// Enumeration of AGE versions. #[derive(Default, Clone, Serialize, Deserialize, Eq, PartialEq)] pub enum AgeVersion { @@ -843,6 +829,7 @@ pub enum AgeVersion { /// Variants for embedded and external file secrets. #[derive(Serialize, Deserialize)] +#[serde(untagged)] pub enum FileContent { /// Embedded file buffer. Embedded { @@ -1046,7 +1033,7 @@ impl Clone for FileContent { /// * `Url` -> `Secret::Link` /// #[derive(Serialize, Deserialize)] -#[serde(untagged, rename_all = "lowercase")] +#[serde(untagged)] pub enum Secret { /// A UTF-8 encoded note. #[serde(rename_all = "camelCase")] @@ -1510,6 +1497,154 @@ impl Secret { } } + /// Try to redact a secret. + /// + /// When `preserve_length` is set fields that are redacted preserve the + /// original byte length so UIs that show the field will leak the + /// secret length. Otherwise the value of `default_length` will be used. + /// + /// Not all secret types may be redacted; unsupported types are: + /// + /// * File + /// * Contact + /// * Totp + /// * Link + /// * Age + /// * Signer + /// + /// Attempting to redact a secret for these types will return `false`; + /// this may change in the future. + /// + pub fn redact( + &mut self, + preserve_length: bool, + default_length: usize, + ) -> bool { + let redact_string = |s: &mut SecretString| { + let value = s.expose_secret(); + let len = if preserve_length { + value.len() + } else { + default_length + }; + let redacted = "0".repeat(len); + *s = SecretString::new(redacted.into()); + }; + + let redact_buffer = |s: &mut SecretBox>| { + let value = s.expose_secret(); + let len = if preserve_length { + value.len() + } else { + default_length + }; + let redacted = "0".repeat(len); + *s = SecretBox::new(redacted.as_bytes().to_vec().into()); + }; + + match self { + Secret::Account { password, .. } => { + redact_string(password); + true + } + Secret::Note { text, .. } => { + redact_string(text); + true + } + Secret::File { .. } => false, + Secret::List { items, .. } => { + for (_, value) in items { + redact_string(value); + } + true + } + Secret::Pem { certificates, .. } => { + for cert in certificates { + let tag = cert.tag().to_owned(); + let mut pem = + SecretBox::new(cert.contents().to_vec().into()); + redact_buffer(&mut pem); + *cert = Pem::new(tag, pem.expose_secret().to_vec()); + } + true + } + Secret::Page { document, .. } => { + redact_string(document); + true + } + Secret::Contact { .. } => false, + Secret::Totp { .. } => false, + Secret::Card { number, .. } => { + redact_string(number); + true + } + Secret::Bank { number, .. } => { + redact_string(number); + true + } + Secret::Link { .. } => false, + Secret::Password { password, .. } => { + redact_string(password); + true + } + Secret::Identity { number, .. } => { + redact_string(number); + true + } + Secret::Age { .. } => false, + Secret::Signer { .. } => false, + } + } + + /// Value formatted to copy to the clipboard. + pub fn copy_value_unsafe(&self) -> Option { + match self { + Secret::Account { password, .. } => { + Some(password.expose_secret().to_owned()) + } + Secret::Note { text, .. } => { + Some(text.expose_secret().to_owned()) + } + Secret::File { content, .. } => Some(content.name().to_string()), + Secret::List { items, .. } => { + let mut s = String::new(); + for (name, value) in items { + s.push_str(name); + s.push('='); + s.push_str(value.expose_secret()); + s.push('\n'); + } + Some(s) + } + Secret::Pem { certificates, .. } => { + let text: Vec = + certificates.iter().map(|s| s.to_string()).collect::<_>(); + Some(text.join("\n")) + } + Secret::Page { document, .. } => { + Some(document.expose_secret().to_owned()) + } + Secret::Contact { vcard, .. } => Some(vcard.to_string()), + Secret::Totp { totp, .. } => Some(totp.get_url()), + Secret::Card { number, .. } => { + Some(number.expose_secret().to_string()) + } + // TODO: concatenate fields + Secret::Bank { number, .. } => { + Some(number.expose_secret().to_string()) + } + Secret::Link { url, .. } => Some(url.expose_secret().to_string()), + Secret::Password { password, .. } => { + Some(password.expose_secret().to_string()) + } + Secret::Identity { number, .. } => { + Some(number.expose_secret().to_string()) + } + Secret::Age { .. } => None, + Secret::Signer { .. } => None, + } + } + /// Plain text unencrypted display for secrets /// that can be represented as UTF-8 text. /// @@ -1576,6 +1711,18 @@ impl Secret { Ok(credentials) } + /// Collection of website URLs associated with + /// this secret. + /// + /// Used by the search index to locate secrets by + /// associated URL. + pub fn websites(&self) -> Option> { + match self { + Self::Account { url, .. } => Some(url.iter().collect()), + _ => None, + } + } + /// Encode a map into key value pairs. pub fn encode_list(list: &HashMap) -> String { let mut output = String::new(); diff --git a/crates/sdk/src/vault/vault.rs b/crates/sdk/src/vault/vault.rs index 5b2d5e2ce6..20c0cc689f 100644 --- a/crates/sdk/src/vault/vault.rs +++ b/crates/sdk/src/vault/vault.rs @@ -16,12 +16,13 @@ use std::{ borrow::Cow, cmp::Ordering, collections::HashMap, fmt, path::Path, str::FromStr, }; +use typeshare::typeshare; use urn::Urn; use uuid::Uuid; use crate::{ commit::CommitHash, - constants::{DEFAULT_VAULT_NAME, VAULT_IDENTITY, VAULT_NSS}, + constants::{DEFAULT_VAULT_NAME, URN_NID, VAULT_IDENTITY, VAULT_NSS}, crypto::{ AccessKey, AeadPack, Cipher, Deriver, KeyDerivation, PrivateKey, Seed, }, @@ -288,6 +289,7 @@ pub struct Auth { /// Summary holding basic file information such as version, /// unique identifier and name. +#[typeshare] #[derive(Serialize, Deserialize, Debug, Hash, Eq, PartialEq, Clone)] pub struct Summary { /// Encoding version. @@ -636,7 +638,8 @@ impl Vault { /// Get the URN for a vault identifier. pub fn vault_urn(id: &VaultId) -> Result { - let vault_urn = format!("urn:sos:{}{}", VAULT_NSS, id); + // FIXME: use UrnBuilder + let vault_urn = format!("urn:{}:{}{}", URN_NID, VAULT_NSS, id); Ok(vault_urn.parse()?) } diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 45b69f1624..725357ab26 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sos-server" -version = "0.15.1" +version = "0.16.1" edition = "2021" description = "Server for the Save Our Secrets sync protocol." homepage = "https://saveoursecrets.com" @@ -13,6 +13,7 @@ rustdoc-args = ["--cfg", "docsrs"] [features] default = ["listen", "audit", "pairing", "acme"] +prometheus = ["axum-prometheus"] acme = ["dep:tokio-rustls-acme"] listen = ["sos-protocol/listen"] pairing = ["sos-protocol/pairing"] @@ -44,17 +45,18 @@ rustls.workspace = true axum = { version = "0.7", features = ["ws", "original-uri"] } axum-extra = {version = "0.9", features = ["typed-header"] } -axum-macros = { version = "0.4" } +# axum-macros = { version = "0.4" } +axum-prometheus = { version = "0.7", optional = true } tower-http = { version = "0.6", features = ["cors", "trace"] } tokio-stream = { version = "0.1" } utoipa = { version = "5", features = ["uuid"] } utoipa-rapidoc = { version = "5", features = ["axum"] } tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync", "macros"] } -tokio-rustls-acme = { version = "0.4", features = ["axum"], optional = true } +tokio-rustls-acme = { version = "0.6", features = ["axum"], optional = true } [dependencies.sos-protocol] -version = "0.15.1" +version = "0.16" path = "../protocol" features = ["files"] diff --git a/crates/server/src/api_docs.rs b/crates/server/src/api_docs.rs index 83308dacd2..711bdc7c9d 100644 --- a/crates/server/src/api_docs.rs +++ b/crates/server/src/api_docs.rs @@ -1,19 +1,5 @@ use crate::handlers::{account, files}; -use utoipa::{openapi::security::*, Modify, OpenApi, ToSchema}; - -/* -#[derive(ToSchema)] -#[allow(dead_code)] -struct CreateSet(sos_protocol::CreateSet); - -#[derive(ToSchema)] -#[allow(dead_code)] -struct SyncStatus(sos_protocol::SyncStatus); - -#[derive(ToSchema)] -#[allow(dead_code)] -struct SyncPacket(sos_protocol::SyncPacket); -*/ +use utoipa::{openapi::security::*, Modify, OpenApi}; #[derive(OpenApi)] #[openapi( @@ -39,7 +25,7 @@ struct SyncPacket(sos_protocol::SyncPacket); account::sync_status, account::sync_account, account::update_account, - account::event_proofs, + account::event_scan, account::event_diff, account::event_patch, account::delete_account, @@ -49,11 +35,7 @@ struct SyncPacket(sos_protocol::SyncPacket); files::delete_file, ), components( - schemas( - // CreateSet, - // SyncStatus, - // SyncPacket, - ), + schemas(), ), )] pub struct ApiDoc; diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs index 14e0e02030..ae9273411b 100644 --- a/crates/server/src/config.rs +++ b/crates/server/src/config.rs @@ -199,6 +199,14 @@ impl ServerConfig { let dir = config.directory(); + if config.log.directory.is_relative() { + config.log.directory = dir.join(&config.log.directory); + if !config.log.directory.exists() { + vfs::create_dir_all(&config.log.directory).await?; + } + config.log.directory = config.log.directory.canonicalize()?; + } + if let Some(SslConfig::Tls(tls)) = &mut config.net.ssl { if tls.cert.is_relative() { tls.cert = dir.join(&tls.cert); diff --git a/crates/server/src/handlers/account.rs b/crates/server/src/handlers/account.rs index dce35be2b3..6f14ff4b5c 100644 --- a/crates/server/src/handlers/account.rs +++ b/crates/server/src/handlers/account.rs @@ -374,7 +374,7 @@ pub(crate) async fn sync_status( ), ), )] -pub(crate) async fn event_proofs( +pub(crate) async fn event_scan( Extension(state): Extension, Extension(backend): Extension, TypedHeader(bearer): TypedHeader>, @@ -393,7 +393,7 @@ pub(crate) async fn event_proofs( .await { Ok(caller) => { - match handlers::event_proofs(state, backend, caller, bytes) + match handlers::event_scan(state, backend, caller, bytes) .await { Ok(result) => result.into_response(), @@ -591,37 +591,20 @@ pub(crate) async fn sync_account( mod handlers { use super::Caller; - use crate::{ - backend::AccountStorage, Error, Result, ServerBackend, ServerState, - }; + use crate::{Error, Result, ServerBackend, ServerState}; use axum::body::Bytes; - use binary_stream::futures::{Decodable, Encodable}; use http::{ header::{self, HeaderMap, HeaderValue}, StatusCode, }; use sos_protocol::{ - sdk::{ - constants::MIME_TYPE_PROTOBUF, - events::{ - AccountDiff, AccountEvent, CheckedPatch, DiscEventLog, - EventLogExt, EventRecord, FolderDiff, Patch, WriteEvent, - }, - storage::StorageEventLogs, - }, - CreateSet, DiffRequest, DiffResponse, EventLogType, Merge, - MergeOutcome, PatchRequest, PatchResponse, ScanRequest, ScanResponse, - SyncPacket, SyncStorage, UpdateSet, WireEncodeDecode, + constants::MIME_TYPE_PROTOBUF, server_helpers, CreateSet, + DiffRequest, PatchRequest, ScanRequest, SyncPacket, SyncStorage, + UpdateSet, WireEncodeDecode, }; - use tokio::sync::RwLock; - use std::sync::Arc; - use sos_protocol::sdk::events::{FileDiff, FileEvent}; - - use sos_protocol::sdk::events::{DeviceDiff, DeviceEvent}; - #[cfg(feature = "listen")] use sos_protocol::ChangeNotification; @@ -719,7 +702,7 @@ mod handlers { Ok((headers, status.encode().await?)) } - pub(super) async fn event_proofs( + pub(super) async fn event_scan( _state: ServerState, backend: ServerBackend, caller: Caller, @@ -742,38 +725,9 @@ mod handlers { return Err(Error::BadRequest); } - let response = match &req.log_type { - EventLogType::Identity => { - let reader = account.read().await; - let log = reader.storage.identity_log(); - let event_log = log.read().await; - scan_log(&req, &*event_log).await? - } - EventLogType::Account => { - let reader = account.read().await; - let log = reader.storage.account_log(); - let event_log = log.read().await; - scan_log(&req, &*event_log).await? - } - EventLogType::Device => { - let reader = account.read().await; - let log = reader.storage.device_log().await?; - let event_log = log.read().await; - scan_log(&req, &*event_log).await? - } - EventLogType::Files => { - let reader = account.read().await; - let log = reader.storage.file_log().await?; - let event_log = log.read().await; - scan_log(&req, &*event_log).await? - } - EventLogType::Folder(id) => { - let reader = account.read().await; - let log = reader.storage.folder_log(&id).await?; - let event_log = log.read().await; - scan_log(&req, &*event_log).await? - } - }; + let reader = account.read().await; + let response = + server_helpers::event_scan(&req, &reader.storage).await?; let mut headers = HeaderMap::new(); headers.insert( @@ -784,68 +738,6 @@ mod handlers { Ok((headers, response.encode().await?)) } - async fn scan_log( - req: &ScanRequest, - event_log: &DiscEventLog, - ) -> Result - where - T: Default + Encodable + Decodable + Send + Sync + 'static, - { - let mut res = ScanResponse { - first_proof: None, - proofs: vec![], - offset: 0, - }; - let offset = req.offset; - let num_commits = event_log.tree().len() as u64; - - let mut index = if event_log.tree().len() > 0 { - event_log.tree().len() - 1 - } else { - 0 - }; - - if event_log.tree().len() > 0 { - res.first_proof = Some(event_log.tree().proof(&[0])?); - } - - // Short circuit if the offset is clearly out of bounds - if offset >= num_commits { - res.offset = num_commits; - return Ok(res); - } - - let mut it = event_log.iter(true).await?; - let mut skip = 0; - - loop { - let event = it.next().await?; - if offset > 0 && skip < offset { - if index > 0 { - index -= 1; - } - skip += 1; - continue; - } - if let Some(_event) = event { - let proof = event_log.tree().proof(&[index])?; - res.proofs.insert(0, proof); - res.offset = offset + res.proofs.len() as u64; - - if index > 0 { - index -= 1; - } - - if res.proofs.len() == req.limit as usize { - break; - } - } else { - break; - } - } - Ok(res) - } - pub(super) async fn event_diff( _state: ServerState, backend: ServerBackend, @@ -864,38 +756,9 @@ mod handlers { let req = DiffRequest::decode(bytes).await?; - let response = match &req.log_type { - EventLogType::Identity => { - let reader = account.read().await; - let log = reader.storage.identity_log(); - let event_log = log.read().await; - diff_log(&req, &*event_log).await? - } - EventLogType::Account => { - let reader = account.read().await; - let log = reader.storage.account_log(); - let event_log = log.read().await; - diff_log(&req, &*event_log).await? - } - EventLogType::Device => { - let reader = account.read().await; - let log = reader.storage.device_log().await?; - let event_log = log.read().await; - diff_log(&req, &*event_log).await? - } - EventLogType::Files => { - let reader = account.read().await; - let log = reader.storage.file_log().await?; - let event_log = log.read().await; - diff_log(&req, &*event_log).await? - } - EventLogType::Folder(id) => { - let reader = account.read().await; - let log = reader.storage.folder_log(id).await?; - let event_log = log.read().await; - diff_log(&req, &*event_log).await? - } - }; + let reader = account.read().await; + let response = + server_helpers::event_diff(&req, &reader.storage).await?; let mut headers = HeaderMap::new(); headers.insert( @@ -903,21 +766,7 @@ mod handlers { HeaderValue::from_static(MIME_TYPE_PROTOBUF), ); - Ok((headers, response)) - } - - async fn diff_log( - req: &DiffRequest, - event_log: &DiscEventLog, - ) -> Result> - where - T: Default + Encodable + Decodable + Send + Sync + 'static, - { - let response = DiffResponse { - patch: event_log.diff_records(req.from_hash.as_ref()).await?, - checkpoint: event_log.tree().head()?, - }; - Ok(response.encode().await?) + Ok((headers, response.encode().await?)) } pub(super) async fn event_patch( @@ -938,141 +787,9 @@ mod handlers { let req = PatchRequest::decode(bytes).await?; - let (checked_patch, outcome, records) = match &req.log_type { - EventLogType::Identity => { - let patch = Patch::::new(req.patch); - let mut writer = account.write().await; - let (last_commit, records) = if let Some(commit) = &req.commit - { - let log = writer.storage.identity_log(); - let mut event_log = log.write().await; - let records = event_log.rewind(commit).await?; - (Some(*commit), records) - } else { - (None, vec![]) - }; - - let diff = FolderDiff { - last_commit, - checkpoint: req.proof, - patch, - }; - - let mut outcome = MergeOutcome::default(); - ( - writer.storage.merge_identity(diff, &mut outcome).await?, - outcome, - records, - ) - } - EventLogType::Account => { - let patch = Patch::::new(req.patch); - let mut writer = account.write().await; - let (last_commit, records) = if let Some(commit) = &req.commit - { - let log = writer.storage.account_log(); - let mut event_log = log.write().await; - let records = event_log.rewind(commit).await?; - (Some(*commit), records) - } else { - (None, vec![]) - }; - - let diff = AccountDiff { - last_commit, - checkpoint: req.proof, - patch, - }; - - let mut outcome = MergeOutcome::default(); - ( - writer.storage.merge_account(diff, &mut outcome).await?.0, - outcome, - records, - ) - } - EventLogType::Device => { - let patch = Patch::::new(req.patch); - let mut writer = account.write().await; - let (last_commit, records) = if let Some(commit) = &req.commit - { - let log = writer.storage.device_log().await?; - let mut event_log = log.write().await; - let records = event_log.rewind(commit).await?; - (Some(*commit), records) - } else { - (None, vec![]) - }; - - let diff = DeviceDiff { - last_commit, - checkpoint: req.proof, - patch, - }; - - let mut outcome = MergeOutcome::default(); - ( - writer.storage.merge_device(diff, &mut outcome).await?, - outcome, - records, - ) - } - EventLogType::Files => { - let patch = Patch::::new(req.patch); - let mut writer = account.write().await; - let (last_commit, records) = if let Some(commit) = &req.commit - { - let log = writer.storage.file_log().await?; - let mut event_log = log.write().await; - let records = event_log.rewind(commit).await?; - (Some(*commit), records) - } else { - (None, vec![]) - }; - - let diff = FileDiff { - last_commit, - checkpoint: req.proof, - patch, - }; - - let mut outcome = MergeOutcome::default(); - ( - writer.storage.merge_files(diff, &mut outcome).await?, - outcome, - records, - ) - } - EventLogType::Folder(id) => { - let patch = Patch::::new(req.patch); - let mut writer = account.write().await; - let (last_commit, records) = if let Some(commit) = &req.commit - { - let log = writer.storage.folder_log(&id).await?; - let mut event_log = log.write().await; - let records = event_log.rewind(commit).await?; - (Some(*commit), records) - } else { - (None, vec![]) - }; - - let diff = FolderDiff { - last_commit, - checkpoint: req.proof, - patch, - }; - - let mut outcome = MergeOutcome::default(); - ( - writer - .storage - .merge_folder(&id, diff, &mut outcome) - .await? - .0, - outcome, - records, - ) - } + let (response, outcome) = { + let mut writer = account.write().await; + server_helpers::event_patch(req, &mut writer.storage).await? }; #[cfg(feature = "listen")] @@ -1091,62 +808,15 @@ mod handlers { } } - // Rollback the rewind if the merge failed - if let CheckedPatch::Conflict { head, .. } = &checked_patch { - tracing::warn!( - head = ?head, - num_records = ?records.len(), - "events_patch::rollback_rewind"); - rollback_rewind(&req.log_type, account, records).await?; - } - let mut headers = HeaderMap::new(); headers.insert( header::CONTENT_TYPE, HeaderValue::from_static(MIME_TYPE_PROTOBUF), ); - let response = PatchResponse { checked_patch }; Ok((headers, response.encode().await?)) } - async fn rollback_rewind( - log_type: &EventLogType, - account: Arc>, - records: Vec, - ) -> Result<()> { - let reader = account.read().await; - match log_type { - EventLogType::Identity => { - let log = reader.storage.identity_log(); - let mut event_log = log.write().await; - event_log.apply_records(records).await?; - } - EventLogType::Account => { - let log = reader.storage.account_log(); - let mut event_log = log.write().await; - event_log.apply_records(records).await?; - } - EventLogType::Device => { - let log = reader.storage.device_log().await?; - let mut event_log = log.write().await; - event_log.apply_records(records).await?; - } - EventLogType::Files => { - let log = reader.storage.file_log().await?; - let mut event_log = log.write().await; - event_log.apply_records(records).await?; - } - EventLogType::Folder(id) => { - let log = reader.storage.folder_log(id).await?; - let mut event_log = log.write().await; - event_log.apply_records(records).await?; - } - } - - Ok(()) - } - pub(super) async fn sync_account( state: ServerState, backend: ServerBackend, @@ -1164,29 +834,10 @@ mod handlers { }; let packet = SyncPacket::decode(bytes).await?; - let (remote_status, mut diff) = (packet.status, packet.diff); - // Apply the diff to the storage - let mut outcome = MergeOutcome::default(); - let compare = { - tracing::debug!("merge_server"); + let (packet, outcome) = { let mut writer = account.write().await; - - // Only try to merge folders that exist in storage - // otherwise after folder deletion sync will fail - let folders = writer.storage.folder_identifiers().await?; - diff.folders.retain(|k, _| folders.contains(k)); - - writer.storage.merge(diff, &mut outcome).await? - }; - - // Generate a new diff so the client can apply changes - // that exist in remote but not in the local - let (local_status, diff) = { - let reader = account.read().await; - let (_, local_status, diff) = - sos_protocol::diff(&reader.storage, remote_status).await?; - (local_status, diff) + server_helpers::sync_account(packet, &mut writer.storage).await? }; #[cfg(feature = "listen")] @@ -1195,7 +846,7 @@ mod handlers { let notification = ChangeNotification::new( caller.address(), conn_id.to_string(), - local_status.root, + packet.status.root, outcome, ); let reader = state.read().await; @@ -1203,12 +854,6 @@ mod handlers { } } - let packet = SyncPacket { - status: local_status, - diff, - compare: Some(compare), - }; - let mut headers = HeaderMap::new(); headers.insert( header::CONTENT_TYPE, diff --git a/crates/server/src/handlers/files.rs b/crates/server/src/handlers/files.rs index 01f0f6c8c1..86c4e7521d 100644 --- a/crates/server/src/handlers/files.rs +++ b/crates/server/src/handlers/files.rs @@ -362,13 +362,14 @@ pub(crate) async fn compare_files( mod handlers { use super::MoveFileQuery; use sos_protocol::{ + constants::MIME_TYPE_PROTOBUF, sdk::{ - constants::MIME_TYPE_PROTOBUF, sha2::{Digest, Sha256}, storage::files::{list_external_files, ExternalFileName}, vault::{secret::SecretId, VaultId}, }, - FileSet, FileTransfersSet, WireEncodeDecode, + transfer::{FileSet, FileTransfersSet}, + WireEncodeDecode, }; use crate::{ diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 11b8b478e3..bfaa6c34a7 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -135,8 +135,8 @@ mod cli { use axum_server::Handle; use sos_protocol::sdk::vfs; use sos_server::{ - AcmeConfig, Error, Result, Server, ServerConfig, SslConfig, - State, StorageConfig, + Error, Result, Server, ServerConfig, SslConfig, State, + StorageConfig, }; use std::{net::SocketAddr, path::PathBuf, sync::Arc}; use tokio::sync::RwLock; @@ -163,13 +163,15 @@ mod cli { config.set_bind_address(addr); } + #[cfg(feature = "acme")] if let (Some(cache), false) = (cache, domains.is_empty()) { - config.net.ssl = Some(SslConfig::Acme(AcmeConfig { - cache, - domains, - email, - production, - })) + config.net.ssl = + Some(SslConfig::Acme(sos_server::AcmeConfig { + cache, + domains, + email, + production, + })) } let content = toml::to_string_pretty(&config)?; @@ -182,7 +184,7 @@ mod cli { let backend = config.backend().await?; let state = Arc::new(RwLock::new(State::new(config))); let handle = Handle::new(); - let server = Server::new(backend.directory()).await?; + let server = Server::new().await?; server .start(state, Arc::new(RwLock::new(backend)), handle) .await?; diff --git a/crates/server/src/server.rs b/crates/server/src/server.rs index 561563d514..148fed9f5d 100644 --- a/crates/server/src/server.rs +++ b/crates/server/src/server.rs @@ -17,13 +17,10 @@ use axum::{ use axum_server::{tls_rustls::RustlsConfig, Handle}; use colored::Colorize; use futures::StreamExt; -use sos_protocol::sdk::{ - signer::ecdsa::Address, storage::FileLock, UtcDateTime, -}; +use sos_protocol::sdk::{signer::ecdsa::Address, UtcDateTime}; use std::{ collections::{HashMap, HashSet}, net::SocketAddr, - path::Path, sync::Arc, }; use tokio::sync::{Mutex, RwLock, RwLockReadGuard}; @@ -75,10 +72,7 @@ pub type TransferOperations = HashSet; pub type ServerTransfer = Arc>; /// Web server implementation. -pub struct Server { - #[allow(dead_code)] - guard: FileLock, -} +pub struct Server {} impl Server { /// Create a new server. @@ -87,17 +81,8 @@ impl Server { /// will store account files; if a server is already /// running and has a lock on the directory this will /// block until the lock is released. - pub async fn new(path: impl AsRef) -> Result { - let lock_path = path.as_ref().join("server.lock"); - let guard = FileLock::acquire(lock_path, || async { - println!( - "Blocking waiting for lock on {} ...", - path.as_ref().display() - ); - Ok(()) - }) - .await?; - Ok(Self { guard }) + pub async fn new() -> Result { + Ok(Self {}) } /// Start the server. @@ -298,7 +283,7 @@ impl Server { .route("/sync/account/status", get(account::sync_status)) .route( "/sync/account/events", - get(account::event_proofs) + get(account::event_scan) .post(account::event_diff) .patch(account::event_patch), ); @@ -355,10 +340,27 @@ impl Server { v1 = v1.layer(Extension(file_operations)); } - let app = Router::new() + #[allow(unused_mut)] + let mut app = Router::new() .route("/", get(home)) .nest_service("/api/v1", v1); + #[cfg(feature = "prometheus")] + { + let (prometheus_layer, metric_handle) = + axum_prometheus::PrometheusMetricLayerBuilder::new() + .with_default_metrics() + .enable_response_body_size(true) + .build_pair(); + + app = app + .route( + "/metrics", + get(|| async move { metric_handle.render() }), + ) + .layer(prometheus_layer); + } + Ok(app) } } diff --git a/crates/server/src/storage/filesystem/mod.rs b/crates/server/src/storage/filesystem/mod.rs index be06ee787f..116b8da443 100644 --- a/crates/server/src/storage/filesystem/mod.rs +++ b/crates/server/src/storage/filesystem/mod.rs @@ -114,16 +114,6 @@ impl ServerStorage { &self.address } - /// Access to the identity log. - pub fn identity_log(&self) -> Arc> { - Arc::clone(&self.identity_log) - } - - /// Access to the account log. - pub fn account_log(&self) -> Arc> { - Arc::clone(&self.account_log) - } - async fn initialize_device_log( paths: &Paths, ) -> Result<(DeviceEventLog, IndexSet)> { @@ -166,13 +156,6 @@ impl ServerStorage { &mut self.cache } - /* - /// Set of folders identifiers. - pub fn folder_identifiers(&self) -> HashSet { - self.cache.keys().copied().collect::>() - } - */ - /// Get the computed storage directories for the provider. pub fn paths(&self) -> Arc { Arc::clone(&self.paths) @@ -349,9 +332,7 @@ impl ServerStorage { Ok(()) } -} -impl ServerStorage { /// List the public keys of trusted devices. pub fn list_device_keys(&self) -> HashSet<&DevicePublicKey> { self.devices.iter().map(|d| d.public_key()).collect() diff --git a/crates/server/src/storage/filesystem/sync.rs b/crates/server/src/storage/filesystem/sync.rs index bbfe76dbc4..29c6308a00 100644 --- a/crates/server/src/storage/filesystem/sync.rs +++ b/crates/server/src/storage/filesystem/sync.rs @@ -194,8 +194,7 @@ impl ForceMerge for ServerStorage { "force_merge::account", ); - let event_log = self.account_log(); - let mut event_log = event_log.write().await; + let mut event_log = self.account_log.write().await; event_log.patch_replace(&diff).await?; outcome.changes += len; @@ -579,13 +578,9 @@ impl StorageEventLogs for ServerStorage { Ok(Arc::clone(&self.file_log)) } - async fn folder_identifiers(&self) -> Result> { - Ok(self.cache.keys().copied().collect()) - } - async fn folder_details(&self) -> Result> { + let ids = self.cache.keys().copied().collect::>(); let mut output = IndexSet::new(); - let ids = self.folder_identifiers().await?; for id in &ids { let path = self.paths.vault_path(id); let summary = Header::read_summary_file(path).await?; diff --git a/crates/sos/Cargo.toml b/crates/sos/Cargo.toml index 1ef8c5fddd..01a2a0a4e3 100644 --- a/crates/sos/Cargo.toml +++ b/crates/sos/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sos" -version = "0.15.1" +version = "0.16.1" edition = "2021" description = "Distributed, encrypted database for private secrets." homepage = "https://saveoursecrets.com" @@ -33,7 +33,6 @@ enum-iterator.workspace = true clap.workspace = true arboard.workspace = true -axum-server.workspace = true tokio-rustls.workspace = true rustls.workspace = true @@ -42,16 +41,15 @@ tempfile = "3.5" shell-words = "1" terminal-banner = { version = "0.4.1", features = ["color"] } unicode-width = "0.2" -kdam = { version = "0.5", features = ["rich", "spinner"] } -num_cpus = "1" +kdam = { version = "0.6", features = ["rich", "spinner"] } crossterm = "0.28" ctrlc = "3" tokio = { version = "1", features = ["rt", "rt-multi-thread", "time", "sync"] } rustyline = "14" -rustyline-derive = "0.10" +rustyline-derive = "0.11" [dependencies.sos-net] -version = "0.15.1" +version = "0.16" features = ["full"] path = "../net" diff --git a/crates/sos/src/cli/sos.rs b/crates/sos/src/cli/sos.rs index daa352ebfb..42bebf3970 100644 --- a/crates/sos/src/cli/sos.rs +++ b/crates/sos/src/cli/sos.rs @@ -9,7 +9,7 @@ use crate::{ EnvironmentCommand, FolderCommand, PreferenceCommand, SecretCommand, ServerCommand, SyncCommand, ToolsCommand, }, - helpers::{PROGRESS_MONITOR, USER}, + helpers::{account::SHELL, PROGRESS_MONITOR}, CommandTree, Result, }; @@ -120,7 +120,7 @@ pub async fn run() -> Result<()> { } ctrlc::set_handler(move || { - let is_shell = USER.get().is_some(); + let is_shell = *SHELL.lock(); if is_shell { let tx = PROGRESS_MONITOR.lock(); if let Some(tx) = &*tx { @@ -133,6 +133,7 @@ pub async fn run() -> Result<()> { } })?; + #[allow(unused_mut)] let mut args = Sos::parse(); if let Some(storage) = &args.storage { diff --git a/crates/sos/src/commands/account.rs b/crates/sos/src/commands/account.rs index 6b5ee6ccdd..3356b971ff 100644 --- a/crates/sos/src/commands/account.rs +++ b/crates/sos/src/commands/account.rs @@ -22,7 +22,7 @@ use crate::{ helpers::{ account::{ find_account, list_accounts, new_account, resolve_account, - resolve_user, sign_in, verify, Owner, USER, + resolve_user, sign_in, verify, Owner, SHELL, USER, }, messages::success, readline::read_flag, @@ -185,7 +185,7 @@ pub enum ContactsCommand { } pub async fn run(cmd: Command) -> Result<()> { - let is_shell = USER.get().is_some(); + let is_shell = *SHELL.lock(); match cmd { Command::New { name, folder_name } => { new_account(name, folder_name).await?; @@ -246,13 +246,12 @@ pub async fn run(cmd: Command) -> Result<()> { // Get the current folder so that the shell client // does not lose context when importing and exporting contacts let original_folder = { - let mut owner = user.write().await; + let owner = user.read().await; + let owner = owner + .selected_account() + .ok_or(Error::NoSelectedAccount)?; - let current = { - let storage = owner.storage().await?; - let reader = storage.read().await; - reader.current_folder() - }; + let current = owner.current_folder().await?; let contacts = owner .contacts_folder() @@ -267,7 +266,10 @@ pub async fn run(cmd: Command) -> Result<()> { contacts_export(Arc::clone(&user), output, force).await?; success("Contacts exported"); if let Some(folder) = original_folder { - let mut owner = user.write().await; + let owner = user.read().await; + let owner = owner + .selected_account() + .ok_or(Error::NoSelectedAccount)?; owner.open_folder(&folder).await?; } } @@ -275,7 +277,10 @@ pub async fn run(cmd: Command) -> Result<()> { contacts_import(Arc::clone(&user), input).await?; success("Contacts imported"); if let Some(folder) = original_folder { - let mut owner = user.write().await; + let owner = user.read().await; + let owner = owner + .selected_account() + .ok_or(Error::NoSelectedAccount)?; owner.open_folder(&folder).await?; } } @@ -290,6 +295,8 @@ pub async fn run(cmd: Command) -> Result<()> { } => { let user = resolve_user(account.as_ref(), true).await?; let owner = user.read().await; + let owner = + owner.selected_account().ok_or(Error::NoSelectedAccount)?; let statistics = owner.statistics().await; if json { @@ -348,6 +355,7 @@ async fn account_info( ) -> Result<()> { let user = resolve_user(account.as_ref(), false).await?; let owner = user.read().await; + let owner = owner.selected_account().ok_or(Error::NoSelectedAccount)?; let data = owner.account_data().await?; if json { @@ -405,7 +413,7 @@ async fn account_restore(input: PathBuf) -> Result> { let account = find_account(&account_ref).await?; - let mut owner = if let Some(account) = account { + let mut password = if let Some(account) = account { let confirmed = read_flag(Some( "Overwrite all account data from backup? (y/n) ", ))?; @@ -414,13 +422,17 @@ async fn account_restore(input: PathBuf) -> Result> { } let account = AccountRef::Name(account.label().to_owned()); - let (owner, password) = sign_in(&account).await?; - Some((owner, password)) + let password = sign_in(&account).await?; + Some(password) } else { None }; - let account = if let Some((mut owner, password)) = owner.take() { + let account = if let Some(password) = password.take() { + let mut owner = USER.write().await; + let owner = owner + .selected_account_mut() + .ok_or(Error::NoSelectedAccount)?; let paths = owner.paths(); let files_dir = paths.files_dir(); let options = RestoreOptions { @@ -451,14 +463,16 @@ async fn account_rename( ) -> Result<()> { let user = resolve_user(account.as_ref(), false).await?; let mut owner = user.write().await; + let owner = owner + .selected_account_mut() + .ok_or(Error::NoSelectedAccount)?; owner.rename_account(name).await?; Ok(()) } /// Delete an account. async fn account_delete(account: Option) -> Result { - let is_shell = USER.get().is_some(); - + let is_shell = *SHELL.lock(); let account = if !is_shell { // For deletion we don't accept account inference, it must // be specified explicitly @@ -473,18 +487,21 @@ async fn account_delete(account: Option) -> Result { return Err(Error::NotShellAccount); } - let user = USER.get().unwrap(); - // Verify the password for shell users // before deletion - verify(Arc::clone(user)).await?; + verify(Arc::clone(&USER)).await?; - let owner = user.read().await; + let owner = USER.read().await; + let owner = + owner.selected_account().ok_or(Error::NoSelectedAccount)?; (&*owner).into() }; let user = resolve_user(Some(&account), false).await?; let mut owner = user.write().await; + let owner = owner + .selected_account_mut() + .ok_or(Error::NoSelectedAccount)?; let prompt = format!( r#"Delete account "{}" (y/n)? "#, @@ -511,6 +528,8 @@ async fn migrate_export( } let owner = user.read().await; + let owner = owner.selected_account().ok_or(Error::NoSelectedAccount)?; + let prompt = format!( r#"Export UNENCRYPTED account "{}" (y/n)? "#, owner.account_label().await?, @@ -539,6 +558,9 @@ async fn migrate_import( format, }; let mut owner = user.write().await; + let owner = owner + .selected_account_mut() + .ok_or(Error::NoSelectedAccount)?; let _ = owner.import_file(target).await?; Ok(()) } @@ -552,7 +574,8 @@ async fn contacts_export( if !force && vfs::try_exists(&output).await? { return Err(Error::FileExists(output)); } - let mut owner = user.write().await; + let owner = user.read().await; + let owner = owner.selected_account().ok_or(Error::NoSelectedAccount)?; owner.export_all_contacts(output).await?; Ok(()) } @@ -560,6 +583,9 @@ async fn contacts_export( /// Import contacts from a vCard. async fn contacts_import(user: Owner, input: PathBuf) -> Result<()> { let mut owner = user.write().await; + let owner = owner + .selected_account_mut() + .ok_or(Error::NoSelectedAccount)?; let content = vfs::read_to_string(&input).await?; owner.import_contacts(&content, |_| {}).await?; Ok(()) diff --git a/crates/sos/src/commands/device.rs b/crates/sos/src/commands/device.rs index 5ebb047c05..b8e20bc72c 100644 --- a/crates/sos/src/commands/device.rs +++ b/crates/sos/src/commands/device.rs @@ -1,15 +1,15 @@ use clap::Subcommand; use std::sync::Arc; -use tokio::sync::RwLock; -use sos_net::{ - sdk::{account::Account, device::TrustedDevice, identity::AccountRef}, - NetworkAccount, +use sos_net::sdk::{ + account::Account, device::TrustedDevice, identity::AccountRef, }; use crate::{ helpers::{ - account::resolve_user, messages::success, readline::read_flag, + account::{resolve_user, Owner}, + messages::success, + readline::read_flag, }, Error, Result, }; @@ -39,10 +39,11 @@ pub enum Command { } async fn resolve_device( - user: Arc>, + user: Owner, id: &str, ) -> Result> { let owner = user.read().await; + let owner = owner.selected_account().ok_or(Error::NoSelectedAccount)?; let devices = owner.trusted_devices().await?; for device in devices { if device.public_id()? == id { @@ -57,6 +58,8 @@ pub async fn run(cmd: Command) -> Result<()> { Command::List { account, verbose } => { let user = resolve_user(account.as_ref(), false).await?; let owner = user.read().await; + let owner = + owner.selected_account().ok_or(Error::NoSelectedAccount)?; let devices = owner.trusted_devices().await?; if verbose { println!("{}", serde_json::to_string_pretty(&devices)?); @@ -74,6 +77,9 @@ pub async fn run(cmd: Command) -> Result<()> { let prompt = format!(r#"Revoke device "{}" (y/n)? "#, &id); if read_flag(Some(&prompt))? { let mut owner = user.write().await; + let owner = owner + .selected_account_mut() + .ok_or(Error::NoSelectedAccount)?; owner.revoke_device(device.public_key()).await?; success("Device revoked"); } diff --git a/crates/sos/src/commands/folder.rs b/crates/sos/src/commands/folder.rs index 581a121b84..9e2cf92e89 100644 --- a/crates/sos/src/commands/folder.rs +++ b/crates/sos/src/commands/folder.rs @@ -11,7 +11,7 @@ use sos_net::sdk::{ use crate::{ helpers::{ - account::{cd_folder, resolve_folder, resolve_user, USER}, + account::{cd_folder, resolve_folder, resolve_user, SHELL}, messages::success, readline::read_flag, }, @@ -152,25 +152,30 @@ pub enum History { } pub async fn run(cmd: Command) -> Result<()> { - let is_shell = USER.get().is_some(); + let is_shell = *SHELL.lock(); match cmd { Command::New { account, name, cwd } => { let user = resolve_user(account.as_ref(), false).await?; - let mut writer = user.write().await; + let folder = { + let mut owner = user.write().await; + let owner = owner + .selected_account_mut() + .ok_or(Error::NoSelectedAccount)?; - let existing = writer.find(|s| s.name() == name).await; - if existing.is_some() { - return Err(Error::FolderExists(name)); - } + let existing = owner.find(|s| s.name() == name).await; + if existing.is_some() { + return Err(Error::FolderExists(name)); + } - let FolderCreate { folder, .. } = - writer.create_folder(name, Default::default()).await?; - success("Folder created"); - drop(writer); + let FolderCreate { folder, .. } = + owner.create_folder(name, Default::default()).await?; + success("Folder created"); + folder + }; if cwd { let target = Some(FolderRef::Id(*folder.id())); - cd_folder(user, target.as_ref()).await?; + cd_folder(target.as_ref()).await?; } } Command::Remove { account, folder } => { @@ -185,9 +190,11 @@ pub async fn run(cmd: Command) -> Result<()> { let is_current = { let owner = user.read().await; - let storage = owner.storage().await?; - let reader = storage.read().await; - if let Some(current) = reader.current_folder() { + let owner = owner + .selected_account() + .ok_or(Error::NoSelectedAccount)?; + + if let Some(current) = owner.current_folder().await? { current.id() == summary.id() } else { false @@ -198,22 +205,28 @@ pub async fn run(cmd: Command) -> Result<()> { format!(r#"Delete folder "{}" (y/n)? "#, summary.name()); if read_flag(Some(&prompt))? { let mut owner = user.write().await; - owner.delete_folder(&summary).await?; - success("Folder deleted"); + { + let owner = owner + .selected_account_mut() + .ok_or(Error::NoSelectedAccount)?; + owner.delete_folder(&summary).await?; + success("Folder deleted"); + } drop(owner); // Removing current folder so try to use // the default folder if is_current { - cd_folder(user, None).await?; + cd_folder(None).await?; } } } Command::List { account, verbose } => { let user = resolve_user(account.as_ref(), false).await?; let owner = user.read().await; - let mut folders = owner.list_folders().await?; - folders.sort_by(|a, b| b.name().partial_cmp(a.name()).unwrap()); + let owner = + owner.selected_account().ok_or(Error::NoSelectedAccount)?; + let folders = owner.list_folders().await?; for summary in folders { if verbose { println!("{} {}", summary.id(), summary.name()); @@ -243,7 +256,9 @@ pub async fn run(cmd: Command) -> Result<()> { .await? .ok_or_else(|| Error::NoFolderFound)?; - let mut owner = user.write().await; + let owner = user.read().await; + let owner = + owner.selected_account().ok_or(Error::NoSelectedAccount)?; if !is_shell { owner.open_folder(&summary).await?; @@ -261,7 +276,12 @@ pub async fn run(cmd: Command) -> Result<()> { .ok_or_else(|| Error::NoFolderFound)?; let owner = user.read().await; - let storage = owner.storage().await?; + let owner = + owner.selected_account().ok_or(Error::NoSelectedAccount)?; + let storage = owner + .storage() + .await + .ok_or(sos_net::sdk::Error::NoStorage)?; let reader = storage.read().await; if let Some(folder) = reader.cache().get(summary.id()) { let event_log = folder.event_log(); @@ -289,8 +309,11 @@ pub async fn run(cmd: Command) -> Result<()> { .await? .ok_or_else(|| Error::NoFolderFound)?; - let mut writer = user.write().await; - writer.rename_folder(&summary, name.clone()).await?; + let mut owner = user.write().await; + let owner = owner + .selected_account_mut() + .ok_or(Error::NoSelectedAccount)?; + owner.rename_folder(&summary, name.clone()).await?; success(format!("{} -> {}", summary.name(), name)); } @@ -313,9 +336,12 @@ pub async fn run(cmd: Command) -> Result<()> { .ok_or_else(|| Error::NoFolderFound)?; { - let mut writer = user.write().await; + let owner = user.read().await; + let owner = owner + .selected_account() + .ok_or(Error::NoSelectedAccount)?; if !is_shell { - writer.open_folder(&summary).await?; + owner.open_folder(&summary).await?; } } @@ -323,10 +349,12 @@ pub async fn run(cmd: Command) -> Result<()> { History::Compact { .. } => { let summary = { let owner = user.read().await; - let storage = owner.storage().await?; - let reader = storage.read().await; - let summary = reader + let owner = owner + .selected_account() + .ok_or(Error::NoSelectedAccount)?; + let summary = owner .current_folder() + .await? .ok_or(Error::NoVaultSelected)?; summary.clone() }; @@ -336,6 +364,9 @@ pub async fn run(cmd: Command) -> Result<()> { ); if read_flag(prompt)? { let mut owner = user.write().await; + let owner = owner + .selected_account_mut() + .ok_or(Error::NoSelectedAccount)?; let (_, old_size, new_size) = owner.compact_folder(&summary).await?; println!("Old: {}", human_bytes(old_size as f64)); @@ -344,22 +375,36 @@ pub async fn run(cmd: Command) -> Result<()> { } History::Check { .. } => { let owner = user.read().await; - let storage = owner.storage().await?; - let reader = storage.read().await; - let summary = reader + let owner = owner + .selected_account() + .ok_or(Error::NoSelectedAccount)?; + let summary = owner .current_folder() + .await? .ok_or(Error::NoVaultSelected)?; - reader.verify(&summary).await?; + let storage = owner + .storage() + .await + .ok_or(sos_net::sdk::Error::NoStorage)?; + let owner = storage.read().await; + owner.verify(&summary).await?; success("Verified"); } History::List { verbose, .. } => { let owner = user.read().await; - let storage = owner.storage().await?; - let reader = storage.read().await; - let summary = reader + let owner = owner + .selected_account() + .ok_or(Error::NoSelectedAccount)?; + let summary = owner .current_folder() + .await? .ok_or(Error::NoVaultSelected)?; - let records = reader.history(&summary).await?; + let storage = owner + .storage() + .await + .ok_or(sos_net::sdk::Error::NoStorage)?; + let owner = storage.read().await; + let records = owner.history(&summary).await?; for (commit, time, event) in records { print!("{} {} ", event.event_kind(), time); if verbose { diff --git a/crates/sos/src/commands/preferences.rs b/crates/sos/src/commands/preferences.rs index 5c10d9afe9..db421cd385 100644 --- a/crates/sos/src/commands/preferences.rs +++ b/crates/sos/src/commands/preferences.rs @@ -3,11 +3,11 @@ use crate::{ account::resolve_user, messages::{fail, success}, }, - Result, + Error, Result, }; use clap::Subcommand; use sos_net::extras::preferences::*; -use sos_net::sdk::prelude::*; +use sos_net::sdk::prelude::{Account, AccountRef}; #[derive(Subcommand, Debug)] pub enum Command { @@ -102,6 +102,8 @@ pub async fn run(cmd: Command) -> Result<()> { Command::List { account } => { let user = resolve_user(account.as_ref(), false).await?; let owner = user.read().await; + let owner = + owner.selected_account().ok_or(Error::NoSelectedAccount)?; let paths = owner.paths(); let mut prefs = Preferences::new(&paths); prefs.load().await?; @@ -116,6 +118,8 @@ pub async fn run(cmd: Command) -> Result<()> { Command::Get { account, key } => { let user = resolve_user(account.as_ref(), false).await?; let owner = user.read().await; + let owner = + owner.selected_account().ok_or(Error::NoSelectedAccount)?; let paths = owner.paths(); let mut prefs = Preferences::new(&paths); prefs.load().await?; @@ -128,6 +132,8 @@ pub async fn run(cmd: Command) -> Result<()> { Command::Remove { account, key } => { let user = resolve_user(account.as_ref(), false).await?; let owner = user.read().await; + let owner = + owner.selected_account().ok_or(Error::NoSelectedAccount)?; let paths = owner.paths(); let mut prefs = Preferences::new(&paths); prefs.load().await?; @@ -170,6 +176,8 @@ pub async fn run(cmd: Command) -> Result<()> { Command::Clear { account } => { let user = resolve_user(account.as_ref(), false).await?; let owner = user.read().await; + let owner = + owner.selected_account().ok_or(Error::NoSelectedAccount)?; let paths = owner.paths(); let mut prefs = Preferences::new(&paths); prefs.clear().await?; @@ -186,6 +194,7 @@ async fn set_pref( ) -> Result<()> { let user = resolve_user(account.as_ref(), false).await?; let owner = user.read().await; + let owner = owner.selected_account().ok_or(Error::NoSelectedAccount)?; let paths = owner.paths(); let mut prefs = Preferences::new(&paths); prefs.load().await?; diff --git a/crates/sos/src/commands/secret.rs b/crates/sos/src/commands/secret.rs index c1c6631ea0..b091466cd9 100644 --- a/crates/sos/src/commands/secret.rs +++ b/crates/sos/src/commands/secret.rs @@ -1,6 +1,6 @@ use crate::{ helpers::{ - account::{resolve_folder, resolve_user, verify, Owner, USER}, + account::{resolve_folder, resolve_user, verify, Owner, SHELL}, editor, messages::success, readline::{read_flag, read_line}, @@ -600,7 +600,7 @@ async fn resolve_verify<'a>( predicate: FolderPredicate<'a>, secret: &SecretRef, ) -> Result { - let is_shell = USER.get().is_some(); + let is_shell = *SHELL.lock(); let mut user = resolve_user(account, true).await?; @@ -618,7 +618,9 @@ async fn resolve_verify<'a>( }; if !is_shell || should_open { - let mut owner = user.write().await; + let owner = user.read().await; + let owner = + owner.selected_account().ok_or(Error::NoSelectedAccount)?; owner.open_folder(&summary).await?; } @@ -643,7 +645,7 @@ async fn resolve_verify<'a>( } pub async fn run(cmd: Command) -> Result<()> { - let is_shell = USER.get().is_some(); + let is_shell = *SHELL.lock(); match cmd { Command::List { @@ -655,6 +657,8 @@ pub async fn run(cmd: Command) -> Result<()> { } => { let user = resolve_user(account.as_ref(), true).await?; let owner = user.read().await; + let owner = + owner.selected_account().ok_or(Error::NoSelectedAccount)?; let archive_folder = owner.archive_folder().await; let summary = resolve_folder(&user, folder.as_ref()) @@ -673,7 +677,10 @@ pub async fn run(cmd: Command) -> Result<()> { ignored_types: None, }]; } else if let Some(folder) = &folder { - let storage = owner.storage().await?; + let storage = owner + .storage() + .await + .ok_or(sos_net::sdk::Error::NoStorage)?; let reader = storage.read().await; let summary = reader .find_folder(folder) @@ -684,7 +691,8 @@ pub async fn run(cmd: Command) -> Result<()> { views = vec![DocumentView::Favorites]; } - let documents = owner.query_view(views, archive_filter).await?; + let documents = + owner.query_view(&views, archive_filter.as_ref()).await?; let docs: Vec<&Document> = documents.iter().collect(); print_documents(&docs, verbose)?; } @@ -715,11 +723,18 @@ pub async fn run(cmd: Command) -> Result<()> { .ok_or_else(|| Error::NoFolderFound)?; if !is_shell || folder.is_some() { - let mut owner = user.write().await; + let owner = user.read().await; + let owner = owner + .selected_account() + .ok_or(Error::NoSelectedAccount)?; owner.open_folder(&summary).await?; } let mut owner = user.write().await; + let owner = owner + .selected_account_mut() + .ok_or(Error::NoSelectedAccount)?; + let result = match cmd { AddCommand::Note { name, tags, .. } => add_note(name, tags)?, AddCommand::List { name, tags, .. } => add_list(name, tags)?, @@ -755,7 +770,10 @@ pub async fn run(cmd: Command) -> Result<()> { ) .await?; if resolved.verified { - let mut owner = resolved.user.write().await; + let owner = resolved.user.read().await; + let owner = owner + .selected_account() + .ok_or(Error::NoSelectedAccount)?; let (data, _) = owner.read_secret(&resolved.secret_id, None).await?; print_secret(data.meta(), data.secret())?; @@ -773,7 +791,10 @@ pub async fn run(cmd: Command) -> Result<()> { ) .await?; if resolved.verified { - let mut owner = resolved.user.write().await; + let owner = resolved.user.read().await; + let owner = owner + .selected_account() + .ok_or(Error::NoSelectedAccount)?; let (data, _) = owner.read_secret(&resolved.secret_id, None).await?; let copied = copy_secret_text(data.secret())?; @@ -891,6 +912,9 @@ pub async fn run(cmd: Command) -> Result<()> { if save_updates { let mut owner = resolved.user.write().await; + let owner = owner + .selected_account_mut() + .ok_or(Error::NoSelectedAccount)?; owner .update_secret( &resolved.secret_id, @@ -916,6 +940,9 @@ pub async fn run(cmd: Command) -> Result<()> { .await?; if resolved.verified { let mut owner = resolved.user.write().await; + let owner = owner + .selected_account_mut() + .ok_or(Error::NoSelectedAccount)?; let (data, _) = owner.read_secret(&resolved.secret_id, None).await?; @@ -974,6 +1001,9 @@ pub async fn run(cmd: Command) -> Result<()> { resolved.meta.set_favorite(value); let mut owner = resolved.user.write().await; + let owner = owner + .selected_account_mut() + .ok_or(Error::NoSelectedAccount)?; owner .update_secret( &resolved.secret_id, @@ -1003,6 +1033,9 @@ pub async fn run(cmd: Command) -> Result<()> { resolved.meta.set_label(name); let mut owner = resolved.user.write().await; + let owner = owner + .selected_account_mut() + .ok_or(Error::NoSelectedAccount)?; owner .update_secret( &resolved.secret_id, @@ -1034,6 +1067,9 @@ pub async fn run(cmd: Command) -> Result<()> { to.ok_or_else(|| Error::FolderNotFound(target.to_string()))?; if resolved.verified { let mut owner = resolved.user.write().await; + let owner = owner + .selected_account_mut() + .ok_or(Error::NoSelectedAccount)?; owner .move_secret( &resolved.secret_id, @@ -1063,6 +1099,9 @@ pub async fn run(cmd: Command) -> Result<()> { ); if read_flag(Some(&prompt))? { let mut owner = resolved.user.write().await; + let owner = owner + .selected_account_mut() + .ok_or(Error::NoSelectedAccount)?; owner .delete_secret( &resolved.secret_id, @@ -1087,7 +1126,10 @@ pub async fn run(cmd: Command) -> Result<()> { .await?; if resolved.verified { let mut data = { - let mut owner = resolved.user.write().await; + let owner = resolved.user.read().await; + let owner = owner + .selected_account() + .ok_or(Error::NoSelectedAccount)?; let (data, _) = owner.read_secret(&resolved.secret_id, None).await?; data @@ -1112,6 +1154,9 @@ pub async fn run(cmd: Command) -> Result<()> { let value = value.filter(|value| !value.is_empty()); data.secret_mut().user_data_mut().set_comment(value); let mut owner = resolved.user.write().await; + let owner = owner + .selected_account_mut() + .ok_or(Error::NoSelectedAccount)?; owner .update_secret( &resolved.secret_id, @@ -1144,7 +1189,10 @@ pub async fn run(cmd: Command) -> Result<()> { } let data = { - let mut owner = resolved.user.write().await; + let owner = resolved.user.read().await; + let owner = owner + .selected_account() + .ok_or(Error::NoSelectedAccount)?; let (data, _) = owner.read_secret(&resolved.secret_id, None).await?; data @@ -1166,6 +1214,9 @@ pub async fn run(cmd: Command) -> Result<()> { .await?; if resolved.verified { let mut owner = resolved.user.write().await; + let owner = owner + .selected_account_mut() + .ok_or(Error::NoSelectedAccount)?; owner .archive( &resolved.summary, @@ -1180,9 +1231,10 @@ pub async fn run(cmd: Command) -> Result<()> { let original_folder = if is_shell { let user = resolve_user(account.as_ref(), false).await?; let owner = user.read().await; - let storage = owner.storage().await?; - let reader = storage.read().await; - reader.current_folder() + let owner = owner + .selected_account() + .ok_or(Error::NoSelectedAccount)?; + owner.current_folder().await? } else { None }; @@ -1191,7 +1243,10 @@ pub async fn run(cmd: Command) -> Result<()> { account.as_ref(), FolderPredicate::Func(Box::new(|user| { Box::pin(async { - let owner = user.write().await; + let owner = user.read().await; + let owner = owner + .selected_account() + .ok_or(Error::NoSelectedAccount)?; owner .archive_folder() .await @@ -1204,6 +1259,9 @@ pub async fn run(cmd: Command) -> Result<()> { if resolved.verified { let mut owner = resolved.user.write().await; + let owner = owner + .selected_account_mut() + .ok_or(Error::NoSelectedAccount)?; owner .unarchive( &resolved.secret_id, @@ -1285,7 +1343,9 @@ async fn attachment(cmd: AttachCommand) -> Result<()> { .await?; if resolved.verified { let mut data = { - let mut owner = resolved.user.write().await; + let owner = resolved.user.read().await; + let owner = + owner.selected_account().ok_or(Error::NoSelectedAccount)?; let (data, _) = owner.read_secret(&resolved.secret_id, None).await?; data @@ -1422,6 +1482,9 @@ async fn attachment(cmd: AttachCommand) -> Result<()> { if let Some(new_secret) = new_secret { let mut owner = resolved.user.write().await; + let owner = owner + .selected_account_mut() + .ok_or(Error::NoSelectedAccount)?; let (options, shutdown_tx, closed_rx) = access_options(); owner .update_secret( diff --git a/crates/sos/src/commands/server.rs b/crates/sos/src/commands/server.rs index d980e9f871..407ab07590 100644 --- a/crates/sos/src/commands/server.rs +++ b/crates/sos/src/commands/server.rs @@ -7,9 +7,8 @@ use crate::{ }; use clap::Subcommand; use sos_net::{ - protocol::{Origin, SyncOptions}, + protocol::{AccountSync, Origin, SyncOptions}, sdk::{identity::AccountRef, url::Url}, - AccountSync, }; #[derive(Subcommand, Debug)] @@ -46,6 +45,9 @@ pub async fn run(cmd: Command) -> Result<()> { Command::Add { account, url } => { let user = resolve_user(account.as_ref(), false).await?; let mut owner = user.write().await; + let owner = owner + .selected_account_mut() + .ok_or(Error::NoSelectedAccount)?; let origin: Origin = url.into(); owner.add_server(origin.clone()).await?; let options = SyncOptions { @@ -64,6 +66,8 @@ pub async fn run(cmd: Command) -> Result<()> { Command::List { account } => { let user = resolve_user(account.as_ref(), false).await?; let owner = user.read().await; + let owner = + owner.selected_account().ok_or(Error::NoSelectedAccount)?; let servers = owner.servers().await; if servers.is_empty() { println!("No servers yet"); @@ -77,6 +81,9 @@ pub async fn run(cmd: Command) -> Result<()> { Command::Remove { account, url } => { let user = resolve_user(account.as_ref(), false).await?; let mut owner = user.write().await; + let owner = owner + .selected_account_mut() + .ok_or(Error::NoSelectedAccount)?; let origin: Origin = url.into(); let remote = owner.remove_server(&origin).await?; diff --git a/crates/sos/src/commands/shell/cli.rs b/crates/sos/src/commands/shell/cli.rs index e54a8cffa1..78c775dbc0 100644 --- a/crates/sos/src/commands/shell/cli.rs +++ b/crates/sos/src/commands/shell/cli.rs @@ -1,19 +1,12 @@ -use std::sync::Arc; - use terminal_banner::{Banner, Padding}; -use sos_net::{ - sdk::{ - account::Account, identity::AccountRef, vault::FolderRef, vfs, Paths, - }, - NetworkAccount, +use sos_net::sdk::{ + account::Account, identity::AccountRef, vault::FolderRef, vfs, Paths, }; -use tokio::sync::RwLock; - use crate::{ helpers::{ - account::{cd_folder, choose_account, sign_in, USER}, + account::{cd_folder, choose_account, sign_in, SHELL, USER}, messages::fail, readline, }, @@ -38,10 +31,10 @@ Type "quit" or "q" to exit"#; } /// Loop sign in for shell authentication. -async fn auth(account: &AccountRef) -> Result { +async fn auth(account: &AccountRef) -> Result<()> { loop { match sign_in(account).await { - Ok((owner, _)) => return Ok(owner), + Ok(_) => return Ok(()), Err(e) => { fail(e.to_string()); if matches!(e, Error::NoAccount(_)) { @@ -74,14 +67,23 @@ pub async fn run( account.into() }; - let mut owner = auth(&account).await?; - owner.initialize_search_index().await?; + auth(&account).await?; + { + let mut owner = USER.write().await; + let user_account = owner + .selected_account_mut() + .ok_or(Error::NoSelectedAccount)?; + user_account.initialize_search_index().await?; + } welcome()?; - // Prepare state for shell execution - let user = USER.get_or_init(|| Arc::new(RwLock::new(owner))); + { + let mut is_shell = SHELL.lock(); + *is_shell = true; + } - cd_folder(Arc::clone(user), folder.as_ref()).await?; + // Prepare state for shell execution + cd_folder(folder.as_ref()).await?; let mut rl = readline::basic_editor()?; loop { @@ -89,11 +91,12 @@ pub async fn run( if let Ok(prompt) = std::env::var("SOS_PROMPT") { prompt } else { - let owner = user.read().await; + let owner = USER.read().await; + let owner = owner + .selected_account() + .ok_or(Error::NoSelectedAccount)?; let account_name = owner.account_label().await?; - let storage = owner.storage().await?; - let reader = storage.read().await; - if let Some(current) = reader.current_folder() { + if let Some(current) = owner.current_folder().await? { format!("{}@{}> ", account_name, current.name()) } else { format!("{}> ", account_name) @@ -105,8 +108,7 @@ pub async fn run( match readline { Ok(line) => { rl.add_history_entry(line.as_str())?; - let provider = Arc::clone(user); - if let Err(e) = exec(&line, provider).await { + if let Err(e) = exec(&line).await { fail(e.to_string()); } } diff --git a/crates/sos/src/commands/shell/repl.rs b/crates/sos/src/commands/shell/repl.rs index 28cf662fc8..3ad9283c2f 100644 --- a/crates/sos/src/commands/shell/repl.rs +++ b/crates/sos/src/commands/shell/repl.rs @@ -1,4 +1,4 @@ -use std::{ffi::OsString, sync::Arc}; +use std::ffi::OsString; use clap::{CommandFactory, Parser, Subcommand}; @@ -11,7 +11,8 @@ use crate::{ AccountCommand, EnvironmentCommand, FolderCommand, PreferenceCommand, SecretCommand, ServerCommand, SyncCommand, }, - helpers::account::{cd_folder, switch, Owner}, + helpers::account::{cd_folder, switch, USER}, + Error, }; use crate::Result; @@ -161,7 +162,7 @@ where */ /// Execute the program command. -async fn exec_program(program: Shell, user: Owner) -> Result<()> { +async fn exec_program(program: Shell) -> Result<()> { match program.cmd { ShellCommand::Account { cmd } => { let mut new_name: Option = None; @@ -172,7 +173,10 @@ async fn exec_program(program: Shell, user: Owner) -> Result<()> { crate::commands::account::run(cmd).await?; if let Some(new_name) = new_name { - let mut owner = user.write().await; + let mut owner = USER.write().await; + let owner = owner + .selected_account_mut() + .ok_or(Error::NoSelectedAccount)?; owner.rename_account(new_name).await?; } @@ -194,7 +198,7 @@ async fn exec_program(program: Shell, user: Owner) -> Result<()> { ShellCommand::Environment { cmd } => { crate::commands::environment::run(cmd).await } - ShellCommand::Cd { folder } => cd_folder(user, folder.as_ref()).await, + ShellCommand::Cd { folder } => cd_folder(folder.as_ref()).await, /* ShellCommand::Password => { @@ -267,52 +271,57 @@ async fn exec_program(program: Shell, user: Owner) -> Result<()> { // Try to select the default folder let default_folder = { let owner = user.read().await; + let owner = owner + .selected_account() + .ok_or(Error::NoSelectedAccount)?; owner.default_folder().await }; if let Some(summary) = default_folder { let folder = Some(FolderRef::Id(*summary.id())); - cd_folder(Arc::clone(&user), folder.as_ref()).await?; + cd_folder(folder.as_ref()).await?; } Ok(()) } ShellCommand::Whoami => { - let owner = user.read().await; + let owner = USER.read().await; + let owner = + owner.selected_account().ok_or(Error::NoSelectedAccount)?; println!("{} {}", owner.account_label().await?, owner.address()); Ok(()) } ShellCommand::Pwd => { - let owner = user.read().await; - let storage = owner.storage().await?; - let reader = storage.read().await; - if let Some(current) = reader.current_folder() { + let owner = USER.read().await; + let owner = + owner.selected_account().ok_or(Error::NoSelectedAccount)?; + if let Some(current) = owner.current_folder().await? { println!("{} {}", current.name(), current.id(),); } Ok(()) } ShellCommand::Quit => { - let mut owner = user.write().await; - owner.sign_out().await?; + let mut owner = USER.write().await; + owner.sign_out_all().await?; std::process::exit(0); } } } /// Intermediary to pretty print clap parse errors. -async fn exec_args(it: I, user: Owner) -> Result<()> +async fn exec_args(it: I) -> Result<()> where I: IntoIterator, T: Into + Clone, { match Shell::try_parse_from(it) { - Ok(program) => exec_program(program, user).await?, + Ok(program) => exec_program(program).await?, Err(e) => e.print().expect("unable to write error output"), } Ok(()) } /// Execute a line of input in the context of the shell program. -pub async fn exec(line: &str, user: Owner) -> Result<()> { +pub async fn exec(line: &str) -> Result<()> { // ignore comments if line.trim().starts_with('#') { return Ok(()); @@ -334,7 +343,7 @@ pub async fn exec(line: &str, user: Owner) -> Result<()> { } else if line == "help" || line == "--help" { cmd.print_long_help()?; } else { - exec_args(it, user).await?; + exec_args(it).await?; } } Ok(()) diff --git a/crates/sos/src/commands/sync.rs b/crates/sos/src/commands/sync.rs index 09d490b8d7..f09f5c7f6a 100644 --- a/crates/sos/src/commands/sync.rs +++ b/crates/sos/src/commands/sync.rs @@ -4,7 +4,7 @@ use crate::{ }; use clap::Subcommand; use sos_net::{ - protocol::{Origin, SyncOptions, SyncStatus, SyncStorage}, + protocol::{AccountSync, Origin, SyncOptions, SyncStatus, SyncStorage}, sdk::{ account::Account, commit::{CommitState, CommitTree, Comparison}, @@ -13,7 +13,7 @@ use sos_net::{ storage::StorageEventLogs, url::Url, }, - AccountSync, NetworkAccount, + NetworkAccount, }; #[derive(Subcommand, Debug)] @@ -52,6 +52,8 @@ pub async fn run(cmd: Command) -> Result<()> { } => { let user = resolve_user(account.as_ref(), false).await?; let owner = user.read().await; + let owner = + owner.selected_account().ok_or(Error::NoSelectedAccount)?; let servers = owner.servers().await; if servers.is_empty() { return Err(Error::NoServers); @@ -97,6 +99,8 @@ pub async fn run(cmd: Command) -> Result<()> { Command::Status { account, url } => { let user = resolve_user(account.as_ref(), false).await?; let owner = user.read().await; + let owner = + owner.selected_account().ok_or(Error::NoSelectedAccount)?; let servers = owner.servers().await; if servers.is_empty() { return Err(Error::NoServers); @@ -201,7 +205,10 @@ async fn print_status( let folders = owner.list_folders().await?; for folder in folders { let id = folder.id(); - let storage = owner.storage().await?; + let storage = owner + .storage() + .await + .ok_or(sos_net::sdk::Error::NoStorage)?; let storage = storage.read().await; let disc_folder = storage.cache().get(id).unwrap(); let log = disc_folder.event_log(); diff --git a/crates/sos/src/commands/tools/authenticator.rs b/crates/sos/src/commands/tools/authenticator.rs index ef1a226bdb..35e9bee756 100644 --- a/crates/sos/src/commands/tools/authenticator.rs +++ b/crates/sos/src/commands/tools/authenticator.rs @@ -50,12 +50,17 @@ pub async fn run(cmd: Command) -> Result<()> { } => { let user = resolve_user(account.as_ref(), false).await?; let owner = user.write().await; + let owner = + owner.selected_account().ok_or(Error::NoSelectedAccount)?; let authenticator = owner .authenticator_folder() .await .ok_or(Error::NoAuthenticatorFolder)?; - let storage = owner.storage().await?; + let storage = owner + .storage() + .await + .ok_or(sos_net::sdk::Error::NoStorage)?; let storage = storage.read().await; let folder = storage.cache().get(authenticator.id()).unwrap(); @@ -69,6 +74,9 @@ pub async fn run(cmd: Command) -> Result<()> { } => { let user = resolve_user(account.as_ref(), false).await?; let mut owner = user.write().await; + let owner = owner + .selected_account_mut() + .ok_or(Error::NoSelectedAccount)?; let folder = if let Some(authenticator) = owner.authenticator_folder().await @@ -100,7 +108,10 @@ pub async fn run(cmd: Command) -> Result<()> { }; if let Some(folder) = folder { - let storage = owner.storage().await?; + let storage = owner + .storage() + .await + .ok_or(sos_net::sdk::Error::NoStorage)?; let mut storage = storage.write().await; let folder = storage.cache_mut().get_mut(folder.id()).unwrap(); diff --git a/crates/sos/src/commands/tools/mod.rs b/crates/sos/src/commands/tools/mod.rs index 7de9037620..1c6182b9b3 100644 --- a/crates/sos/src/commands/tools/mod.rs +++ b/crates/sos/src/commands/tools/mod.rs @@ -25,12 +25,14 @@ mod audit; mod authenticator; mod check; mod events; +// mod ipc; mod security_report; use audit::Command as AuditCommand; use authenticator::Command as AuthenticatorCommand; use check::{verify_events, Command as CheckCommand}; use events::Command as EventsCommand; +// use ipc::Command as IpcCommand; use security_report::SecurityReportFormat; #[derive(Subcommand, Debug)] @@ -70,6 +72,13 @@ pub enum Command { #[clap(subcommand)] cmd: EventsCommand, }, + /* + /// Inter-process communication utilities. + Ipc { + #[clap(subcommand)] + cmd: IpcCommand, + }, + */ /// Repair a vault from a corresponding events file. RepairVault { /// Account name or address. @@ -123,6 +132,9 @@ pub async fn run(cmd: Command) -> Result<()> { let (user, password) = resolve_user_with_password(account.as_ref(), false).await?; let mut owner = user.write().await; + let owner = owner + .selected_account_mut() + .ok_or(Error::NoSelectedAccount)?; let banner = Banner::new() .padding(Padding::one()) @@ -160,6 +172,7 @@ pub async fn run(cmd: Command) -> Result<()> { } } Command::Events { cmd } => events::run(cmd).await?, + // Command::Ipc { cmd } => ipc::run(cmd).await?, Command::RepairVault { account, folder } => { let account = resolve_account(Some(&account)) .await diff --git a/crates/sos/src/commands/tools/security_report.rs b/crates/sos/src/commands/tools/security_report.rs index 91def87dd9..3127283719 100644 --- a/crates/sos/src/commands/tools/security_report.rs +++ b/crates/sos/src/commands/tools/security_report.rs @@ -61,6 +61,7 @@ pub async fn run( let user = resolve_user(account.as_ref(), false).await?; let owner = user.read().await; + let owner = owner.selected_account().ok_or(Error::NoSelectedAccount)?; let report_options = SecurityReportOptions { excludes: vec![], @@ -78,7 +79,7 @@ pub async fn run( bool, _, _, - >(&*owner, report_options) + >(owner, report_options) .await?; let rows: Vec> = report.into(); diff --git a/crates/sos/src/error.rs b/crates/sos/src/error.rs index 58ab1f5682..31ea284f28 100644 --- a/crates/sos/src/error.rs +++ b/crates/sos/src/error.rs @@ -37,6 +37,10 @@ pub enum Error { #[error("could not find an authenticator folder")] NoAuthenticatorFolder, + /// Account is not authenticated. + #[error("no selected account available")] + NoSelectedAccount, + /// Sync failed. #[error(r#"sync failed"#)] SyncFail, diff --git a/crates/sos/src/helpers/account.rs b/crates/sos/src/helpers/account.rs index 50aeacdc9c..2d00ca74ce 100644 --- a/crates/sos/src/helpers/account.rs +++ b/crates/sos/src/helpers/account.rs @@ -1,9 +1,10 @@ //! Helpers for creating and switching accounts. use std::{borrow::Cow, sync::Arc}; +use parking_lot::Mutex; use sos_net::{ sdk::{ - account::{Account, AccountLocked, SigninOptions}, + account::Account, constants::DEFAULT_VAULT_NAME, crypto::AccessKey, identity::{AccountRef, Identity, PublicIdentity}, @@ -13,10 +14,10 @@ use sos_net::{ vault::{FolderRef, Summary}, Paths, }, - NetworkAccount, + NetworkAccount, NetworkAccountSwitcher, }; use terminal_banner::{Banner, Padding}; -use tokio::sync::{mpsc, RwLock}; +use tokio::sync::RwLock; use crate::helpers::{ display_passphrase, @@ -24,15 +25,19 @@ use crate::helpers::{ readline::{choose, choose_password, read_flag, read_password, Choice}, }; -use once_cell::sync::OnceCell; +use once_cell::sync::Lazy; use crate::{Error, Result}; /// Account owner. -pub type Owner = Arc>; +pub type Owner = Arc>; /// Current user for the shell REPL. -pub static USER: OnceCell = OnceCell::new(); +pub static USER: Lazy = + Lazy::new(|| Arc::new(RwLock::new(NetworkAccountSwitcher::new()))); + +/// Flag used to test is we are running a shell context. +pub static SHELL: Lazy> = Lazy::new(|| Mutex::new(false)); #[derive(Copy, Clone)] enum AccountPasswordOption { @@ -67,8 +72,9 @@ pub async fn resolve_user( account: Option<&AccountRef>, build_search_index: bool, ) -> Result { - if let Some(owner) = USER.get() { - return Ok(Arc::clone(owner)); + let is_shell = *SHELL.lock(); + if is_shell { + return Ok(Arc::clone(&USER)); } // let account = resolve_account(account) @@ -102,21 +108,26 @@ pub async fn resolve_user_with_password( account: Option<&AccountRef>, build_search_index: bool, ) -> Result<(Owner, SecretString)> { + let is_shell = *SHELL.lock(); let account = resolve_account(account) .await .ok_or_else(|| Error::NoAccountFound)?; - let (mut owner, password) = sign_in(&account).await?; + let password = sign_in(&account).await?; // For non-shell we need to initialize the search index - if USER.get().is_none() { + if !is_shell { + let mut owner = USER.write().await; + let owner = owner + .selected_account_mut() + .ok_or(Error::NoSelectedAccount)?; if build_search_index { owner.initialize_search_index().await?; } owner.list_folders().await?; } - Ok((Arc::new(RwLock::new(owner)), password)) + Ok((Arc::clone(&USER), password)) } /// Take the optional account reference and resolve it. @@ -127,11 +138,14 @@ pub async fn resolve_user_with_password( pub async fn resolve_account( account: Option<&AccountRef>, ) -> Option { + let is_shell = *SHELL.lock(); if account.is_none() { - if let Some(owner) = USER.get() { - let reader = owner.read().await; - if reader.is_authenticated().await { - return Some((&*reader).into()); + if is_shell { + let owner = USER.read().await; + if let Some(owner) = owner.selected_account() { + if owner.is_authenticated().await { + return Some((&*owner).into()); + } } } @@ -173,9 +187,14 @@ pub async fn resolve_folder( user: &Owner, folder: Option<&FolderRef>, ) -> Result> { + let is_shell = *SHELL.lock(); let owner = user.read().await; + let owner = owner.selected_account().ok_or(Error::NoSelectedAccount)?; if let Some(vault) = folder { - let storage = owner.storage().await?; + let storage = owner + .storage() + .await + .ok_or(sos_net::sdk::Error::NoStorage)?; let reader = storage.read().await; Ok(Some( reader @@ -183,27 +202,34 @@ pub async fn resolve_folder( .cloned() .ok_or(Error::FolderNotFound(vault.to_string()))?, )) - } else if let Some(owner) = USER.get() { - let owner = owner.read().await; - let storage = owner.storage().await?; - let reader = storage.read().await; - let summary = - reader.current_folder().ok_or(Error::NoVaultSelected)?; + } else if is_shell { + let owner = USER.read().await; + let owner = + owner.selected_account().ok_or(Error::NoSelectedAccount)?; + let summary = owner + .current_folder() + .await? + .ok_or(Error::NoVaultSelected)?; Ok(Some(summary.clone())) } else { - let storage = owner.storage().await?; + let storage = owner + .storage() + .await + .ok_or(sos_net::sdk::Error::NoStorage)?; let reader = storage.read().await; Ok(reader.find(|s| s.flags().is_default()).cloned()) } } -pub async fn cd_folder( - user: Owner, - folder: Option<&FolderRef>, -) -> Result<()> { +pub async fn cd_folder(folder: Option<&FolderRef>) -> Result<()> { let summary = { - let owner = user.read().await; - let storage = owner.storage().await?; + let owner = USER.read().await; + let owner = + owner.selected_account().ok_or(Error::NoSelectedAccount)?; + let storage = owner + .storage() + .await + .ok_or(sos_net::sdk::Error::NoStorage)?; let reader = storage.read().await; let summary = if let Some(vault) = folder { Some( @@ -218,7 +244,8 @@ pub async fn cd_folder( summary.ok_or(Error::NoFolderFound)? }; - let mut owner = user.write().await; + let owner = USER.read().await; + let owner = owner.selected_account().ok_or(Error::NoSelectedAccount)?; owner.open_folder(&summary).await?; Ok(()) } @@ -227,6 +254,7 @@ pub async fn cd_folder( pub async fn verify(user: Owner) -> Result { let passphrase = read_password(Some("Password: "))?; let owner = user.read().await; + let owner = owner.selected_account().ok_or(Error::NoSelectedAccount)?; Ok(owner.verify(&AccessKey::Password(passphrase)).await) } @@ -261,60 +289,59 @@ pub async fn find_account( } /// Helper to sign in to an account. -pub async fn sign_in( - account: &AccountRef, -) -> Result<(NetworkAccount, SecretString)> { +pub async fn sign_in(account: &AccountRef) -> Result { let account = find_account(account) .await? .ok_or(Error::NoAccount(account.to_string()))?; - let passphrase = read_password(Some("Password: "))?; - let mut owner = NetworkAccount::new_unauthenticated( - *account.address(), - None, - Default::default(), - ) - .await?; - - let (tx, mut rx) = mpsc::channel::<()>(8); - tokio::task::spawn(async move { - while let Some(_) = rx.recv().await { - let banner = Banner::new() - .padding(Padding::one()) - .text("Account locked".into()) - .newline() - .text( - "This account is locked because another program is already signed in; this may be another terminal or application window.".into()) - .newline() - .text( - "To continue sign out of the account in the other window.".into()) - .render(); - println!("{}", banner); + let mut owner = USER.write().await; + + let is_authenticated = { + if let Some(current) = + owner.iter().find(|a| a.address() == account.address()) + { + current.is_authenticated().await + } else { + false } - }); + }; - let options = SigninOptions { - locked: AccountLocked::Notify(tx), + let passphrase = if !is_authenticated { + let mut current_account = NetworkAccount::new_unauthenticated( + *account.address(), + None, + Default::default(), + ) + .await?; + + let passphrase = read_password(Some("Password: "))?; + let key: AccessKey = passphrase.clone().into(); + current_account.sign_in(&key).await?; + + owner.add_account(current_account); + passphrase + } else { + SecretString::new("".into()) }; - let key: AccessKey = passphrase.clone().into(); - owner.sign_in_with_options(&key, options).await?; + owner.switch_account(account.address()); - Ok((owner, passphrase)) + Ok(passphrase) } /// Switch to a different account. -pub async fn switch( - account: &AccountRef, -) -> Result>> { - let (mut owner, _) = sign_in(account).await?; - - owner.initialize_search_index().await?; - owner.list_folders().await?; - - let mut writer = USER.get().unwrap().write().await; - *writer = owner; - Ok(Arc::clone(USER.get().unwrap())) +pub async fn switch(account: &AccountRef) -> Result { + sign_in(account).await?; + { + let mut owner = USER.write().await; + let owner = owner + .selected_account_mut() + .ok_or(Error::NoSelectedAccount)?; + + owner.initialize_search_index().await?; + owner.list_folders().await?; + } + Ok(Arc::clone(&USER)) } /// Create a new local account. diff --git a/crates/sos/src/helpers/secret.rs b/crates/sos/src/helpers/secret.rs index 402bd2a0c2..d0c961e29d 100644 --- a/crates/sos/src/helpers/secret.rs +++ b/crates/sos/src/helpers/secret.rs @@ -48,6 +48,7 @@ pub async fn resolve_secret( secret: &SecretRef, ) -> Result> { let owner = user.read().await; + let owner = owner.selected_account().ok_or(Error::NoSelectedAccount)?; let search = owner.index().await?; let index_reader = search.read().await; if let Some(Document { @@ -447,6 +448,7 @@ pub(crate) async fn download_file_secret( secret: Secret, ) -> Result<()> { let owner = resolved.user.read().await; + let owner = owner.selected_account().ok_or(Error::NoSelectedAccount)?; if let Secret::File { content, .. } = secret { match content { FileContent::External { checksum, .. } => { diff --git a/crates/sos/src/lib.rs b/crates/sos/src/lib.rs index 7869b19e65..a2a167e39a 100644 --- a/crates/sos/src/lib.rs +++ b/crates/sos/src/lib.rs @@ -26,3 +26,29 @@ pub use error::Error; /// Result type for the executable library. #[doc(hidden)] pub type Result = std::result::Result; + +/// Run the command line tool. +pub async fn run() -> Result<()> { + use kdam::term; + use sos_cli_helpers::messages::{fail, warn}; + use sos_net::sdk::logs::Logger; + + let logger: Logger = Default::default(); + logger.init_subscriber(None)?; + + if let Err(e) = crate::cli::sos::run().await { + if !e.is_interrupted() { + fail(e.to_string()); + } + + let mut owner = USER.write().await; + if let Err(e) = owner.sign_out_all().await { + warn(format!("sign out {e}")); + } + + let _ = term::show_cursor(); + std::process::exit(1); + } + + Ok(()) +} diff --git a/crates/sos/src/main.rs b/crates/sos/src/main.rs index c100bae028..1c0b1afdf1 100644 --- a/crates/sos/src/main.rs +++ b/crates/sos/src/main.rs @@ -1,34 +1,4 @@ -#[cfg(not(target_arch = "wasm32"))] -use sos::{Result, USER}; -use sos_cli_helpers::messages::{fail, warn}; - -#[cfg(not(target_arch = "wasm32"))] #[tokio::main] -async fn main() -> Result<()> { - use kdam::term; - use sos_net::sdk::{account::Account, logs::Logger}; - - let logger: Logger = Default::default(); - logger.init_subscriber(None)?; - - if let Err(e) = sos::cli::sos::run().await { - if !e.is_interrupted() { - fail(e.to_string()); - } - - if let Some(user) = USER.get() { - let mut owner = user.write().await; - if let Err(e) = owner.sign_out().await { - warn(format!("sign out {e}")); - } - } - - let _ = term::show_cursor(); - std::process::exit(1); - } - - Ok(()) +async fn main() -> sos::Result<()> { + sos::run().await } - -#[cfg(target_arch = "wasm32")] -fn main() {} diff --git a/crates/test_utils/Cargo.toml b/crates/test_utils/Cargo.toml index 93e42f53f4..df92735971 100644 --- a/crates/test_utils/Cargo.toml +++ b/crates/test_utils/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "sos_test_utils" +name = "sos-test-utils" version = "0.1.0" edition = "2021" license = "MIT OR Apache-2.0" diff --git a/crates/test_utils/src/lib.rs b/crates/test_utils/src/lib.rs index ebbfabf2bd..dcff3e17fe 100644 --- a/crates/test_utils/src/lib.rs +++ b/crates/test_utils/src/lib.rs @@ -99,7 +99,7 @@ impl MockServer { let backend = config.backend().await?; let state = Arc::new(RwLock::new(State::new(config))); - let server = Server::new(backend.directory()).await?; + let server = Server::new().await?; server .start(state, Arc::new(RwLock::new(backend)), self.handle.clone()) .await?; diff --git a/crates/test_utils/src/mock/mod.rs b/crates/test_utils/src/mock/mod.rs index 682d2171c8..8d907f6f93 100644 --- a/crates/test_utils/src/mock/mod.rs +++ b/crates/test_utils/src/mock/mod.rs @@ -7,6 +7,7 @@ use sos_net::sdk::{ device::TrustedDevice, pem, sha2::{Digest, Sha256}, + url::Url, vault::secret::{FileContent, IdentityKind, Secret, SecretMeta}, }; use std::collections::HashMap; @@ -32,6 +33,23 @@ pub fn login( (secret_meta, secret_value) } +/// Create a login secret with website urls. +pub fn login_websites( + label: &str, + account: &str, + password: SecretString, + url: Vec, +) -> (SecretMeta, Secret) { + let secret_value = Secret::Account { + account: account.to_owned(), + password, + url, + user_data: Default::default(), + }; + let secret_meta = SecretMeta::new(label.to_string(), secret_value.kind()); + (secret_meta, secret_value) +} + /// Create a note secret. pub fn note(label: &str, text: &str) -> (SecretMeta, Secret) { let secret_value = Secret::Note { diff --git a/crates/test_utils/src/network.rs b/crates/test_utils/src/network.rs index 2f9afa87a5..1cdfddff0d 100644 --- a/crates/test_utils/src/network.rs +++ b/crates/test_utils/src/network.rs @@ -3,7 +3,10 @@ use anyhow::Result; use copy_dir::copy_dir; use secrecy::SecretString; use sos_net::{ - protocol::{Origin, SyncStorage}, + protocol::{ + network_client::{ListenOptions, HttpClient}, AccountSync, Origin, + RemoteSyncHandler, SyncClient, SyncStorage, + }, sdk::{ account::{Account, AccountBuilder}, constants::{FILES_DIR, VAULT_EXT}, @@ -16,8 +19,7 @@ use sos_net::{ vault::{Summary, VaultId}, vfs, Paths, }, - AccountSync, InflightNotification, InflightTransfers, ListenOptions, - NetworkAccount, RemoteBridge, SyncClient, + InflightNotification, InflightTransfers, NetworkAccount, RemoteBridge, }; use std::{ path::PathBuf, @@ -26,6 +28,22 @@ use std::{ }; use tokio::sync::Mutex; +/// Wait for a number of websocket connections to be reported +/// by a server. +pub async fn wait_num_websocket_connections(origin: &Origin, target: usize) -> anyhow::Result<()> { + #[allow(unused_assignments)] + let mut num_conns = 0; + loop { + num_conns = HttpClient::num_connections(origin.url()).await?; + tokio::time::sleep(Duration::from_millis(50)).await; + if num_conns == target { + break; + } + } + Ok(()) +} + + /// Simulated device information. pub struct SimulatedDevice { /// Test identifier for the device. @@ -212,7 +230,10 @@ pub async fn assert_local_remote_vaults_eq( owner: &mut NetworkAccount, _provider: &mut RemoteBridge, ) -> Result<()> { - let storage = owner.storage().await?; + let storage = owner + .storage() + .await + .ok_or(sos_net::sdk::Error::NoStorage)?; let reader = storage.read().await; // Compare vault buffers @@ -245,7 +266,8 @@ pub async fn assert_local_remote_events_eq( // Compare event log status (commit proofs) let local_status = owner.sync_status().await?; - let remote_status = provider.client().sync_status().await?; + let remote_status = + provider.client().sync_status(owner.address()).await?; //println!(" local {:#?}", local_status); //println!("remote {:#?}", remote_status); diff --git a/crates/vfs/Cargo.toml b/crates/vfs/Cargo.toml index 32767420ec..95b8f9f12a 100644 --- a/crates/vfs/Cargo.toml +++ b/crates/vfs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sos-vfs" -version = "0.2.3" +version = "0.2.5" edition = "2021" description = "Virtual file system same as tokio::fs." homepage = "https://saveoursecrets.com" @@ -10,12 +10,6 @@ repository = "https://github.com/saveoursecrets/sdk" [features] mem-fs = [] -[target.'cfg(target_arch = "wasm32")'.dependencies] -tokio = { version = "1", default-features = false, features = ["rt", "sync"] } - -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] -tokio = { version = "1", default-features = false, features = ["rt", "fs", "sync", "io-util", "macros"] } - [dependencies] bitflags.workspace = true futures.workspace = true @@ -23,5 +17,14 @@ once_cell.workspace = true parking_lot.workspace = true async-recursion.workspace = true +[target.'cfg(target_arch = "wasm32")'.dependencies] +tokio = { version = "1", default-features = false, features = ["rt", "sync"] } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tokio = { version = "1", default-features = false, features = ["rt", "fs", "sync", "io-util", "macros"] } + +[target.'cfg(all(not(target_arch = "wasm32"), not(target_os = "ios"), not(target_os = "android")))'.dependencies] +async-fd-lock.workspace = true + [dev-dependencies] anyhow = "1" diff --git a/crates/vfs/src/advisory_locks.rs b/crates/vfs/src/advisory_locks.rs new file mode 100644 index 0000000000..0bb135143f --- /dev/null +++ b/crates/vfs/src/advisory_locks.rs @@ -0,0 +1,108 @@ +//! Advisory file lock functions exported for desktop platforms. +#[cfg(all( + not(test), + not(all(target_arch = "wasm32", target_os = "unknown")), + not(feature = "mem-fs"), + not(target_os = "windows"), + not(target_os = "ios"), + not(target_os = "android"), +))] +mod sys { + use async_fd_lock::{LockWrite, RwLockWriteGuard}; + use std::path::Path; + use tokio::fs::{File, OpenOptions}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + /// Write a file acquiring an exclusive lock. + /// + /// The file is created if it does not exist and + /// truncated if it does exist. + pub async fn write_exclusive( + path: impl AsRef, + buf: impl AsRef<[u8]>, + ) -> std::io::Result<()> { + let file = OpenOptions::new() + .create(true) + .truncate(true) + .read(true) + .write(true) + .open(path.as_ref()) + .await?; + let mut guard = lock_write(file).await?; + guard.write_all(buf.as_ref()).await?; + guard.flush().await?; + Ok(()) + } + + /// Acquire an exclusive write lock. + pub async fn lock_write( + file: File, + ) -> std::io::Result> { + Ok(file.lock_write().await?) + } + + /// Read acquiring an exclusive lock. + pub async fn read_exclusive( + path: impl AsRef, + ) -> std::io::Result> { + let mut guard = File::open(path.as_ref()).await?.lock_write().await?; + let mut out = Vec::new(); + guard.read_to_end(&mut out).await?; + Ok(out) + } +} + +#[cfg(all( + not(test), + not(all(target_arch = "wasm32", target_os = "unknown")), + not(feature = "mem-fs"), + not(target_os = "windows"), + not(target_os = "ios"), + not(target_os = "android"), +))] +pub use sys::*; + +#[cfg(any( + feature = "mem-fs", + all(target_arch = "wasm32", target_os = "unknown"), + target_os = "windows", + target_os = "ios", + target_os = "android", +))] +mod noop { + use crate::{read, write, File}; + use std::path::Path; + + /// Write acquiring an exclusive lock. + /// + /// Currently a NOOP for the in-memory implementation. + pub async fn write_exclusive( + path: impl AsRef, + buf: impl AsRef<[u8]>, + ) -> std::io::Result<()> { + write(path, buf).await + } + + /// Acquire an exclusive write lock. + pub async fn lock_write(file: File) -> std::io::Result { + Ok(file) + } + + /// Read acquiring an exclusive lock. + /// + /// Currently a NOOP for the in-memory implementation. + pub async fn read_exclusive( + path: impl AsRef, + ) -> std::io::Result> { + read(path).await + } +} + +#[cfg(any( + feature = "mem-fs", + all(target_arch = "wasm32", target_os = "unknown"), + target_os = "windows", + target_os = "ios", + target_os = "android", +))] +pub use noop::*; diff --git a/crates/vfs/src/lib.rs b/crates/vfs/src/lib.rs index 1e2c339a07..3323da87d3 100644 --- a/crates/vfs/src/lib.rs +++ b/crates/vfs/src/lib.rs @@ -42,34 +42,33 @@ //! `symlink()`, `symlink_metadata()`, `symlink_file()` and //! `symlink_dir()` functions are not available. //! -#![allow(dead_code)] +#[allow(dead_code)] +mod advisory_locks; #[cfg(any( - test, feature = "mem-fs", all(target_arch = "wasm32", target_os = "unknown") ))] mod memory; +#[cfg(any( + feature = "mem-fs", + all(target_arch = "wasm32", target_os = "unknown") +))] +pub use {advisory_locks::*, memory::*}; + #[cfg(all( - not(test), not(all(target_arch = "wasm32", target_os = "unknown")), not(feature = "mem-fs") ))] mod os; -#[cfg(any( - feature = "mem-fs", - all(target_arch = "wasm32", target_os = "unknown") -))] -pub use memory::*; - #[cfg(all( - not(test), not(all(target_arch = "wasm32", target_os = "unknown")), not(feature = "mem-fs") ))] -pub use os::*; +#[allow(unused_imports)] +pub use {advisory_locks::*, os::*}; #[cfg(test)] mod tests; diff --git a/crates/vfs/src/memory/file.rs b/crates/vfs/src/memory/file.rs index fdfc0415dd..105e2f0df1 100644 --- a/crates/vfs/src/memory/file.rs +++ b/crates/vfs/src/memory/file.rs @@ -5,13 +5,15 @@ //! use self::State::*; use futures::ready; -use tokio::io::{AsyncRead, AsyncSeek, AsyncWrite, ReadBuf}; -use tokio::sync::Mutex; -use tokio::{runtime::Handle, task::JoinHandle}; +use std::future::Future; + +use tokio::{ + io::{AsyncRead, AsyncSeek, AsyncWrite, ReadBuf}, + sync::Mutex, +}; use std::cmp; use std::fmt; -use std::future::Future; use std::io::{self, prelude::*, ErrorKind, Seek, SeekFrom}; use std::path::{Path, PathBuf}; use std::pin::Pin; @@ -26,15 +28,6 @@ use super::{ Metadata, OpenOptions, Permissions, }; -pub(crate) fn spawn_blocking(func: F) -> JoinHandle -where - F: FnOnce() -> R + Send + 'static, - R: Send + 'static, -{ - let rt = Handle::current(); - rt.spawn_blocking(func) -} - /// A reference to an open file on the filesystem. pub struct File { std: FileContent, @@ -90,10 +83,9 @@ struct Inner { pos: u64, } -#[derive(Debug)] enum State { Idle(Option), - Busy(JoinHandle<(Operation, Buf)>), + Busy(Pin + Send>>), } #[derive(Debug)] @@ -162,7 +154,7 @@ impl File { let std = self.std.clone(); - inner.state = Busy(spawn_blocking(move || { + inner.state = Busy(Box::pin(async move { let mut std = std.lock(); let len = std.get_ref().len() as u64; @@ -199,7 +191,7 @@ impl File { let (op, buf) = match inner.state { Idle(_) => unreachable!(), - Busy(ref mut rx) => rx.await?, + Busy(ref mut rx) => rx.await, }; inner.state = Idle(Some(buf)); @@ -258,14 +250,14 @@ impl AsyncRead for File { buf.ensure_capacity_for(dst); let std = me.std.clone(); - inner.state = Busy(spawn_blocking(move || { + inner.state = Busy(Box::pin(async move { let mut std = std.lock(); let res = buf.read_from(&mut *std); (Operation::Read(res), buf) })); } Busy(ref mut rx) => { - let (op, mut buf) = ready!(Pin::new(rx).poll(cx))?; + let (op, mut buf) = ready!(Pin::new(rx).poll(cx)); match op { Operation::Read(Ok(_)) => { @@ -328,7 +320,7 @@ impl AsyncSeek for File { let std = me.std.clone(); - inner.state = Busy(spawn_blocking(move || { + inner.state = Busy(Box::pin(async move { let mut std = std.lock(); let res = std.seek(pos); (Operation::Seek(res), buf) @@ -348,7 +340,7 @@ impl AsyncSeek for File { match inner.state { Idle(_) => return Poll::Ready(Ok(inner.pos)), Busy(ref mut rx) => { - let (op, buf) = ready!(Pin::new(rx).poll(cx))?; + let (op, buf) = ready!(Pin::new(rx).poll(cx)); inner.state = Idle(Some(buf)); match op { @@ -399,26 +391,25 @@ impl AsyncWrite for File { let std = me.std.clone(); - let blocking_task_join_handle = - spawn_blocking(move || { - let mut std = std.lock(); + let blocking_task_join_handle = Box::pin(async move { + let mut std = std.lock(); - let res = if let Some(seek) = seek { - std.seek(seek) - .and_then(|_| buf.write_to(&mut *std)) - } else { - buf.write_to(&mut *std) - }; + let res = if let Some(seek) = seek { + std.seek(seek) + .and_then(|_| buf.write_to(&mut *std)) + } else { + buf.write_to(&mut *std) + }; - (Operation::Write(res), buf) - }); + (Operation::Write(res), buf) + }); inner.state = Busy(blocking_task_join_handle); return Ready(Ok(n)); } Busy(ref mut rx) => { - let (op, buf) = ready!(Pin::new(rx).poll(cx))?; + let (op, buf) = ready!(Pin::new(rx).poll(cx)); inner.state = Idle(Some(buf)); match op { @@ -493,7 +484,7 @@ impl Inner { let (op, buf) = match self.state { Idle(_) => return Ready(Ok(())), - Busy(ref mut rx) => ready!(Pin::new(rx).poll(cx))?, + Busy(ref mut rx) => ready!(Pin::new(rx).poll(cx)), }; // The buffer is not used here diff --git a/crates/vfs/src/memory/mod.rs b/crates/vfs/src/memory/mod.rs index 9d66a8b26f..4f3b7bc637 100644 --- a/crates/vfs/src/memory/mod.rs +++ b/crates/vfs/src/memory/mod.rs @@ -1,6 +1,8 @@ //! File system backed by in-memory buffers. #![allow(unused_imports)] +use std::path::Path; + mod dir_builder; mod file; mod fs; diff --git a/crates/vfs/src/tests.rs b/crates/vfs/src/tests.rs index a5d4947cd1..af2be23abd 100644 --- a/crates/vfs/src/tests.rs +++ b/crates/vfs/src/tests.rs @@ -1,363 +1,398 @@ -use anyhow::Result; - -use std::ffi::OsString; -use std::path::{PathBuf, MAIN_SEPARATOR}; - -use crate::memory::{self as vfs, File, OpenOptions, Permissions}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; - -#[tokio::test] -async fn memory_vfs() -> Result<()> { - file_write_read().await?; - file_append().await?; - read_to_string().await?; - metadata().await?; - file_overwrite().await?; - set_len().await?; - absolute_file_write().await?; - copy_file().await?; - set_permissions().await?; - - write_read().await?; - remove_file().await?; - create_dir_remove_dir().await?; - create_dir_all_remove_dir_all().await?; - read_dir().await?; - rename().await?; - rename_replace_file().await?; - - canonicalize().await?; - - Ok(()) -} - -async fn file_write_read() -> Result<()> { - let path = "test.txt"; - let contents = "Mock content"; - - let mut fd = File::create(path).await?; - fd.write_all(contents.as_bytes()).await?; - fd.flush().await?; - assert!(vfs::try_exists(path).await?); - - let mut file_contents = Vec::new(); - let mut fd = File::open(path).await?; - fd.read_to_end(&mut file_contents).await?; - assert_eq!(contents.as_bytes(), &file_contents); - - vfs::remove_file(path).await?; - - Ok(()) -} - -async fn file_append() -> Result<()> { - let path = "test.txt"; - vfs::write(path, "one".as_bytes()).await?; - - let mut fd = OpenOptions::new() - .write(true) - .append(true) - .open(path) - .await?; - fd.write_all("two".as_bytes()).await?; - fd.flush().await?; - - let file_contents = vfs::read(path).await?; - assert_eq!("onetwo".as_bytes(), &file_contents); - - vfs::remove_file(path).await?; - - Ok(()) -} - -async fn read_to_string() -> Result<()> { - let path = "test.txt"; - let contents = "Mock content"; - vfs::write(path, contents.as_bytes()).await?; - assert!(vfs::try_exists(path).await?); +#[cfg(all(test, feature = "mem-fs"))] +mod tests { + use anyhow::Result; + + use std::ffi::OsString; + use std::io::SeekFrom; + use std::path::{PathBuf, MAIN_SEPARATOR}; + + use crate::memory::{self as vfs, File, OpenOptions, Permissions}; + use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; + + #[tokio::test] + async fn memory_vfs() -> Result<()> { + file_write_read().await?; + file_append().await?; + file_seek().await?; + read_to_string().await?; + metadata().await?; + file_overwrite().await?; + set_len().await?; + absolute_file_write().await?; + copy_file().await?; + set_permissions().await?; + + write_read().await?; + remove_file().await?; + create_dir_remove_dir().await?; + create_dir_all_remove_dir_all().await?; + read_dir().await?; + rename().await?; + rename_replace_file().await?; + + canonicalize().await?; + + Ok(()) + } + + async fn file_write_read() -> Result<()> { + let path = "test.txt"; + let contents = "Mock content"; + + let mut fd = File::create(path).await?; + fd.write_all(contents.as_bytes()).await?; + fd.flush().await?; + assert!(vfs::try_exists(path).await?); + + let mut file_contents = Vec::new(); + let mut fd = File::open(path).await?; + fd.read_to_end(&mut file_contents).await?; + assert_eq!(contents.as_bytes(), &file_contents); + + vfs::remove_file(path).await?; + + Ok(()) + } + + async fn file_append() -> Result<()> { + let path = "test.txt"; + vfs::write(path, "one".as_bytes()).await?; + + let mut fd = OpenOptions::new() + .write(true) + .append(true) + .open(path) + .await?; + fd.write_all("two".as_bytes()).await?; + fd.flush().await?; + + let file_contents = vfs::read(path).await?; + assert_eq!("onetwo".as_bytes(), &file_contents); - let file_contents = vfs::read_to_string(path).await?; - assert_eq!(contents, &file_contents); + vfs::remove_file(path).await?; - vfs::remove_file(&path).await?; - Ok(()) -} + Ok(()) + } -async fn metadata() -> Result<()> { - let path = "test.txt"; - let contents = "Mock content"; - vfs::write(path, contents.as_bytes()).await?; - assert!(vfs::try_exists(path).await?); + async fn file_seek() -> Result<()> { + let path = "test.txt"; + vfs::write(path, "one".as_bytes()).await?; - let metadata = vfs::metadata(path).await?; - assert_eq!(contents.len(), metadata.len() as usize); - assert!(metadata.is_file()); + let mut fd = OpenOptions::new().write(true).open(path).await?; + fd.seek(SeekFrom::End(0)).await?; + fd.write_all("two".as_bytes()).await?; + fd.flush().await?; - let dir_path = "test-dir"; - vfs::create_dir(dir_path).await?; + fd.seek(SeekFrom::Start(1)).await?; + let mut buf = [0; 2]; + fd.read_exact(&mut buf).await?; - let metadata = vfs::metadata(dir_path).await?; - assert_eq!(0, metadata.len() as usize); - assert!(metadata.is_dir()); + let val = std::str::from_utf8(&buf).unwrap(); + assert_eq!("ne", val); - vfs::remove_file(path).await?; - vfs::remove_dir(dir_path).await?; - Ok(()) -} + let file_contents = vfs::read(path).await?; + assert_eq!("onetwo".as_bytes(), &file_contents); -async fn file_overwrite() -> Result<()> { - let path = "test.txt"; - let one = "one"; - let two = "two"; + vfs::remove_file(path).await?; - vfs::write(path, one.as_bytes()).await?; - let contents = vfs::read_to_string(path).await?; - assert_eq!(one, &contents); + Ok(()) + } - vfs::write(path, two.as_bytes()).await?; - let contents = vfs::read_to_string(path).await?; - assert_eq!(two, &contents); + async fn read_to_string() -> Result<()> { + let path = "test.txt"; + let contents = "Mock content"; + vfs::write(path, contents.as_bytes()).await?; + assert!(vfs::try_exists(path).await?); - vfs::remove_file(path).await?; + let file_contents = vfs::read_to_string(path).await?; + assert_eq!(contents, &file_contents); - Ok(()) -} + vfs::remove_file(&path).await?; + Ok(()) + } -async fn set_len() -> Result<()> { - let path = "test.txt"; + async fn metadata() -> Result<()> { + let path = "test.txt"; + let contents = "Mock content"; + vfs::write(path, contents.as_bytes()).await?; + assert!(vfs::try_exists(path).await?); - let fd = File::create(path).await?; - // Extend length with zeroes - fd.set_len(1024).await?; + let metadata = vfs::metadata(path).await?; + assert_eq!(contents.len(), metadata.len() as usize); + assert!(metadata.is_file()); - let metadata = fd.metadata().await?; - assert_eq!(1024, metadata.len()); + let dir_path = "test-dir"; + vfs::create_dir(dir_path).await?; - // Truncate length - fd.set_len(512).await?; + let metadata = vfs::metadata(dir_path).await?; + assert_eq!(0, metadata.len() as usize); + assert!(metadata.is_dir()); - let metadata = fd.metadata().await?; - assert_eq!(512, metadata.len()); + vfs::remove_file(path).await?; + vfs::remove_dir(dir_path).await?; + Ok(()) + } - vfs::remove_file(path).await?; + async fn file_overwrite() -> Result<()> { + let path = "test.txt"; + let one = "one"; + let two = "two"; - Ok(()) -} + vfs::write(path, one.as_bytes()).await?; + let contents = vfs::read_to_string(path).await?; + assert_eq!(one, &contents); -async fn absolute_file_write() -> Result<()> { - let parent = "/foo/bar/baz"; - vfs::create_dir_all(parent).await?; - assert!(vfs::try_exists(parent).await?); + vfs::write(path, two.as_bytes()).await?; + let contents = vfs::read_to_string(path).await?; + assert_eq!(two, &contents); - let file = format!("{}/qux.vault", parent); + vfs::remove_file(path).await?; - vfs::write(&file, "mock").await?; - assert!(vfs::try_exists(&file).await?); + Ok(()) + } - vfs::remove_dir_all("/foo").await?; + async fn set_len() -> Result<()> { + let path = "test.txt"; - Ok(()) -} + let fd = File::create(path).await?; + // Extend length with zeroes + fd.set_len(1024).await?; -async fn copy_file() -> Result<()> { - let from = "from.txt"; - let to = "to.txt"; - let data = "data to copy"; + let metadata = fd.metadata().await?; + assert_eq!(1024, metadata.len()); - vfs::write(from, data.as_bytes()).await?; - assert!(vfs::try_exists(from).await?); + // Truncate length + fd.set_len(512).await?; - // Copy to same path is a noop - assert!(vfs::copy(from, from).await.is_ok()); + let metadata = fd.metadata().await?; + assert_eq!(512, metadata.len()); - vfs::copy(from, to).await?; - assert!(vfs::try_exists(to).await?); + vfs::remove_file(path).await?; - let file_contents = vfs::read(to).await?; - assert_eq!(data.as_bytes(), &file_contents); + Ok(()) + } - // Trigger the code path that overwrites an existing file - vfs::copy(from, to).await?; + async fn absolute_file_write() -> Result<()> { + let parent = "/foo/bar/baz"; + vfs::create_dir_all(parent).await?; + assert!(vfs::try_exists(parent).await?); - vfs::remove_file(from).await?; - vfs::remove_file(to).await?; + let file = format!("{}/qux.vault", parent); - Ok(()) -} + vfs::write(&file, "mock").await?; + assert!(vfs::try_exists(&file).await?); -async fn set_permissions() -> Result<()> { - let path = "test.txt"; + vfs::remove_dir_all("/foo").await?; - vfs::write(path, "mock").await?; - assert!(vfs::try_exists(path).await?); + Ok(()) + } - let mut perm: Permissions = Default::default(); - perm.set_readonly(true); + async fn copy_file() -> Result<()> { + let from = "from.txt"; + let to = "to.txt"; + let data = "data to copy"; - vfs::set_permissions(path, perm).await?; - assert!(vfs::metadata(path).await?.permissions().readonly()); + vfs::write(from, data.as_bytes()).await?; + assert!(vfs::try_exists(from).await?); - vfs::remove_file(path).await?; + // Copy to same path is a noop + assert!(vfs::copy(from, from).await.is_ok()); - Ok(()) -} + vfs::copy(from, to).await?; + assert!(vfs::try_exists(to).await?); -async fn write_read() -> Result<()> { - let path = "test.txt"; - let contents = b"Mock content".to_vec(); - vfs::write(path, &contents).await?; + let file_contents = vfs::read(to).await?; + assert_eq!(data.as_bytes(), &file_contents); - assert!(vfs::try_exists(path).await?); + // Trigger the code path that overwrites an existing file + vfs::copy(from, to).await?; - let file_contents = vfs::read(path).await?; - assert_eq!(&contents, &file_contents); + vfs::remove_file(from).await?; + vfs::remove_file(to).await?; - vfs::remove_file(path).await?; - Ok(()) -} + Ok(()) + } -async fn remove_file() -> Result<()> { - let path = "test.txt"; - let contents = b"Mock content".to_vec(); - vfs::write(path, &contents).await?; + async fn set_permissions() -> Result<()> { + let path = "test.txt"; - vfs::remove_file(path).await?; - assert!(!vfs::try_exists(path).await?); - Ok(()) -} + vfs::write(path, "mock").await?; + assert!(vfs::try_exists(path).await?); -async fn create_dir_remove_dir() -> Result<()> { - vfs::create_dir("foo").await?; - assert!(vfs::try_exists("foo").await?); + let mut perm: Permissions = Default::default(); + perm.set_readonly(true); - vfs::remove_dir("foo").await?; - assert!(!vfs::try_exists("foo").await?); - Ok(()) -} + vfs::set_permissions(path, perm).await?; + assert!(vfs::metadata(path).await?.permissions().readonly()); -async fn create_dir_all_remove_dir_all() -> Result<()> { - vfs::create_dir_all("foo/bar").await?; - assert!(vfs::try_exists("foo").await?); - assert!(vfs::try_exists("foo/bar").await?); + vfs::remove_file(path).await?; - vfs::remove_dir_all("foo").await?; - assert!(!vfs::try_exists("foo/bar").await?); - assert!(!vfs::try_exists("foo").await?); - Ok(()) -} + Ok(()) + } -async fn read_dir() -> Result<()> { - let dir = "read-dir"; - vfs::create_dir(dir).await?; - - let one = b"one".to_vec(); - let two = b"two".to_vec(); - vfs::write("read-dir/abc.txt", &one).await?; - vfs::write("read-dir/def.txt", &two).await?; - vfs::create_dir("read-dir/ghi").await?; - - let mut dir_reader = vfs::read_dir(dir).await?; - let first = dir_reader.next_entry().await?; - - assert_eq!( - OsString::from("abc.txt"), - first.as_ref().unwrap().file_name() - ); - assert_eq!( - PathBuf::from("/read-dir/abc.txt"), - first.as_ref().unwrap().path() - ); - assert!(first.as_ref().unwrap().file_type().await?.is_file()); - - let second = dir_reader.next_entry().await?; - assert_eq!( - OsString::from("def.txt"), - second.as_ref().unwrap().file_name() - ); - assert_eq!( - PathBuf::from("/read-dir/def.txt"), - second.as_ref().unwrap().path() - ); - assert!(second.as_ref().unwrap().file_type().await?.is_file()); - - let third = dir_reader.next_entry().await?; - assert_eq!(OsString::from("ghi"), third.as_ref().unwrap().file_name()); - assert_eq!( - PathBuf::from("/read-dir/ghi"), - third.as_ref().unwrap().path() - ); - assert!(third.as_ref().unwrap().file_type().await?.is_dir()); - - vfs::remove_dir_all("read-dir").await?; - - Ok(()) -} + async fn write_read() -> Result<()> { + let path = "test.txt"; + let contents = b"Mock content".to_vec(); + vfs::write(path, &contents).await?; -async fn rename() -> Result<()> { - vfs::create_dir("foo").await?; - let exists = vfs::try_exists("foo").await?; - assert!(exists); + assert!(vfs::try_exists(path).await?); - vfs::rename("foo", "bar").await?; - assert!(!vfs::try_exists("foo").await?); - assert!(vfs::try_exists("bar").await?); + let file_contents = vfs::read(path).await?; + assert_eq!(&contents, &file_contents); - vfs::remove_dir_all("bar").await?; + vfs::remove_file(path).await?; + Ok(()) + } - Ok(()) -} + async fn remove_file() -> Result<()> { + let path = "test.txt"; + let contents = b"Mock content".to_vec(); + vfs::write(path, &contents).await?; -async fn rename_replace_file() -> Result<()> { - vfs::write("foo.txt", b"foo").await?; - vfs::write("bar.txt", b"bar").await?; - assert!(vfs::try_exists("foo.txt").await?); - assert!(vfs::try_exists("bar.txt").await?); + vfs::remove_file(path).await?; + assert!(!vfs::try_exists(path).await?); + Ok(()) + } - vfs::rename("foo.txt", "bar.txt").await?; + async fn create_dir_remove_dir() -> Result<()> { + vfs::create_dir("foo").await?; + assert!(vfs::try_exists("foo").await?); - assert!(!vfs::try_exists("foo.txt").await?); - assert!(vfs::try_exists("bar.txt").await?); + vfs::remove_dir("foo").await?; + assert!(!vfs::try_exists("foo").await?); + Ok(()) + } - Ok(()) -} + async fn create_dir_all_remove_dir_all() -> Result<()> { + vfs::create_dir_all("foo/bar").await?; + assert!(vfs::try_exists("foo").await?); + assert!(vfs::try_exists("foo/bar").await?); -async fn canonicalize() -> Result<()> { - assert!(vfs::canonicalize("").await.is_err()); - assert_eq!( - PathBuf::from(MAIN_SEPARATOR.to_string()), - vfs::canonicalize(MAIN_SEPARATOR.to_string()).await? - ); - - vfs::create_dir("baz").await?; - assert!(vfs::try_exists("baz").await?); - vfs::create_dir_all("foo/bar/qux").await?; - assert!(vfs::try_exists("foo").await?); - assert!(vfs::try_exists("foo/bar").await?); - assert!(vfs::try_exists("foo/bar/qux").await?); - - assert_eq!(PathBuf::from("/"), vfs::canonicalize("foo/..").await?,); - - assert_eq!(PathBuf::from("/foo"), vfs::canonicalize("foo/././.").await?,); - - assert_eq!( - PathBuf::from("/baz"), - vfs::canonicalize("foo/../baz").await?, - ); - - assert_eq!( - PathBuf::from("/foo/bar/qux"), - vfs::canonicalize("foo/../foo/bar/qux").await?, - ); - - assert_eq!( - PathBuf::from("/"), - vfs::canonicalize("foo/bar/../..").await?, - ); - - assert_eq!( - PathBuf::from("/foo/bar/qux"), - vfs::canonicalize("foo/bar/../../foo/bar/qux").await?, - ); - - Ok(()) + vfs::remove_dir_all("foo").await?; + assert!(!vfs::try_exists("foo/bar").await?); + assert!(!vfs::try_exists("foo").await?); + Ok(()) + } + + async fn read_dir() -> Result<()> { + let dir = "read-dir"; + vfs::create_dir(dir).await?; + + let one = b"one".to_vec(); + let two = b"two".to_vec(); + vfs::write("read-dir/abc.txt", &one).await?; + vfs::write("read-dir/def.txt", &two).await?; + vfs::create_dir("read-dir/ghi").await?; + + let mut dir_reader = vfs::read_dir(dir).await?; + let first = dir_reader.next_entry().await?; + + assert_eq!( + OsString::from("abc.txt"), + first.as_ref().unwrap().file_name() + ); + assert_eq!( + PathBuf::from("/read-dir/abc.txt"), + first.as_ref().unwrap().path() + ); + assert!(first.as_ref().unwrap().file_type().await?.is_file()); + + let second = dir_reader.next_entry().await?; + assert_eq!( + OsString::from("def.txt"), + second.as_ref().unwrap().file_name() + ); + assert_eq!( + PathBuf::from("/read-dir/def.txt"), + second.as_ref().unwrap().path() + ); + assert!(second.as_ref().unwrap().file_type().await?.is_file()); + + let third = dir_reader.next_entry().await?; + assert_eq!( + OsString::from("ghi"), + third.as_ref().unwrap().file_name() + ); + assert_eq!( + PathBuf::from("/read-dir/ghi"), + third.as_ref().unwrap().path() + ); + assert!(third.as_ref().unwrap().file_type().await?.is_dir()); + + vfs::remove_dir_all("read-dir").await?; + + Ok(()) + } + + async fn rename() -> Result<()> { + vfs::create_dir("foo").await?; + let exists = vfs::try_exists("foo").await?; + assert!(exists); + + vfs::rename("foo", "bar").await?; + assert!(!vfs::try_exists("foo").await?); + assert!(vfs::try_exists("bar").await?); + + vfs::remove_dir_all("bar").await?; + + Ok(()) + } + + async fn rename_replace_file() -> Result<()> { + vfs::write("foo.txt", b"foo").await?; + vfs::write("bar.txt", b"bar").await?; + assert!(vfs::try_exists("foo.txt").await?); + assert!(vfs::try_exists("bar.txt").await?); + + vfs::rename("foo.txt", "bar.txt").await?; + + assert!(!vfs::try_exists("foo.txt").await?); + assert!(vfs::try_exists("bar.txt").await?); + + Ok(()) + } + + async fn canonicalize() -> Result<()> { + assert!(vfs::canonicalize("").await.is_err()); + assert_eq!( + PathBuf::from(MAIN_SEPARATOR.to_string()), + vfs::canonicalize(MAIN_SEPARATOR.to_string()).await? + ); + + vfs::create_dir("baz").await?; + assert!(vfs::try_exists("baz").await?); + vfs::create_dir_all("foo/bar/qux").await?; + assert!(vfs::try_exists("foo").await?); + assert!(vfs::try_exists("foo/bar").await?); + assert!(vfs::try_exists("foo/bar/qux").await?); + + assert_eq!(PathBuf::from("/"), vfs::canonicalize("foo/..").await?,); + + assert_eq!( + PathBuf::from("/foo"), + vfs::canonicalize("foo/././.").await?, + ); + + assert_eq!( + PathBuf::from("/baz"), + vfs::canonicalize("foo/../baz").await?, + ); + + assert_eq!( + PathBuf::from("/foo/bar/qux"), + vfs::canonicalize("foo/../foo/bar/qux").await?, + ); + + assert_eq!( + PathBuf::from("/"), + vfs::canonicalize("foo/bar/../..").await?, + ); + + assert_eq!( + PathBuf::from("/foo/bar/qux"), + vfs::canonicalize("foo/bar/../../foo/bar/qux").await?, + ); + + Ok(()) + } } diff --git a/crates/web/Cargo.toml b/crates/web/Cargo.toml new file mode 100644 index 0000000000..29501c761d --- /dev/null +++ b/crates/web/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "sos-web" +version = "0.16.5" +edition = "2021" +description = "Thin client for webassembly bindings to the Save Our Secrets SDK." +homepage = "https://saveoursecrets.com" +license = "MIT OR Apache-2.0" +repository = "https://github.com/saveoursecrets/sdk" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + +[features] +account = ["sos-sdk/account", "sos-protocol/account"] +archive = [] +clipboard = [] +contacts = ["sos-sdk/contacts", "sos-protocol/contacts"] +files = [] +migrate = [] +search = ["sos-sdk/search", "sos-protocol/search"] + +[dependencies] +thiserror.workspace = true +tracing.workspace = true +indexmap.workspace = true +async-trait.workspace = true + +sos-sdk = { version = "0.16", path = "../sdk", features = ["account"] } +sos-protocol = { version = "0.16", path = "../protocol", features = ["network-client"] } + +tokio = { version = "1", features = ["rt", "io-util", "sync"] } + +[build-dependencies] +rustc_version = "0.4.1" + diff --git a/crates/web/build.rs b/crates/web/build.rs new file mode 100644 index 0000000000..5976a1c6d5 --- /dev/null +++ b/crates/web/build.rs @@ -0,0 +1,14 @@ +use rustc_version::{version_meta, Channel}; + +fn main() { + println!("cargo::rustc-check-cfg=cfg(CHANNEL_NIGHTLY)"); + + // Set cfg flags depending on release channel + let channel = match version_meta().unwrap().channel { + Channel::Stable => "CHANNEL_STABLE", + Channel::Beta => "CHANNEL_BETA", + Channel::Nightly => "CHANNEL_NIGHTLY", + Channel::Dev => "CHANNEL_DEV", + }; + println!("cargo:rustc-cfg={}", channel); +} diff --git a/crates/web/src/error.rs b/crates/web/src/error.rs new file mode 100644 index 0000000000..3373b725a5 --- /dev/null +++ b/crates/web/src/error.rs @@ -0,0 +1,43 @@ +use sos_protocol::{AsConflict, ConflictError}; +use thiserror::Error; + +/// Error type for the library. +#[derive(Error, Debug)] +pub enum Error { + /// Errors generated by the IO module. + #[error(transparent)] + Io(#[from] std::io::Error), + + /// Errors generated by the SDK library. + #[error(transparent)] + Sdk(#[from] sos_sdk::Error), + + /// Errors generated by the protocol library. + #[error(transparent)] + Protocol(#[from] sos_protocol::Error), + + /// Errors generated from network responses. + #[error(transparent)] + Network(#[from] sos_protocol::NetworkError), + + /// Errors generated on conflict. + #[error(transparent)] + Conflict(#[from] sos_protocol::ConflictError), +} + +impl AsConflict for Error { + fn is_conflict(&self) -> bool { + matches!(self, Error::Conflict(_)) + } + + fn is_hard_conflict(&self) -> bool { + matches!(self, Error::Conflict(ConflictError::Hard)) + } + + fn take_conflict(self) -> Option { + match self { + Self::Conflict(err) => Some(err), + _ => None, + } + } +} diff --git a/crates/web/src/lib.rs b/crates/web/src/lib.rs new file mode 100644 index 0000000000..b543114ed3 --- /dev/null +++ b/crates/web/src/lib.rs @@ -0,0 +1,23 @@ +#![deny(missing_docs)] +#![forbid(unsafe_code)] +#![cfg_attr(all(doc, CHANNEL_NIGHTLY), feature(doc_auto_cfg))] +//! Web accounts for the [Save Our Secrets SDK](https://saveoursecrets.com/) intended to be used in webassembly bindings. + +use sos_sdk::prelude::{Account, AccountSwitcher}; + +mod error; + +mod linked_account; +pub use linked_account::*; + +/// Account switcher for linked accounts. +pub type LinkedAccountSwitcher = AccountSwitcher< + LinkedAccount, + ::NetworkResult, + ::Error, +>; + +pub use error::Error; + +/// Result type for the library. +pub type Result = std::result::Result; diff --git a/crates/web/src/linked_account.rs b/crates/web/src/linked_account.rs new file mode 100644 index 0000000000..242123ecf7 --- /dev/null +++ b/crates/web/src/linked_account.rs @@ -0,0 +1,1172 @@ +//! Linked account supports syncing with a single remote. +use crate::{Error, Result}; +use async_trait::async_trait; +use indexmap::IndexSet; +use sos_protocol::{ + network_client::HttpClient, AutoMerge, Origin, RemoteResult, RemoteSync, + RemoteSyncHandler, SyncClient, SyncDirection, SyncOptions, SyncStatus, + SyncStorage, UpdateSet, +}; +use sos_sdk::{ + events::{ + AccountEventLog, AccountPatch, DeviceEventLog, DevicePatch, + FolderEventLog, FolderPatch, + }, + prelude::{ + AccessKey, AccessOptions, Account, AccountChange, AccountData, + AccountEvent, Address, Cipher, CipherComparison, ClientStorage, + CommitHash, CommitState, DetachedView, DeviceManager, + DevicePublicKey, DeviceSigner, EventRecord, FolderChange, + FolderCreate, FolderDelete, KeyDerivation, LocalAccount, + NewFolderOptions, Paths, PublicIdentity, ReadEvent, Secret, + SecretChange, SecretDelete, SecretId, SecretInsert, SecretMeta, + SecretMove, SecretRow, StorageEventLogs, Summary, TrustedDevice, + Vault, VaultCommit, VaultFlags, VaultId, + }, + secrecy::SecretString, + signer::ecdsa::BoxedEcdsaSigner, + vfs, +}; +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + sync::Arc, +}; +use tokio::sync::{Mutex, RwLock}; + +#[cfg(feature = "clipboard")] +use sos_sdk::{ + prelude::{ClipboardCopyRequest, SecretPath}, + xclipboard::Clipboard, +}; + +#[cfg(feature = "search")] +use sos_sdk::prelude::{ + AccountStatistics, ArchiveFilter, Document, DocumentCount, DocumentView, + QueryFilter, SearchIndex, +}; + +#[cfg(feature = "archive")] +use sos_sdk::prelude::{Inventory, RestoreOptions}; + +#[cfg(feature = "contacts")] +use sos_sdk::prelude::ContactImportProgress; + +#[cfg(feature = "archive")] +use tokio::io::{AsyncRead, AsyncSeek}; + +#[cfg(feature = "migrate")] +use sos_sdk::prelude::ImportTarget; + +#[cfg(feature = "files")] +use sos_protocol::transfer::FileTransferQueueSender; + +#[cfg(feature = "files")] +use sos_sdk::prelude::{FileEventLog, FilePatch}; + +/// Linked account syncs with a local account on the same device. +pub struct LinkedAccount { + account: Arc>, + address: Address, + paths: Arc, + client: HttpClient, + /// Lock to prevent write to local storage + /// whilst a sync operation is in progress. + sync_lock: Arc>, +} + +impl LinkedAccount { + /// Create a new unauthenticated linked account. + pub async fn new_unauthenticated( + address: Address, + client: HttpClient, + data_dir: Option, + ) -> Result { + let account = + LocalAccount::new_unauthenticated(address, data_dir).await?; + Ok(Self { + paths: account.paths(), + account: Arc::new(Mutex::new(account)), + address, + client, + sync_lock: Arc::new(Mutex::new(())), + }) + } + + /// Create a new linked account. + pub async fn new_account( + account_name: String, + passphrase: SecretString, + client: HttpClient, + data_dir: Option, + ) -> Result { + let account = + LocalAccount::new_account(account_name, passphrase, data_dir) + .await?; + Ok(Self { + address: *account.address(), + paths: account.paths(), + account: Arc::new(Mutex::new(account)), + client, + sync_lock: Arc::new(Mutex::new(())), + }) + } +} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl Account for LinkedAccount { + type Error = Error; + type NetworkResult = RemoteResult; + + fn address(&self) -> &Address { + &self.address + } + + fn paths(&self) -> Arc { + self.paths.clone() + } + + async fn is_authenticated(&self) -> bool { + let account = self.account.lock().await; + account.is_authenticated().await + } + + async fn account_signer(&self) -> Result { + let account = self.account.lock().await; + Ok(account.account_signer().await?) + } + + async fn import_account_events( + &mut self, + identity: FolderPatch, + account: AccountPatch, + device: DevicePatch, + folders: HashMap, + #[cfg(feature = "files")] files: FilePatch, + ) -> Result<()> { + let mut inner = self.account.lock().await; + Ok(inner + .import_account_events( + identity, + account, + device, + folders, + #[cfg(feature = "files")] + files, + ) + .await?) + } + + async fn new_device_vault( + &mut self, + ) -> Result<(DeviceSigner, DeviceManager)> { + let mut account = self.account.lock().await; + Ok(account.new_device_vault().await?) + } + + async fn device_signer(&self) -> Result { + let account = self.account.lock().await; + Ok(account.device_signer().await?) + } + + async fn device_public_key(&self) -> Result { + let account = self.account.lock().await; + Ok(account.device_public_key().await?) + } + + async fn current_device(&self) -> Result { + let account = self.account.lock().await; + Ok(account.current_device().await?) + } + + async fn trusted_devices(&self) -> Result> { + let account = self.account.lock().await; + Ok(account.trusted_devices().await?) + } + + async fn public_identity(&self) -> Result { + let account = self.account.lock().await; + Ok(account.public_identity().await?) + } + + async fn account_label(&self) -> Result { + let account = self.account.lock().await; + Ok(account.account_label().await?) + } + + async fn folder_description( + &mut self, + folder: &Summary, + ) -> Result { + let mut account = self.account.lock().await; + Ok(account.folder_description(folder).await?) + } + + async fn set_folder_description( + &mut self, + folder: &Summary, + description: impl AsRef + Send + Sync, + ) -> Result> { + let _ = self.sync_lock.lock().await; + + let result = { + let mut account = self.account.lock().await; + account.set_folder_description(folder, description).await? + }; + + let result = FolderChange { + event: result.event, + commit_state: result.commit_state, + sync_result: self.sync().await, + }; + + Ok(result) + } + + async fn find_folder_password( + &self, + folder_id: &VaultId, + ) -> Result> { + let account = self.account.lock().await; + Ok(account.find_folder_password(folder_id).await?) + } + + async fn generate_folder_password(&self) -> Result { + let account = self.account.lock().await; + Ok(account.generate_folder_password().await?) + } + + async fn identity_vault_buffer(&self) -> Result> { + let account = self.account.lock().await; + Ok(account.identity_vault_buffer().await?) + } + + async fn identity_folder_summary(&self) -> Result { + let account = self.account.lock().await; + Ok(account.identity_folder_summary().await?) + } + + async fn reload_identity_folder(&mut self) -> Result<()> { + let mut account = self.account.lock().await; + Ok(account.reload_identity_folder().await?) + } + + async fn change_cipher( + &mut self, + account_key: &AccessKey, + cipher: &Cipher, + kdf: Option, + ) -> Result { + let mut account = self.account.lock().await; + Ok(account.change_cipher(account_key, cipher, kdf).await?) + } + + async fn change_account_password( + &mut self, + password: SecretString, + ) -> Result<()> { + let mut account = self.account.lock().await; + Ok(account.change_account_password(password).await?) + } + + async fn sign_in(&mut self, key: &AccessKey) -> Result> { + let mut account = self.account.lock().await; + Ok(account.sign_in(key).await?) + } + + async fn verify(&self, key: &AccessKey) -> bool { + let account = self.account.lock().await; + account.verify(key).await + } + + async fn open_folder(&self, summary: &Summary) -> Result<()> { + let account = self.account.lock().await; + Ok(account.open_folder(summary).await?) + } + + async fn current_folder(&self) -> Result> { + let account = self.account.lock().await; + Ok(account.current_folder().await?) + } + + async fn find

(&self, predicate: P) -> Option

+ where + P: FnMut(&&Summary) -> bool + Send, + { + let account = self.account.lock().await; + account.find(predicate).await + } + + async fn sign_out(&mut self) -> Result<()> { + let mut account = self.account.lock().await; + Ok(account.sign_out().await?) + } + + async fn rename_account( + &mut self, + account_name: String, + ) -> Result> { + let _ = self.sync_lock.lock().await; + let result = { + let mut account = self.account.lock().await; + account.rename_account(account_name).await? + }; + + let result = AccountChange { + event: result.event, + sync_result: self.sync().await, + }; + + Ok(result) + } + + async fn delete_account(&mut self) -> Result<()> { + let mut account = self.account.lock().await; + Ok(account.delete_account().await?) + } + + async fn storage(&self) -> Option>> { + let account = self.account.lock().await; + account.storage().await + } + + async fn set_storage( + &mut self, + storage: Option>>, + ) { + let mut account = self.account.lock().await; + account.set_storage(storage).await + } + + async fn secret_ids(&self, summary: &Summary) -> Result> { + let account = self.account.lock().await; + Ok(account.secret_ids(summary).await?) + } + + async fn load_folders(&mut self) -> Result> { + let mut account = self.account.lock().await; + Ok(account.load_folders().await?) + } + + async fn list_folders(&self) -> Result> { + let account = self.account.lock().await; + Ok(account.list_folders().await?) + } + + async fn account_data(&self) -> Result { + let account = self.account.lock().await; + Ok(account.account_data().await?) + } + + async fn root_commit(&self, summary: &Summary) -> Result { + let account = self.account.lock().await; + Ok(account.root_commit(summary).await?) + } + + async fn identity_state(&self) -> Result { + let account = self.account.lock().await; + Ok(account.identity_state().await?) + } + + async fn commit_state(&self, summary: &Summary) -> Result { + let account = self.account.lock().await; + Ok(account.commit_state(summary).await?) + } + + async fn compact_account( + &mut self, + ) -> Result> { + let mut account = self.account.lock().await; + Ok(account.compact_account().await?) + } + + async fn compact_folder( + &mut self, + summary: &Summary, + ) -> Result<(AccountEvent, u64, u64)> { + let mut account = self.account.lock().await; + Ok(account.compact_folder(summary).await?) + } + + async fn restore_folder( + &mut self, + folder_id: &VaultId, + records: Vec, + ) -> Result { + let mut account = self.account.lock().await; + Ok(account.restore_folder(folder_id, records).await?) + } + + async fn change_folder_password( + &mut self, + folder: &Summary, + new_key: AccessKey, + ) -> Result<()> { + let mut account = self.account.lock().await; + Ok(account.change_folder_password(folder, new_key).await?) + } + + #[cfg(feature = "search")] + async fn detached_view( + &self, + summary: &Summary, + commit: CommitHash, + ) -> Result { + let account = self.account.lock().await; + Ok(account.detached_view(summary, commit).await?) + } + + #[cfg(feature = "search")] + async fn initialize_search_index( + &mut self, + ) -> Result<(DocumentCount, Vec)> { + let mut account = self.account.lock().await; + Ok(account.initialize_search_index().await?) + } + + #[cfg(feature = "search")] + async fn statistics(&self) -> AccountStatistics { + let account = self.account.lock().await; + account.statistics().await + } + + #[cfg(feature = "search")] + async fn index(&self) -> Result>> { + let account = self.account.lock().await; + Ok(account.index().await?) + } + + #[cfg(feature = "search")] + async fn query_view( + &self, + views: &[DocumentView], + archive: Option<&ArchiveFilter>, + ) -> Result> { + let account = self.account.lock().await; + Ok(account.query_view(views, archive).await?) + } + + #[cfg(feature = "search")] + async fn query_map( + &self, + query: &str, + filter: QueryFilter, + ) -> Result> { + let account = self.account.lock().await; + Ok(account.query_map(query, filter).await?) + } + + #[cfg(feature = "search")] + async fn document_count(&self) -> Result { + let account = self.account.lock().await; + Ok(account.document_count().await?) + } + + #[cfg(feature = "search")] + async fn document_exists( + &self, + vault_id: &VaultId, + label: &str, + id: Option<&SecretId>, + ) -> Result { + let account = self.account.lock().await; + Ok(account.document_exists(vault_id, label, id).await?) + } + + #[cfg(feature = "files")] + async fn download_file( + &self, + vault_id: &VaultId, + secret_id: &SecretId, + file_name: &str, + ) -> Result> { + let account = self.account.lock().await; + Ok(account + .download_file(vault_id, secret_id, file_name) + .await?) + } + + async fn create_secret( + &mut self, + meta: SecretMeta, + secret: Secret, + options: AccessOptions, + ) -> Result> { + let _ = self.sync_lock.lock().await; + + let result = { + let mut account = self.account.lock().await; + account.create_secret(meta, secret, options).await? + }; + + let result = SecretChange { + id: result.id, + event: result.event, + commit_state: result.commit_state, + folder: result.folder, + sync_result: self.sync().await, + #[cfg(feature = "files")] + file_events: result.file_events, + }; + + /* + #[cfg(feature = "files")] + self.queue_file_mutation_events(&result.file_events).await?; + */ + + Ok(result) + } + + async fn insert_secrets( + &mut self, + secrets: Vec<(SecretMeta, Secret)>, + ) -> Result> { + let _ = self.sync_lock.lock().await; + + let result = { + let mut account = self.account.lock().await; + account.insert_secrets(secrets).await? + }; + + #[cfg(feature = "files")] + let mut file_events = Vec::new(); + + let result = SecretInsert { + results: result + .results + .into_iter() + .map(|#[allow(unused_mut)] mut result| { + #[cfg(feature = "files")] + file_events.append(&mut result.file_events); + SecretChange { + id: result.id, + event: result.event, + commit_state: result.commit_state, + folder: result.folder, + sync_result: RemoteResult { + origin: self.client.origin().clone(), + result: Ok(None), + }, + #[cfg(feature = "files")] + file_events: result.file_events, + } + }) + .collect(), + sync_result: self.sync().await, + }; + + /* + #[cfg(feature = "files")] + self.queue_file_mutation_events(&file_events).await?; + */ + + Ok(result) + } + + async fn update_secret( + &mut self, + secret_id: &SecretId, + meta: SecretMeta, + secret: Option, + options: AccessOptions, + destination: Option<&Summary>, + ) -> Result> { + let _ = self.sync_lock.lock().await; + + let result = { + let mut account = self.account.lock().await; + account + .update_secret(secret_id, meta, secret, options, destination) + .await? + }; + + let result = SecretChange { + id: result.id, + event: result.event, + commit_state: result.commit_state, + folder: result.folder, + sync_result: self.sync().await, + #[cfg(feature = "files")] + file_events: result.file_events, + }; + + /* + #[cfg(feature = "files")] + self.queue_file_mutation_events(&result.file_events).await?; + */ + + Ok(result) + } + + async fn move_secret( + &mut self, + secret_id: &SecretId, + from: &Summary, + to: &Summary, + options: AccessOptions, + ) -> Result> { + let _ = self.sync_lock.lock().await; + + let result = { + let mut account = self.account.lock().await; + account.move_secret(secret_id, from, to, options).await? + }; + + let result = SecretMove { + id: result.id, + event: result.event, + sync_result: self.sync().await, + #[cfg(feature = "files")] + file_events: result.file_events, + }; + + /* + #[cfg(feature = "files")] + self.queue_file_mutation_events(&result.file_events).await?; + */ + + Ok(result) + } + + async fn read_secret( + &self, + secret_id: &SecretId, + folder: Option, + ) -> Result<(SecretRow, ReadEvent)> { + let account = self.account.lock().await; + Ok(account.read_secret(secret_id, folder).await?) + } + + async fn raw_secret( + &self, + folder_id: &VaultId, + secret_id: &SecretId, + ) -> Result<(Option, ReadEvent)> { + let account = self.account.lock().await; + Ok(account.raw_secret(folder_id, secret_id).await?) + } + + async fn delete_secret( + &mut self, + secret_id: &SecretId, + options: AccessOptions, + ) -> Result> { + let _ = self.sync_lock.lock().await; + + let result = { + let mut account = self.account.lock().await; + account.delete_secret(secret_id, options).await? + }; + + let result = SecretDelete { + event: result.event, + commit_state: result.commit_state, + folder: result.folder, + sync_result: self.sync().await, + #[cfg(feature = "files")] + file_events: result.file_events, + }; + + /* + #[cfg(feature = "files")] + self.queue_file_mutation_events(&result.file_events).await?; + */ + + Ok(result) + } + + async fn archive( + &mut self, + from: &Summary, + secret_id: &SecretId, + options: AccessOptions, + ) -> Result> { + let _ = self.sync_lock.lock().await; + let result = { + let mut account = self.account.lock().await; + account.archive(from, secret_id, options).await? + }; + + let result = SecretMove { + id: result.id, + event: result.event, + sync_result: self.sync().await, + #[cfg(feature = "files")] + file_events: result.file_events, + }; + + /* + #[cfg(feature = "files")] + self.queue_file_mutation_events(&result.file_events).await?; + */ + + Ok(result) + } + + async fn unarchive( + &mut self, + secret_id: &SecretId, + secret_meta: &SecretMeta, + options: AccessOptions, + ) -> Result<(SecretMove, Summary)> { + let _ = self.sync_lock.lock().await; + + let (result, to) = { + let mut account = self.account.lock().await; + account.unarchive(secret_id, secret_meta, options).await? + }; + + let result = SecretMove { + id: result.id, + event: result.event, + sync_result: self.sync().await, + #[cfg(feature = "files")] + file_events: result.file_events, + }; + + /* + #[cfg(feature = "files")] + self.queue_file_mutation_events(&result.file_events).await?; + */ + + Ok((result, to)) + } + + #[cfg(feature = "files")] + async fn update_file( + &mut self, + secret_id: &SecretId, + meta: SecretMeta, + path: impl AsRef + Send + Sync, + options: AccessOptions, + destination: Option<&Summary>, + ) -> Result> { + let _ = self.sync_lock.lock().await; + + let result = { + let mut account = self.account.lock().await; + account + .update_file(secret_id, meta, path, options, destination) + .await? + }; + + let result = SecretChange { + id: result.id, + event: result.event, + commit_state: result.commit_state, + folder: result.folder, + sync_result: self.sync().await, + #[cfg(feature = "files")] + file_events: result.file_events, + }; + + /* + #[cfg(feature = "files")] + self.queue_file_mutation_events(&result.file_events).await?; + */ + + Ok(result) + } + + async fn create_folder( + &mut self, + name: String, + options: NewFolderOptions, + ) -> Result> { + let _ = self.sync_lock.lock().await; + let result = { + let mut account = self.account.lock().await; + account.create_folder(name, options).await? + }; + + let result = FolderCreate { + folder: result.folder, + event: result.event, + commit_state: result.commit_state, + sync_result: self.sync().await, + }; + + Ok(result) + } + + async fn rename_folder( + &mut self, + summary: &Summary, + name: String, + ) -> Result> { + let _ = self.sync_lock.lock().await; + let result = { + let mut account = self.account.lock().await; + account.rename_folder(summary, name).await? + }; + + let result = FolderChange { + event: result.event, + commit_state: result.commit_state, + sync_result: self.sync().await, + }; + + Ok(result) + } + + async fn update_folder_flags( + &mut self, + summary: &Summary, + flags: VaultFlags, + ) -> Result> { + let _ = self.sync_lock.lock().await; + let result = { + let mut account = self.account.lock().await; + account.update_folder_flags(summary, flags).await? + }; + + let result = FolderChange { + event: result.event, + commit_state: result.commit_state, + sync_result: self.sync().await, + }; + + Ok(result) + } + + async fn import_folder( + &mut self, + path: impl AsRef + Send + Sync, + key: AccessKey, + overwrite: bool, + ) -> Result> { + let buffer = vfs::read(path.as_ref()).await?; + self.import_folder_buffer(&buffer, key, overwrite).await + } + + async fn import_folder_buffer( + &mut self, + buffer: impl AsRef<[u8]> + Send + Sync, + key: AccessKey, + overwrite: bool, + ) -> Result> { + let _ = self.sync_lock.lock().await; + + let result = { + let mut account = self.account.lock().await; + account.import_folder_buffer(buffer, key, overwrite).await? + }; + + let result = FolderCreate { + folder: result.folder, + event: result.event, + commit_state: result.commit_state, + sync_result: self.sync().await, + }; + + Ok(result) + } + + async fn import_identity_folder( + &mut self, + vault: Vault, + ) -> Result { + let mut account = self.account.lock().await; + Ok(account.import_identity_folder(vault).await?) + } + + async fn export_folder( + &mut self, + path: impl AsRef + Send + Sync, + summary: &Summary, + new_key: AccessKey, + save_key: bool, + ) -> Result<()> { + let mut account = self.account.lock().await; + Ok(account + .export_folder(path, summary, new_key, save_key) + .await?) + } + + async fn export_folder_buffer( + &mut self, + summary: &Summary, + new_key: AccessKey, + save_key: bool, + ) -> Result> { + let mut account = self.account.lock().await; + Ok(account + .export_folder_buffer(summary, new_key, save_key) + .await?) + } + + async fn delete_folder( + &mut self, + summary: &Summary, + ) -> Result> { + let _ = self.sync_lock.lock().await; + let result = { + let mut account = self.account.lock().await; + account.delete_folder(summary).await? + }; + + let result = FolderDelete { + events: result.events, + commit_state: result.commit_state, + sync_result: self.sync().await, + }; + + Ok(result) + } + + #[cfg(feature = "contacts")] + async fn load_avatar( + &self, + secret_id: &SecretId, + folder: Option, + ) -> Result>> { + let account = self.account.lock().await; + Ok(account.load_avatar(secret_id, folder).await?) + } + + #[cfg(feature = "contacts")] + async fn export_contact( + &self, + path: impl AsRef + Send + Sync, + secret_id: &SecretId, + folder: Option, + ) -> Result<()> { + let account = self.account.lock().await; + Ok(account.export_contact(path, secret_id, folder).await?) + } + + #[cfg(feature = "contacts")] + async fn export_all_contacts( + &self, + path: impl AsRef + Send + Sync, + ) -> Result<()> { + let account = self.account.lock().await; + Ok(account.export_all_contacts(path).await?) + } + + #[cfg(feature = "contacts")] + async fn import_contacts( + &mut self, + content: &str, + progress: impl Fn(ContactImportProgress) + Send + Sync, + ) -> Result> { + let mut account = self.account.lock().await; + Ok(account.import_contacts(content, progress).await?) + } + + #[cfg(feature = "migrate")] + async fn export_unsafe_archive( + &self, + _path: impl AsRef + Send + Sync, + ) -> Result<()> { + unimplemented!(); + } + + #[cfg(feature = "migrate")] + async fn import_file( + &mut self, + _target: ImportTarget, + ) -> Result> { + unimplemented!(); + } + + #[cfg(feature = "archive")] + async fn export_backup_archive( + &self, + _path: impl AsRef + Send + Sync, + ) -> Result<()> { + unimplemented!(); + } + + #[cfg(feature = "archive")] + async fn restore_archive_inventory< + R: AsyncRead + AsyncSeek + Unpin + Send + Sync, + >( + _buffer: R, + ) -> Result { + unimplemented!(); + } + + #[cfg(feature = "archive")] + async fn import_backup_archive( + _path: impl AsRef + Send + Sync, + _options: RestoreOptions, + _data_dir: Option, + ) -> Result { + unimplemented!(); + } + + #[cfg(feature = "archive")] + async fn restore_backup_archive( + &mut self, + _path: impl AsRef + Send + Sync, + _password: SecretString, + _options: RestoreOptions, + _data_dir: Option, + ) -> Result { + unimplemented!(); + } + + #[cfg(feature = "clipboard")] + async fn copy_clipboard( + &self, + _clipboard: &Clipboard, + _target: &SecretPath, + _request: &ClipboardCopyRequest, + ) -> Result { + unimplemented!(); + } +} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl StorageEventLogs for LinkedAccount { + async fn identity_log( + &self, + ) -> sos_sdk::Result>> { + let account = self.account.lock().await; + account.identity_log().await + } + + async fn account_log( + &self, + ) -> sos_sdk::Result>> { + let account = self.account.lock().await; + account.account_log().await + } + + async fn device_log( + &self, + ) -> sos_sdk::Result>> { + let account = self.account.lock().await; + account.device_log().await + } + + #[cfg(feature = "files")] + async fn file_log(&self) -> sos_sdk::Result>> { + let account = self.account.lock().await; + account.file_log().await + } + + async fn folder_details(&self) -> sos_sdk::Result> { + let account = self.account.lock().await; + account.folder_details().await + } + + async fn folder_log( + &self, + id: &VaultId, + ) -> sos_sdk::Result>> { + let account = self.account.lock().await; + account.folder_log(id).await + } +} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl SyncStorage for LinkedAccount { + fn is_client_storage(&self) -> bool { + true + } + + async fn sync_status(&self) -> sos_sdk::Result { + let account = self.account.lock().await; + account.sync_status().await + } +} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl RemoteSyncHandler for LinkedAccount { + type Client = HttpClient; + type Account = LocalAccount; + type Error = Error; + + fn direction(&self) -> SyncDirection { + SyncDirection::Pull + } + + fn client(&self) -> &Self::Client { + &self.client + } + + fn origin(&self) -> &Origin { + self.client.origin() + } + + fn address(&self) -> &Address { + &self.address + } + + fn account(&self) -> Arc> { + self.account.clone() + } + + #[cfg(feature = "files")] + fn file_transfer_queue(&self) -> &FileTransferQueueSender { + unimplemented!(); + } + + #[cfg(feature = "files")] + async fn execute_sync_file_transfers(&self) -> Result<()> { + unimplemented!(); + } +} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl AutoMerge for LinkedAccount {} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl RemoteSync for LinkedAccount { + type Error = Error; + + async fn sync(&self) -> RemoteResult { + self.sync_with_options(&Default::default()).await + } + + async fn sync_with_options( + &self, + options: &SyncOptions, + ) -> RemoteResult { + match self.execute_sync(options).await { + Ok(outcome) => RemoteResult { + origin: self.origin().clone(), + result: Ok(outcome), + }, + Err(e) => RemoteResult { + origin: self.origin().clone(), + result: Err(e), + }, + } + } + + async fn force_update( + &self, + account_data: UpdateSet, + ) -> RemoteResult { + match self + .client + .update_account(&self.address, account_data) + .await + { + Ok(_) => RemoteResult { + origin: self.origin().clone(), + result: Ok(None), + }, + Err(e) => RemoteResult { + origin: self.origin().clone(), + result: Err(e.into()), + }, + } + } + + #[cfg(feature = "files")] + async fn sync_file_transfers(&self) -> RemoteResult { + unimplemented!( + "sync file transfers not supported for linked accounts" + ); + } +} diff --git a/deny.toml b/deny.toml index 15f8b4f290..abe10368ec 100644 --- a/deny.toml +++ b/deny.toml @@ -9,7 +9,10 @@ db-urls = ["https://github.com/rustsec/advisory-db"] yanked = "warn" ignore = [ # we need "age" to update i18n-embed-fl to fix this - "RUSTSEC-2024-0370" + "RUSTSEC-2024-0370", + # need an update to notify -> notify-types to remove + # the dependency in the unmaintained `instant` crate + "RUSTSEC-2024-0384" ] # Threshold for security vulnerabilities, any vulnerability with a CVSS score # lower than the range specified will be ignored. Note that ignored advisories diff --git a/doc/developer/ipc.md b/doc/developer/ipc.md new file mode 100644 index 0000000000..974fa13bf1 --- /dev/null +++ b/doc/developer/ipc.md @@ -0,0 +1,48 @@ +# IPC + +This document describes the tools available to debug and inspect inter-process communication. + +***Warn: if you use the `--release` flag or an actual installed release build (eg: `sos`) this would use the directory for production data.*** + +## Browser Extensions + +To test browser extensions communicating with the IPC service without starting the GUI app you should use the socket name for the GUI: + +``` +cargo run -p sos -- tool ipc server --socket com.saveoursecrets.gui.sock +``` + +## Server + +To start a standalone IPC service that uses local accounts: + +``` +cargo run -p sos -- tool ipc server +``` + +Note: that this uses the inferred data directory for the accounts so you can use `SOS_DATA_DIR` if you need to use different accounts. + +## Client + +Then you can send either directly as protobuf via IPC, for example: + +``` +cargo run -p sos -- tool ipc send list-accounts +``` + +Or as JSON via the native bridge executable by specifying the command and arguments: + +``` +cargo run -p sos -- tool ipc send -c target/debug/sos -a tool -a ipc -a bridge list-accounts +``` + +Note that you must be at the root of the repository so the executable is found. + +## Logs + +To see the log messages for the native bridge you can tail the standard logs, for example on MacOS (replace `YYYY-MM-DD` with the today): + +``` +tail -f ~/Library/Application\ Support/SaveOurSecrets/debug/logs/saveoursecrets.log.YYYY-MM-DD +``` + diff --git a/RELEASE.md b/doc/developer/release.md similarity index 100% rename from RELEASE.md rename to doc/developer/release.md diff --git a/TEST.md b/doc/developer/test.md similarity index 100% rename from TEST.md rename to doc/developer/test.md diff --git a/packages/types/.gitignore b/packages/types/.gitignore new file mode 100644 index 0000000000..063134a24c --- /dev/null +++ b/packages/types/.gitignore @@ -0,0 +1 @@ +types.ts diff --git a/packages/types/index.ts b/packages/types/index.ts new file mode 100644 index 0000000000..28e26cc092 --- /dev/null +++ b/packages/types/index.ts @@ -0,0 +1,566 @@ +export type TupleOfOne = [T]; +export type TupleOfTwo = [T, U]; +export type TupleOfThree = [T, U, V]; +export type AccountsList = PublicIdentity[]; +export type DocumentsList = Document[]; +export type SecretPath = TupleOfTwo; +export type HashSet = Set; +export type Uri = string; +export type Method = string; +export type VaultId = string; +export type VaultFlags = number; +export type SecretId = string; +export type Cipher = string; +export type KeyDerivation = string; +export type JsonPath = string; +export type SecretBox = T; +export type Set = T[]; +export type SecretString = SecretBox; +export type Vcard = never; +export type Totp = never; +export type AgeVersion = never; + +// Internally this is a HashMap but we can't serialize +// that to JSON so for Javascript it's just an array +export type Headers = [string, string[]][]; +// Backwards compatible aliases +export type AccountState = PublicIdentity; + +export type FolderInfo = Summary; +export interface FoldersList { + [accountId: string]: FolderInfo[]; +} +export interface SearchResults { + [accountId: string]: DocumentsList; +} + +export interface AuthenticatedList { + [accountId: string]: boolean; +} + +export enum Kind { + Individual = "individual", + Group = "group", + Org = "org", + Location = "location", +} + +export type EmbeddedFileContent = { + name: string; + mime: string; + buffer: number[]; + checksum: string; +}; + +export type ExternalFileContent = { + name: string; + mime: string; + checksum: string; + size: number; + path?: string; +}; + +export type FileContent = EmbeddedFileContent | ExternalFileContent; + +// Define secret enum variants manually as we +// want to use untagged enum representation which +// is not supported by typesafe + +export type NoteSecret = { + text: string; + userData: UserData; +}; + +export type FileSecret = { + content: FileContent; + userData: UserData; +}; + +export type LoginSecret = { + account: string; + password: string; + url: string[]; + userData: UserData; +}; + +export type ListItems = { + [key: string]: string; +}; + +export type ListSecret = { + items: ListItems; + userData: UserData; +}; + +export type PemSecret = { + certificates: string[]; + userData: UserData; +}; + +export type PageSecret = { + title: string; + mime: string; + document: string; + userData: UserData; +}; + +export type SignerSecret = { + privateKey: string; + userData: UserData; +}; + +export type ContactSecret = { + vcard: Vcard; + userData: UserData; +}; + +export type TotpSecret = { + totp: Totp; + userData: UserData; +}; + +export type CardSecret = { + number: string; + cvv: string; + name?: string; + expiry?: string; + atmPin?: string; + userData: UserData; +}; + +export type BankSecret = { + number?: string; + routing?: string; + iban?: string; + bic?: string; + swift?: string; + userData: UserData; +}; + +export type LinkSecret = { + url: string; + label?: string; + title?: string; + userData: UserData; +}; + +export type PasswordSecret = { + password: string; + name?: string; + userData: UserData; +}; + +export type IdentitySecret = { + idKind: IdentityKind; + number: string; + issuePlace?: string; + issueDate?: string; + expiryDate?: string; + userData: UserData; +}; + +export type AgeSecret = { + ageVersion: AgeVersion; + key: string; + userData: UserData; +}; + +export type Secret = + | NoteSecret + | FileSecret + | LoginSecret + | ListSecret + | PemSecret + | PageSecret + | SignerSecret + | ContactSecret + | TotpSecret + | CardSecret + | BankSecret + | LinkSecret + | PasswordSecret + | IdentitySecret + | AgeSecret; + +export type CreateVault = { createVault: never }; +export type SetVaultName = { setVaultName: TupleOfOne }; +export type SetVaultFlags = { setVaultFlags: TupleOfOne }; +export type SetVaultMeta = { setVaultMeta: never }; +export type CreateSecret = { createSecret: TupleOfOne }; +export type UpdateSecret = { updateSecret: TupleOfOne }; +export type DeleteSecret = { deleteSecret: string }; +export type WriteEvent = + | CreateVault + | SetVaultName + | SetVaultFlags + | SetVaultMeta + | CreateSecret + | UpdateSecret + | DeleteSecret; + +export type RenameAccount = { renameAccount: TupleOfOne }; +export type UpdateIdentity = { updateIdentity: never }; +export type CreateFolder = { createFolder: TupleOfOne }; +export type RenameFolder = { renameFolder: TupleOfTwo }; +export type UpdateFolder = { updateFolder: TupleOfOne }; +export type ChangeFolderPassword = { changeFolderPassword: TupleOfOne }; +export type DeleteFolder = { deleteFolder: TupleOfOne }; +export type AccountEvent = + | RenameAccount + | UpdateIdentity + | CreateFolder + | RenameFolder + | UpdateFolder + | ChangeFolderPassword + | DeleteFolder; + +export type AccountChangeRecords = { + account: TupleOfOne; +}; + +export type FolderChangeRecords = { + folder: TupleOfTwo; +}; + +export type ChangeRecords = AccountChangeRecords | FolderChangeRecords; +/* + Generated by typeshare 1.12.0 +*/ + +/** Event broadcast when an account changes on disc. */ +export interface AccountChangeEvent { + /** Account identifier. */ + accountId: string; + /** Event records with information about the changes. */ + records: ChangeRecords; +} + +/** Filter for archived documents. */ +export interface ArchiveFilter { + /** Identifier of the archive vault. */ + id: string; + /** Whether to include archived documents. */ + includeDocuments: boolean; +} + +/** Clipboard text formatter. */ +export type ClipboardTextFormat = + /** + * Parse as a RFC3339 date string and + * format according to the given format string. + */ + { + kind: "date"; + body: { + /** Format string. */ + formatDescription: string; + }; + }; + +/** Request a clipboard copy operation. */ +export interface ClipboardCopyRequest { + /** Target paths. */ + paths?: JsonPath[]; + /** Format option. */ + format?: ClipboardTextFormat; +} + +/** + * Type of secret assigned to the secret meta data. + * + * Matches the enum variants for a secret and is used + * so we can know the type of secret from the meta data + * before secret data has been decrypted. + */ +export enum SecretType { + /** UTF-8 encoded note. */ + Note = "note", + /** Binary blob. */ + File = "file", + /** Account with login password. */ + Account = "account", + /** Collection of credentials as key/value pairs. */ + List = "list", + /** PEM encoded binary data. */ + Pem = "pem", + /** UTF-8 text document. */ + Page = "page", + /** Private signing key. */ + Signer = "signer", + /** Contact for an organization, person, group or location. */ + Contact = "contact", + /** Two-factor authentication using a TOTP. */ + Totp = "totp", + /** Credit or debit card. */ + Card = "card", + /** Bank account. */ + Bank = "bank", + /** External link; intended to be used in embedded user fields. */ + Link = "link", + /** Standalone password; intended to be used in embedded user fields. */ + Password = "password", + /** Identity secret for passports, driving licenses etc. */ + Identity = "identity", + /** AGE encryption standard. */ + Age = "age", +} + +/** Encapsulates the meta data for a secret. */ +export interface SecretMeta { + /** Kind of the secret. */ + kind: SecretType; + /** Flags for the secret. */ + flags: number; + /** Human-friendly label for the secret. */ + label: string; + /** Collection of tags. */ + tags: HashSet; + /** Whether this secret is a favorite. */ + favorite: boolean; + /** + * A URN identifier for this secret. + * + * This is used when an identity vault stores passphrases + * for other vault folders on behalf of a user and can also + * be used to assign a predictable identifier for a secret. + */ + urn?: string; + /** + * An optional owner identifier. + * + * This can be used when creating secrets on behalf of a + * third-party plugin or application to indicate the identifier + * of the third-party application. + */ + ownerId?: string; + /** Date created timestamp. */ + dateCreated: string; + /** Last updated timestamp. */ + lastUpdated: string; +} + +/** + * Additional fields that can exposed via search results + * that are extracted from the secret data but safe to + * be exposed. + */ +export interface ExtraFields { + /** Comment about a secret. */ + comment?: string; + /** Contact type for contact secrets. */ + contactType?: Kind; + /** Collection of websites. */ + websites?: string[]; +} + +/** Document that can be indexed. */ +export interface Document { + /** Folder identifier. */ + folderId: string; + /** Secret identifier. */ + secretId: string; + /** Secret meta data. */ + meta: SecretMeta; + /** Extra fields for the document. */ + extra: ExtraFields; +} + +/** + * Request that can be sent to a local data source. + * + * Supports serde so this type is compatible with the + * browser extension which transfers JSON via the + * native messaging API. + * + * The body will usually be protobuf-encoded binary data. + */ +export interface LocalRequest { + /** Request method. */ + method: Method; + /** Request URL. */ + uri: Uri; + /** Request headers. */ + headers?: Headers; + /** Request body. */ + body?: number[]; + /** Number of chunks for this message. */ + chunksLength: number; + /** Chunk index for this message. */ + chunkIndex: number; +} + +/** + * Response received from a local data source. + * + * Supports serde so this type is compatible with the + * browser extension which transfers JSON via the + * native messaging API. + * + * The body will usually be protobuf-encoded binary data. + */ +export interface LocalResponse { + /** Response status code. */ + status: number; + /** Response headers. */ + headers?: Headers; + /** Response body. */ + body?: number[]; + /** Number of chunks for this message. */ + chunksLength: number; + /** Chunk index for this message. */ + chunkIndex: number; +} + +/** Public account identity information. */ +export interface PublicIdentity { + /** + * Address identifier for the account. + * + * This corresponds to the address of the signing key + * for the account. + */ + address: string; + /** + * Label for the account. + * + * This is the name given to the identity vault. + */ + label: string; +} + +/** Filter for a search query. */ +export interface QueryFilter { + /** List of tags. */ + tags: string[]; + /** List of vault identifiers. */ + folders: string[]; + /** List of type identifiers. */ + types: SecretType[]; +} + +/** Secret with it's associated meta data and identifier. */ +export interface SecretRow { + /** Identifier for the secret. */ + id: string; + /** Meta data for the secret. */ + meta: SecretMeta; + /** The data for the secret. */ + secret: Secret; +} + +/** Information about the service. */ +export interface ServiceAppInfo { + /** App name. */ + name: string; + /** App version. */ + version: string; +} + +/** + * Summary holding basic file information such as version, + * unique identifier and name. + */ +export interface Summary { + /** Encoding version. */ + version: number; + /** Unique identifier for the vault. */ + id: string; + /** Vault name. */ + name: string; + /** Encryption cipher. */ + cipher: Cipher; + /** Key derivation function. */ + kdf: KeyDerivation; + /** Flags for the vault. */ + flags: VaultFlags; +} + +/** Collection of custom user data. */ +export interface UserData { + /** Collection of custom user fields. */ + fields: SecretRow[]; + /** Comment for the secret. */ + comment?: string; + /** + * Recovery notes. + * + * These are notes specific for a person that might recover + * the vault information and is intended to provide additional + * information on how to use this secret in the event of an + * emergency. + */ + recoveryNote?: string; +} + +/** View of documents in the search index. */ +export type DocumentView = + /** View all documents in the search index. */ + | { + kind: "all"; + body: { + /** List of secret types to ignore. */ + ignoredTypes?: SecretType[]; + }; + } + /** View all the documents for a folder. */ + | { kind: "vault"; body: string } + /** View documents across all vaults by type identifier. */ + | { kind: "typeId"; body: SecretType } + /** View for all favorites. */ + | { kind: "favorites"; body?: undefined } + /** View documents that have one or more tags. */ + | { kind: "tags"; body: string[] } + /** Contacts of the given types. */ + | { + kind: "contact"; + body: { + /** + * Contact types to include in the results. + * + * If no types are specified all types are included. + */ + include_types?: Kind[]; + }; + } + /** Documents with the specific identifiers. */ + | { + kind: "documents"; + body: { + /** Vault identifier. */ + folderId: string; + /** Secret identifiers. */ + identifiers: string[]; + }; + } + /** Secrets with the associated websites. */ + | { + kind: "websites"; + body: { + /** Secrets that match the given target URLs. */ + matches?: string[]; + /** + * Exact match requires that the match targets and + * websites are exactly equal. Otherwise, comparison + * is performed using the URL origin. + */ + exact: boolean; + }; + }; + +/** Enumeration of types of identification. */ +export enum IdentityKind { + /** Personal identification number (PIN). */ + PersonalIdNumber = "personalIdNumber", + /** Generic id card. */ + IdCard = "idCard", + /** Passport identification. */ + Passport = "passport", + /** Driver license identification. */ + DriverLicense = "driverLicense", + /** Social security identification. */ + SocialSecurity = "socialSecurity", + /** Tax number identification. */ + TaxNumber = "taxNumber", + /** Medical card identification. */ + MedicalCard = "medicalCard", +} diff --git a/packages/types/package-lock.json b/packages/types/package-lock.json new file mode 100644 index 0000000000..4424879875 --- /dev/null +++ b/packages/types/package-lock.json @@ -0,0 +1,185 @@ +{ + "name": "@saveoursecrets/types", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@saveoursecrets/types", + "version": "0.0.1", + "license": "ISC", + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "typescript": "^5.6.2" + } + }, + "node_modules/@biomejs/biome": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz", + "integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==", + "dev": true, + "hasInstallScript": true, + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "1.9.4", + "@biomejs/cli-darwin-x64": "1.9.4", + "@biomejs/cli-linux-arm64": "1.9.4", + "@biomejs/cli-linux-arm64-musl": "1.9.4", + "@biomejs/cli-linux-x64": "1.9.4", + "@biomejs/cli-linux-x64-musl": "1.9.4", + "@biomejs/cli-win32-arm64": "1.9.4", + "@biomejs/cli-win32-x64": "1.9.4" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz", + "integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz", + "integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz", + "integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz", + "integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz", + "integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz", + "integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz", + "integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz", + "integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/packages/types/package.json b/packages/types/package.json new file mode 100644 index 0000000000..1b8445a8cf --- /dev/null +++ b/packages/types/package.json @@ -0,0 +1,18 @@ +{ + "name": "@saveoursecrets/types", + "version": "0.0.1", + "description": "Type definitions for the Save Our Secrets SDK.", + "main": "index.ts", + "scripts": { + "compile": "tsc --noEmit", + "lint": "biome check index.ts preamble.ts", + "fix": "biome check --write index.ts preamble.ts", + "fmt": "biome format --write index.ts preamble.ts" + }, + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "typescript": "^5.6.2" + }, + "author": "dev@saveoursecrets.com", + "license": "ISC" +} diff --git a/packages/types/preamble.ts b/packages/types/preamble.ts new file mode 100644 index 0000000000..45488e758b --- /dev/null +++ b/packages/types/preamble.ts @@ -0,0 +1,225 @@ +export type TupleOfOne = [T]; +export type TupleOfTwo = [T, U]; +export type TupleOfThree = [T, U, V]; +export type AccountsList = PublicIdentity[]; +export type DocumentsList = Document[]; +export type SecretPath = TupleOfTwo; +export type HashSet = Set; +export type Uri = string; +export type Method = string; +export type VaultId = string; +export type VaultFlags = number; +export type SecretId = string; +export type Cipher = string; +export type KeyDerivation = string; +export type JsonPath = string; +export type SecretBox = T; +export type Set = T[]; +export type SecretString = SecretBox; +export type Vcard = never; +export type Totp = never; +export type AgeVersion = never; + +// Internally this is a HashMap but we can't serialize +// that to JSON so for Javascript it's just an array +export type Headers = [string, string[]][]; +// Backwards compatible aliases +export type AccountState = PublicIdentity; + +export type FolderInfo = Summary; +export interface FoldersList { + [accountId: string]: FolderInfo[]; +} +export interface SearchResults { + [accountId: string]: DocumentsList; +} + +export interface AuthenticatedList { + [accountId: string]: boolean; +} + +export enum Kind { + Individual = "individual", + Group = "group", + Org = "org", + Location = "location", +} + +export type EmbeddedFileContent = { + name: string; + mime: string; + buffer: number[]; + checksum: string; +}; + +export type ExternalFileContent = { + name: string; + mime: string; + checksum: string; + size: number; + path?: string; +}; + +export type FileContent = EmbeddedFileContent | ExternalFileContent; + +// Define secret enum variants manually as we +// want to use untagged enum representation which +// is not supported by typesafe + +export type NoteSecret = { + text: string; + userData: UserData; +}; + +export type FileSecret = { + content: FileContent; + userData: UserData; +}; + +export type LoginSecret = { + account: string; + password: string; + url: string[]; + userData: UserData; +}; + +export type ListItems = { + [key: string]: string; +}; + +export type ListSecret = { + items: ListItems; + userData: UserData; +}; + +export type PemSecret = { + certificates: string[]; + userData: UserData; +}; + +export type PageSecret = { + title: string; + mime: string; + document: string; + userData: UserData; +}; + +export type SignerSecret = { + privateKey: string; + userData: UserData; +}; + +export type ContactSecret = { + vcard: Vcard; + userData: UserData; +}; + +export type TotpSecret = { + totp: Totp; + userData: UserData; +}; + +export type CardSecret = { + number: string; + cvv: string; + name?: string; + expiry?: string; + atmPin?: string; + userData: UserData; +}; + +export type BankSecret = { + number?: string; + routing?: string; + iban?: string; + bic?: string; + swift?: string; + userData: UserData; +}; + +export type LinkSecret = { + url: string; + label?: string; + title?: string; + userData: UserData; +}; + +export type PasswordSecret = { + password: string; + name?: string; + userData: UserData; +}; + +export type IdentitySecret = { + idKind: IdentityKind; + number: string; + issuePlace?: string; + issueDate?: string; + expiryDate?: string; + userData: UserData; +}; + +export type AgeSecret = { + ageVersion: AgeVersion; + key: string; + userData: UserData; +}; + +export type Secret = + | NoteSecret + | FileSecret + | LoginSecret + | ListSecret + | PemSecret + | PageSecret + | SignerSecret + | ContactSecret + | TotpSecret + | CardSecret + | BankSecret + | LinkSecret + | PasswordSecret + | IdentitySecret + | AgeSecret; + +export type CreateVault = { createVault: never }; +export type SetVaultName = { setVaultName: TupleOfOne }; +export type SetVaultFlags = { setVaultFlags: TupleOfOne }; +export type SetVaultMeta = { setVaultMeta: never }; +export type CreateSecret = { createSecret: TupleOfOne }; +export type UpdateSecret = { updateSecret: TupleOfOne }; +export type DeleteSecret = { deleteSecret: string }; +export type WriteEvent = + | CreateVault + | SetVaultName + | SetVaultFlags + | SetVaultMeta + | CreateSecret + | UpdateSecret + | DeleteSecret; + +export type RenameAccount = { renameAccount: TupleOfOne }; +export type UpdateIdentity = { updateIdentity: never }; +export type CreateFolder = { createFolder: TupleOfOne }; +export type RenameFolder = { renameFolder: TupleOfTwo }; +export type UpdateFolder = { updateFolder: TupleOfOne }; +export type ChangeFolderPassword = { changeFolderPassword: TupleOfOne }; +export type DeleteFolder = { deleteFolder: TupleOfOne }; +export type AccountEvent = + | RenameAccount + | UpdateIdentity + | CreateFolder + | RenameFolder + | UpdateFolder + | ChangeFolderPassword + | DeleteFolder; + +export type AccountChangeRecords = { + account: TupleOfOne; +}; + +export type FolderChangeRecords = { + folder: TupleOfTwo; +}; + +export type ChangeRecords = AccountChangeRecords | FolderChangeRecords; diff --git a/packages/types/tsconfig.json b/packages/types/tsconfig.json new file mode 100644 index 0000000000..4f148e5efa --- /dev/null +++ b/packages/types/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "lib": ["es2015"], + "jsx": "react-jsx", + "baseUrl": "." + }, + "exclude": [ + "types.ts", + "preamble.ts", + "tsconfig.json" + ] +} diff --git a/sandbox/config.toml b/sandbox/config.toml index a3b44ca118..4029c8ab42 100644 --- a/sandbox/config.toml +++ b/sandbox/config.toml @@ -4,6 +4,11 @@ path = "./accounts" [net] bind = "0.0.0.0:5053" +[log] +directory = "./accounts/logs" +name = "sos-server.log" +level = "sos_server=info" + #[access] #allow = [ #"0x6f4e977644ca8f21d335ab13271616b615ea28cb" diff --git a/typeshare.toml b/typeshare.toml new file mode 100644 index 0000000000..bcea00a3f4 --- /dev/null +++ b/typeshare.toml @@ -0,0 +1,9 @@ +[typescript.type_mappings] +"Address" = "string" +"VaultId" = "string" +"SecretId" = "string" +"SecretFlags" = "number" +"UtcDateTime" = "string" +"Urn" = "string" +"Url" = "string" +