From 58c7b425246e25afc9d725202e95dae2086cae17 Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Wed, 7 Feb 2024 21:19:36 -0500 Subject: [PATCH 01/44] init --- .gitignore | 2 + Cargo.lock | 1611 +++++++++++++++++++++++++++++++++++++++ Cargo.toml | 17 + src/logging.rs | 36 + src/login.rs | 76 ++ src/main.rs | 47 ++ src/non_empty_string.rs | 43 ++ 7 files changed, 1832 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/logging.rs create mode 100644 src/login.rs create mode 100644 src/main.rs create mode 100644 src/non_empty_string.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fedaa2b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +.env diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..b9ce8b3 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1611 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "aho-corasick" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e1ebcb11de5c03c67de28a7df593d32191b44939c482e97702baaaa6ab6a5" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8901269c6307e8d93993578286ac0edf7f195079ffff5ebdeea6a59ffb7e36bc" + +[[package]] +name = "anstyle-parse" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7" +dependencies = [ + "anstyle", + "windows-sys 0.52.0", +] + +[[package]] +name = "anyhow" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca" + +[[package]] +name = "async-trait" +version = "0.1.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c980ee35e870bd1a4d2c8294d4c04d0499e67bca1e4b5cefcc693c2fa00caea9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "backtrace" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bumpalo" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" + +[[package]] +name = "bytes" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" + +[[package]] +name = "cc" +version = "1.0.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" +dependencies = [ + "libc", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clap" +version = "4.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e578d6ec4194633722ccf9544794b71b1385c3c027efe0c55db226fc880865c" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4df4df40ec50c46000231c914968278b1eb05098cf8f1b3a518a95030e71d1c7" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf9804afaaf59a91e75b022a30fb7229a7901f60c755489cc61c9b423b836442" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1" + +[[package]] +name = "colorchoice" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" + +[[package]] +name = "data-encoding" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5" + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "encoding_rs" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7268b386296a025e474d5140678f75d6de9493ae55a5d709eeb9dd08149945e1" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enum-as-inner" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ffccbb6966c05b32ef8fbac435df276c4ae4d3dc55a8cd0eb9745e6c12f546a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" + +[[package]] +name = "futures-io" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" + +[[package]] +name = "futures-sink" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" + +[[package]] +name = "futures-task" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" + +[[package]] +name = "futures-util" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "gimli" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" + +[[package]] +name = "h2" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb2c4422095b67ee78da96fbb51a4cc413b3b25883c7717ff7ca1ab31022c9c9" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "hermit-abi" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0c62115964e08cb8039170eb33c1d0e2388a256930279edca206fff675f82c3" + +[[package]] +name = "hostname" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867" +dependencies = [ + "libc", + "match_cfg", + "winapi", +] + +[[package]] +name = "http" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8947b1a6fad4393052c7ba1f4cd97bed3e953a95c79c92ad9b051a04611d9fbb" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf96e135eb83a2a8ddf766e426a841d8ddd7449d5f00d34ea02b41d2f19eef80" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http", + "hyper", + "rustls", + "tokio", + "tokio-rustls", +] + +[[package]] +name = "idna" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "idna" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "indexmap" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824b2ae422412366ba479e8111fd301f7b5faece8149317bb81925979a53f520" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipconfig" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +dependencies = [ + "socket2", + "widestring", + "windows-sys 0.48.0", + "winreg", +] + +[[package]] +name = "ipnet" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" + +[[package]] +name = "itoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" + +[[package]] +name = "js-sys" +version = "0.3.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "406cda4b368d531c842222cf9d2600a9a4acce8d29423695379c6868a143a9ee" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.153" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "lock_api" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" + +[[package]] +name = "lru-cache" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "match_cfg" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" + +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + +[[package]] +name = "memchr" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d811f3e15f28568be3407c8e7fdb6514c1cda3cb30683f15b6a1a1dc4ea14a7" +dependencies = [ + "adler", +] + +[[package]] +name = "mio" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "object" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.48.5", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project-lite" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + +[[package]] +name = "proc-macro2" +version = "1.0.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quote" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62dbe01f0b06f9d8dc7d49e05a0785f153b00b2c227856282f671e0318c9b15" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata 0.4.5", + "regex-syntax 0.8.2", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", +] + +[[package]] +name = "regex-automata" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bb987efffd3c6d0d8f5f89510bb458559eab11e4f869acb20bf845e016259cd" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.8.2", +] + +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + +[[package]] +name = "regex-syntax" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" + +[[package]] +name = "reqwest" +version = "0.11.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6920094eb85afde5e4a138be3f2de8bbdf28000f0029e72c45025a56b042251" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-rustls", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-rustls", + "tower-service", + "trust-dns-resolver", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", + "winreg", +] + +[[package]] +name = "resolv-conf" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52e44394d2086d010551b14b53b1f24e31647570cd1deb0379e2c21b329aba00" +dependencies = [ + "hostname", + "quick-error", +] + +[[package]] +name = "ring" +version = "0.17.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688c63d65483050968b2a8937f7995f443e27041a0f7700aa59b0822aedebb74" +dependencies = [ + "cc", + "getrandom", + "libc", + "spin", + "untrusted", + "windows-sys 0.48.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" + +[[package]] +name = "rustls" +version = "0.21.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d5a6813c0759e4609cd494e8e725babae6a2ca7b62a5536a13daaec6fcb7ba" +dependencies = [ + "log", + "ring", + "rustls-webpki", + "sct", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "ryu" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "serde" +version = "1.0.196" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "870026e60fa08c69f064aa766c10f10b1d62db9ccd4d0abb206472bee0ce3b32" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.196" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33c85360c95e7d137454dc81d9a4ed2b8efd8fbe19cee57357b32b9771fccb67" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69801b70b1c3dac963ecb03a364ba0ceda9cf60c71cfe475e99864759c8b8a79" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7" + +[[package]] +name = "socket2" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9" +dependencies = [ + "libc", + "windows-sys 0.48.0", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "syn" +version = "2.0.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" +dependencies = [ + "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 = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tatutanatata2" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "dotenvy", + "reqwest", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-log 0.1.4", + "tracing-subscriber", +] + +[[package]] +name = "thiserror" +version = "1.0.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d54378c645627613241d077a3a79db965db602882668f9136ac42af9ecb730ad" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa0faa943b50f3db30a20aa7e265dbc66076993efed8463e8de414e5d06d3471" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152" +dependencies = [ + "cfg-if", + "once_cell", +] + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "num_cpus", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.48.0", +] + +[[package]] +name = "tokio-macros" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", + "tracing", +] + +[[package]] +name = "tower-service" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" + +[[package]] +name = "tracing" +version = "0.1.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f751112709b4e791d8ce53e32c4ed2d353565a795ce84da2285393f41557bdf2" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log 0.2.0", +] + +[[package]] +name = "trust-dns-proto" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3119112651c157f4488931a01e586aa459736e9d6046d3bd9105ffb69352d374" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna 0.4.0", + "ipnet", + "once_cell", + "rand", + "smallvec", + "thiserror", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "trust-dns-resolver" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a3e6c3aff1718b3c73e395d1f35202ba2ffa847c6a62eea0db8fb4cfe30be6" +dependencies = [ + "cfg-if", + "futures-util", + "ipconfig", + "lru-cache", + "once_cell", + "parking_lot", + "rand", + "resolv-conf", + "smallvec", + "thiserror", + "tokio", + "tracing", + "trust-dns-proto", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-bidi" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "unicode-normalization" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" +dependencies = [ + "form_urlencoded", + "idna 0.5.0", + "percent-encoding", +] + +[[package]] +name = "utf8parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" + +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1e124130aee3fb58c5bdd6b639a0509486b0338acaaae0c84a5124b0f588b7f" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e7e1900c352b609c8488ad12639a311045f40a35491fb69ba8c12f758af70b" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877b9c3f61ceea0e56331985743b13f3d25c406a7098d45180fb5f09bc19ed97" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b30af9e2d358182b5c7449424f017eba305ed32a7010509ede96cdc4696c46ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "642f325be6301eb8107a83d12a8ac6c1e1c54345a7ef1a9261962dfefda09e66" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f186bd2dcf04330886ce82d6f33dd75a7bfcf69ecf5763b89fcde53b6ac9838" + +[[package]] +name = "web-sys" +version = "0.3.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96565907687f7aceb35bc5fc03770a8a0471d82e479f25832f54a0e3f4b28446" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + +[[package]] +name = "widestring" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "653f141f39ec16bba3c5abe400a0c60da7468261cc2cbf36805022876bc721a8" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.0", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd" +dependencies = [ + "windows_aarch64_gnullvm 0.52.0", + "windows_aarch64_msvc 0.52.0", + "windows_i686_gnu 0.52.0", + "windows_i686_msvc 0.52.0", + "windows_x86_64_gnu 0.52.0", + "windows_x86_64_gnullvm 0.52.0", + "windows_x86_64_msvc 0.52.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..b447bf6 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "tatutanatata2" +version = "0.1.0" +edition = "2021" +license = "MIT/Apache-2.0" + +[dependencies] +anyhow = "1.0.75" +clap = { version = "4.4.6", features = ["derive", "env"] } +dotenvy = "0.15.7" +tokio = { version = "1.32.0", features = ["macros", "rt-multi-thread"] } +reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls-webpki-roots", "trust-dns"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tracing = "0.1.38" +tracing-log = "0.1.3" +tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 0000000..5cd6a42 --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,36 @@ +//! Logging setup. +use anyhow::Result; +use clap::Parser; +use tracing_log::LogTracer; +use tracing_subscriber::{EnvFilter, FmtSubscriber}; + +/// Logging CLI config. +#[derive(Debug, Parser)] +pub struct LoggingCLIConfig { + /// Log verbosity. + #[clap( + short = 'v', + long = "verbose", + action = clap::ArgAction::Count, + )] + log_verbose_count: u8, +} + +/// Setup process-wide logging. +pub fn setup_logging(config: LoggingCLIConfig) -> Result<()> { + LogTracer::init()?; + + let base_filter = match config.log_verbose_count { + 0 => "warn", + 1 => "info", + 2 => "debug", + _ => "trace", + }; + let filter = EnvFilter::try_new(format!("{base_filter},hyper=info"))?; + + let subscriber = FmtSubscriber::builder().with_env_filter(filter).finish(); + + tracing::subscriber::set_global_default(subscriber)?; + + Ok(()) +} diff --git a/src/login.rs b/src/login.rs new file mode 100644 index 0000000..e7fe487 --- /dev/null +++ b/src/login.rs @@ -0,0 +1,76 @@ +use anyhow::{Context, Result}; +use clap::Parser; +use reqwest::Client; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use tracing::debug; + +use crate::non_empty_string::NonEmptyString; + +/// Login CLI config. +#[derive(Debug, Parser)] +pub struct LoginCLIConfig { + /// Username + #[clap(long, env = "TUTANOTA_CLI_USERNAME")] + username: NonEmptyString, + + /// Password + #[clap(long, env = "TUTANOTA_CLI_PASSWORD")] + password: NonEmptyString, +} + +/// Perform tutanota webinterface login. +pub async fn perform_login(config: LoginCLIConfig, client: &Client) -> Result<()> { + debug!("perform login"); + + let req = SaltServiceRequest { + format: "0".to_owned(), + mail_address: config.username.to_string(), + }; + let salt: SaltServiceResponse = service_requst(client, "saltservice", &req) + .await + .context("get salt")?; + + Ok(()) +} + +async fn service_requst(client: &Client, path: &str, req: &Req) -> Result +where + Req: serde::Serialize, + Resp: DeserializeOwned, +{ + debug!(path, "service request",); + + let resp = client + .get(format!("https://app.tuta.com/rest/sys/{path}")) + .json(req) + .send() + .await + .context("initial request")? + .error_for_status() + .context("return status")? + .json::() + .await + .context("fetch JSON response")?; + + Ok(resp) +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct SaltServiceRequest { + #[serde(rename = "_format")] + format: String, + + mail_address: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SaltServiceResponse { + #[serde(rename = "_format")] + format: String, + + kdf_version: String, + + salt: String, +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..cbfea23 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,47 @@ +use anyhow::{Context, Result}; +use clap::{Parser, Subcommand}; +use logging::{setup_logging, LoggingCLIConfig}; +use login::{perform_login, LoginCLIConfig}; +use reqwest::Client; + +mod logging; +mod login; +mod non_empty_string; + +/// CLI args. +#[derive(Debug, Parser)] +struct Args { + /// Logging config. + #[clap(flatten)] + logging_cfg: LoggingCLIConfig, + + /// Login config. + #[clap(flatten)] + login_cfg: LoginCLIConfig, + + /// Command + #[clap(subcommand)] + command: Command, +} + +/// Command +#[derive(Debug, Subcommand)] +enum Command { + /// List folders. + ListFolders, +} + +#[tokio::main] +async fn main() -> Result<()> { + dotenvy::dotenv().ok(); + let args = Args::parse(); + setup_logging(args.logging_cfg).context("logging setup")?; + + let client = Client::builder().build().context("set up HTTPs client")?; + + perform_login(args.login_cfg, &client) + .await + .context("perform login")?; + + Ok(()) +} diff --git a/src/non_empty_string.rs b/src/non_empty_string.rs new file mode 100644 index 0000000..517b8c2 --- /dev/null +++ b/src/non_empty_string.rs @@ -0,0 +1,43 @@ +use std::{ops::Deref, str::FromStr}; + +/// Non-empty [`String`]. +#[derive(Clone)] +pub struct NonEmptyString(String); + +impl std::fmt::Debug for NonEmptyString { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + self.0.fmt(f) + } +} + +impl std::fmt::Display for NonEmptyString { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + self.0.fmt(f) + } +} + +impl Deref for NonEmptyString { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.0.deref() + } +} + +impl AsRef for NonEmptyString { + fn as_ref(&self) -> &str { + self.0.as_ref() + } +} + +impl FromStr for NonEmptyString { + type Err = String; + + fn from_str(s: &str) -> std::result::Result { + if s.is_empty() { + Err("cannot be empty".to_owned()) + } else { + Ok(Self(s.to_owned())) + } + } +} From 3728adcff766e6520114fbe2b3c0cd4bcadffc6f Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Wed, 7 Feb 2024 21:55:28 -0500 Subject: [PATCH 02/44] extract proto --- src/login.rs | 29 +++--------- src/main.rs | 1 + src/proto.rs | 125 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 23 deletions(-) create mode 100644 src/proto.rs diff --git a/src/login.rs b/src/login.rs index e7fe487..ec6b79d 100644 --- a/src/login.rs +++ b/src/login.rs @@ -1,10 +1,13 @@ use anyhow::{Context, Result}; use clap::Parser; use reqwest::Client; -use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde::de::DeserializeOwned; use tracing::debug; -use crate::non_empty_string::NonEmptyString; +use crate::{ + non_empty_string::NonEmptyString, + proto::{SaltServiceRequest, SaltServiceResponse}, +}; /// Login CLI config. #[derive(Debug, Parser)] @@ -23,7 +26,7 @@ pub async fn perform_login(config: LoginCLIConfig, client: &Client) -> Result<() debug!("perform login"); let req = SaltServiceRequest { - format: "0".to_owned(), + format: Default::default(), mail_address: config.username.to_string(), }; let salt: SaltServiceResponse = service_requst(client, "saltservice", &req) @@ -54,23 +57,3 @@ where Ok(resp) } - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct SaltServiceRequest { - #[serde(rename = "_format")] - format: String, - - mail_address: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SaltServiceResponse { - #[serde(rename = "_format")] - format: String, - - kdf_version: String, - - salt: String, -} diff --git a/src/main.rs b/src/main.rs index cbfea23..0acebc6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ use reqwest::Client; mod logging; mod login; mod non_empty_string; +mod proto; /// CLI args. #[derive(Debug, Parser)] diff --git a/src/proto.rs b/src/proto.rs new file mode 100644 index 0000000..2417e68 --- /dev/null +++ b/src/proto.rs @@ -0,0 +1,125 @@ +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Format; + +impl serde::Serialize for Format { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&F.to_string()) + } +} + +impl<'de, const F: u8> serde::Deserialize<'de> for Format { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde::de::Error; + + let s = String::deserialize(deserializer)?; + let f: u8 = s.parse().map_err(|e| D::Error::custom(e))?; + if f == F { + Ok(Self) + } else { + Err(D::Error::custom(format!("invalid format: {f}"))) + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum KdfVersion { + Bcrypt, + Argon2id, +} + +impl serde::Serialize for KdfVersion { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let s = match self { + Self::Bcrypt => "0", + Self::Argon2id => "1", + }; + serializer.serialize_str(s) + } +} + +impl<'de> serde::Deserialize<'de> for KdfVersion { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde::de::Error; + + let s = String::deserialize(deserializer)?; + match s.as_str() { + "0" => Ok(Self::Bcrypt), + "1" => Ok(Self::Argon2id), + s => Err(D::Error::custom(format!("invalid KDF version: {s}"))), + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SaltServiceRequest { + #[serde(rename = "_format")] + pub format: Format<0>, + + pub mail_address: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaltServiceResponse { + #[serde(rename = "_format")] + pub format: Format<0>, + + pub kdf_version: KdfVersion, + + pub salt: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_roundtrip_format() { + assert_roundtrip(Format::<0>); + assert_roundtrip(Format::<1>); + assert_roundtrip(Format::<2>); + assert_roundtrip(Format::<255>); + + assert_deser_error::>(r#""0""#, "invalid format: 0"); + } + + #[test] + fn test_roundtrip_kdf_version() { + assert_roundtrip(KdfVersion::Bcrypt); + assert_roundtrip(KdfVersion::Argon2id); + } + + #[track_caller] + fn assert_roundtrip(orig: T) + where + T: Eq + std::fmt::Debug + serde::Serialize + serde::de::DeserializeOwned, + { + let s = serde_json::to_string(&orig).expect("serialize"); + let recovered = serde_json::from_str(&s).expect("deserialize"); + assert_eq!(orig, recovered); + } + + #[track_caller] + fn assert_deser_error(s: &str, expected: &str) + where + T: std::fmt::Debug + serde::de::DeserializeOwned, + { + let err = serde_json::from_str::(s).expect_err("deserialize error"); + assert_eq!(err.to_string(), expected); + } +} From 4545c4b6d55c0e9988f0bc4e4260eea8c9d24880 Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Wed, 7 Feb 2024 22:02:42 -0500 Subject: [PATCH 03/44] extract client --- src/client.rs | 40 ++++++++++++++++++++++++++++++++++++++++ src/login.rs | 28 +++------------------------- src/main.rs | 6 ++++-- 3 files changed, 47 insertions(+), 27 deletions(-) create mode 100644 src/client.rs diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..46801b0 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,40 @@ +use anyhow::{Context, Result}; +use serde::de::DeserializeOwned; +use tracing::debug; + +#[derive(Debug)] +pub struct Client { + inner: reqwest::Client, +} + +impl Client { + pub fn try_new() -> Result { + let inner = reqwest::Client::builder() + .build() + .context("set up HTTPs client")?; + Ok(Self { inner }) + } + + pub async fn service_requst(&self, path: &str, req: &Req) -> Result + where + Req: serde::Serialize, + Resp: DeserializeOwned, + { + debug!(path, "service request",); + + let resp = self + .inner + .get(format!("https://app.tuta.com/rest/sys/{path}")) + .json(req) + .send() + .await + .context("initial request")? + .error_for_status() + .context("return status")? + .json::() + .await + .context("fetch JSON response")?; + + Ok(resp) + } +} diff --git a/src/login.rs b/src/login.rs index ec6b79d..ed9a355 100644 --- a/src/login.rs +++ b/src/login.rs @@ -1,10 +1,9 @@ use anyhow::{Context, Result}; use clap::Parser; -use reqwest::Client; -use serde::de::DeserializeOwned; use tracing::debug; use crate::{ + client::Client, non_empty_string::NonEmptyString, proto::{SaltServiceRequest, SaltServiceResponse}, }; @@ -29,31 +28,10 @@ pub async fn perform_login(config: LoginCLIConfig, client: &Client) -> Result<() format: Default::default(), mail_address: config.username.to_string(), }; - let salt: SaltServiceResponse = service_requst(client, "saltservice", &req) + let salt: SaltServiceResponse = client + .service_requst("saltservice", &req) .await .context("get salt")?; Ok(()) } - -async fn service_requst(client: &Client, path: &str, req: &Req) -> Result -where - Req: serde::Serialize, - Resp: DeserializeOwned, -{ - debug!(path, "service request",); - - let resp = client - .get(format!("https://app.tuta.com/rest/sys/{path}")) - .json(req) - .send() - .await - .context("initial request")? - .error_for_status() - .context("return status")? - .json::() - .await - .context("fetch JSON response")?; - - Ok(resp) -} diff --git a/src/main.rs b/src/main.rs index 0acebc6..b43afdd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,13 +2,15 @@ use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; use logging::{setup_logging, LoggingCLIConfig}; use login::{perform_login, LoginCLIConfig}; -use reqwest::Client; +mod client; mod logging; mod login; mod non_empty_string; mod proto; +use client::Client; + /// CLI args. #[derive(Debug, Parser)] struct Args { @@ -38,7 +40,7 @@ async fn main() -> Result<()> { let args = Args::parse(); setup_logging(args.logging_cfg).context("logging setup")?; - let client = Client::builder().build().context("set up HTTPs client")?; + let client = Client::try_new().context("set up client")?; perform_login(args.login_cfg, &client) .await From 25da5661f01c3df97477e20c7d11fd97b629c589 Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Wed, 7 Feb 2024 22:21:43 -0500 Subject: [PATCH 04/44] base64 serde --- Cargo.lock | 1 + Cargo.toml | 1 + src/proto.rs | 84 +++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index b9ce8b3..7f15a0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1055,6 +1055,7 @@ name = "tatutanatata2" version = "0.1.0" dependencies = [ "anyhow", + "base64", "clap", "dotenvy", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index b447bf6..df4b4c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,3 +15,4 @@ serde_json = "1.0" tracing = "0.1.38" tracing-log = "0.1.3" tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } +base64 = "0.21.7" diff --git a/src/proto.rs b/src/proto.rs index 2417e68..4067bb1 100644 --- a/src/proto.rs +++ b/src/proto.rs @@ -1,3 +1,4 @@ +use base64::prelude::*; use serde::{Deserialize, Deserializer, Serialize, Serializer}; #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -64,6 +65,81 @@ impl<'de> serde::Deserialize<'de> for KdfVersion { } } +#[derive(PartialEq, Eq, Hash)] +pub struct Base64String(Box<[u8]>); + +impl Base64String { + pub fn base64(&self) -> String { + BASE64_STANDARD.encode(self.0.as_ref()) + } +} + +impl std::fmt::Debug for Base64String { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.base64()) + } +} + +impl std::fmt::Display for Base64String { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.base64()) + } +} + +impl From> for Base64String { + fn from(value: Vec) -> Self { + Self(value.into()) + } +} + +impl From<&[u8]> for Base64String { + fn from(value: &[u8]) -> Self { + Self(value.into()) + } +} + +impl From<[u8; N]> for Base64String { + fn from(value: [u8; N]) -> Self { + Self(value.into()) + } +} + +impl From<&[u8; N]> for Base64String { + fn from(value: &[u8; N]) -> Self { + Self(value.to_owned().into()) + } +} + +impl AsRef<[u8]> for Base64String { + fn as_ref(&self) -> &[u8] { + self.0.as_ref() + } +} + +impl serde::Serialize for Base64String { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.base64()) + } +} + +impl<'de> serde::Deserialize<'de> for Base64String { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde::de::Error; + + let s = String::deserialize(deserializer)?; + let data = BASE64_STANDARD + .decode(&s) + .map_err(|e| D::Error::custom(e))?; + Ok(Self(data.into())) + } +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct SaltServiceRequest { @@ -81,7 +157,7 @@ pub struct SaltServiceResponse { pub kdf_version: KdfVersion, - pub salt: String, + pub salt: Base64String, } #[cfg(test)] @@ -104,6 +180,12 @@ mod tests { assert_roundtrip(KdfVersion::Argon2id); } + #[test] + fn test_roundtrip_base64trip() { + assert_roundtrip(Base64String::from(b"")); + assert_roundtrip(Base64String::from(b"foo")); + } + #[track_caller] fn assert_roundtrip(orig: T) where From 69ef1f06a21806c2106bd34b5ec3ed6f4d9ece38 Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Thu, 8 Feb 2024 00:08:11 -0500 Subject: [PATCH 05/44] session creation --- Cargo.lock | 133 ++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 2 + src/client.rs | 12 +++-- src/login.rs | 58 ++++++++++++++++++++-- src/proto.rs | 46 +++++++++++++++++ 5 files changed, 245 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7f15a0d..04fa97c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,18 +118,56 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "bcrypt" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d1c9c15093eb224f0baa400f38fcd713fc1391a6f1c389d886beef146d60a3" +dependencies = [ + "base64", + "blowfish", + "getrandom", + "subtle", + "zeroize", +] + [[package]] name = "bitflags" version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "bumpalo" version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.5.0" @@ -151,6 +189,16 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.4.18" @@ -213,12 +261,41 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" +[[package]] +name = "cpufeatures" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "data-encoding" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -313,6 +390,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.12" @@ -480,6 +567,15 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +dependencies = [ + "generic-array", +] + [[package]] name = "ipconfig" version = "0.3.2" @@ -966,6 +1062,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1012,6 +1119,12 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "subtle" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" + [[package]] name = "syn" version = "2.0.48" @@ -1056,11 +1169,13 @@ version = "0.1.0" dependencies = [ "anyhow", "base64", + "bcrypt", "clap", "dotenvy", "reqwest", "serde", "serde_json", + "sha2", "tokio", "tracing", "tracing-log 0.1.4", @@ -1294,6 +1409,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + [[package]] name = "unicode-bidi" version = "0.3.15" @@ -1344,6 +1465,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + [[package]] name = "want" version = "0.3.1" @@ -1610,3 +1737,9 @@ dependencies = [ "cfg-if", "windows-sys 0.48.0", ] + +[[package]] +name = "zeroize" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" diff --git a/Cargo.toml b/Cargo.toml index df4b4c8..87ef04e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,3 +16,5 @@ tracing = "0.1.38" tracing-log = "0.1.3" tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } base64 = "0.21.7" +sha2 = "0.10.8" +bcrypt = "0.15.0" diff --git a/src/client.rs b/src/client.rs index 46801b0..aa48674 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,4 +1,5 @@ use anyhow::{Context, Result}; +use reqwest::Method; use serde::de::DeserializeOwned; use tracing::debug; @@ -15,16 +16,21 @@ impl Client { Ok(Self { inner }) } - pub async fn service_requst(&self, path: &str, req: &Req) -> Result + pub async fn service_requst( + &self, + method: Method, + path: &str, + req: &Req, + ) -> Result where Req: serde::Serialize, Resp: DeserializeOwned, { - debug!(path, "service request",); + debug!(%method, path, "service request",); let resp = self .inner - .get(format!("https://app.tuta.com/rest/sys/{path}")) + .request(method, format!("https://app.tuta.com/rest/sys/{path}")) .json(req) .send() .await diff --git a/src/login.rs b/src/login.rs index ed9a355..dcf2427 100644 --- a/src/login.rs +++ b/src/login.rs @@ -1,11 +1,16 @@ use anyhow::{Context, Result}; use clap::Parser; +use reqwest::Method; +use sha2::{Digest, Sha256}; use tracing::debug; use crate::{ client::Client, non_empty_string::NonEmptyString, - proto::{SaltServiceRequest, SaltServiceResponse}, + proto::{ + Base64String, KdfVersion, SaltServiceRequest, SaltServiceResponse, SessionServiceRequest, + SessionServiceResponse, + }, }; /// Login CLI config. @@ -28,10 +33,57 @@ pub async fn perform_login(config: LoginCLIConfig, client: &Client) -> Result<() format: Default::default(), mail_address: config.username.to_string(), }; - let salt: SaltServiceResponse = client - .service_requst("saltservice", &req) + let resp: SaltServiceResponse = client + .service_requst(Method::GET, "saltservice", &req) .await .context("get salt")?; + let passkey = derive_passkey(resp.kdf_version, &config.password, resp.salt.as_ref()) + .context("derive passkey")?; + let auth_verifier = encode_auth_verifier(&passkey); + + let req = SessionServiceRequest { + format: Default::default(), + access_key: Default::default(), + auth_token: Default::default(), + auth_verifier, + client_identifier: "test".to_owned(), + mail_address: config.username.to_string(), + recover_code_verifier: Default::default(), + user: Default::default(), + }; + let resp: SessionServiceResponse = client + .service_requst(Method::POST, "sessionservice", &req) + .await + .context("get session")?; + + debug!(user = resp.user.as_str(), "got user"); + Ok(()) } + +fn derive_passkey(kdf_version: KdfVersion, passphrase: &str, salt: &[u8]) -> Result> { + match kdf_version { + KdfVersion::Bcrypt => { + let mut hasher = Sha256::new(); + hasher.update(passphrase.as_bytes()); + let passphrase = hasher.finalize(); + + let salt: [u8; 16] = salt.try_into().context("salt length")?; + + let hashed = bcrypt::bcrypt(8, salt, &passphrase); + + let mut hasher = Sha256::new(); + hasher.update(&hashed[..16]); + let res = hasher.finalize().to_vec(); + + Ok(res) + } + KdfVersion::Argon2id => unimplemented!("Argon2id"), + } +} + +fn encode_auth_verifier(passkey: &[u8]) -> String { + let base64 = Base64String::from(passkey).to_string(); + base64.replace('+', "-").replace('/', "_").replace('=', "") +} diff --git a/src/proto.rs b/src/proto.rs index 4067bb1..7c99d13 100644 --- a/src/proto.rs +++ b/src/proto.rs @@ -140,6 +140,18 @@ impl<'de> serde::Deserialize<'de> for Base64String { } } +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Null; + +impl serde::Serialize for Null { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_none() + } +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct SaltServiceRequest { @@ -160,6 +172,40 @@ pub struct SaltServiceResponse { pub salt: Base64String, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionServiceRequest { + #[serde(rename = "_format")] + pub format: Format<0>, + + pub access_key: Null, + + pub auth_token: Null, + + pub auth_verifier: String, + + pub client_identifier: String, + + pub mail_address: String, + + pub recover_code_verifier: Null, + + pub user: Null, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionServiceResponse { + #[serde(rename = "_format")] + pub format: Format<0>, + + pub access_token: String, + + pub challenges: Vec, + + pub user: String, +} + #[cfg(test)] mod tests { use super::*; From 0cb217e8f03dc08ce6fca2b18d56414bbb27a37e Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Thu, 8 Feb 2024 07:15:22 -0500 Subject: [PATCH 06/44] map group type --- src/proto.rs | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/src/proto.rs b/src/proto.rs index 7c99d13..cde0ce4 100644 --- a/src/proto.rs +++ b/src/proto.rs @@ -152,6 +152,71 @@ impl serde::Serialize for Null { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum GroupType { + User, + Admin, + MailingList, + Customer, + External, + Mail, + Contact, + File, + LocalAdmin, + Calendar, + Template, + ContactList, +} + +impl serde::Serialize for GroupType { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let s = match self { + Self::User => "0", + Self::Admin => "1", + Self::MailingList => "2", + Self::Customer => "3", + Self::External => "4", + Self::Mail => "5", + Self::Contact => "6", + Self::File => "7", + Self::LocalAdmin => "8", + Self::Calendar => "9", + Self::Template => "10", + Self::ContactList => "11", + }; + serializer.serialize_str(s) + } +} + +impl<'de> serde::Deserialize<'de> for GroupType { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde::de::Error; + + let s = String::deserialize(deserializer)?; + match s.as_str() { + "0" => Ok(Self::User), + "1" => Ok(Self::Admin), + "2" => Ok(Self::MailingList), + "3" => Ok(Self::Customer), + "4" => Ok(Self::External), + "5" => Ok(Self::Mail), + "6" => Ok(Self::Contact), + "7" => Ok(Self::File), + "8" => Ok(Self::LocalAdmin), + "9" => Ok(Self::Calendar), + "10" => Ok(Self::Template), + "11" => Ok(Self::ContactList), + s => Err(D::Error::custom(format!("invalid group type: {s}"))), + } + } +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct SaltServiceRequest { @@ -224,6 +289,8 @@ mod tests { fn test_roundtrip_kdf_version() { assert_roundtrip(KdfVersion::Bcrypt); assert_roundtrip(KdfVersion::Argon2id); + + assert_deser_error::(r#""2""#, "invalid KDF version: 2"); } #[test] @@ -232,6 +299,24 @@ mod tests { assert_roundtrip(Base64String::from(b"foo")); } + #[test] + fn test_roundtrip_group_type() { + assert_roundtrip(GroupType::User); + assert_roundtrip(GroupType::Admin); + assert_roundtrip(GroupType::MailingList); + assert_roundtrip(GroupType::Customer); + assert_roundtrip(GroupType::External); + assert_roundtrip(GroupType::Mail); + assert_roundtrip(GroupType::Contact); + assert_roundtrip(GroupType::File); + assert_roundtrip(GroupType::LocalAdmin); + assert_roundtrip(GroupType::Calendar); + assert_roundtrip(GroupType::Template); + assert_roundtrip(GroupType::ContactList); + + assert_deser_error::(r#""20""#, "invalid group type: 20"); + } + #[track_caller] fn assert_roundtrip(orig: T) where From 36bd268e35f482aee171290f5e4b35066b139ce8 Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Thu, 8 Feb 2024 07:33:01 -0500 Subject: [PATCH 07/44] clean ups --- src/crypto/auth.rs | 53 ++++++++++++++++++++++++++++++++++++++++++++++ src/crypto/mod.rs | 3 +++ src/login.rs | 36 ++++--------------------------- src/main.rs | 1 + src/proto.rs | 10 +-------- 5 files changed, 62 insertions(+), 41 deletions(-) create mode 100644 src/crypto/auth.rs create mode 100644 src/crypto/mod.rs diff --git a/src/crypto/auth.rs b/src/crypto/auth.rs new file mode 100644 index 0000000..4a95259 --- /dev/null +++ b/src/crypto/auth.rs @@ -0,0 +1,53 @@ +use anyhow::{bail, Context, Result}; +use sha2::{Digest, Sha256}; + +use crate::proto::{Base64String, KdfVersion}; + +/// Build auth verifier for session creation. +pub fn build_auth_verifier( + kdf_version: KdfVersion, + passphrase: &str, + salt: &[u8], +) -> Result { + let passkey = derive_passkey(kdf_version, passphrase, salt).context("derive passkey")?; + Ok(encode_auth_verifier(&passkey)) +} + +fn derive_passkey(kdf_version: KdfVersion, passphrase: &str, salt: &[u8]) -> Result> { + match kdf_version { + KdfVersion::Bcrypt => { + let mut hasher = Sha256::new(); + hasher.update(passphrase.as_bytes()); + let passphrase = hasher.finalize(); + + let salt: [u8; 16] = salt.try_into().context("salt length")?; + + let hashed = bcrypt::bcrypt(8, salt, &passphrase); + + Ok(hashed[..16].to_owned()) + } + KdfVersion::Argon2id => bail!("not implemented: Argon2id"), + } +} + +fn encode_auth_verifier(passkey: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(passkey); + let hashed = hasher.finalize().to_vec(); + + let base64 = Base64String::from(hashed).to_string(); + base64.replace('+', "-").replace('/', "_").replace('=', "") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_auth_verifier() { + assert_eq!( + build_auth_verifier(KdfVersion::Bcrypt, "password", b"saltsaltsaltsalt").unwrap(), + "r3YdONamUCQ7yFZwPFX8KLWZ4kKnAZLyt7rwi1DCE1I", + ); + } +} diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs new file mode 100644 index 0000000..cfca2f9 --- /dev/null +++ b/src/crypto/mod.rs @@ -0,0 +1,3 @@ +//! Crypto methods. + +pub mod auth; diff --git a/src/login.rs b/src/login.rs index dcf2427..4cb49b7 100644 --- a/src/login.rs +++ b/src/login.rs @@ -1,15 +1,14 @@ use anyhow::{Context, Result}; use clap::Parser; use reqwest::Method; -use sha2::{Digest, Sha256}; use tracing::debug; use crate::{ client::Client, + crypto::auth::build_auth_verifier, non_empty_string::NonEmptyString, proto::{ - Base64String, KdfVersion, SaltServiceRequest, SaltServiceResponse, SessionServiceRequest, - SessionServiceResponse, + SaltServiceRequest, SaltServiceResponse, SessionServiceRequest, SessionServiceResponse, }, }; @@ -38,9 +37,8 @@ pub async fn perform_login(config: LoginCLIConfig, client: &Client) -> Result<() .await .context("get salt")?; - let passkey = derive_passkey(resp.kdf_version, &config.password, resp.salt.as_ref()) - .context("derive passkey")?; - let auth_verifier = encode_auth_verifier(&passkey); + let auth_verifier = build_auth_verifier(resp.kdf_version, &config.password, resp.salt.as_ref()) + .context("build auth verifier")?; let req = SessionServiceRequest { format: Default::default(), @@ -61,29 +59,3 @@ pub async fn perform_login(config: LoginCLIConfig, client: &Client) -> Result<() Ok(()) } - -fn derive_passkey(kdf_version: KdfVersion, passphrase: &str, salt: &[u8]) -> Result> { - match kdf_version { - KdfVersion::Bcrypt => { - let mut hasher = Sha256::new(); - hasher.update(passphrase.as_bytes()); - let passphrase = hasher.finalize(); - - let salt: [u8; 16] = salt.try_into().context("salt length")?; - - let hashed = bcrypt::bcrypt(8, salt, &passphrase); - - let mut hasher = Sha256::new(); - hasher.update(&hashed[..16]); - let res = hasher.finalize().to_vec(); - - Ok(res) - } - KdfVersion::Argon2id => unimplemented!("Argon2id"), - } -} - -fn encode_auth_verifier(passkey: &[u8]) -> String { - let base64 = Base64String::from(passkey).to_string(); - base64.replace('+', "-").replace('/', "_").replace('=', "") -} diff --git a/src/main.rs b/src/main.rs index b43afdd..d74fad6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ use logging::{setup_logging, LoggingCLIConfig}; use login::{perform_login, LoginCLIConfig}; mod client; +mod crypto; mod logging; mod login; mod non_empty_string; diff --git a/src/proto.rs b/src/proto.rs index cde0ce4..77bda32 100644 --- a/src/proto.rs +++ b/src/proto.rs @@ -1,5 +1,5 @@ use base64::prelude::*; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde::{de::Error, Deserialize, Deserializer, Serialize, Serializer}; #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Format; @@ -18,8 +18,6 @@ impl<'de, const F: u8> serde::Deserialize<'de> for Format { where D: Deserializer<'de>, { - use serde::de::Error; - let s = String::deserialize(deserializer)?; let f: u8 = s.parse().map_err(|e| D::Error::custom(e))?; if f == F { @@ -54,8 +52,6 @@ impl<'de> serde::Deserialize<'de> for KdfVersion { where D: Deserializer<'de>, { - use serde::de::Error; - let s = String::deserialize(deserializer)?; match s.as_str() { "0" => Ok(Self::Bcrypt), @@ -130,8 +126,6 @@ impl<'de> serde::Deserialize<'de> for Base64String { where D: Deserializer<'de>, { - use serde::de::Error; - let s = String::deserialize(deserializer)?; let data = BASE64_STANDARD .decode(&s) @@ -196,8 +190,6 @@ impl<'de> serde::Deserialize<'de> for GroupType { where D: Deserializer<'de>, { - use serde::de::Error; - let s = String::deserialize(deserializer)?; match s.as_str() { "0" => Ok(Self::User), From fc8e032c8c4ce40a4b3ceab9aad40c02d6a1417f Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Thu, 8 Feb 2024 07:43:51 -0500 Subject: [PATCH 08/44] finish login stuff --- src/login.rs | 22 ++++++++++++++++++---- src/non_empty_string.rs | 22 +++++++++++++++++++++- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/login.rs b/src/login.rs index 4cb49b7..24a3567 100644 --- a/src/login.rs +++ b/src/login.rs @@ -1,4 +1,4 @@ -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use clap::Parser; use reqwest::Method; use tracing::debug; @@ -24,8 +24,15 @@ pub struct LoginCLIConfig { password: NonEmptyString, } -/// Perform tutanota webinterface login. -pub async fn perform_login(config: LoginCLIConfig, client: &Client) -> Result<()> { +/// User session +#[derive(Debug)] +pub struct Session { + pub user_id: String, + pub access_token: String, +} + +/// Perform tutanota login. +pub async fn perform_login(config: LoginCLIConfig, client: &Client) -> Result { debug!("perform login"); let req = SaltServiceRequest { @@ -57,5 +64,12 @@ pub async fn perform_login(config: LoginCLIConfig, client: &Client) -> Result<() debug!(user = resp.user.as_str(), "got user"); - Ok(()) + if !resp.challenges.is_empty() { + bail!("not implemented: challenges"); + } + + Ok(Session { + user_id: resp.user, + access_token: resp.access_token, + }) } diff --git a/src/non_empty_string.rs b/src/non_empty_string.rs index 517b8c2..e85f7d8 100644 --- a/src/non_empty_string.rs +++ b/src/non_empty_string.rs @@ -1,7 +1,7 @@ use std::{ops::Deref, str::FromStr}; /// Non-empty [`String`]. -#[derive(Clone)] +#[derive(Clone, PartialEq, Eq, Hash)] pub struct NonEmptyString(String); impl std::fmt::Debug for NonEmptyString { @@ -41,3 +41,23 @@ impl FromStr for NonEmptyString { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_roundtrip() { + let non_empty = NonEmptyString::from_str("foo").unwrap(); + assert_eq!(non_empty.as_ref(), "foo"); + assert_eq!(non_empty.deref(), "foo"); + assert_eq!(format!("{}", non_empty), "foo"); + assert_eq!(format!("{:?}", non_empty), r#""foo""#); + } + + #[test] + fn test_failure() { + let err = NonEmptyString::from_str("").unwrap_err(); + assert_eq!(err.to_string(), "cannot be empty"); + } +} From 5df4ebd6099a720a3d65b150a79a9231262f5658 Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Thu, 8 Feb 2024 19:43:28 -0500 Subject: [PATCH 09/44] fetch user memberships for folder listing --- src/client.rs | 14 ++++++++++---- src/folders.rs | 24 ++++++++++++++++++++++++ src/login.rs | 6 +++--- src/main.rs | 10 +++++++++- src/proto.rs | 16 ++++++++++++++++ 5 files changed, 62 insertions(+), 8 deletions(-) create mode 100644 src/folders.rs diff --git a/src/client.rs b/src/client.rs index aa48674..f77f229 100644 --- a/src/client.rs +++ b/src/client.rs @@ -20,7 +20,8 @@ impl Client { &self, method: Method, path: &str, - req: &Req, + data: &Req, + access_token: Option<&str>, ) -> Result where Req: serde::Serialize, @@ -28,10 +29,15 @@ impl Client { { debug!(%method, path, "service request",); - let resp = self + let mut req = self .inner - .request(method, format!("https://app.tuta.com/rest/sys/{path}")) - .json(req) + .request(method, format!("https://app.tuta.com/rest/sys/{path}")); + + if let Some(access_token) = access_token { + req = req.header("accessToken", access_token); + } + + let resp = req.json(data) .send() .await .context("initial request")? diff --git a/src/folders.rs b/src/folders.rs new file mode 100644 index 0000000..0b961a6 --- /dev/null +++ b/src/folders.rs @@ -0,0 +1,24 @@ +use std::collections::{HashMap, hash_map::Entry}; + +use anyhow::{Result, Context, bail}; +use reqwest::Method; + +use crate::{client::Client, login::Session, proto::{UserResponse, GroupType}}; + +pub async fn get_folders(client: &Client, session: &Session) -> Result<()> { + let resp: UserResponse = client.service_requst(Method::GET, &format!("user/{}", session.user_id), &(), Some(&session.access_token)).await.context("get user")?; + + let mut memberships = HashMap::with_capacity(resp.memberships.len()); + for membership in resp.memberships { + match memberships.entry(membership.group_type) { + Entry::Vacant(v) => { + v.insert(membership); + } + Entry::Occupied(_) => bail!("duplicate group membership for type {:?}", membership.group_type), + } + } + + let mail_group = memberships.get(&GroupType::Mail).context("no mail group found")?; + + Ok(()) +} diff --git a/src/login.rs b/src/login.rs index 24a3567..611ef2a 100644 --- a/src/login.rs +++ b/src/login.rs @@ -40,7 +40,7 @@ pub async fn perform_login(config: LoginCLIConfig, client: &Client) -> Result Result Result<()> { let client = Client::try_new().context("set up client")?; - perform_login(args.login_cfg, &client) + let session = perform_login(args.login_cfg, &client) .await .context("perform login")?; + match args.command { + Command::ListFolders => { + get_folders(&client, &session).await.context("get folders")?; + } + } + Ok(()) } diff --git a/src/proto.rs b/src/proto.rs index 77bda32..29aaede 100644 --- a/src/proto.rs +++ b/src/proto.rs @@ -263,6 +263,22 @@ pub struct SessionServiceResponse { pub user: String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UserMembership { + pub group_type: GroupType, + pub group: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UserResponse { + #[serde(rename = "_format")] + pub format: Format<0>, + + pub memberships: Vec, +} + #[cfg(test)] mod tests { use super::*; From 0176a924e1d1704de9819b13905eecb34f15985a Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Thu, 8 Feb 2024 20:53:14 -0500 Subject: [PATCH 10/44] destroy session on shutdonw --- src/client.rs | 36 ++++++++++--- src/crypto/auth.rs | 13 ++--- src/folders.rs | 29 ++++++++--- src/login.rs | 75 --------------------------- src/main.rs | 32 ++++++++---- src/proto.rs | 123 ++++++++++++++++++++++++++++++++++++++++++--- src/session.rs | 122 ++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 318 insertions(+), 112 deletions(-) delete mode 100644 src/login.rs create mode 100644 src/session.rs diff --git a/src/client.rs b/src/client.rs index f77f229..d3beb65 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,8 +1,10 @@ use anyhow::{Context, Result}; -use reqwest::Method; +use reqwest::{Method, Response}; use serde::de::DeserializeOwned; use tracing::debug; +use crate::proto::Base64Url; + #[derive(Debug)] pub struct Client { inner: reqwest::Client, @@ -21,11 +23,31 @@ impl Client { method: Method, path: &str, data: &Req, - access_token: Option<&str>, + access_token: Option<&Base64Url>, ) -> Result where Req: serde::Serialize, Resp: DeserializeOwned, + { + let resp = self + .service_requst_no_response(method, path, data, access_token) + .await? + .json::() + .await + .context("fetch JSON response")?; + + Ok(resp) + } + + pub async fn service_requst_no_response( + &self, + method: Method, + path: &str, + data: &Req, + access_token: Option<&Base64Url>, + ) -> Result + where + Req: serde::Serialize, { debug!(%method, path, "service request",); @@ -34,18 +56,16 @@ impl Client { .request(method, format!("https://app.tuta.com/rest/sys/{path}")); if let Some(access_token) = access_token { - req = req.header("accessToken", access_token); + req = req.header("accessToken", access_token.to_string()); } - let resp = req.json(data) + let resp = req + .json(data) .send() .await .context("initial request")? .error_for_status() - .context("return status")? - .json::() - .await - .context("fetch JSON response")?; + .context("return status")?; Ok(resp) } diff --git a/src/crypto/auth.rs b/src/crypto/auth.rs index 4a95259..a0bb383 100644 --- a/src/crypto/auth.rs +++ b/src/crypto/auth.rs @@ -1,14 +1,14 @@ use anyhow::{bail, Context, Result}; use sha2::{Digest, Sha256}; -use crate::proto::{Base64String, KdfVersion}; +use crate::proto::{Base64Url, KdfVersion}; /// Build auth verifier for session creation. pub fn build_auth_verifier( kdf_version: KdfVersion, passphrase: &str, salt: &[u8], -) -> Result { +) -> Result { let passkey = derive_passkey(kdf_version, passphrase, salt).context("derive passkey")?; Ok(encode_auth_verifier(&passkey)) } @@ -30,13 +30,12 @@ fn derive_passkey(kdf_version: KdfVersion, passphrase: &str, salt: &[u8]) -> Res } } -fn encode_auth_verifier(passkey: &[u8]) -> String { +fn encode_auth_verifier(passkey: &[u8]) -> Base64Url { let mut hasher = Sha256::new(); hasher.update(passkey); let hashed = hasher.finalize().to_vec(); - let base64 = Base64String::from(hashed).to_string(); - base64.replace('+', "-").replace('/', "_").replace('=', "") + Base64Url::from(hashed) } #[cfg(test)] @@ -46,7 +45,9 @@ mod tests { #[test] fn test_build_auth_verifier() { assert_eq!( - build_auth_verifier(KdfVersion::Bcrypt, "password", b"saltsaltsaltsalt").unwrap(), + build_auth_verifier(KdfVersion::Bcrypt, "password", b"saltsaltsaltsalt") + .unwrap() + .to_string(), "r3YdONamUCQ7yFZwPFX8KLWZ4kKnAZLyt7rwi1DCE1I", ); } diff --git a/src/folders.rs b/src/folders.rs index 0b961a6..43f2116 100644 --- a/src/folders.rs +++ b/src/folders.rs @@ -1,12 +1,24 @@ -use std::collections::{HashMap, hash_map::Entry}; +use std::collections::{hash_map::Entry, HashMap}; -use anyhow::{Result, Context, bail}; +use anyhow::{bail, Context, Result}; use reqwest::Method; -use crate::{client::Client, login::Session, proto::{UserResponse, GroupType}}; +use crate::{ + client::Client, + proto::{GroupType, UserResponse}, + session::Session, +}; pub async fn get_folders(client: &Client, session: &Session) -> Result<()> { - let resp: UserResponse = client.service_requst(Method::GET, &format!("user/{}", session.user_id), &(), Some(&session.access_token)).await.context("get user")?; + let resp: UserResponse = client + .service_requst( + Method::GET, + &format!("user/{}", session.user_id), + &(), + Some(&session.access_token), + ) + .await + .context("get user")?; let mut memberships = HashMap::with_capacity(resp.memberships.len()); for membership in resp.memberships { @@ -14,11 +26,16 @@ pub async fn get_folders(client: &Client, session: &Session) -> Result<()> { Entry::Vacant(v) => { v.insert(membership); } - Entry::Occupied(_) => bail!("duplicate group membership for type {:?}", membership.group_type), + Entry::Occupied(_) => bail!( + "duplicate group membership for type {:?}", + membership.group_type + ), } } - let mail_group = memberships.get(&GroupType::Mail).context("no mail group found")?; + let mail_group = memberships + .get(&GroupType::Mail) + .context("no mail group found")?; Ok(()) } diff --git a/src/login.rs b/src/login.rs deleted file mode 100644 index 611ef2a..0000000 --- a/src/login.rs +++ /dev/null @@ -1,75 +0,0 @@ -use anyhow::{bail, Context, Result}; -use clap::Parser; -use reqwest::Method; -use tracing::debug; - -use crate::{ - client::Client, - crypto::auth::build_auth_verifier, - non_empty_string::NonEmptyString, - proto::{ - SaltServiceRequest, SaltServiceResponse, SessionServiceRequest, SessionServiceResponse, - }, -}; - -/// Login CLI config. -#[derive(Debug, Parser)] -pub struct LoginCLIConfig { - /// Username - #[clap(long, env = "TUTANOTA_CLI_USERNAME")] - username: NonEmptyString, - - /// Password - #[clap(long, env = "TUTANOTA_CLI_PASSWORD")] - password: NonEmptyString, -} - -/// User session -#[derive(Debug)] -pub struct Session { - pub user_id: String, - pub access_token: String, -} - -/// Perform tutanota login. -pub async fn perform_login(config: LoginCLIConfig, client: &Client) -> Result { - debug!("perform login"); - - let req = SaltServiceRequest { - format: Default::default(), - mail_address: config.username.to_string(), - }; - let resp: SaltServiceResponse = client - .service_requst(Method::GET, "saltservice", &req, None) - .await - .context("get salt")?; - - let auth_verifier = build_auth_verifier(resp.kdf_version, &config.password, resp.salt.as_ref()) - .context("build auth verifier")?; - - let req = SessionServiceRequest { - format: Default::default(), - access_key: Default::default(), - auth_token: Default::default(), - auth_verifier, - client_identifier: env!("CARGO_PKG_NAME").to_owned(), - mail_address: config.username.to_string(), - recover_code_verifier: Default::default(), - user: Default::default(), - }; - let resp: SessionServiceResponse = client - .service_requst(Method::POST, "sessionservice", &req, None) - .await - .context("get session")?; - - debug!(user = resp.user.as_str(), "got user"); - - if !resp.challenges.is_empty() { - bail!("not implemented: challenges"); - } - - Ok(Session { - user_id: resp.user, - access_token: resp.access_token, - }) -} diff --git a/src/main.rs b/src/main.rs index 3a8c8f5..2044ee6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,18 +1,19 @@ +use crate::{ + client::Client, + session::{LoginCLIConfig, Session}, +}; use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; use folders::get_folders; use logging::{setup_logging, LoggingCLIConfig}; -use login::{perform_login, LoginCLIConfig}; mod client; mod crypto; mod folders; mod logging; -mod login; mod non_empty_string; mod proto; - -use client::Client; +mod session; /// CLI args. #[derive(Debug, Parser)] @@ -45,15 +46,28 @@ async fn main() -> Result<()> { let client = Client::try_new().context("set up client")?; - let session = perform_login(args.login_cfg, &client) + let session = Session::login(args.login_cfg, &client) .await .context("perform login")?; - match args.command { + let cmd_res = exec_cmd(&client, &session, args.command) + .await + .context("execute command"); + let logout_res = session.logout(&client).await.context("logout"); + + match (cmd_res, logout_res) { + (Err(e), _) => Err(e), + (_, Err(e)) => Err(e), + (Ok(()), Ok(())) => Ok(()), + } +} + +async fn exec_cmd(client: &Client, session: &Session, cmd: Command) -> Result<()> { + match cmd { Command::ListFolders => { - get_folders(&client, &session).await.context("get folders")?; + get_folders(client, session).await.context("get folders")?; + + Ok(()) } } - - Ok(()) } diff --git a/src/proto.rs b/src/proto.rs index 29aaede..46c54b9 100644 --- a/src/proto.rs +++ b/src/proto.rs @@ -1,3 +1,4 @@ +use anyhow::{bail, Result}; use base64::prelude::*; use serde::{de::Error, Deserialize, Deserializer, Serialize, Serializer}; @@ -61,10 +62,15 @@ impl<'de> serde::Deserialize<'de> for KdfVersion { } } -#[derive(PartialEq, Eq, Hash)] +#[derive(Clone, PartialEq, Eq, Hash)] pub struct Base64String(Box<[u8]>); impl Base64String { + pub fn try_new(s: &str) -> Result { + let data = BASE64_STANDARD.decode(&s)?; + Ok(Self(data.into())) + } + pub fn base64(&self) -> String { BASE64_STANDARD.encode(self.0.as_ref()) } @@ -127,10 +133,98 @@ impl<'de> serde::Deserialize<'de> for Base64String { D: Deserializer<'de>, { let s = String::deserialize(deserializer)?; - let data = BASE64_STANDARD - .decode(&s) - .map_err(|e| D::Error::custom(e))?; - Ok(Self(data.into())) + Self::try_new(&s).map_err(|e| D::Error::custom(e)) + } +} + +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct Base64Url(Base64String); + +impl Base64Url { + pub fn try_new(s: &str) -> Result { + let mut s = s.replace('-', "+").replace('_', "/"); + match s.len() % 4 { + 0 => {} + 2 => { + s.push_str("=="); + } + 3 => { + s.push_str("="); + } + _ => { + bail!("invalid base64 URL") + } + } + Ok(Self(Base64String::try_new(&s)?)) + } + + pub fn url(&self) -> String { + self.0 + .base64() + .replace('+', "-") + .replace('/', "_") + .replace('=', "") + } +} + +impl std::fmt::Debug for Base64Url { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.url()) + } +} + +impl std::fmt::Display for Base64Url { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.url()) + } +} + +impl From> for Base64Url { + fn from(value: Vec) -> Self { + Self(value.into()) + } +} + +impl From<&[u8]> for Base64Url { + fn from(value: &[u8]) -> Self { + Self(value.into()) + } +} + +impl From<[u8; N]> for Base64Url { + fn from(value: [u8; N]) -> Self { + Self(value.into()) + } +} + +impl From<&[u8; N]> for Base64Url { + fn from(value: &[u8; N]) -> Self { + Self(value.to_owned().into()) + } +} + +impl AsRef<[u8]> for Base64Url { + fn as_ref(&self) -> &[u8] { + self.0.as_ref() + } +} + +impl serde::Serialize for Base64Url { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.url()) + } +} + +impl<'de> serde::Deserialize<'de> for Base64Url { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Self::try_new(&s).map_err(|e| D::Error::custom(e)) } } @@ -239,7 +333,7 @@ pub struct SessionServiceRequest { pub auth_token: Null, - pub auth_verifier: String, + pub auth_verifier: Base64Url, pub client_identifier: String, @@ -256,7 +350,7 @@ pub struct SessionServiceResponse { #[serde(rename = "_format")] pub format: Format<0>, - pub access_token: String, + pub access_token: Base64Url, pub challenges: Vec, @@ -270,6 +364,12 @@ pub struct UserMembership { pub group: String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UserAuth { + pub sessions: String, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UserResponse { @@ -277,6 +377,7 @@ pub struct UserResponse { pub format: Format<0>, pub memberships: Vec, + pub auth: UserAuth, } #[cfg(test)] @@ -302,11 +403,17 @@ mod tests { } #[test] - fn test_roundtrip_base64trip() { + fn test_roundtrip_base64string() { assert_roundtrip(Base64String::from(b"")); assert_roundtrip(Base64String::from(b"foo")); } + #[test] + fn test_roundtrip_base64url() { + assert_roundtrip(Base64Url::from(b"")); + assert_roundtrip(Base64Url::from(b"foo")); + } + #[test] fn test_roundtrip_group_type() { assert_roundtrip(GroupType::User); diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..29c13f9 --- /dev/null +++ b/src/session.rs @@ -0,0 +1,122 @@ +use anyhow::{bail, Context, Result}; +use clap::Parser; +use reqwest::Method; +use sha2::{Digest, Sha256}; +use tracing::debug; + +use crate::{ + client::Client, + crypto::auth::build_auth_verifier, + non_empty_string::NonEmptyString, + proto::{ + Base64Url, SaltServiceRequest, SaltServiceResponse, SessionServiceRequest, + SessionServiceResponse, UserResponse, + }, +}; + +/// Login CLI config. +#[derive(Debug, Parser)] +pub struct LoginCLIConfig { + /// Username + #[clap(long, env = "TUTANOTA_CLI_USERNAME")] + username: NonEmptyString, + + /// Password + #[clap(long, env = "TUTANOTA_CLI_PASSWORD")] + password: NonEmptyString, +} + +/// User session +#[derive(Debug)] +pub struct Session { + pub user_id: String, + pub access_token: Base64Url, +} + +impl Session { + /// Perform tutanota login. + pub async fn login(config: LoginCLIConfig, client: &Client) -> Result { + debug!("perform login"); + + let req = SaltServiceRequest { + format: Default::default(), + mail_address: config.username.to_string(), + }; + let resp: SaltServiceResponse = client + .service_requst(Method::GET, "saltservice", &req, None) + .await + .context("get salt")?; + + let auth_verifier = + build_auth_verifier(resp.kdf_version, &config.password, resp.salt.as_ref()) + .context("build auth verifier")?; + + let req = SessionServiceRequest { + format: Default::default(), + access_key: Default::default(), + auth_token: Default::default(), + auth_verifier, + client_identifier: env!("CARGO_PKG_NAME").to_owned(), + mail_address: config.username.to_string(), + recover_code_verifier: Default::default(), + user: Default::default(), + }; + let resp: SessionServiceResponse = client + .service_requst(Method::POST, "sessionservice", &req, None) + .await + .context("get session")?; + + debug!(user = resp.user.as_str(), "got user"); + + if !resp.challenges.is_empty() { + bail!("not implemented: challenges"); + } + + Ok(Self { + user_id: resp.user, + access_token: resp.access_token, + }) + } + + pub async fn logout(self, client: &Client) -> Result<()> { + let resp: UserResponse = client + .service_requst( + Method::GET, + &format!("user/{}", self.user_id), + &(), + Some(&self.access_token), + ) + .await + .context("get user")?; + + client + .service_requst_no_response( + Method::DELETE, + &format!( + "session/{}/{}", + resp.auth.sessions, + // session_list_id(&self.access_token), + session_element_id(&self.access_token) + ), + &(), + Some(&self.access_token), + ) + .await + .context("session deletion")?; + + Ok(()) + } +} + +const GENERATE_ID_BYTES_LENGTH: usize = 9; + +fn session_element_id(access_token: &Base64Url) -> Base64Url { + let mut hasher = Sha256::new(); + hasher.update(&access_token.as_ref()[GENERATE_ID_BYTES_LENGTH..]); + hasher.finalize().to_vec().into() +} + +#[allow(dead_code)] +fn session_list_id(access_token: &Base64Url) -> Base64Url { + access_token.as_ref()[..GENERATE_ID_BYTES_LENGTH].into() +} From 154cdf558e321b79dcde204d4576381ca84f1f52 Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Thu, 8 Feb 2024 21:06:35 -0500 Subject: [PATCH 11/44] improve session handling --- src/folders.rs | 31 ++++++++++++++++--------------- src/proto.rs | 2 +- src/session.rs | 35 ++++++++++++++++++++++------------- 3 files changed, 39 insertions(+), 29 deletions(-) diff --git a/src/folders.rs b/src/folders.rs index 43f2116..8148dad 100644 --- a/src/folders.rs +++ b/src/folders.rs @@ -2,26 +2,25 @@ use std::collections::{hash_map::Entry, HashMap}; use anyhow::{bail, Context, Result}; use reqwest::Method; +use tracing::debug; use crate::{ client::Client, - proto::{GroupType, UserResponse}, + proto::{GroupType, UserMembership, UserResponse}, session::Session, }; pub async fn get_folders(client: &Client, session: &Session) -> Result<()> { - let resp: UserResponse = client - .service_requst( - Method::GET, - &format!("user/{}", session.user_id), - &(), - Some(&session.access_token), - ) - .await - .context("get user")?; - - let mut memberships = HashMap::with_capacity(resp.memberships.len()); - for membership in resp.memberships { + let mail_group = get_mail_membership(session).context("get mail group")?; + + Ok(()) +} + +fn get_mail_membership(session: &Session) -> Result { + debug!("get mail membership"); + + let mut memberships = HashMap::with_capacity(session.user_data.memberships.len()); + for membership in &session.user_data.memberships { match memberships.entry(membership.group_type) { Entry::Vacant(v) => { v.insert(membership); @@ -33,9 +32,11 @@ pub async fn get_folders(client: &Client, session: &Session) -> Result<()> { } } - let mail_group = memberships + let membership = *memberships .get(&GroupType::Mail) .context("no mail group found")?; - Ok(()) + debug!(group = membership.group.as_str(), "got mail membership"); + + Ok(membership.clone()) } diff --git a/src/proto.rs b/src/proto.rs index 46c54b9..358cb83 100644 --- a/src/proto.rs +++ b/src/proto.rs @@ -357,7 +357,7 @@ pub struct SessionServiceResponse { pub user: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UserMembership { pub group_type: GroupType, diff --git a/src/session.rs b/src/session.rs index 29c13f9..382f745 100644 --- a/src/session.rs +++ b/src/session.rs @@ -31,6 +31,7 @@ pub struct LoginCLIConfig { pub struct Session { pub user_id: String, pub access_token: Base64Url, + pub user_data: UserResponse, } impl Session { @@ -65,37 +66,43 @@ impl Session { .service_requst(Method::POST, "sessionservice", &req, None) .await .context("get session")?; + let user_id = resp.user; + let access_token = resp.access_token; - debug!(user = resp.user.as_str(), "got user"); + debug!(user = user_id.as_str(), "got user"); if !resp.challenges.is_empty() { bail!("not implemented: challenges"); } - Ok(Self { - user_id: resp.user, - access_token: resp.access_token, - }) - } - - pub async fn logout(self, client: &Client) -> Result<()> { - let resp: UserResponse = client + let user_data: UserResponse = client .service_requst( Method::GET, - &format!("user/{}", self.user_id), + &format!("user/{}", user_id), &(), - Some(&self.access_token), + Some(&access_token), ) .await .context("get user")?; + Ok(Self { + user_id, + access_token, + user_data, + }) + } + + pub async fn logout(self, client: &Client) -> Result<()> { + let session = &self.user_data.auth.sessions; + + debug!(session = session.as_str(), "performing logout",); + client .service_requst_no_response( Method::DELETE, &format!( "session/{}/{}", - resp.auth.sessions, - // session_list_id(&self.access_token), + session, session_element_id(&self.access_token) ), &(), @@ -104,6 +111,8 @@ impl Session { .await .context("session deletion")?; + debug!("logout done"); + Ok(()) } } From 4202638af4b8e0eac72695933a952ec4ab916b02 Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Thu, 8 Feb 2024 22:45:51 -0500 Subject: [PATCH 12/44] list folder w/o encryption --- src/client.rs | 55 ++++++++++++++++++++++--- src/folders.rs | 55 +++++++++++++++++++++++-- src/main.rs | 5 ++- src/proto.rs | 110 +++++++++++++++++++++++++++++++++++++++++++++++++ src/session.rs | 8 ++-- 5 files changed, 220 insertions(+), 13 deletions(-) diff --git a/src/client.rs b/src/client.rs index d3beb65..03d4b40 100644 --- a/src/client.rs +++ b/src/client.rs @@ -18,19 +18,63 @@ impl Client { Ok(Self { inner }) } - pub async fn service_requst( + pub async fn service_request( &self, method: Method, path: &str, data: &Req, access_token: Option<&Base64Url>, ) -> Result + where + Req: serde::Serialize, + Resp: DeserializeOwned, + { + self.do_json(method, "sys", path, data, access_token).await + } + + pub async fn service_request_tutanota( + &self, + method: Method, + path: &str, + data: &Req, + access_token: Option<&Base64Url>, + ) -> Result + where + Req: serde::Serialize, + Resp: DeserializeOwned, + { + self.do_json(method, "tutanota", path, data, access_token) + .await + } + + pub async fn service_request_no_response( + &self, + method: Method, + path: &str, + data: &Req, + access_token: Option<&Base64Url>, + ) -> Result + where + Req: serde::Serialize, + { + self.do_request(method, "sys", path, data, access_token) + .await + } + + async fn do_json( + &self, + method: Method, + prefix: &str, + path: &str, + data: &Req, + access_token: Option<&Base64Url>, + ) -> Result where Req: serde::Serialize, Resp: DeserializeOwned, { let resp = self - .service_requst_no_response(method, path, data, access_token) + .do_request(method, prefix, path, data, access_token) .await? .json::() .await @@ -39,9 +83,10 @@ impl Client { Ok(resp) } - pub async fn service_requst_no_response( + async fn do_request( &self, method: Method, + prefix: &str, path: &str, data: &Req, access_token: Option<&Base64Url>, @@ -49,11 +94,11 @@ impl Client { where Req: serde::Serialize, { - debug!(%method, path, "service request",); + debug!(%method, prefix, path, "service request",); let mut req = self .inner - .request(method, format!("https://app.tuta.com/rest/sys/{path}")); + .request(method, format!("https://app.tuta.com/rest/{prefix}/{path}")); if let Some(access_token) = access_token { req = req.header("accessToken", access_token.to_string()); diff --git a/src/folders.rs b/src/folders.rs index 8148dad..7778e73 100644 --- a/src/folders.rs +++ b/src/folders.rs @@ -6,14 +6,63 @@ use tracing::debug; use crate::{ client::Client, - proto::{GroupType, UserMembership, UserResponse}, + proto::{FolderResponse, GroupType, MailboxGroupRootResponse, MailboxResponse, UserMembership}, session::Session, }; -pub async fn get_folders(client: &Client, session: &Session) -> Result<()> { +#[derive(Debug)] +pub struct Folder { + pub name: String, + pub mails: String, +} + +pub async fn get_folders(client: &Client, session: &Session) -> Result> { let mail_group = get_mail_membership(session).context("get mail group")?; - Ok(()) + let resp: MailboxGroupRootResponse = client + .service_request_tutanota( + Method::GET, + &format!("mailboxgrouproot/{}", mail_group.group), + &(), + Some(&session.access_token), + ) + .await + .context("get mailbox group root")?; + let mailbox = resp.mailbox; + + debug!(mailbox = mailbox.as_str(), "mailbox found"); + + let resp: MailboxResponse = client + .service_request_tutanota( + Method::GET, + &format!("mailbox/{mailbox}"), + &(), + Some(&session.access_token), + ) + .await + .context("get mailbox")?; + let folders = resp.folders.folders; + + debug!(folders = folders.as_str(), "folders found"); + + let resp: Vec = client + .service_request_tutanota( + Method::GET, + &format!("mailfolder/{folders}?start=------------&count=1000&reverse=false"), + &(), + Some(&session.access_token), + ) + .await + .context("get folders")?; + + // TODO: decrypt name for custom folders + Ok(resp + .into_iter() + .map(|f| Folder { + name: f.folder_type.name().to_owned(), + mails: f.mails, + }) + .collect()) } fn get_mail_membership(session: &Session) -> Result { diff --git a/src/main.rs b/src/main.rs index 2044ee6..72deac0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -65,7 +65,10 @@ async fn main() -> Result<()> { async fn exec_cmd(client: &Client, session: &Session, cmd: Command) -> Result<()> { match cmd { Command::ListFolders => { - get_folders(client, session).await.context("get folders")?; + let folders = get_folders(client, session).await.context("get folders")?; + for f in folders { + println!("{}", f.name); + } Ok(()) } diff --git a/src/proto.rs b/src/proto.rs index 358cb83..89214e3 100644 --- a/src/proto.rs +++ b/src/proto.rs @@ -303,6 +303,68 @@ impl<'de> serde::Deserialize<'de> for GroupType { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MailFolderType { + Custom, + Inbox, + Sent, + Trash, + Archive, + Spam, + Draft, +} + +impl MailFolderType { + pub fn name(&self) -> &'static str { + match self { + Self::Custom => "Custom", + Self::Inbox => "Inbox", + Self::Sent => "Sent", + Self::Trash => "Trash", + Self::Archive => "Archive", + Self::Spam => "Spam", + Self::Draft => "Draft", + } + } +} + +impl serde::Serialize for MailFolderType { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let s = match self { + Self::Custom => "0", + Self::Inbox => "1", + Self::Sent => "2", + Self::Trash => "3", + Self::Archive => "4", + Self::Spam => "5", + Self::Draft => "6", + }; + serializer.serialize_str(s) + } +} + +impl<'de> serde::Deserialize<'de> for MailFolderType { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + match s.as_str() { + "0" => Ok(Self::Custom), + "1" => Ok(Self::Inbox), + "2" => Ok(Self::Sent), + "3" => Ok(Self::Trash), + "4" => Ok(Self::Archive), + "5" => Ok(Self::Spam), + "6" => Ok(Self::Draft), + s => Err(D::Error::custom(format!("invalid mail folder type: {s}"))), + } + } +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct SaltServiceRequest { @@ -380,6 +442,41 @@ pub struct UserResponse { pub auth: UserAuth, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MailboxGroupRootResponse { + #[serde(rename = "_format")] + pub format: Format<0>, + + pub mailbox: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Folders { + pub folders: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MailboxResponse { + #[serde(rename = "_format")] + pub format: Format<0>, + + pub folders: Folders, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FolderResponse { + #[serde(rename = "_format")] + pub format: Format<0>, + + pub folder_type: MailFolderType, + pub name: String, + pub mails: String, +} + #[cfg(test)] mod tests { use super::*; @@ -432,6 +529,19 @@ mod tests { assert_deser_error::(r#""20""#, "invalid group type: 20"); } + #[test] + fn test_roundtrip_mail_folder_type() { + assert_roundtrip(MailFolderType::Custom); + assert_roundtrip(MailFolderType::Inbox); + assert_roundtrip(MailFolderType::Sent); + assert_roundtrip(MailFolderType::Trash); + assert_roundtrip(MailFolderType::Archive); + assert_roundtrip(MailFolderType::Spam); + assert_roundtrip(MailFolderType::Draft); + + assert_deser_error::(r#""20""#, "invalid mail folder type: 20"); + } + #[track_caller] fn assert_roundtrip(orig: T) where diff --git a/src/session.rs b/src/session.rs index 382f745..234910d 100644 --- a/src/session.rs +++ b/src/session.rs @@ -44,7 +44,7 @@ impl Session { mail_address: config.username.to_string(), }; let resp: SaltServiceResponse = client - .service_requst(Method::GET, "saltservice", &req, None) + .service_request(Method::GET, "saltservice", &req, None) .await .context("get salt")?; @@ -63,7 +63,7 @@ impl Session { user: Default::default(), }; let resp: SessionServiceResponse = client - .service_requst(Method::POST, "sessionservice", &req, None) + .service_request(Method::POST, "sessionservice", &req, None) .await .context("get session")?; let user_id = resp.user; @@ -76,7 +76,7 @@ impl Session { } let user_data: UserResponse = client - .service_requst( + .service_request( Method::GET, &format!("user/{}", user_id), &(), @@ -98,7 +98,7 @@ impl Session { debug!(session = session.as_str(), "performing logout",); client - .service_requst_no_response( + .service_request_no_response( Method::DELETE, &format!( "session/{}/{}", From 8ceb5b25068f3d29ddc7fd8750ba4742481bf6c7 Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Fri, 9 Feb 2024 18:51:24 -0500 Subject: [PATCH 13/44] decrypt folder names --- Cargo.lock | 43 +++++++++++++ Cargo.toml | 3 + src/crypto/auth.rs | 41 ++++++++----- src/crypto/encryption.rs | 129 +++++++++++++++++++++++++++++++++++++++ src/crypto/mod.rs | 1 + src/folders.rs | 38 +++++++++--- src/proto.rs | 10 ++- src/session.rs | 27 ++++++-- 8 files changed, 265 insertions(+), 27 deletions(-) create mode 100644 src/crypto/encryption.rs diff --git a/Cargo.lock b/Cargo.lock index 04fa97c..f6c1e5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,17 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "aes" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac1f845298e95f983ff1944b728ae08b8cebab80d684f0a832ed0fc74dfa27e2" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "aho-corasick" version = "1.1.2" @@ -146,6 +157,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "blowfish" version = "0.9.1" @@ -174,6 +194,15 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.0.83" @@ -294,6 +323,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -454,6 +484,15 @@ version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0c62115964e08cb8039170eb33c1d0e2388a256930279edca206fff675f82c3" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "hostname" version = "0.3.1" @@ -573,6 +612,7 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" dependencies = [ + "block-padding", "generic-array", ] @@ -1167,11 +1207,14 @@ dependencies = [ name = "tatutanatata2" version = "0.1.0" dependencies = [ + "aes", "anyhow", "base64", "bcrypt", + "cbc", "clap", "dotenvy", + "hmac", "reqwest", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 87ef04e..3b1e8f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,3 +18,6 @@ tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } base64 = "0.21.7" sha2 = "0.10.8" bcrypt = "0.15.0" +aes = "0.8.3" +cbc = { version = "0.1.2", features = ["alloc"] } +hmac = "0.12.1" diff --git a/src/crypto/auth.rs b/src/crypto/auth.rs index a0bb383..c8474dd 100644 --- a/src/crypto/auth.rs +++ b/src/crypto/auth.rs @@ -1,19 +1,32 @@ +use std::ops::Deref; + use anyhow::{bail, Context, Result}; use sha2::{Digest, Sha256}; use crate::proto::{Base64Url, KdfVersion}; -/// Build auth verifier for session creation. -pub fn build_auth_verifier( +#[derive(Debug)] +pub struct UserPassphraseKey(Box<[u8]>); + +impl AsRef<[u8]> for UserPassphraseKey { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +impl Deref for UserPassphraseKey { + type Target = [u8]; + + fn deref(&self) -> &Self::Target { + self.0.deref() + } +} + +pub fn derive_passkey( kdf_version: KdfVersion, passphrase: &str, salt: &[u8], -) -> Result { - let passkey = derive_passkey(kdf_version, passphrase, salt).context("derive passkey")?; - Ok(encode_auth_verifier(&passkey)) -} - -fn derive_passkey(kdf_version: KdfVersion, passphrase: &str, salt: &[u8]) -> Result> { +) -> Result { match kdf_version { KdfVersion::Bcrypt => { let mut hasher = Sha256::new(); @@ -24,15 +37,15 @@ fn derive_passkey(kdf_version: KdfVersion, passphrase: &str, salt: &[u8]) -> Res let hashed = bcrypt::bcrypt(8, salt, &passphrase); - Ok(hashed[..16].to_owned()) + Ok(UserPassphraseKey(hashed[..16].to_owned().into())) } KdfVersion::Argon2id => bail!("not implemented: Argon2id"), } } -fn encode_auth_verifier(passkey: &[u8]) -> Base64Url { +pub fn encode_auth_verifier(passkey: &UserPassphraseKey) -> Base64Url { let mut hasher = Sha256::new(); - hasher.update(passkey); + hasher.update(&passkey.0); let hashed = hasher.finalize().to_vec(); Base64Url::from(hashed) @@ -44,10 +57,10 @@ mod tests { #[test] fn test_build_auth_verifier() { + let pk = derive_passkey(KdfVersion::Bcrypt, "password", b"saltsaltsaltsalt").unwrap(); + let verifier = encode_auth_verifier(&pk); assert_eq!( - build_auth_verifier(KdfVersion::Bcrypt, "password", b"saltsaltsaltsalt") - .unwrap() - .to_string(), + verifier.to_string(), "r3YdONamUCQ7yFZwPFX8KLWZ4kKnAZLyt7rwi1DCE1I", ); } diff --git a/src/crypto/encryption.rs b/src/crypto/encryption.rs new file mode 100644 index 0000000..e6a22ac --- /dev/null +++ b/src/crypto/encryption.rs @@ -0,0 +1,129 @@ +use anyhow::{anyhow, bail, Context, Result}; +use cbc::cipher::{ + block_padding::{NoPadding, Pkcs7}, + BlockDecryptMut, KeyIvInit, +}; +use hmac::{Hmac, Mac}; +use sha2::{Digest, Sha256, Sha512}; + +type Aes128CbcDec = cbc::Decryptor; +type Aes256CbcDec = cbc::Decryptor; +type HmacSha256 = Hmac; + +pub fn decrypt_key(encryption_key: &[u8], key_to_be_decrypted: &[u8]) -> Result> { + if let Ok(k) = TryInto::<[u8; 16]>::try_into(encryption_key) { + let iv: [u8; 16] = [128u8 + 8; 16]; + Aes128CbcDec::new(&k.into(), &iv.into()) + .decrypt_padded_vec_mut::(key_to_be_decrypted) + .map_err(|e| anyhow!("{e}")) + .context("AES decryption") + } else if let Ok(_k) = TryInto::<[u8; 32]>::try_into(encryption_key) { + bail!("not implemented: AES256") + } else { + bail!("invalid encryption key length: {}", encryption_key.len()) + } +} + +pub fn decrypt_value(encryption_key: &[u8], value: &[u8]) -> Result> { + dbg!(encryption_key); + dbg!(value); + if let Ok(_k) = TryInto::<[u8; 16]>::try_into(encryption_key) { + bail!("not implemented: AES128") + } else if let Ok(k) = TryInto::<[u8; 32]>::try_into(encryption_key) { + let (k, value) = if value.len() % 2 == 1 { + // use mac + const MAC_LEN: usize = 32; + if value.len() < MAC_LEN + 1 { + bail!("mac missing") + } + let payload = &value[1..(value.len() - MAC_LEN)]; + let mac = &value[value.len() - MAC_LEN..]; + let subkeys = Aes256Subkeys::from(k); + + // check mac + let mut m = HmacSha256::new_from_slice(&subkeys.mkey).expect("checked length"); + m.update(payload); + m.verify_slice(mac) + .map_err(|e| anyhow!("{e}")) + .context("HMAC verification")?; + + (subkeys.ckey, payload) + } else { + (k, value) + }; + + // get IV + const IV_LEN: usize = 16; + if value.len() < IV_LEN { + bail!("IV missing") + } + let iv: [u8; IV_LEN] = value[..IV_LEN].try_into().expect("checked length"); + let value = &value[IV_LEN..]; + Aes256CbcDec::new(&k.into(), &iv.into()) + .decrypt_padded_vec_mut::(value) + .map_err(|e| anyhow!("{e}")) + .context("AES decryption") + } else { + bail!("invalid encryption key length: {}", encryption_key.len()) + } +} + +struct Aes256Subkeys { + ckey: [u8; 32], + mkey: [u8; 32], +} + +impl From<[u8; 32]> for Aes256Subkeys { + fn from(k: [u8; 32]) -> Self { + let mut hasher = Sha512::new(); + hasher.update(k); + let hashed = hasher.finalize().to_vec(); + + Self { + ckey: hashed[..32].try_into().expect("check length"), + mkey: hashed[32..].try_into().expect("check length"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_decrypt_key() { + assert_eq!( + decrypt_key( + &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], + &[10u8, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160] + ) + .unwrap(), + vec![177u8, 11, 11, 117, 32, 75, 2, 15, 107, 230, 248, 94, 26, 11, 143, 0], + ); + } + + #[test] + fn test_decrypt_value() { + let k = [ + 163, 52, 230, 134, 76, 199, 13, 61, 124, 69, 58, 80, 3, 1, 198, 219, 215, 51, 42, 8, + 59, 76, 55, 188, 101, 165, 209, 167, 111, 205, 128, 60, + ]; + + let v = [ + 1, 1, 221, 88, 186, 70, 178, 125, 28, 66, 245, 102, 7, 214, 121, 162, 88, 138, 118, + 208, 12, 173, 154, 251, 201, 68, 94, 254, 228, 178, 138, 73, 52, 118, 21, 143, 248, + 117, 32, 158, 29, 154, 194, 98, 55, 215, 5, 129, 18, 13, 32, 165, 44, 185, 129, 14, 78, + 146, 134, 10, 134, 81, 50, 252, 212, + ]; + + assert_eq!(decrypt_value(&k, &v,).unwrap(), b"fooooo".to_owned(),); + + let mut v_broken = v.clone(); + v_broken[1] = 0; + + assert_eq!( + decrypt_value(&k, &v_broken).unwrap_err().to_string(), + "HMAC verification", + ); + } +} diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs index cfca2f9..bfcc91f 100644 --- a/src/crypto/mod.rs +++ b/src/crypto/mod.rs @@ -1,3 +1,4 @@ //! Crypto methods. pub mod auth; +pub mod encryption; diff --git a/src/folders.rs b/src/folders.rs index 7778e73..44bd66f 100644 --- a/src/folders.rs +++ b/src/folders.rs @@ -6,7 +6,11 @@ use tracing::debug; use crate::{ client::Client, - proto::{FolderResponse, GroupType, MailboxGroupRootResponse, MailboxResponse, UserMembership}, + crypto::encryption::{decrypt_key, decrypt_value}, + proto::{ + FolderResponse, GroupType, MailFolderType, MailboxGroupRootResponse, MailboxResponse, + UserMembership, + }, session::Session, }; @@ -55,14 +59,32 @@ pub async fn get_folders(client: &Client, session: &Session) -> Result Result { diff --git a/src/proto.rs b/src/proto.rs index 89214e3..81e7651 100644 --- a/src/proto.rs +++ b/src/proto.rs @@ -424,6 +424,7 @@ pub struct SessionServiceResponse { pub struct UserMembership { pub group_type: GroupType, pub group: String, + pub sym_enc_g_key: Base64String, } #[derive(Debug, Deserialize)] @@ -440,6 +441,7 @@ pub struct UserResponse { pub memberships: Vec, pub auth: UserAuth, + pub user_group: UserMembership, } #[derive(Debug, Deserialize)] @@ -472,8 +474,14 @@ pub struct FolderResponse { #[serde(rename = "_format")] pub format: Format<0>, + #[serde(rename = "_ownerEncSessionKey")] + pub owner_enc_session_key: Base64String, + + #[serde(rename = "_ownerGroup")] + pub owner_group: String, + pub folder_type: MailFolderType, - pub name: String, + pub name: Base64String, pub mails: String, } diff --git a/src/session.rs b/src/session.rs index 234910d..b925f68 100644 --- a/src/session.rs +++ b/src/session.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use anyhow::{bail, Context, Result}; use clap::Parser; use reqwest::Method; @@ -6,7 +8,10 @@ use tracing::debug; use crate::{ client::Client, - crypto::auth::build_auth_verifier, + crypto::{ + auth::{derive_passkey, encode_auth_verifier}, + encryption::decrypt_key, + }, non_empty_string::NonEmptyString, proto::{ Base64Url, SaltServiceRequest, SaltServiceResponse, SessionServiceRequest, @@ -31,6 +36,7 @@ pub struct LoginCLIConfig { pub struct Session { pub user_id: String, pub access_token: Base64Url, + pub group_keys: HashMap>, pub user_data: UserResponse, } @@ -48,9 +54,9 @@ impl Session { .await .context("get salt")?; - let auth_verifier = - build_auth_verifier(resp.kdf_version, &config.password, resp.salt.as_ref()) - .context("build auth verifier")?; + let pk = derive_passkey(resp.kdf_version, &config.password, resp.salt.as_ref()) + .context("derive passkey")?; + let auth_verifier = encode_auth_verifier(&pk); let req = SessionServiceRequest { format: Default::default(), @@ -85,9 +91,22 @@ impl Session { .await .context("get user")?; + let user_key = decrypt_key(&pk, user_data.user_group.sym_enc_g_key.as_ref()) + .context("decrypt user group key")?; + let mut group_keys = HashMap::default(); + group_keys.insert(user_data.user_group.group.clone(), user_key.clone()); + for group in &user_data.memberships { + group_keys.insert( + group.group.clone(), + decrypt_key(&user_key, group.sym_enc_g_key.as_ref()) + .context("decrypt membership group key")?, + ); + } + Ok(Self { user_id, access_token, + group_keys, user_data, }) } From 22475a3ce651b100ce1d8aa1b28f53c4a7bfb891 Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Fri, 9 Feb 2024 18:55:34 -0500 Subject: [PATCH 14/44] remove dbg --- src/crypto/encryption.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/crypto/encryption.rs b/src/crypto/encryption.rs index e6a22ac..a8be46b 100644 --- a/src/crypto/encryption.rs +++ b/src/crypto/encryption.rs @@ -25,8 +25,6 @@ pub fn decrypt_key(encryption_key: &[u8], key_to_be_decrypted: &[u8]) -> Result< } pub fn decrypt_value(encryption_key: &[u8], value: &[u8]) -> Result> { - dbg!(encryption_key); - dbg!(value); if let Ok(_k) = TryInto::<[u8; 16]>::try_into(encryption_key) { bail!("not implemented: AES128") } else if let Ok(k) = TryInto::<[u8; 32]>::try_into(encryption_key) { From 758b9b23507c060cb681e2f441839125e79bd644 Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Fri, 9 Feb 2024 18:55:46 -0500 Subject: [PATCH 15/44] end2end tests --- Cargo.lock | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 18 +++++++----- tests/cli.rs | 27 +++++++++++++++++ 3 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 tests/cli.rs diff --git a/Cargo.lock b/Cargo.lock index f6c1e5a..46d1420 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,6 +91,21 @@ version = "1.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca" +[[package]] +name = "assert_cmd" +version = "2.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00ad3f3a942eee60335ab4342358c161ee296829e0d16ff42fc1d6cb07815467" +dependencies = [ + "anstyle", + "bstr", + "doc-comment", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "async-trait" version = "0.1.77" @@ -176,6 +191,17 @@ dependencies = [ "cipher", ] +[[package]] +name = "bstr" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c48f0051a4b4c5e0b6d365cd04af53aeaa209e3cc15ec2cdb69e73cc87fbd0dc" +dependencies = [ + "memchr", + "regex-automata 0.4.5", + "serde", +] + [[package]] name = "bumpalo" version = "3.14.0" @@ -315,6 +341,12 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5" +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.10.7" @@ -326,6 +358,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "doc-comment" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" + [[package]] name = "dotenvy" version = "0.15.7" @@ -827,6 +865,33 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +[[package]] +name = "predicates" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b87bfd4605926cdfefc1c3b5f8fe560e3feca9d5552cf68c466d3d8236c7e8" +dependencies = [ + "anstyle", + "difflib", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b794032607612e7abeb4db69adb4e33590fa6cf1149e95fd7cb00e634b92f174" + +[[package]] +name = "predicates-tree" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368ba315fb8c5052ab692e68a0eefec6ec57b23a36959c14496f0b0df2c0cecf" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "proc-macro2" version = "1.0.78" @@ -1209,12 +1274,14 @@ version = "0.1.0" dependencies = [ "aes", "anyhow", + "assert_cmd", "base64", "bcrypt", "cbc", "clap", "dotenvy", "hmac", + "predicates", "reqwest", "serde", "serde_json", @@ -1225,6 +1292,12 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "termtree" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" + [[package]] name = "thiserror" version = "1.0.56" @@ -1514,6 +1587,15 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "wait-timeout" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f200f5b12eb75f8c1ed65abd4b2db8a6e1b138a20de009dacee265a2498f3f6" +dependencies = [ + "libc", +] + [[package]] name = "want" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index 3b1e8f8..e128898 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,19 +5,23 @@ edition = "2021" license = "MIT/Apache-2.0" [dependencies] +aes = "0.8.3" anyhow = "1.0.75" +base64 = "0.21.7" +bcrypt = "0.15.0" +cbc = { version = "0.1.2", features = ["alloc"] } clap = { version = "4.4.6", features = ["derive", "env"] } dotenvy = "0.15.7" -tokio = { version = "1.32.0", features = ["macros", "rt-multi-thread"] } +hmac = "0.12.1" reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls-webpki-roots", "trust-dns"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +sha2 = "0.10.8" +tokio = { version = "1.32.0", features = ["macros", "rt-multi-thread"] } tracing = "0.1.38" tracing-log = "0.1.3" tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } -base64 = "0.21.7" -sha2 = "0.10.8" -bcrypt = "0.15.0" -aes = "0.8.3" -cbc = { version = "0.1.2", features = ["alloc"] } -hmac = "0.12.1" + +[dev-dependencies] +assert_cmd = "2.0.12" +predicates = { version = "3.0.4", default-features = false } diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..ec9272f --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,27 @@ +use assert_cmd::Command; +use predicates::prelude::*; + +#[test] +fn test_help() { + let mut cmd = cmd(); + cmd.arg("--help").assert().success(); +} + +#[test] +fn test_list_folders() { + let mut cmd = cmd(); + cmd.arg("-vv") + .arg("list-folders") + .assert() + .success() + .stdout(predicate::str::contains( + [ + "Inbox", "Sent", "Trash", "Archive", "Spam", "Draft", "fooooo", + ] + .join("\n"), + )); +} + +fn cmd() -> Command { + Command::cargo_bin(env!("CARGO_PKG_NAME")).unwrap() +} From c4e3c5e8ecc03da59e126afd0401841153d85623 Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Fri, 9 Feb 2024 18:57:34 -0500 Subject: [PATCH 16/44] clippy --- src/crypto/encryption.rs | 2 +- src/proto.rs | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/crypto/encryption.rs b/src/crypto/encryption.rs index a8be46b..cf799bc 100644 --- a/src/crypto/encryption.rs +++ b/src/crypto/encryption.rs @@ -116,7 +116,7 @@ mod tests { assert_eq!(decrypt_value(&k, &v,).unwrap(), b"fooooo".to_owned(),); - let mut v_broken = v.clone(); + let mut v_broken = v; v_broken[1] = 0; assert_eq!( diff --git a/src/proto.rs b/src/proto.rs index 81e7651..cd781e7 100644 --- a/src/proto.rs +++ b/src/proto.rs @@ -20,7 +20,7 @@ impl<'de, const F: u8> serde::Deserialize<'de> for Format { D: Deserializer<'de>, { let s = String::deserialize(deserializer)?; - let f: u8 = s.parse().map_err(|e| D::Error::custom(e))?; + let f: u8 = s.parse().map_err(D::Error::custom)?; if f == F { Ok(Self) } else { @@ -67,7 +67,7 @@ pub struct Base64String(Box<[u8]>); impl Base64String { pub fn try_new(s: &str) -> Result { - let data = BASE64_STANDARD.decode(&s)?; + let data = BASE64_STANDARD.decode(s)?; Ok(Self(data.into())) } @@ -133,7 +133,7 @@ impl<'de> serde::Deserialize<'de> for Base64String { D: Deserializer<'de>, { let s = String::deserialize(deserializer)?; - Self::try_new(&s).map_err(|e| D::Error::custom(e)) + Self::try_new(&s).map_err(D::Error::custom) } } @@ -149,7 +149,7 @@ impl Base64Url { s.push_str("=="); } 3 => { - s.push_str("="); + s.push('='); } _ => { bail!("invalid base64 URL") @@ -224,7 +224,7 @@ impl<'de> serde::Deserialize<'de> for Base64Url { D: Deserializer<'de>, { let s = String::deserialize(deserializer)?; - Self::try_new(&s).map_err(|e| D::Error::custom(e)) + Self::try_new(&s).map_err(D::Error::custom) } } From 1dcfd6944118612baac7525643b702296686ea58 Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Sat, 10 Feb 2024 12:43:48 +0100 Subject: [PATCH 17/44] log to stderr --- src/logging.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/logging.rs b/src/logging.rs index 5cd6a42..cfcaa46 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -28,7 +28,10 @@ pub fn setup_logging(config: LoggingCLIConfig) -> Result<()> { }; let filter = EnvFilter::try_new(format!("{base_filter},hyper=info"))?; - let subscriber = FmtSubscriber::builder().with_env_filter(filter).finish(); + let subscriber = FmtSubscriber::builder() + .with_env_filter(filter) + .with_writer(std::io::stderr) + .finish(); tracing::subscriber::set_global_default(subscriber)?; From a73d2698a4604dbe95151ff98227a40aa46bd924 Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Sat, 10 Feb 2024 12:46:34 +0100 Subject: [PATCH 18/44] remove ansi colors for non-TTY outputs --- src/logging.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/logging.rs b/src/logging.rs index cfcaa46..fa1f4d7 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -1,4 +1,6 @@ //! Logging setup. +use std::io::IsTerminal; + use anyhow::Result; use clap::Parser; use tracing_log::LogTracer; @@ -28,9 +30,11 @@ pub fn setup_logging(config: LoggingCLIConfig) -> Result<()> { }; let filter = EnvFilter::try_new(format!("{base_filter},hyper=info"))?; + let writer = std::io::stderr; let subscriber = FmtSubscriber::builder() + .with_ansi(writer().is_terminal()) .with_env_filter(filter) - .with_writer(std::io::stderr) + .with_writer(writer) .finish(); tracing::subscriber::set_global_default(subscriber)?; From 1b0e5ada1c23e9df0a5c642790f0c59aa9111e2b Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Sat, 10 Feb 2024 12:46:43 +0100 Subject: [PATCH 19/44] paging --- Cargo.lock | 44 ++++++++++++++++++++++++++ Cargo.toml | 1 + src/client.rs | 83 ++++++++++++++++++++++++++++++++++++++++++++++---- src/folders.rs | 76 ++++++++++++++++++++++++--------------------- src/main.rs | 5 ++- src/proto.rs | 13 ++++++++ 6 files changed, 180 insertions(+), 42 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 46d1420..5bfec40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -412,6 +412,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.30" @@ -419,6 +434,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -427,12 +443,34 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" +[[package]] +name = "futures-executor" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" +[[package]] +name = "futures-macro" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.30" @@ -451,8 +489,13 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", "slab", @@ -1280,6 +1323,7 @@ dependencies = [ "cbc", "clap", "dotenvy", + "futures", "hmac", "predicates", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index e128898..391cc5f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ bcrypt = "0.15.0" cbc = { version = "0.1.2", features = ["alloc"] } clap = { version = "4.4.6", features = ["derive", "env"] } dotenvy = "0.15.7" +futures = "0.3.28" hmac = "0.12.1" reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls-webpki-roots", "trust-dns"] } serde = { version = "1.0", features = ["derive"] } diff --git a/src/client.rs b/src/client.rs index 03d4b40..612d79b 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,11 +1,16 @@ +use std::{collections::VecDeque, sync::Arc}; + use anyhow::{Context, Result}; +use futures::Stream; use reqwest::{Method, Response}; use serde::de::DeserializeOwned; use tracing::debug; -use crate::proto::Base64Url; +use crate::proto::{Base64Url, Entity}; + +const STREAM_BATCH_SIZE: u64 = 1000; -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct Client { inner: reqwest::Client, } @@ -29,7 +34,8 @@ impl Client { Req: serde::Serialize, Resp: DeserializeOwned, { - self.do_json(method, "sys", path, data, access_token).await + self.do_json(method, "sys", path, data, access_token, &[]) + .await } pub async fn service_request_tutanota( @@ -43,7 +49,7 @@ impl Client { Req: serde::Serialize, Resp: DeserializeOwned, { - self.do_json(method, "tutanota", path, data, access_token) + self.do_json(method, "tutanota", path, data, access_token, &[]) .await } @@ -57,10 +63,67 @@ impl Client { where Req: serde::Serialize, { - self.do_request(method, "sys", path, data, access_token) + self.do_request(method, "sys", path, data, access_token, &[]) .await } + pub fn stream( + &self, + path: &str, + access_token: Option<&Base64Url>, + ) -> impl Stream> + where + Resp: DeserializeOwned + Entity, + { + let state = StreamState { + buffer: VecDeque::default(), + next_start: "------------".to_owned(), + }; + let path = Arc::new(path.to_owned()); + let access_token = Arc::new(access_token.cloned()); + let this = self.clone(); + + futures::stream::try_unfold(state, move |mut state: StreamState| { + let path = Arc::clone(&path); + let access_token = Arc::clone(&access_token); + let this = this.clone(); + async move { + loop { + if let Some(next) = state.buffer.pop_front() { + return Ok(Some((next, state))); + } + + // buffer empty + state.buffer = this + .do_json::<(), Vec>( + Method::GET, + "tutanota", + &path, + &(), + access_token.as_ref().as_ref(), + &[ + ("start", &state.next_start), + ("count", &STREAM_BATCH_SIZE.to_string()), + ("reverse", "false"), + ], + ) + .await + .context("fetch next page")? + .into(); + match state.buffer.back() { + None => { + // reached end + return Ok(None); + } + Some(o) => { + state.next_start = o.id().to_owned(); + } + } + } + } + }) + } + async fn do_json( &self, method: Method, @@ -68,13 +131,14 @@ impl Client { path: &str, data: &Req, access_token: Option<&Base64Url>, + query: &[(&str, &str)], ) -> Result where Req: serde::Serialize, Resp: DeserializeOwned, { let resp = self - .do_request(method, prefix, path, data, access_token) + .do_request(method, prefix, path, data, access_token, query) .await? .json::() .await @@ -90,6 +154,7 @@ impl Client { path: &str, data: &Req, access_token: Option<&Base64Url>, + query: &[(&str, &str)], ) -> Result where Req: serde::Serialize, @@ -106,6 +171,7 @@ impl Client { let resp = req .json(data) + .query(query) .send() .await .context("initial request")? @@ -115,3 +181,8 @@ impl Client { Ok(resp) } } + +struct StreamState { + buffer: VecDeque, + next_start: String, +} diff --git a/src/folders.rs b/src/folders.rs index 44bd66f..6ab4ba6 100644 --- a/src/folders.rs +++ b/src/folders.rs @@ -1,6 +1,10 @@ -use std::collections::{hash_map::Entry, HashMap}; +use std::{ + collections::{hash_map::Entry, HashMap}, + sync::Arc, +}; use anyhow::{bail, Context, Result}; +use futures::{Stream, TryStreamExt}; use reqwest::Method; use tracing::debug; @@ -20,7 +24,10 @@ pub struct Folder { pub mails: String, } -pub async fn get_folders(client: &Client, session: &Session) -> Result> { +pub async fn get_folders( + client: &Client, + session: &Session, +) -> Result>> { let mail_group = get_mail_membership(session).context("get mail group")?; let resp: MailboxGroupRootResponse = client @@ -49,42 +56,41 @@ pub async fn get_folders(client: &Client, session: &Session) -> Result = client - .service_request_tutanota( - Method::GET, - &format!("mailfolder/{folders}?start=------------&count=1000&reverse=false"), - &(), + let group_keys = Arc::new(session.group_keys.clone()); + let stream = client + .stream::( + &format!("mailfolder/{folders}"), Some(&session.access_token), ) - .await - .context("get folders")?; - - resp.into_iter() - .map(|f| { - let session_key = decrypt_key( - session - .group_keys - .get(&f.owner_group) - .context("getting owner group key")?, - f.owner_enc_session_key.as_ref(), - ) - .context("decrypting session key")?; - - let name = if f.folder_type == MailFolderType::Custom { - String::from_utf8( - decrypt_value(&session_key, f.name.as_ref()).context("decrypt folder name")?, + .and_then(move |f| { + let group_keys = Arc::clone(&group_keys); + async move { + let session_key = decrypt_key( + group_keys + .get(&f.owner_group) + .context("getting owner group key")?, + f.owner_enc_session_key.as_ref(), ) - .context("invalid UTF8 string")? - } else { - f.folder_type.name().to_owned() - }; - - Ok(Folder { - name, - mails: f.mails, - }) - }) - .collect() + .context("decrypting session key")?; + + let name = if f.folder_type == MailFolderType::Custom { + String::from_utf8( + decrypt_value(&session_key, f.name.as_ref()) + .context("decrypt folder name")?, + ) + .context("invalid UTF8 string")? + } else { + f.folder_type.name().to_owned() + }; + + Ok(Folder { + name, + mails: f.mails, + }) + } + }); + + Ok(stream) } fn get_mail_membership(session: &Session) -> Result { diff --git a/src/main.rs b/src/main.rs index 72deac0..cfe3ce0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ use crate::{ use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; use folders::get_folders; +use futures::TryStreamExt; use logging::{setup_logging, LoggingCLIConfig}; mod client; @@ -66,7 +67,9 @@ async fn exec_cmd(client: &Client, session: &Session, cmd: Command) -> Result<() match cmd { Command::ListFolders => { let folders = get_folders(client, session).await.context("get folders")?; - for f in folders { + let mut folders = std::pin::pin!(folders); + + while let Some(f) = folders.try_next().await.context("poll folder")? { println!("{}", f.name); } diff --git a/src/proto.rs b/src/proto.rs index cd781e7..d88d017 100644 --- a/src/proto.rs +++ b/src/proto.rs @@ -2,6 +2,10 @@ use anyhow::{bail, Result}; use base64::prelude::*; use serde::{de::Error, Deserialize, Deserializer, Serialize, Serializer}; +pub trait Entity { + fn id(&self) -> &str; +} + #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Format; @@ -474,6 +478,9 @@ pub struct FolderResponse { #[serde(rename = "_format")] pub format: Format<0>, + #[serde(rename = "_id")] + pub id: [String; 2], + #[serde(rename = "_ownerEncSessionKey")] pub owner_enc_session_key: Base64String, @@ -485,6 +492,12 @@ pub struct FolderResponse { pub mails: String, } +impl Entity for FolderResponse { + fn id(&self) -> &str { + &self.id[1] + } +} + #[cfg(test)] mod tests { use super::*; From 54769142e0f111500e6f9a73510e15a3f2a417b0 Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Sat, 10 Feb 2024 12:50:41 +0100 Subject: [PATCH 20/44] improve logging --- src/client.rs | 5 +++++ src/logging.rs | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/client.rs b/src/client.rs index 612d79b..a320b03 100644 --- a/src/client.rs +++ b/src/client.rs @@ -94,6 +94,11 @@ impl Client { } // buffer empty + debug!( + path = path.as_str(), + start = state.next_start.as_str(), + "fetch new page", + ); state.buffer = this .do_json::<(), Vec>( Method::GET, diff --git a/src/logging.rs b/src/logging.rs index fa1f4d7..8866f99 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -28,7 +28,9 @@ pub fn setup_logging(config: LoggingCLIConfig) -> Result<()> { 2 => "debug", _ => "trace", }; - let filter = EnvFilter::try_new(format!("{base_filter},hyper=info"))?; + let filter = EnvFilter::try_new(format!( + "{base_filter},h2=info,hyper=info,log=info,trust_dns_proto=info,trust_dns_resolver=info" + ))?; let writer = std::io::stderr; let subscriber = FmtSubscriber::builder() From de3193d9e5c9a49193bbcce60a65d9dd41d6eb07 Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Sun, 11 Feb 2024 15:50:01 +0100 Subject: [PATCH 21/44] improve logging --- src/logging.rs | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/src/logging.rs b/src/logging.rs index 8866f99..7d4bd31 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -9,10 +9,21 @@ use tracing_subscriber::{EnvFilter, FmtSubscriber}; /// Logging CLI config. #[derive(Debug, Parser)] pub struct LoggingCLIConfig { - /// Log verbosity. + /// Log filter. + /// + /// Conflicts with `-v`/`--verbose`. + #[clap(conflicts_with = "log_verbose_count", long, action)] + log_filter: Option, + + /// Verbose logs. + /// + /// Repeat to increase verbosity. + /// + /// Conflicts with `--log-filter`. #[clap( short = 'v', long = "verbose", + conflicts_with="log_filter", action = clap::ArgAction::Count, )] log_verbose_count: u8, @@ -22,15 +33,17 @@ pub struct LoggingCLIConfig { pub fn setup_logging(config: LoggingCLIConfig) -> Result<()> { LogTracer::init()?; - let base_filter = match config.log_verbose_count { - 0 => "warn", - 1 => "info", - 2 => "debug", - _ => "trace", + let filter = match config.log_filter { + Some(filter) => filter, + None => match config.log_verbose_count { + 0 => "warn".to_owned(), + 1 => "info".to_owned(), + 2 => format!("info,{}=debug", env!("CARGO_PKG_NAME")), + 3 => "debug".to_owned(), + _ => "trace".to_owned(), + }, }; - let filter = EnvFilter::try_new(format!( - "{base_filter},h2=info,hyper=info,log=info,trust_dns_proto=info,trust_dns_resolver=info" - ))?; + let filter = EnvFilter::try_new(filter)?; let writer = std::io::stderr; let subscriber = FmtSubscriber::builder() From e068a2f4b4c41e9557f4cb3f6637baa5f8922522 Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Sun, 11 Feb 2024 15:59:13 +0100 Subject: [PATCH 22/44] harden client --- Cargo.lock | 71 ++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 2 +- src/client.rs | 9 +++++- src/constants.rs | 1 + src/main.rs | 1 + src/session.rs | 3 +- 6 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 src/constants.rs diff --git a/Cargo.lock b/Cargo.lock index 5bfec40..8067a6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "anstream" version = "0.6.11" @@ -106,6 +121,20 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "async-compression" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc2d0cfb2a7388d34f590e76686704c494ed7aaceed62ee1ba35cbf363abc2a5" +dependencies = [ + "brotli", + "flate2", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-trait" version = "0.1.77" @@ -191,6 +220,27 @@ dependencies = [ "cipher", ] +[[package]] +name = "brotli" +version = "3.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a0b1dbcc8ae29329621f8d4f0d835787c1c38bb1401979b49d13b0b305ff68" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "2.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b6561fd3f895a11e8f72af2cb7d22e08366bebc2b6b57f7744c4bda27034744" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bstr" version = "1.9.0" @@ -325,6 +375,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" +dependencies = [ + "cfg-if", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -397,6 +456,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "flate2" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46303f565772937ffe1d394a4fac6f411c6013172fadde9dcdb1e147a086940e" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1048,6 +1117,7 @@ version = "0.11.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6920094eb85afde5e4a138be3f2de8bbdf28000f0029e72c45025a56b042251" dependencies = [ + "async-compression", "base64", "bytes", "encoding_rs", @@ -1074,6 +1144,7 @@ dependencies = [ "system-configuration", "tokio", "tokio-rustls", + "tokio-util", "tower-service", "trust-dns-resolver", "url", diff --git a/Cargo.toml b/Cargo.toml index 391cc5f..e1a009b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ clap = { version = "4.4.6", features = ["derive", "env"] } dotenvy = "0.15.7" futures = "0.3.28" hmac = "0.12.1" -reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls-webpki-roots", "trust-dns"] } +reqwest = { version = "0.11", default-features = false, features = ["brotli", "deflate", "gzip", "json", "rustls-tls-webpki-roots", "trust-dns"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sha2 = "0.10.8" diff --git a/src/client.rs b/src/client.rs index a320b03..cb290cb 100644 --- a/src/client.rs +++ b/src/client.rs @@ -6,7 +6,10 @@ use reqwest::{Method, Response}; use serde::de::DeserializeOwned; use tracing::debug; -use crate::proto::{Base64Url, Entity}; +use crate::{ + constants::APP_USER_AGENT, + proto::{Base64Url, Entity}, +}; const STREAM_BATCH_SIZE: u64 = 1000; @@ -18,8 +21,12 @@ pub struct Client { impl Client { pub fn try_new() -> Result { let inner = reqwest::Client::builder() + .min_tls_version(reqwest::tls::Version::TLS_1_3) + .http2_prior_knowledge() + .user_agent(APP_USER_AGENT) .build() .context("set up HTTPs client")?; + Ok(Self { inner }) } diff --git a/src/constants.rs b/src/constants.rs new file mode 100644 index 0000000..c67ee01 --- /dev/null +++ b/src/constants.rs @@ -0,0 +1 @@ +pub static APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),); diff --git a/src/main.rs b/src/main.rs index cfe3ce0..47e6b66 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ use futures::TryStreamExt; use logging::{setup_logging, LoggingCLIConfig}; mod client; +mod constants; mod crypto; mod folders; mod logging; diff --git a/src/session.rs b/src/session.rs index b925f68..c34b153 100644 --- a/src/session.rs +++ b/src/session.rs @@ -8,6 +8,7 @@ use tracing::debug; use crate::{ client::Client, + constants::APP_USER_AGENT, crypto::{ auth::{derive_passkey, encode_auth_verifier}, encryption::decrypt_key, @@ -63,7 +64,7 @@ impl Session { access_key: Default::default(), auth_token: Default::default(), auth_verifier, - client_identifier: env!("CARGO_PKG_NAME").to_owned(), + client_identifier: APP_USER_AGENT.to_owned(), mail_address: config.username.to_string(), recover_code_verifier: Default::default(), user: Default::default(), From c2b9144430d14e70cadaabcdf6ba7d8cf4809d93 Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Sun, 11 Feb 2024 16:01:17 +0100 Subject: [PATCH 23/44] improve metadata --- Cargo.lock | 2 +- Cargo.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8067a6b..17f701f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1383,7 +1383,7 @@ dependencies = [ ] [[package]] -name = "tatutanatata2" +name = "tatutanatata" version = "0.1.0" dependencies = [ "aes", diff --git a/Cargo.toml b/Cargo.toml index e1a009b..767b305 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,8 @@ [package] -name = "tatutanatata2" +name = "tatutanatata" version = "0.1.0" edition = "2021" -license = "MIT/Apache-2.0" +license = "MIT OR Apache-2.0" [dependencies] aes = "0.8.3" From 524429d7e2f11046023624be0e330f47fa10adaa Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Wed, 14 Feb 2024 18:31:47 +0100 Subject: [PATCH 24/44] remove untested code --- src/crypto/encryption.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/crypto/encryption.rs b/src/crypto/encryption.rs index cf799bc..47a0d17 100644 --- a/src/crypto/encryption.rs +++ b/src/crypto/encryption.rs @@ -39,15 +39,19 @@ pub fn decrypt_value(encryption_key: &[u8], value: &[u8]) -> Result> { let subkeys = Aes256Subkeys::from(k); // check mac - let mut m = HmacSha256::new_from_slice(&subkeys.mkey).expect("checked length"); + let mut m = HmacSha256::new_from_slice(&subkeys.mac_key).expect("checked length"); m.update(payload); m.verify_slice(mac) .map_err(|e| anyhow!("{e}")) .context("HMAC verification")?; - (subkeys.ckey, payload) + (subkeys.encryption_key, payload) } else { - (k, value) + // technically this is + // (k, value) + // + // however we haven't seen this used yet so we just bail out for now + bail!("not implemented: value w/o MAC") }; // get IV @@ -67,8 +71,8 @@ pub fn decrypt_value(encryption_key: &[u8], value: &[u8]) -> Result> { } struct Aes256Subkeys { - ckey: [u8; 32], - mkey: [u8; 32], + encryption_key: [u8; 32], + mac_key: [u8; 32], } impl From<[u8; 32]> for Aes256Subkeys { @@ -78,8 +82,8 @@ impl From<[u8; 32]> for Aes256Subkeys { let hashed = hasher.finalize().to_vec(); Self { - ckey: hashed[..32].try_into().expect("check length"), - mkey: hashed[32..].try_into().expect("check length"), + encryption_key: hashed[..32].try_into().expect("check length"), + mac_key: hashed[32..].try_into().expect("check length"), } } } From 3459937e5b2cdf3f738ab14aeda850b42b7100d5 Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Wed, 14 Feb 2024 18:33:29 +0100 Subject: [PATCH 25/44] make code nicer --- src/folders.rs | 134 +++++++++++++++++++++++++------------------------ src/main.rs | 4 +- 2 files changed, 70 insertions(+), 68 deletions(-) diff --git a/src/folders.rs b/src/folders.rs index 6ab4ba6..9816fe9 100644 --- a/src/folders.rs +++ b/src/folders.rs @@ -24,73 +24,75 @@ pub struct Folder { pub mails: String, } -pub async fn get_folders( - client: &Client, - session: &Session, -) -> Result>> { - let mail_group = get_mail_membership(session).context("get mail group")?; - - let resp: MailboxGroupRootResponse = client - .service_request_tutanota( - Method::GET, - &format!("mailboxgrouproot/{}", mail_group.group), - &(), - Some(&session.access_token), - ) - .await - .context("get mailbox group root")?; - let mailbox = resp.mailbox; - - debug!(mailbox = mailbox.as_str(), "mailbox found"); - - let resp: MailboxResponse = client - .service_request_tutanota( - Method::GET, - &format!("mailbox/{mailbox}"), - &(), - Some(&session.access_token), - ) - .await - .context("get mailbox")?; - let folders = resp.folders.folders; - - debug!(folders = folders.as_str(), "folders found"); - - let group_keys = Arc::new(session.group_keys.clone()); - let stream = client - .stream::( - &format!("mailfolder/{folders}"), - Some(&session.access_token), - ) - .and_then(move |f| { - let group_keys = Arc::clone(&group_keys); - async move { - let session_key = decrypt_key( - group_keys - .get(&f.owner_group) - .context("getting owner group key")?, - f.owner_enc_session_key.as_ref(), - ) - .context("decrypting session key")?; - - let name = if f.folder_type == MailFolderType::Custom { - String::from_utf8( - decrypt_value(&session_key, f.name.as_ref()) - .context("decrypt folder name")?, +impl Folder { + pub async fn list( + client: &Client, + session: &Session, + ) -> Result>> { + let mail_group = get_mail_membership(session).context("get mail group")?; + + let resp: MailboxGroupRootResponse = client + .service_request_tutanota( + Method::GET, + &format!("mailboxgrouproot/{}", mail_group.group), + &(), + Some(&session.access_token), + ) + .await + .context("get mailbox group root")?; + let mailbox = resp.mailbox; + + debug!(mailbox = mailbox.as_str(), "mailbox found"); + + let resp: MailboxResponse = client + .service_request_tutanota( + Method::GET, + &format!("mailbox/{mailbox}"), + &(), + Some(&session.access_token), + ) + .await + .context("get mailbox")?; + let folders = resp.folders.folders; + + debug!(folders = folders.as_str(), "folders found"); + + let group_keys = Arc::new(session.group_keys.clone()); + let stream = client + .stream::( + &format!("mailfolder/{folders}"), + Some(&session.access_token), + ) + .and_then(move |f| { + let group_keys = Arc::clone(&group_keys); + async move { + let session_key = decrypt_key( + group_keys + .get(&f.owner_group) + .context("getting owner group key")?, + f.owner_enc_session_key.as_ref(), ) - .context("invalid UTF8 string")? - } else { - f.folder_type.name().to_owned() - }; - - Ok(Folder { - name, - mails: f.mails, - }) - } - }); - - Ok(stream) + .context("decrypting session key")?; + + let name = if f.folder_type == MailFolderType::Custom { + String::from_utf8( + decrypt_value(&session_key, f.name.as_ref()) + .context("decrypt folder name")?, + ) + .context("invalid UTF8 string")? + } else { + f.folder_type.name().to_owned() + }; + + Ok(Folder { + name, + mails: f.mails, + }) + } + }); + + Ok(stream) + } } fn get_mail_membership(session: &Session) -> Result { diff --git a/src/main.rs b/src/main.rs index 47e6b66..618c1a8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,7 @@ use crate::{ }; use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; -use folders::get_folders; +use folders::Folder; use futures::TryStreamExt; use logging::{setup_logging, LoggingCLIConfig}; @@ -67,7 +67,7 @@ async fn main() -> Result<()> { async fn exec_cmd(client: &Client, session: &Session, cmd: Command) -> Result<()> { match cmd { Command::ListFolders => { - let folders = get_folders(client, session).await.context("get folders")?; + let folders = Folder::list(client, session).await.context("get folders")?; let mut folders = std::pin::pin!(folders); while let Some(f) = folders.try_next().await.context("poll folder")? { From 99f3445518da5a1ce2d9479ab90c08c91f25f6c9 Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Wed, 14 Feb 2024 19:36:25 +0100 Subject: [PATCH 26/44] mail listing --- .gitignore | 1 + Cargo.toml | 2 +- src/folders.rs | 53 ++++++++++++++++++++++++------------------------ src/mails.rs | 36 +++++++++++++++++++++++++++++++++ src/main.rs | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/proto.rs | 16 +++++++++++++++ src/session.rs | 47 +++++++++++++++++++++++++++++------------- 7 files changed, 169 insertions(+), 41 deletions(-) create mode 100644 src/mails.rs diff --git a/.gitignore b/.gitignore index fedaa2b..783d57f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target .env +out diff --git a/Cargo.toml b/Cargo.toml index 767b305..1e06e48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ reqwest = { version = "0.11", default-features = false, features = ["brotli", "d serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sha2 = "0.10.8" -tokio = { version = "1.32.0", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1.32.0", features = ["fs", "macros", "rt-multi-thread"] } tracing = "0.1.38" tracing-log = "0.1.3" tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } diff --git a/src/folders.rs b/src/folders.rs index 9816fe9..68f5fd9 100644 --- a/src/folders.rs +++ b/src/folders.rs @@ -15,7 +15,7 @@ use crate::{ FolderResponse, GroupType, MailFolderType, MailboxGroupRootResponse, MailboxResponse, UserMembership, }, - session::Session, + session::{GroupKeys, Session}, }; #[derive(Debug)] @@ -57,7 +57,7 @@ impl Folder { debug!(folders = folders.as_str(), "folders found"); - let group_keys = Arc::new(session.group_keys.clone()); + let group_keys = Arc::clone(&session.group_keys); let stream = client .stream::( &format!("mailfolder/{folders}"), @@ -65,34 +65,35 @@ impl Folder { ) .and_then(move |f| { let group_keys = Arc::clone(&group_keys); - async move { - let session_key = decrypt_key( - group_keys - .get(&f.owner_group) - .context("getting owner group key")?, - f.owner_enc_session_key.as_ref(), - ) - .context("decrypting session key")?; - - let name = if f.folder_type == MailFolderType::Custom { - String::from_utf8( - decrypt_value(&session_key, f.name.as_ref()) - .context("decrypt folder name")?, - ) - .context("invalid UTF8 string")? - } else { - f.folder_type.name().to_owned() - }; - - Ok(Folder { - name, - mails: f.mails, - }) - } + async move { Self::decode(f, &group_keys) } }); Ok(stream) } + + fn decode(resp: FolderResponse, group_keys: &GroupKeys) -> Result { + let session_key = decrypt_key( + group_keys + .get(&resp.owner_group) + .context("getting owner group key")?, + resp.owner_enc_session_key.as_ref(), + ) + .context("decrypting session key")?; + + let name = if resp.folder_type == MailFolderType::Custom { + String::from_utf8( + decrypt_value(&session_key, resp.name.as_ref()).context("decrypt folder name")?, + ) + .context("invalid UTF8 string")? + } else { + resp.folder_type.name().to_owned() + }; + + Ok(Folder { + name, + mails: resp.mails, + }) + } } fn get_mail_membership(session: &Session) -> Result { diff --git a/src/mails.rs b/src/mails.rs new file mode 100644 index 0000000..c91842e --- /dev/null +++ b/src/mails.rs @@ -0,0 +1,36 @@ +use std::path::Path; + +use anyhow::{bail, Context, Result}; +use futures::{Stream, TryStreamExt}; + +use crate::{client::Client, folders::Folder, proto::MailReponse, session::Session}; + +#[derive(Debug)] +pub struct Mail { + pub folder_id: String, + pub mail_id: String, +} + +impl Mail { + pub fn list( + client: &Client, + session: &Session, + folder: &Folder, + ) -> impl Stream> { + client + .stream::( + &format!("mail/{}", folder.mails), + Some(&session.access_token), + ) + .and_then(move |m| async move { + Ok(Mail { + folder_id: m.id[0].clone(), + mail_id: m.id[1].clone(), + }) + }) + } + + pub async fn download(&self, client: &Client, target_file: &Path) -> Result<()> { + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs index 618c1a8..f12cfc5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,8 @@ +use std::path::PathBuf; + use crate::{ client::Client, + mails::Mail, session::{LoginCLIConfig, Session}, }; use anyhow::{Context, Result}; @@ -7,12 +10,14 @@ use clap::{Parser, Subcommand}; use folders::Folder; use futures::TryStreamExt; use logging::{setup_logging, LoggingCLIConfig}; +use tracing::{debug, info}; mod client; mod constants; mod crypto; mod folders; mod logging; +mod mails; mod non_empty_string; mod proto; mod session; @@ -33,11 +38,25 @@ struct Args { command: Command, } +#[derive(Debug, Parser)] +struct DownloadCLIConfig { + /// Folder name. + #[clap(long, action)] + folder: String, + + /// Target path. + #[clap(long, action)] + path: PathBuf, +} + /// Command #[derive(Debug, Subcommand)] enum Command { /// List folders. ListFolders, + + /// Download emails for given folder. + Download(DownloadCLIConfig), } #[tokio::main] @@ -74,6 +93,42 @@ async fn exec_cmd(client: &Client, session: &Session, cmd: Command) -> Result<() println!("{}", f.name); } + Ok(()) + } + Command::Download(cfg) => { + // ensure output exists + tokio::fs::create_dir_all(&cfg.path) + .await + .context("create output dir")?; + + // find folder + let folders = Folder::list(client, session) + .await + .context("get folders")? + .try_filter(|f| futures::future::ready(f.name == cfg.folder)); + let mut folders = std::pin::pin!(folders); + let folder = folders + .try_next() + .await + .context("search folder")? + .context("folder not found")?; + debug!(mails = folder.mails.as_str(), "download mails from folder"); + + let mails = Mail::list(client, session, &folder); + let mut mails = std::pin::pin!(mails); + while let Some(mail) = mails.try_next().await.context("list mails")? { + let target_file = cfg.path.join(&mail.mail_id).with_extension(".eml"); + if tokio::fs::try_exists(&target_file) + .await + .context("check file existence")? + { + info!(id = mail.mail_id.as_str(), "already exists"); + } else { + info!(id = mail.mail_id.as_str(), "download"); + mail.download(client, &target_file).await.context("download mail")?; + } + } + Ok(()) } } diff --git a/src/proto.rs b/src/proto.rs index d88d017..52d4142 100644 --- a/src/proto.rs +++ b/src/proto.rs @@ -498,6 +498,22 @@ impl Entity for FolderResponse { } } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MailReponse { + #[serde(rename = "_format")] + pub format: Format<0>, + + #[serde(rename = "_id")] + pub id: [String; 2], +} + +impl Entity for MailReponse { + fn id(&self) -> &str { + &self.id[1] + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/session.rs b/src/session.rs index c34b153..3f289a6 100644 --- a/src/session.rs +++ b/src/session.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::{collections::HashMap, sync::Arc}; use anyhow::{bail, Context, Result}; use clap::Parser; @@ -10,7 +10,7 @@ use crate::{ client::Client, constants::APP_USER_AGENT, crypto::{ - auth::{derive_passkey, encode_auth_verifier}, + auth::{derive_passkey, encode_auth_verifier, UserPassphraseKey}, encryption::decrypt_key, }, non_empty_string::NonEmptyString, @@ -37,7 +37,7 @@ pub struct LoginCLIConfig { pub struct Session { pub user_id: String, pub access_token: Base64Url, - pub group_keys: HashMap>, + pub group_keys: Arc, pub user_data: UserResponse, } @@ -92,17 +92,8 @@ impl Session { .await .context("get user")?; - let user_key = decrypt_key(&pk, user_data.user_group.sym_enc_g_key.as_ref()) - .context("decrypt user group key")?; - let mut group_keys = HashMap::default(); - group_keys.insert(user_data.user_group.group.clone(), user_key.clone()); - for group in &user_data.memberships { - group_keys.insert( - group.group.clone(), - decrypt_key(&user_key, group.sym_enc_g_key.as_ref()) - .context("decrypt membership group key")?, - ); - } + let group_keys = + Arc::new(GroupKeys::try_new(&pk, &user_data).context("set up group keys")?); Ok(Self { user_id, @@ -137,6 +128,34 @@ impl Session { } } +#[derive(Debug)] +pub struct GroupKeys { + keys: HashMap>, +} + +impl GroupKeys { + fn try_new(pk: &UserPassphraseKey, user_data: &UserResponse) -> Result { + let user_key = decrypt_key(&pk, user_data.user_group.sym_enc_g_key.as_ref()) + .context("decrypt user group key")?; + let mut group_keys = HashMap::default(); + group_keys.insert(user_data.user_group.group.clone(), user_key.clone()); + for group in &user_data.memberships { + group_keys.insert( + group.group.clone(), + decrypt_key(&user_key, group.sym_enc_g_key.as_ref()) + .context("decrypt membership group key")?, + ); + } + + Ok(Self { keys: group_keys }) + } + + pub fn get(&self, group: &str) -> Result<&[u8]> { + let key = self.keys.get(group).context("group key not found")?; + Ok(key) + } +} + const GENERATE_ID_BYTES_LENGTH: usize = 9; fn session_element_id(access_token: &Base64Url) -> Base64Url { From c9309e15ea38eb84323bd91d66c1bc48ddc9f02f Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Wed, 14 Feb 2024 20:41:45 +0100 Subject: [PATCH 27/44] basic blob downloading --- src/blob.rs | 67 ++++++++++++++++++++++++++++++++++++++++++++++ src/client.rs | 74 ++++++++++++++++++++++++++++++++++++++++++++++----- src/mails.rs | 28 +++++++++++++++++-- src/main.rs | 5 +++- src/proto.rs | 67 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 232 insertions(+), 9 deletions(-) create mode 100644 src/blob.rs diff --git a/src/blob.rs b/src/blob.rs new file mode 100644 index 0000000..53277d0 --- /dev/null +++ b/src/blob.rs @@ -0,0 +1,67 @@ +use std::path::Path; + +use anyhow::{bail, Context, Result}; +use futures::{Stream, TryStreamExt}; +use reqwest::Method; +use serde::de::DeserializeOwned; + +use crate::{ + client::Client, + folders::Folder, + proto::{ + BlobAccessTokenServiceRequest, BlobAccessTokenServiceResponse, BlobReadRequest, MailReponse, + }, + session::Session, +}; + +pub async fn get_blob( + client: &Client, + session: &Session, + archive_id: &str, + blob_id: &str, +) -> Result +where + Resp: DeserializeOwned, +{ + let req = BlobAccessTokenServiceRequest { + format: Default::default(), + archive_data_type: Default::default(), + read: BlobReadRequest { + id: "MR9cbw".to_owned(), + archive_id: archive_id.to_owned(), + instance_ids: vec![], + instance_list_id: Default::default(), + }, + write: Default::default(), + }; + let resp: BlobAccessTokenServiceResponse = client + .service_request_storage( + Method::POST, + "blobaccesstokenservice", + &req, + Some(&session.access_token), + ) + .await + .context("blob service access request")?; + + let Some(server) = resp.blob_access_info.servers.first() else { + bail!("no blob servers provided") + }; + + let resp: Vec = client + .blob_request( + &server.url, + &format!("maildetailsblob/{archive_id}"), + &session.access_token, + &[blob_id], + &resp.blob_access_info.blob_access_token, + ) + .await + .context("blob download")?; + + if resp.len() != 1 { + bail!("invalid reponse length") + } + + Ok(resp.into_iter().next().expect("checked length")) +} diff --git a/src/client.rs b/src/client.rs index cb290cb..5d7580e 100644 --- a/src/client.rs +++ b/src/client.rs @@ -12,6 +12,7 @@ use crate::{ }; const STREAM_BATCH_SIZE: u64 = 1000; +const DEFAULT_HOST: &str = "https://app.tuta.com"; #[derive(Debug, Clone)] pub struct Client { @@ -41,7 +42,7 @@ impl Client { Req: serde::Serialize, Resp: DeserializeOwned, { - self.do_json(method, "sys", path, data, access_token, &[]) + self.do_json(method, DEFAULT_HOST, "sys", path, data, access_token, &[]) .await } @@ -56,8 +57,66 @@ impl Client { Req: serde::Serialize, Resp: DeserializeOwned, { - self.do_json(method, "tutanota", path, data, access_token, &[]) - .await + self.do_json( + method, + DEFAULT_HOST, + "tutanota", + path, + data, + access_token, + &[], + ) + .await + } + + pub async fn service_request_storage( + &self, + method: Method, + path: &str, + data: &Req, + access_token: Option<&Base64Url>, + ) -> Result + where + Req: serde::Serialize, + Resp: DeserializeOwned, + { + self.do_json( + method, + DEFAULT_HOST, + "storage", + path, + data, + access_token, + &[], + ) + .await + } + + pub async fn blob_request( + &self, + host: &str, + path: &str, + access_token: &Base64Url, + ids: &[&str], + blob_access_token: &str, + ) -> Result + where + Resp: DeserializeOwned, + { + self.do_json( + Method::GET, + host, + "tutanota", + path, + &(), + None, + &[ + ("accessToken", &access_token.to_string()), + ("ids", &ids.join(",")), + ("blobAccessToken", blob_access_token), + ], + ) + .await } pub async fn service_request_no_response( @@ -70,7 +129,7 @@ impl Client { where Req: serde::Serialize, { - self.do_request(method, "sys", path, data, access_token, &[]) + self.do_request(method, DEFAULT_HOST, "sys", path, data, access_token, &[]) .await } @@ -109,6 +168,7 @@ impl Client { state.buffer = this .do_json::<(), Vec>( Method::GET, + DEFAULT_HOST, "tutanota", &path, &(), @@ -139,6 +199,7 @@ impl Client { async fn do_json( &self, method: Method, + host: &str, prefix: &str, path: &str, data: &Req, @@ -150,7 +211,7 @@ impl Client { Resp: DeserializeOwned, { let resp = self - .do_request(method, prefix, path, data, access_token, query) + .do_request(method, host, prefix, path, data, access_token, query) .await? .json::() .await @@ -162,6 +223,7 @@ impl Client { async fn do_request( &self, method: Method, + host: &str, prefix: &str, path: &str, data: &Req, @@ -175,7 +237,7 @@ impl Client { let mut req = self .inner - .request(method, format!("https://app.tuta.com/rest/{prefix}/{path}")); + .request(method, format!("{host}/rest/{prefix}/{path}")); if let Some(access_token) = access_token { req = req.header("accessToken", access_token.to_string()); diff --git a/src/mails.rs b/src/mails.rs index c91842e..c8f5661 100644 --- a/src/mails.rs +++ b/src/mails.rs @@ -2,13 +2,25 @@ use std::path::Path; use anyhow::{bail, Context, Result}; use futures::{Stream, TryStreamExt}; +use reqwest::Method; -use crate::{client::Client, folders::Folder, proto::MailReponse, session::Session}; +use crate::{ + blob::get_blob, + client::Client, + folders::Folder, + proto::{ + BlobAccessTokenServiceRequest, BlobAccessTokenServiceResponse, BlobReadRequest, + MailDetailsBlob, MailReponse, + }, + session::Session, +}; #[derive(Debug)] pub struct Mail { pub folder_id: String, pub mail_id: String, + pub archive_id: String, + pub blob_id: String, } impl Mail { @@ -26,11 +38,23 @@ impl Mail { Ok(Mail { folder_id: m.id[0].clone(), mail_id: m.id[1].clone(), + archive_id: m.mail_details[0].clone(), + blob_id: m.mail_details[1].clone(), }) }) } - pub async fn download(&self, client: &Client, target_file: &Path) -> Result<()> { + pub async fn download( + &self, + client: &Client, + session: &Session, + target_file: &Path, + ) -> Result<()> { + let mail_details: MailDetailsBlob = + get_blob(client, session, &self.archive_id, &self.blob_id) + .await + .context("download mail details")?; + Ok(()) } } diff --git a/src/main.rs b/src/main.rs index f12cfc5..5d528b4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ use futures::TryStreamExt; use logging::{setup_logging, LoggingCLIConfig}; use tracing::{debug, info}; +mod blob; mod client; mod constants; mod crypto; @@ -125,7 +126,9 @@ async fn exec_cmd(client: &Client, session: &Session, cmd: Command) -> Result<() info!(id = mail.mail_id.as_str(), "already exists"); } else { info!(id = mail.mail_id.as_str(), "download"); - mail.download(client, &target_file).await.context("download mail")?; + mail.download(client, session, &target_file) + .await + .context("download mail")?; } } diff --git a/src/proto.rs b/src/proto.rs index 52d4142..026bb72 100644 --- a/src/proto.rs +++ b/src/proto.rs @@ -506,6 +506,8 @@ pub struct MailReponse { #[serde(rename = "_id")] pub id: [String; 2], + + pub mail_details: [String; 2], } impl Entity for MailReponse { @@ -514,6 +516,71 @@ impl Entity for MailReponse { } } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BlobReadRequest { + #[serde(rename = "_id")] + pub id: String, + + pub archive_id: String, + pub instance_ids: Vec<()>, + pub instance_list_id: Null, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BlobAccessTokenServiceRequest { + #[serde(rename = "_format")] + pub format: Format<0>, + + pub archive_data_type: Null, + pub read: BlobReadRequest, + pub write: Null, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BlobServer { + pub url: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BlobAccessInfo { + pub blob_access_token: String, + pub servers: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BlobAccessTokenServiceResponse { + #[serde(rename = "_format")] + pub format: Format<0>, + + pub blob_access_info: BlobAccessInfo, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MailBody { + pub compressed_text: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MailDetails { + pub body: MailBody, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MailDetailsBlob { + #[serde(rename = "_format")] + pub format: Format<0>, + + pub details: MailDetails, +} + #[cfg(test)] mod tests { use super::*; From 87525ca7c3d6820078b09f9af46b01ba7e78ecab Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Wed, 14 Feb 2024 21:08:07 +0100 Subject: [PATCH 28/44] hacked together body decryption + decompression --- Cargo.lock | 26 ++++++++++++++++++ Cargo.toml | 1 + src/compression.rs | 5 ++++ src/crypto/encryption.rs | 58 ++++++++++++++++++++++++++++++++++++++-- src/mails.rs | 45 ++++++++++++++++++++++++------- src/main.rs | 1 + src/proto.rs | 8 +++++- 7 files changed, 132 insertions(+), 12 deletions(-) create mode 100644 src/compression.rs diff --git a/Cargo.lock b/Cargo.lock index 17f701f..d67d83c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -842,6 +842,15 @@ dependencies = [ "linked-hash-map", ] +[[package]] +name = "lz4_flex" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "912b45c753ff5f7f5208307e8ace7d2a2e30d024e26d3509f3dce546c044ce15" +dependencies = [ + "twox-hash", +] + [[package]] name = "match_cfg" version = "0.1.0" @@ -1332,6 +1341,12 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.10.0" @@ -1396,6 +1411,7 @@ dependencies = [ "dotenvy", "futures", "hmac", + "lz4_flex", "predicates", "reqwest", "serde", @@ -1640,6 +1656,16 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "twox-hash" +version = "1.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" +dependencies = [ + "cfg-if", + "static_assertions", +] + [[package]] name = "typenum" version = "1.17.0" diff --git a/Cargo.toml b/Cargo.toml index 1e06e48..ea8959d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ clap = { version = "4.4.6", features = ["derive", "env"] } dotenvy = "0.15.7" futures = "0.3.28" hmac = "0.12.1" +lz4_flex = "0.11.2" reqwest = { version = "0.11", default-features = false, features = ["brotli", "deflate", "gzip", "json", "rustls-tls-webpki-roots", "trust-dns"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/src/compression.rs b/src/compression.rs new file mode 100644 index 0000000..db1ce7f --- /dev/null +++ b/src/compression.rs @@ -0,0 +1,5 @@ +use anyhow::{Context, Result}; + +pub fn decompress_value(v: &[u8]) -> Result> { + lz4_flex::block::decompress(v, v.len() * 6).context("decompression") +} diff --git a/src/crypto/encryption.rs b/src/crypto/encryption.rs index 47a0d17..e8f80b8 100644 --- a/src/crypto/encryption.rs +++ b/src/crypto/encryption.rs @@ -25,8 +25,44 @@ pub fn decrypt_key(encryption_key: &[u8], key_to_be_decrypted: &[u8]) -> Result< } pub fn decrypt_value(encryption_key: &[u8], value: &[u8]) -> Result> { - if let Ok(_k) = TryInto::<[u8; 16]>::try_into(encryption_key) { - bail!("not implemented: AES128") + if let Ok(k) = TryInto::<[u8; 16]>::try_into(encryption_key) { + let (k, value) = if value.len() % 2 == 1 { + // use mac + const MAC_LEN: usize = 32; + if value.len() < MAC_LEN + 1 { + bail!("mac missing") + } + let payload = &value[1..(value.len() - MAC_LEN)]; + let mac = &value[value.len() - MAC_LEN..]; + let subkeys = Aes128Subkeys::from(k); + + // check mac + let mut m = HmacSha256::new_from_slice(&subkeys.mac_key).expect("checked length"); + m.update(payload); + m.verify_slice(mac) + .map_err(|e| anyhow!("{e}")) + .context("HMAC verification")?; + + (subkeys.encryption_key, payload) + } else { + // technically this is + // (k, value) + // + // however we haven't seen this used yet so we just bail out for now + bail!("not implemented: value w/o MAC") + }; + + // get IV + const IV_LEN: usize = 16; + if value.len() < IV_LEN { + bail!("IV missing") + } + let iv: [u8; IV_LEN] = value[..IV_LEN].try_into().expect("checked length"); + let value = &value[IV_LEN..]; + Aes128CbcDec::new(&k.into(), &iv.into()) + .decrypt_padded_vec_mut::(value) + .map_err(|e| anyhow!("{e}")) + .context("AES decryption") } else if let Ok(k) = TryInto::<[u8; 32]>::try_into(encryption_key) { let (k, value) = if value.len() % 2 == 1 { // use mac @@ -70,6 +106,24 @@ pub fn decrypt_value(encryption_key: &[u8], value: &[u8]) -> Result> { } } +struct Aes128Subkeys { + encryption_key: [u8; 16], + mac_key: [u8; 16], +} + +impl From<[u8; 16]> for Aes128Subkeys { + fn from(k: [u8; 16]) -> Self { + let mut hasher = Sha256::new(); + hasher.update(k); + let hashed = hasher.finalize().to_vec(); + + Self { + encryption_key: hashed[..16].try_into().expect("check length"), + mac_key: hashed[16..].try_into().expect("check length"), + } + } +} + struct Aes256Subkeys { encryption_key: [u8; 32], mac_key: [u8; 32], diff --git a/src/mails.rs b/src/mails.rs index c8f5661..78ee508 100644 --- a/src/mails.rs +++ b/src/mails.rs @@ -1,4 +1,4 @@ -use std::path::Path; +use std::{path::Path, sync::Arc}; use anyhow::{bail, Context, Result}; use futures::{Stream, TryStreamExt}; @@ -7,12 +7,14 @@ use reqwest::Method; use crate::{ blob::get_blob, client::Client, + compression::decompress_value, + crypto::encryption::{decrypt_key, decrypt_value}, folders::Folder, proto::{ BlobAccessTokenServiceRequest, BlobAccessTokenServiceResponse, BlobReadRequest, MailDetailsBlob, MailReponse, }, - session::Session, + session::{GroupKeys, Session}, }; #[derive(Debug)] @@ -21,6 +23,7 @@ pub struct Mail { pub mail_id: String, pub archive_id: String, pub blob_id: String, + pub session_key: Vec, } impl Mail { @@ -29,21 +32,36 @@ impl Mail { session: &Session, folder: &Folder, ) -> impl Stream> { + let group_keys = Arc::clone(&session.group_keys); client .stream::( &format!("mail/{}", folder.mails), Some(&session.access_token), ) - .and_then(move |m| async move { - Ok(Mail { - folder_id: m.id[0].clone(), - mail_id: m.id[1].clone(), - archive_id: m.mail_details[0].clone(), - blob_id: m.mail_details[1].clone(), - }) + .and_then(move |m| { + let group_keys = Arc::clone(&group_keys); + async move { Self::decode(m, &group_keys) } }) } + fn decode(resp: MailReponse, group_keys: &GroupKeys) -> Result { + let session_key = decrypt_key( + group_keys + .get(&resp.owner_group) + .context("getting owner group key")?, + resp.owner_enc_session_key.as_ref(), + ) + .context("decrypting session key")?; + + Ok(Mail { + folder_id: resp.id[0].clone(), + mail_id: resp.id[1].clone(), + archive_id: resp.mail_details[0].clone(), + blob_id: resp.mail_details[1].clone(), + session_key, + }) + } + pub async fn download( &self, client: &Client, @@ -55,6 +73,15 @@ impl Mail { .await .context("download mail details")?; + let body = decrypt_value( + &self.session_key, + mail_details.details.body.compressed_text.as_ref(), + ) + .context("decrypt body")?; + let body = decompress_value(&body).context("decompress body")?; + let body = String::from_utf8(body).context("decode body")?; + dbg!(body); + Ok(()) } } diff --git a/src/main.rs b/src/main.rs index 5d528b4..b97a2be 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,7 @@ use tracing::{debug, info}; mod blob; mod client; +mod compression; mod constants; mod crypto; mod folders; diff --git a/src/proto.rs b/src/proto.rs index 026bb72..d6cdb29 100644 --- a/src/proto.rs +++ b/src/proto.rs @@ -504,6 +504,12 @@ pub struct MailReponse { #[serde(rename = "_format")] pub format: Format<0>, + #[serde(rename = "_ownerEncSessionKey")] + pub owner_enc_session_key: Base64String, + + #[serde(rename = "_ownerGroup")] + pub owner_group: String, + #[serde(rename = "_id")] pub id: [String; 2], @@ -563,7 +569,7 @@ pub struct BlobAccessTokenServiceResponse { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MailBody { - pub compressed_text: String, + pub compressed_text: Base64String, } #[derive(Debug, Deserialize)] From ad593b2bc386c6a48258b92c84586740614430b4 Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Sun, 18 Feb 2024 12:08:34 +0100 Subject: [PATCH 29/44] improve testing --- .gitignore | 1 + Cargo.lock | 47 +++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 5 +++++ tests/cli.rs | 23 ++++++++++++----------- 4 files changed, 65 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 783d57f..53e842b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target .env out +*.pending-snap diff --git a/Cargo.lock b/Cargo.lock index d67d83c..9430eac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -350,6 +350,18 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" +[[package]] +name = "console" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" +dependencies = [ + "encode_unicode", + "lazy_static", + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -429,6 +441,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + [[package]] name = "encoding_rs" version = "0.8.33" @@ -766,6 +784,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "insta" +version = "1.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d64600be34b2fcfc267740a243fa7744441bb4947a619ac4e5bb6507f35fbfc" +dependencies = [ + "console", + "lazy_static", + "linked-hash-map", + "similar", + "yaml-rust", +] + [[package]] name = "ipconfig" version = "0.3.2" @@ -1310,6 +1341,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "similar" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32fea41aca09ee824cc9724996433064c89f7777e60762749a4170a14abbfa21" + [[package]] name = "slab" version = "0.4.9" @@ -1411,6 +1448,7 @@ dependencies = [ "dotenvy", "futures", "hmac", + "insta", "lz4_flex", "predicates", "reqwest", @@ -2004,6 +2042,15 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "zeroize" version = "1.7.0" diff --git a/Cargo.toml b/Cargo.toml index ea8959d..10cfb47 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,4 +26,9 @@ tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } [dev-dependencies] assert_cmd = "2.0.12" +insta = "1.34.0" predicates = { version = "3.0.4", default-features = false } + +[profile.dev.package] +insta.opt-level = 3 +similar.opt-level = 3 diff --git a/tests/cli.rs b/tests/cli.rs index ec9272f..b0f6369 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,5 +1,4 @@ use assert_cmd::Command; -use predicates::prelude::*; #[test] fn test_help() { @@ -10,16 +9,18 @@ fn test_help() { #[test] fn test_list_folders() { let mut cmd = cmd(); - cmd.arg("-vv") - .arg("list-folders") - .assert() - .success() - .stdout(predicate::str::contains( - [ - "Inbox", "Sent", "Trash", "Archive", "Spam", "Draft", "fooooo", - ] - .join("\n"), - )); + let res = cmd.arg("-vv").arg("list-folders").assert().success(); + let stdout = String::from_utf8(res.get_output().stdout.clone()).unwrap(); + + insta::assert_display_snapshot!(stdout, @r###" + Inbox + Sent + Trash + Archive + Spam + Draft + fooooo + "###); } fn cmd() -> Command { From 2563e411412a351e3180baa10be71bd4a28696a5 Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Sun, 18 Feb 2024 12:17:28 +0100 Subject: [PATCH 30/44] split off proto testing --- src/{proto.rs => proto/mod.rs} | 23 ++--------- src/proto/testing.rs | 74 ++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 19 deletions(-) rename src/{proto.rs => proto/mod.rs} (96%) create mode 100644 src/proto/testing.rs diff --git a/src/proto.rs b/src/proto/mod.rs similarity index 96% rename from src/proto.rs rename to src/proto/mod.rs index d6cdb29..6ec1b43 100644 --- a/src/proto.rs +++ b/src/proto/mod.rs @@ -2,6 +2,9 @@ use anyhow::{bail, Result}; use base64::prelude::*; use serde::{de::Error, Deserialize, Deserializer, Serialize, Serializer}; +#[cfg(test)] +mod testing; + pub trait Entity { fn id(&self) -> &str; } @@ -589,6 +592,7 @@ pub struct MailDetailsBlob { #[cfg(test)] mod tests { + use super::testing::{assert_deser_error, assert_roundtrip}; use super::*; #[test] @@ -651,23 +655,4 @@ mod tests { assert_deser_error::(r#""20""#, "invalid mail folder type: 20"); } - - #[track_caller] - fn assert_roundtrip(orig: T) - where - T: Eq + std::fmt::Debug + serde::Serialize + serde::de::DeserializeOwned, - { - let s = serde_json::to_string(&orig).expect("serialize"); - let recovered = serde_json::from_str(&s).expect("deserialize"); - assert_eq!(orig, recovered); - } - - #[track_caller] - fn assert_deser_error(s: &str, expected: &str) - where - T: std::fmt::Debug + serde::de::DeserializeOwned, - { - let err = serde_json::from_str::(s).expect_err("deserialize error"); - assert_eq!(err.to_string(), expected); - } } diff --git a/src/proto/testing.rs b/src/proto/testing.rs new file mode 100644 index 0000000..f341168 --- /dev/null +++ b/src/proto/testing.rs @@ -0,0 +1,74 @@ +#[track_caller] +pub fn assert_roundtrip(orig: T) +where + T: Eq + std::fmt::Debug + serde::Serialize + serde::de::DeserializeOwned, +{ + let s = serde_json::to_string(&orig).expect("serialize"); + let recovered = serde_json::from_str(&s).expect("deserialize"); + assert_eq!(orig, recovered); +} + +#[track_caller] +pub fn assert_deser_error(s: &str, expected: &str) +where + T: std::fmt::Debug + serde::de::DeserializeOwned, +{ + let err = serde_json::from_str::(s).expect_err("no error"); + assert_eq!(err.to_string(), expected); +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::{de::Error, Deserializer, Serializer}; + + #[test] + fn test_assert_roundtrip_ok() { + assert_roundtrip(Helper(1)); + } + + #[test] + #[should_panic(expected = "assertion failed")] + fn test_assert_roundtrip_fail() { + assert_roundtrip(Helper(100)); + } + + #[test] + fn test_assert_deser_error_ok() { + assert_deser_error::("0", "foo"); + } + + #[test] + #[should_panic(expected = "no error")] + fn test_assert_deser_error_fail() { + assert_deser_error::("1", "foo"); + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + struct Helper(u8); + + impl serde::Serialize for Helper { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_u8(self.0) + } + } + + impl<'de> serde::Deserialize<'de> for Helper { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let i = u8::deserialize(deserializer)?; + if i == 0 { + Err(D::Error::custom("foo".to_owned())) + } else if i < 10 { + Ok(Self(i)) + } else { + Ok(Self(0)) + } + } + } +} From d7d7b89cba8371b3eea5c09dd50d10d6e3f75bb5 Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Sun, 18 Feb 2024 12:22:32 +0100 Subject: [PATCH 31/44] split off binary proto --- src/client.rs | 2 +- src/crypto/auth.rs | 2 +- src/proto/binary.rs | 187 ++++++++++++++++++++++++++++++++++++++++++++ src/proto/mod.rs | 185 ++----------------------------------------- src/session.rs | 2 +- 5 files changed, 195 insertions(+), 183 deletions(-) create mode 100644 src/proto/binary.rs diff --git a/src/client.rs b/src/client.rs index 5d7580e..fd725a3 100644 --- a/src/client.rs +++ b/src/client.rs @@ -8,7 +8,7 @@ use tracing::debug; use crate::{ constants::APP_USER_AGENT, - proto::{Base64Url, Entity}, + proto::{binary::Base64Url, Entity}, }; const STREAM_BATCH_SIZE: u64 = 1000; diff --git a/src/crypto/auth.rs b/src/crypto/auth.rs index c8474dd..65b84dc 100644 --- a/src/crypto/auth.rs +++ b/src/crypto/auth.rs @@ -3,7 +3,7 @@ use std::ops::Deref; use anyhow::{bail, Context, Result}; use sha2::{Digest, Sha256}; -use crate::proto::{Base64Url, KdfVersion}; +use crate::proto::{binary::Base64Url, KdfVersion}; #[derive(Debug)] pub struct UserPassphraseKey(Box<[u8]>); diff --git a/src/proto/binary.rs b/src/proto/binary.rs new file mode 100644 index 0000000..81acfc5 --- /dev/null +++ b/src/proto/binary.rs @@ -0,0 +1,187 @@ +use anyhow::{bail, Result}; +use base64::prelude::*; +use serde::{de::Error, Deserializer, Serializer}; + +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct Base64String(Box<[u8]>); + +impl Base64String { + pub fn try_new(s: &str) -> Result { + let data = BASE64_STANDARD.decode(s)?; + Ok(Self(data.into())) + } + + pub fn base64(&self) -> String { + BASE64_STANDARD.encode(self.0.as_ref()) + } +} + +impl std::fmt::Debug for Base64String { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.base64()) + } +} + +impl std::fmt::Display for Base64String { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.base64()) + } +} + +impl From> for Base64String { + fn from(value: Vec) -> Self { + Self(value.into()) + } +} + +impl From<&[u8]> for Base64String { + fn from(value: &[u8]) -> Self { + Self(value.into()) + } +} + +impl From<[u8; N]> for Base64String { + fn from(value: [u8; N]) -> Self { + Self(value.into()) + } +} + +impl From<&[u8; N]> for Base64String { + fn from(value: &[u8; N]) -> Self { + Self(value.to_owned().into()) + } +} + +impl AsRef<[u8]> for Base64String { + fn as_ref(&self) -> &[u8] { + self.0.as_ref() + } +} + +impl serde::Serialize for Base64String { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.base64()) + } +} + +impl<'de> serde::Deserialize<'de> for Base64String { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Self::try_new(&s).map_err(D::Error::custom) + } +} + +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct Base64Url(Base64String); + +impl Base64Url { + pub fn try_new(s: &str) -> Result { + let mut s = s.replace('-', "+").replace('_', "/"); + match s.len() % 4 { + 0 => {} + 2 => { + s.push_str("=="); + } + 3 => { + s.push('='); + } + _ => { + bail!("invalid base64 URL") + } + } + Ok(Self(Base64String::try_new(&s)?)) + } + + pub fn url(&self) -> String { + self.0 + .base64() + .replace('+', "-") + .replace('/', "_") + .replace('=', "") + } +} + +impl std::fmt::Debug for Base64Url { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.url()) + } +} + +impl std::fmt::Display for Base64Url { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.url()) + } +} + +impl From> for Base64Url { + fn from(value: Vec) -> Self { + Self(value.into()) + } +} + +impl From<&[u8]> for Base64Url { + fn from(value: &[u8]) -> Self { + Self(value.into()) + } +} + +impl From<[u8; N]> for Base64Url { + fn from(value: [u8; N]) -> Self { + Self(value.into()) + } +} + +impl From<&[u8; N]> for Base64Url { + fn from(value: &[u8; N]) -> Self { + Self(value.to_owned().into()) + } +} + +impl AsRef<[u8]> for Base64Url { + fn as_ref(&self) -> &[u8] { + self.0.as_ref() + } +} + +impl serde::Serialize for Base64Url { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.url()) + } +} + +impl<'de> serde::Deserialize<'de> for Base64Url { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Self::try_new(&s).map_err(D::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::proto::testing::assert_roundtrip; + + #[test] + fn test_roundtrip_base64string() { + assert_roundtrip(Base64String::from(b"")); + assert_roundtrip(Base64String::from(b"foo")); + } + + #[test] + fn test_roundtrip_base64url() { + assert_roundtrip(Base64Url::from(b"")); + assert_roundtrip(Base64Url::from(b"foo")); + } +} diff --git a/src/proto/mod.rs b/src/proto/mod.rs index 6ec1b43..8ab01d3 100644 --- a/src/proto/mod.rs +++ b/src/proto/mod.rs @@ -1,7 +1,10 @@ -use anyhow::{bail, Result}; -use base64::prelude::*; +use anyhow::Result; use serde::{de::Error, Deserialize, Deserializer, Serialize, Serializer}; +use self::binary::{Base64String, Base64Url}; + +pub mod binary; + #[cfg(test)] mod testing; @@ -69,172 +72,6 @@ impl<'de> serde::Deserialize<'de> for KdfVersion { } } -#[derive(Clone, PartialEq, Eq, Hash)] -pub struct Base64String(Box<[u8]>); - -impl Base64String { - pub fn try_new(s: &str) -> Result { - let data = BASE64_STANDARD.decode(s)?; - Ok(Self(data.into())) - } - - pub fn base64(&self) -> String { - BASE64_STANDARD.encode(self.0.as_ref()) - } -} - -impl std::fmt::Debug for Base64String { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.base64()) - } -} - -impl std::fmt::Display for Base64String { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.base64()) - } -} - -impl From> for Base64String { - fn from(value: Vec) -> Self { - Self(value.into()) - } -} - -impl From<&[u8]> for Base64String { - fn from(value: &[u8]) -> Self { - Self(value.into()) - } -} - -impl From<[u8; N]> for Base64String { - fn from(value: [u8; N]) -> Self { - Self(value.into()) - } -} - -impl From<&[u8; N]> for Base64String { - fn from(value: &[u8; N]) -> Self { - Self(value.to_owned().into()) - } -} - -impl AsRef<[u8]> for Base64String { - fn as_ref(&self) -> &[u8] { - self.0.as_ref() - } -} - -impl serde::Serialize for Base64String { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(&self.base64()) - } -} - -impl<'de> serde::Deserialize<'de> for Base64String { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - Self::try_new(&s).map_err(D::Error::custom) - } -} - -#[derive(Clone, PartialEq, Eq, Hash)] -pub struct Base64Url(Base64String); - -impl Base64Url { - pub fn try_new(s: &str) -> Result { - let mut s = s.replace('-', "+").replace('_', "/"); - match s.len() % 4 { - 0 => {} - 2 => { - s.push_str("=="); - } - 3 => { - s.push('='); - } - _ => { - bail!("invalid base64 URL") - } - } - Ok(Self(Base64String::try_new(&s)?)) - } - - pub fn url(&self) -> String { - self.0 - .base64() - .replace('+', "-") - .replace('/', "_") - .replace('=', "") - } -} - -impl std::fmt::Debug for Base64Url { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.url()) - } -} - -impl std::fmt::Display for Base64Url { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.url()) - } -} - -impl From> for Base64Url { - fn from(value: Vec) -> Self { - Self(value.into()) - } -} - -impl From<&[u8]> for Base64Url { - fn from(value: &[u8]) -> Self { - Self(value.into()) - } -} - -impl From<[u8; N]> for Base64Url { - fn from(value: [u8; N]) -> Self { - Self(value.into()) - } -} - -impl From<&[u8; N]> for Base64Url { - fn from(value: &[u8; N]) -> Self { - Self(value.to_owned().into()) - } -} - -impl AsRef<[u8]> for Base64Url { - fn as_ref(&self) -> &[u8] { - self.0.as_ref() - } -} - -impl serde::Serialize for Base64Url { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(&self.url()) - } -} - -impl<'de> serde::Deserialize<'de> for Base64Url { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - Self::try_new(&s).map_err(D::Error::custom) - } -} - #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] pub struct Null; @@ -613,18 +450,6 @@ mod tests { assert_deser_error::(r#""2""#, "invalid KDF version: 2"); } - #[test] - fn test_roundtrip_base64string() { - assert_roundtrip(Base64String::from(b"")); - assert_roundtrip(Base64String::from(b"foo")); - } - - #[test] - fn test_roundtrip_base64url() { - assert_roundtrip(Base64Url::from(b"")); - assert_roundtrip(Base64Url::from(b"foo")); - } - #[test] fn test_roundtrip_group_type() { assert_roundtrip(GroupType::User); diff --git a/src/session.rs b/src/session.rs index 3f289a6..28b5dad 100644 --- a/src/session.rs +++ b/src/session.rs @@ -15,7 +15,7 @@ use crate::{ }, non_empty_string::NonEmptyString, proto::{ - Base64Url, SaltServiceRequest, SaltServiceResponse, SessionServiceRequest, + binary::Base64Url, SaltServiceRequest, SaltServiceResponse, SessionServiceRequest, SessionServiceResponse, UserResponse, }, }; From 0ab040f2cbd8647f8288c8e31c3f7f4af37ab032 Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Sun, 18 Feb 2024 12:31:00 +0100 Subject: [PATCH 32/44] clippy --- src/blob.rs | 8 +--- src/client.rs | 122 ++++++++++++++++++++++++++++--------------------- src/mails.rs | 12 ++--- src/session.rs | 2 +- 4 files changed, 77 insertions(+), 67 deletions(-) diff --git a/src/blob.rs b/src/blob.rs index 53277d0..2a42cdf 100644 --- a/src/blob.rs +++ b/src/blob.rs @@ -1,16 +1,10 @@ -use std::path::Path; - use anyhow::{bail, Context, Result}; -use futures::{Stream, TryStreamExt}; use reqwest::Method; use serde::de::DeserializeOwned; use crate::{ client::Client, - folders::Folder, - proto::{ - BlobAccessTokenServiceRequest, BlobAccessTokenServiceResponse, BlobReadRequest, MailReponse, - }, + proto::{BlobAccessTokenServiceRequest, BlobAccessTokenServiceResponse, BlobReadRequest}, session::Session, }; diff --git a/src/client.rs b/src/client.rs index fd725a3..5eeedf7 100644 --- a/src/client.rs +++ b/src/client.rs @@ -42,8 +42,16 @@ impl Client { Req: serde::Serialize, Resp: DeserializeOwned, { - self.do_json(method, DEFAULT_HOST, "sys", path, data, access_token, &[]) - .await + self.do_json(Request { + method, + host: DEFAULT_HOST, + prefix: "sys", + path, + data, + access_token, + query: &[], + }) + .await } pub async fn service_request_tutanota( @@ -57,15 +65,15 @@ impl Client { Req: serde::Serialize, Resp: DeserializeOwned, { - self.do_json( + self.do_json(Request { method, - DEFAULT_HOST, - "tutanota", + host: DEFAULT_HOST, + prefix: "tutanota", path, data, access_token, - &[], - ) + query: &[], + }) .await } @@ -80,15 +88,15 @@ impl Client { Req: serde::Serialize, Resp: DeserializeOwned, { - self.do_json( + self.do_json(Request { method, - DEFAULT_HOST, - "storage", + host: DEFAULT_HOST, + prefix: "storage", path, data, access_token, - &[], - ) + query: &[], + }) .await } @@ -103,19 +111,19 @@ impl Client { where Resp: DeserializeOwned, { - self.do_json( - Method::GET, + self.do_json(Request { + method: Method::GET, host, - "tutanota", + prefix: "tutanota", path, - &(), - None, - &[ + data: &(), + access_token: None, + query: &[ ("accessToken", &access_token.to_string()), ("ids", &ids.join(",")), ("blobAccessToken", blob_access_token), ], - ) + }) .await } @@ -129,8 +137,16 @@ impl Client { where Req: serde::Serialize, { - self.do_request(method, DEFAULT_HOST, "sys", path, data, access_token, &[]) - .await + self.do_request(Request { + method, + host: DEFAULT_HOST, + prefix: "sys", + path, + data, + access_token, + query: &[], + }) + .await } pub fn stream( @@ -166,19 +182,19 @@ impl Client { "fetch new page", ); state.buffer = this - .do_json::<(), Vec>( - Method::GET, - DEFAULT_HOST, - "tutanota", - &path, - &(), - access_token.as_ref().as_ref(), - &[ + .do_json::<(), Vec>(Request { + method: Method::GET, + host: DEFAULT_HOST, + prefix: "tutanota", + path: &path, + data: &(), + access_token: access_token.as_ref().as_ref(), + query: &[ ("start", &state.next_start), ("count", &STREAM_BATCH_SIZE.to_string()), ("reverse", "false"), ], - ) + }) .await .context("fetch next page")? .into(); @@ -196,22 +212,13 @@ impl Client { }) } - async fn do_json( - &self, - method: Method, - host: &str, - prefix: &str, - path: &str, - data: &Req, - access_token: Option<&Base64Url>, - query: &[(&str, &str)], - ) -> Result + async fn do_json(&self, r: Request<'_, Req>) -> Result where Req: serde::Serialize, Resp: DeserializeOwned, { let resp = self - .do_request(method, host, prefix, path, data, access_token, query) + .do_request(r) .await? .json::() .await @@ -220,19 +227,19 @@ impl Client { Ok(resp) } - async fn do_request( - &self, - method: Method, - host: &str, - prefix: &str, - path: &str, - data: &Req, - access_token: Option<&Base64Url>, - query: &[(&str, &str)], - ) -> Result + async fn do_request(&self, r: Request<'_, Req>) -> Result where Req: serde::Serialize, { + let Request { + method, + host, + prefix, + path, + data, + access_token, + query, + } = r; debug!(%method, prefix, path, "service request",); let mut req = self @@ -260,3 +267,16 @@ struct StreamState { buffer: VecDeque, next_start: String, } + +struct Request<'a, Req> +where + Req: serde::Serialize, +{ + method: Method, + host: &'a str, + prefix: &'a str, + path: &'a str, + data: &'a Req, + access_token: Option<&'a Base64Url>, + query: &'a [(&'a str, &'a str)], +} diff --git a/src/mails.rs b/src/mails.rs index 78ee508..f13cdff 100644 --- a/src/mails.rs +++ b/src/mails.rs @@ -1,8 +1,7 @@ use std::{path::Path, sync::Arc}; -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result}; use futures::{Stream, TryStreamExt}; -use reqwest::Method; use crate::{ blob::get_blob, @@ -10,10 +9,7 @@ use crate::{ compression::decompress_value, crypto::encryption::{decrypt_key, decrypt_value}, folders::Folder, - proto::{ - BlobAccessTokenServiceRequest, BlobAccessTokenServiceResponse, BlobReadRequest, - MailDetailsBlob, MailReponse, - }, + proto::{MailDetailsBlob, MailReponse}, session::{GroupKeys, Session}, }; @@ -66,7 +62,7 @@ impl Mail { &self, client: &Client, session: &Session, - target_file: &Path, + _target_file: &Path, ) -> Result<()> { let mail_details: MailDetailsBlob = get_blob(client, session, &self.archive_id, &self.blob_id) @@ -80,7 +76,7 @@ impl Mail { .context("decrypt body")?; let body = decompress_value(&body).context("decompress body")?; let body = String::from_utf8(body).context("decode body")?; - dbg!(body); + println!("{}", body); Ok(()) } diff --git a/src/session.rs b/src/session.rs index 28b5dad..675416b 100644 --- a/src/session.rs +++ b/src/session.rs @@ -135,7 +135,7 @@ pub struct GroupKeys { impl GroupKeys { fn try_new(pk: &UserPassphraseKey, user_data: &UserResponse) -> Result { - let user_key = decrypt_key(&pk, user_data.user_group.sym_enc_g_key.as_ref()) + let user_key = decrypt_key(pk, user_data.user_group.sym_enc_g_key.as_ref()) .context("decrypt user group key")?; let mut group_keys = HashMap::default(); group_keys.insert(user_data.user_group.group.clone(), user_key.clone()); From 34a4420f8de0385508ca29d6c6c750715c942e2b Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Sun, 18 Feb 2024 12:55:56 +0100 Subject: [PATCH 33/44] more lints --- Cargo.lock | 1 - Cargo.toml | 22 ++++- src/blob.rs | 2 +- src/client.rs | 30 +++---- src/compression.rs | 2 +- src/constants.rs | 3 +- src/crypto/auth.rs | 6 +- src/crypto/encryption.rs | 4 +- src/crypto/mod.rs | 4 +- src/folders.rs | 12 +-- src/logging.rs | 4 +- src/mails.rs | 21 ++--- src/main.rs | 6 ++ src/non_empty_string.rs | 2 +- src/proto/binary.rs | 12 +-- src/proto/mod.rs | 174 +++++++++++++++++++-------------------- src/proto/testing.rs | 6 +- src/session.rs | 21 ++--- tests/cli.rs | 2 + 19 files changed, 181 insertions(+), 153 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9430eac..1ec052a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1450,7 +1450,6 @@ dependencies = [ "hmac", "insta", "lz4_flex", - "predicates", "reqwest", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 10cfb47..fe32042 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,6 @@ hmac = "0.12.1" lz4_flex = "0.11.2" reqwest = { version = "0.11", default-features = false, features = ["brotli", "deflate", "gzip", "json", "rustls-tls-webpki-roots", "trust-dns"] } serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" sha2 = "0.10.8" tokio = { version = "1.32.0", features = ["fs", "macros", "rt-multi-thread"] } tracing = "0.1.38" @@ -27,7 +26,26 @@ tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } [dev-dependencies] assert_cmd = "2.0.12" insta = "1.34.0" -predicates = { version = "3.0.4", default-features = false } +serde_json = "1.0" + +[lints.rust] +missing_copy_implementations = "deny" +missing_debug_implementations = "deny" +rust_2018_idioms = "deny" +unreachable_pub = "deny" +unused_crate_dependencies = "deny" + +[lints.clippy] +clone_on_ref_ptr = "deny" +dbg_macro = "deny" +explicit_iter_loop = "deny" +future_not_send = "deny" +todo = "deny" +use_self = "deny" + +[lints.rustdoc] +bare_urls = "deny" +broken_intra_doc_links = "deny" [profile.dev.package] insta.opt-level = 3 diff --git a/src/blob.rs b/src/blob.rs index 2a42cdf..348d9b4 100644 --- a/src/blob.rs +++ b/src/blob.rs @@ -8,7 +8,7 @@ use crate::{ session::Session, }; -pub async fn get_blob( +pub(crate) async fn get_blob( client: &Client, session: &Session, archive_id: &str, diff --git a/src/client.rs b/src/client.rs index 5eeedf7..e01adf7 100644 --- a/src/client.rs +++ b/src/client.rs @@ -15,12 +15,12 @@ const STREAM_BATCH_SIZE: u64 = 1000; const DEFAULT_HOST: &str = "https://app.tuta.com"; #[derive(Debug, Clone)] -pub struct Client { +pub(crate) struct Client { inner: reqwest::Client, } impl Client { - pub fn try_new() -> Result { + pub(crate) fn try_new() -> Result { let inner = reqwest::Client::builder() .min_tls_version(reqwest::tls::Version::TLS_1_3) .http2_prior_knowledge() @@ -31,7 +31,7 @@ impl Client { Ok(Self { inner }) } - pub async fn service_request( + pub(crate) async fn service_request( &self, method: Method, path: &str, @@ -39,7 +39,7 @@ impl Client { access_token: Option<&Base64Url>, ) -> Result where - Req: serde::Serialize, + Req: serde::Serialize + Sync, Resp: DeserializeOwned, { self.do_json(Request { @@ -54,7 +54,7 @@ impl Client { .await } - pub async fn service_request_tutanota( + pub(crate) async fn service_request_tutanota( &self, method: Method, path: &str, @@ -62,7 +62,7 @@ impl Client { access_token: Option<&Base64Url>, ) -> Result where - Req: serde::Serialize, + Req: serde::Serialize + Sync, Resp: DeserializeOwned, { self.do_json(Request { @@ -77,7 +77,7 @@ impl Client { .await } - pub async fn service_request_storage( + pub(crate) async fn service_request_storage( &self, method: Method, path: &str, @@ -85,7 +85,7 @@ impl Client { access_token: Option<&Base64Url>, ) -> Result where - Req: serde::Serialize, + Req: serde::Serialize + Sync, Resp: DeserializeOwned, { self.do_json(Request { @@ -100,7 +100,7 @@ impl Client { .await } - pub async fn blob_request( + pub(crate) async fn blob_request( &self, host: &str, path: &str, @@ -127,7 +127,7 @@ impl Client { .await } - pub async fn service_request_no_response( + pub(crate) async fn service_request_no_response( &self, method: Method, path: &str, @@ -135,7 +135,7 @@ impl Client { access_token: Option<&Base64Url>, ) -> Result where - Req: serde::Serialize, + Req: serde::Serialize + Sync, { self.do_request(Request { method, @@ -149,7 +149,7 @@ impl Client { .await } - pub fn stream( + pub(crate) fn stream( &self, path: &str, access_token: Option<&Base64Url>, @@ -214,7 +214,7 @@ impl Client { async fn do_json(&self, r: Request<'_, Req>) -> Result where - Req: serde::Serialize, + Req: serde::Serialize + Sync, Resp: DeserializeOwned, { let resp = self @@ -229,7 +229,7 @@ impl Client { async fn do_request(&self, r: Request<'_, Req>) -> Result where - Req: serde::Serialize, + Req: serde::Serialize + Sync, { let Request { method, @@ -270,7 +270,7 @@ struct StreamState { struct Request<'a, Req> where - Req: serde::Serialize, + Req: serde::Serialize + Sync, { method: Method, host: &'a str, diff --git a/src/compression.rs b/src/compression.rs index db1ce7f..8213836 100644 --- a/src/compression.rs +++ b/src/compression.rs @@ -1,5 +1,5 @@ use anyhow::{Context, Result}; -pub fn decompress_value(v: &[u8]) -> Result> { +pub(crate) fn decompress_value(v: &[u8]) -> Result> { lz4_flex::block::decompress(v, v.len() * 6).context("decompression") } diff --git a/src/constants.rs b/src/constants.rs index c67ee01..bb02bbf 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -1 +1,2 @@ -pub static APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),); +pub(crate) static APP_USER_AGENT: &str = + concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),); diff --git a/src/crypto/auth.rs b/src/crypto/auth.rs index 65b84dc..6beb58d 100644 --- a/src/crypto/auth.rs +++ b/src/crypto/auth.rs @@ -6,7 +6,7 @@ use sha2::{Digest, Sha256}; use crate::proto::{binary::Base64Url, KdfVersion}; #[derive(Debug)] -pub struct UserPassphraseKey(Box<[u8]>); +pub(crate) struct UserPassphraseKey(Box<[u8]>); impl AsRef<[u8]> for UserPassphraseKey { fn as_ref(&self) -> &[u8] { @@ -22,7 +22,7 @@ impl Deref for UserPassphraseKey { } } -pub fn derive_passkey( +pub(crate) fn derive_passkey( kdf_version: KdfVersion, passphrase: &str, salt: &[u8], @@ -43,7 +43,7 @@ pub fn derive_passkey( } } -pub fn encode_auth_verifier(passkey: &UserPassphraseKey) -> Base64Url { +pub(crate) fn encode_auth_verifier(passkey: &UserPassphraseKey) -> Base64Url { let mut hasher = Sha256::new(); hasher.update(&passkey.0); let hashed = hasher.finalize().to_vec(); diff --git a/src/crypto/encryption.rs b/src/crypto/encryption.rs index e8f80b8..9984052 100644 --- a/src/crypto/encryption.rs +++ b/src/crypto/encryption.rs @@ -10,7 +10,7 @@ type Aes128CbcDec = cbc::Decryptor; type Aes256CbcDec = cbc::Decryptor; type HmacSha256 = Hmac; -pub fn decrypt_key(encryption_key: &[u8], key_to_be_decrypted: &[u8]) -> Result> { +pub(crate) fn decrypt_key(encryption_key: &[u8], key_to_be_decrypted: &[u8]) -> Result> { if let Ok(k) = TryInto::<[u8; 16]>::try_into(encryption_key) { let iv: [u8; 16] = [128u8 + 8; 16]; Aes128CbcDec::new(&k.into(), &iv.into()) @@ -24,7 +24,7 @@ pub fn decrypt_key(encryption_key: &[u8], key_to_be_decrypted: &[u8]) -> Result< } } -pub fn decrypt_value(encryption_key: &[u8], value: &[u8]) -> Result> { +pub(crate) fn decrypt_value(encryption_key: &[u8], value: &[u8]) -> Result> { if let Ok(k) = TryInto::<[u8; 16]>::try_into(encryption_key) { let (k, value) = if value.len() % 2 == 1 { // use mac diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs index bfcc91f..e8f4ed9 100644 --- a/src/crypto/mod.rs +++ b/src/crypto/mod.rs @@ -1,4 +1,4 @@ //! Crypto methods. -pub mod auth; -pub mod encryption; +pub(crate) mod auth; +pub(crate) mod encryption; diff --git a/src/folders.rs b/src/folders.rs index 68f5fd9..f49b580 100644 --- a/src/folders.rs +++ b/src/folders.rs @@ -19,16 +19,16 @@ use crate::{ }; #[derive(Debug)] -pub struct Folder { - pub name: String, - pub mails: String, +pub(crate) struct Folder { + pub(crate) name: String, + pub(crate) mails: String, } impl Folder { - pub async fn list( + pub(crate) async fn list( client: &Client, session: &Session, - ) -> Result>> { + ) -> Result>> { let mail_group = get_mail_membership(session).context("get mail group")?; let resp: MailboxGroupRootResponse = client @@ -89,7 +89,7 @@ impl Folder { resp.folder_type.name().to_owned() }; - Ok(Folder { + Ok(Self { name, mails: resp.mails, }) diff --git a/src/logging.rs b/src/logging.rs index 7d4bd31..bd1821b 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -8,7 +8,7 @@ use tracing_subscriber::{EnvFilter, FmtSubscriber}; /// Logging CLI config. #[derive(Debug, Parser)] -pub struct LoggingCLIConfig { +pub(crate) struct LoggingCLIConfig { /// Log filter. /// /// Conflicts with `-v`/`--verbose`. @@ -30,7 +30,7 @@ pub struct LoggingCLIConfig { } /// Setup process-wide logging. -pub fn setup_logging(config: LoggingCLIConfig) -> Result<()> { +pub(crate) fn setup_logging(config: LoggingCLIConfig) -> Result<()> { LogTracer::init()?; let filter = match config.log_filter { diff --git a/src/mails.rs b/src/mails.rs index f13cdff..274a308 100644 --- a/src/mails.rs +++ b/src/mails.rs @@ -14,20 +14,21 @@ use crate::{ }; #[derive(Debug)] -pub struct Mail { - pub folder_id: String, - pub mail_id: String, - pub archive_id: String, - pub blob_id: String, - pub session_key: Vec, +pub(crate) struct Mail { + #[allow(dead_code)] + pub(crate) folder_id: String, + pub(crate) mail_id: String, + pub(crate) archive_id: String, + pub(crate) blob_id: String, + pub(crate) session_key: Vec, } impl Mail { - pub fn list( + pub(crate) fn list( client: &Client, session: &Session, folder: &Folder, - ) -> impl Stream> { + ) -> impl Stream> { let group_keys = Arc::clone(&session.group_keys); client .stream::( @@ -49,7 +50,7 @@ impl Mail { ) .context("decrypting session key")?; - Ok(Mail { + Ok(Self { folder_id: resp.id[0].clone(), mail_id: resp.id[1].clone(), archive_id: resp.mail_details[0].clone(), @@ -58,7 +59,7 @@ impl Mail { }) } - pub async fn download( + pub(crate) async fn download( &self, client: &Client, session: &Session, diff --git a/src/main.rs b/src/main.rs index b97a2be..8cc58e6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,12 @@ use futures::TryStreamExt; use logging::{setup_logging, LoggingCLIConfig}; use tracing::{debug, info}; +// Workaround for "unused crate" lint false positives. +#[cfg(test)] +use assert_cmd as _; +#[cfg(test)] +use insta as _; + mod blob; mod client; mod compression; diff --git a/src/non_empty_string.rs b/src/non_empty_string.rs index e85f7d8..5d814e9 100644 --- a/src/non_empty_string.rs +++ b/src/non_empty_string.rs @@ -2,7 +2,7 @@ use std::{ops::Deref, str::FromStr}; /// Non-empty [`String`]. #[derive(Clone, PartialEq, Eq, Hash)] -pub struct NonEmptyString(String); +pub(crate) struct NonEmptyString(String); impl std::fmt::Debug for NonEmptyString { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { diff --git a/src/proto/binary.rs b/src/proto/binary.rs index 81acfc5..a695e06 100644 --- a/src/proto/binary.rs +++ b/src/proto/binary.rs @@ -3,15 +3,15 @@ use base64::prelude::*; use serde::{de::Error, Deserializer, Serializer}; #[derive(Clone, PartialEq, Eq, Hash)] -pub struct Base64String(Box<[u8]>); +pub(crate) struct Base64String(Box<[u8]>); impl Base64String { - pub fn try_new(s: &str) -> Result { + pub(crate) fn try_new(s: &str) -> Result { let data = BASE64_STANDARD.decode(s)?; Ok(Self(data.into())) } - pub fn base64(&self) -> String { + pub(crate) fn base64(&self) -> String { BASE64_STANDARD.encode(self.0.as_ref()) } } @@ -78,10 +78,10 @@ impl<'de> serde::Deserialize<'de> for Base64String { } #[derive(Clone, PartialEq, Eq, Hash)] -pub struct Base64Url(Base64String); +pub(crate) struct Base64Url(Base64String); impl Base64Url { - pub fn try_new(s: &str) -> Result { + pub(crate) fn try_new(s: &str) -> Result { let mut s = s.replace('-', "+").replace('_', "/"); match s.len() % 4 { 0 => {} @@ -98,7 +98,7 @@ impl Base64Url { Ok(Self(Base64String::try_new(&s)?)) } - pub fn url(&self) -> String { + pub(crate) fn url(&self) -> String { self.0 .base64() .replace('+', "-") diff --git a/src/proto/mod.rs b/src/proto/mod.rs index 8ab01d3..054063b 100644 --- a/src/proto/mod.rs +++ b/src/proto/mod.rs @@ -3,17 +3,17 @@ use serde::{de::Error, Deserialize, Deserializer, Serialize, Serializer}; use self::binary::{Base64String, Base64Url}; -pub mod binary; +pub(crate) mod binary; #[cfg(test)] mod testing; -pub trait Entity { +pub(crate) trait Entity { fn id(&self) -> &str; } #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct Format; +pub(crate) struct Format; impl serde::Serialize for Format { fn serialize(&self, serializer: S) -> Result @@ -40,7 +40,7 @@ impl<'de, const F: u8> serde::Deserialize<'de> for Format { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum KdfVersion { +pub(crate) enum KdfVersion { Bcrypt, Argon2id, } @@ -73,7 +73,7 @@ impl<'de> serde::Deserialize<'de> for KdfVersion { } #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] -pub struct Null; +pub(crate) struct Null; impl serde::Serialize for Null { fn serialize(&self, serializer: S) -> Result @@ -85,7 +85,7 @@ impl serde::Serialize for Null { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum GroupType { +pub(crate) enum GroupType { User, Admin, MailingList, @@ -148,7 +148,7 @@ impl<'de> serde::Deserialize<'de> for GroupType { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum MailFolderType { +pub(crate) enum MailFolderType { Custom, Inbox, Sent, @@ -159,7 +159,7 @@ pub enum MailFolderType { } impl MailFolderType { - pub fn name(&self) -> &'static str { + pub(crate) fn name(&self) -> &'static str { match self { Self::Custom => "Custom", Self::Inbox => "Inbox", @@ -211,125 +211,125 @@ impl<'de> serde::Deserialize<'de> for MailFolderType { #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] -pub struct SaltServiceRequest { +pub(crate) struct SaltServiceRequest { #[serde(rename = "_format")] - pub format: Format<0>, + pub(crate) format: Format<0>, - pub mail_address: String, + pub(crate) mail_address: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct SaltServiceResponse { +pub(crate) struct SaltServiceResponse { #[serde(rename = "_format")] - pub format: Format<0>, + pub(crate) _format: Format<0>, - pub kdf_version: KdfVersion, + pub(crate) kdf_version: KdfVersion, - pub salt: Base64String, + pub(crate) salt: Base64String, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] -pub struct SessionServiceRequest { +pub(crate) struct SessionServiceRequest { #[serde(rename = "_format")] - pub format: Format<0>, + pub(crate) format: Format<0>, - pub access_key: Null, + pub(crate) access_key: Null, - pub auth_token: Null, + pub(crate) auth_token: Null, - pub auth_verifier: Base64Url, + pub(crate) auth_verifier: Base64Url, - pub client_identifier: String, + pub(crate) client_identifier: String, - pub mail_address: String, + pub(crate) mail_address: String, - pub recover_code_verifier: Null, + pub(crate) recover_code_verifier: Null, - pub user: Null, + pub(crate) user: Null, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct SessionServiceResponse { +pub(crate) struct SessionServiceResponse { #[serde(rename = "_format")] - pub format: Format<0>, + pub(crate) _format: Format<0>, - pub access_token: Base64Url, + pub(crate) access_token: Base64Url, - pub challenges: Vec, + pub(crate) challenges: Vec, - pub user: String, + pub(crate) user: String, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct UserMembership { - pub group_type: GroupType, - pub group: String, - pub sym_enc_g_key: Base64String, +pub(crate) struct UserMembership { + pub(crate) group_type: GroupType, + pub(crate) group: String, + pub(crate) sym_enc_g_key: Base64String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct UserAuth { - pub sessions: String, +pub(crate) struct UserAuth { + pub(crate) sessions: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct UserResponse { +pub(crate) struct UserResponse { #[serde(rename = "_format")] - pub format: Format<0>, + pub(crate) _format: Format<0>, - pub memberships: Vec, - pub auth: UserAuth, - pub user_group: UserMembership, + pub(crate) memberships: Vec, + pub(crate) auth: UserAuth, + pub(crate) user_group: UserMembership, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct MailboxGroupRootResponse { +pub(crate) struct MailboxGroupRootResponse { #[serde(rename = "_format")] - pub format: Format<0>, + pub(crate) _format: Format<0>, - pub mailbox: String, + pub(crate) mailbox: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct Folders { - pub folders: String, +pub(crate) struct Folders { + pub(crate) folders: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct MailboxResponse { +pub(crate) struct MailboxResponse { #[serde(rename = "_format")] - pub format: Format<0>, + pub(crate) _format: Format<0>, - pub folders: Folders, + pub(crate) folders: Folders, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct FolderResponse { +pub(crate) struct FolderResponse { #[serde(rename = "_format")] - pub format: Format<0>, + pub(crate) _format: Format<0>, #[serde(rename = "_id")] - pub id: [String; 2], + pub(crate) id: [String; 2], #[serde(rename = "_ownerEncSessionKey")] - pub owner_enc_session_key: Base64String, + pub(crate) owner_enc_session_key: Base64String, #[serde(rename = "_ownerGroup")] - pub owner_group: String, + pub(crate) owner_group: String, - pub folder_type: MailFolderType, - pub name: Base64String, - pub mails: String, + pub(crate) folder_type: MailFolderType, + pub(crate) name: Base64String, + pub(crate) mails: String, } impl Entity for FolderResponse { @@ -340,20 +340,20 @@ impl Entity for FolderResponse { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct MailReponse { +pub(crate) struct MailReponse { #[serde(rename = "_format")] - pub format: Format<0>, + pub(crate) _format: Format<0>, #[serde(rename = "_ownerEncSessionKey")] - pub owner_enc_session_key: Base64String, + pub(crate) owner_enc_session_key: Base64String, #[serde(rename = "_ownerGroup")] - pub owner_group: String, + pub(crate) owner_group: String, #[serde(rename = "_id")] - pub id: [String; 2], + pub(crate) id: [String; 2], - pub mail_details: [String; 2], + pub(crate) mail_details: [String; 2], } impl Entity for MailReponse { @@ -364,67 +364,67 @@ impl Entity for MailReponse { #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] -pub struct BlobReadRequest { +pub(crate) struct BlobReadRequest { #[serde(rename = "_id")] - pub id: String, + pub(crate) id: String, - pub archive_id: String, - pub instance_ids: Vec<()>, - pub instance_list_id: Null, + pub(crate) archive_id: String, + pub(crate) instance_ids: Vec<()>, + pub(crate) instance_list_id: Null, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] -pub struct BlobAccessTokenServiceRequest { +pub(crate) struct BlobAccessTokenServiceRequest { #[serde(rename = "_format")] - pub format: Format<0>, + pub(crate) format: Format<0>, - pub archive_data_type: Null, - pub read: BlobReadRequest, - pub write: Null, + pub(crate) archive_data_type: Null, + pub(crate) read: BlobReadRequest, + pub(crate) write: Null, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct BlobServer { - pub url: String, +pub(crate) struct BlobServer { + pub(crate) url: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct BlobAccessInfo { - pub blob_access_token: String, - pub servers: Vec, +pub(crate) struct BlobAccessInfo { + pub(crate) blob_access_token: String, + pub(crate) servers: Vec, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct BlobAccessTokenServiceResponse { +pub(crate) struct BlobAccessTokenServiceResponse { #[serde(rename = "_format")] - pub format: Format<0>, + pub(crate) _format: Format<0>, - pub blob_access_info: BlobAccessInfo, + pub(crate) blob_access_info: BlobAccessInfo, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct MailBody { - pub compressed_text: Base64String, +pub(crate) struct MailBody { + pub(crate) compressed_text: Base64String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct MailDetails { - pub body: MailBody, +pub(crate) struct MailDetails { + pub(crate) body: MailBody, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct MailDetailsBlob { +pub(crate) struct MailDetailsBlob { #[serde(rename = "_format")] - pub format: Format<0>, + pub(crate) _format: Format<0>, - pub details: MailDetails, + pub(crate) details: MailDetails, } #[cfg(test)] diff --git a/src/proto/testing.rs b/src/proto/testing.rs index f341168..208a879 100644 --- a/src/proto/testing.rs +++ b/src/proto/testing.rs @@ -1,5 +1,5 @@ #[track_caller] -pub fn assert_roundtrip(orig: T) +pub(crate) fn assert_roundtrip(orig: T) where T: Eq + std::fmt::Debug + serde::Serialize + serde::de::DeserializeOwned, { @@ -9,7 +9,7 @@ where } #[track_caller] -pub fn assert_deser_error(s: &str, expected: &str) +pub(crate) fn assert_deser_error(s: &str, expected: &str) where T: std::fmt::Debug + serde::de::DeserializeOwned, { @@ -28,7 +28,7 @@ mod tests { } #[test] - #[should_panic(expected = "assertion failed")] + #[should_panic(expected = "assertion")] fn test_assert_roundtrip_fail() { assert_roundtrip(Helper(100)); } diff --git a/src/session.rs b/src/session.rs index 675416b..6d81eac 100644 --- a/src/session.rs +++ b/src/session.rs @@ -22,7 +22,7 @@ use crate::{ /// Login CLI config. #[derive(Debug, Parser)] -pub struct LoginCLIConfig { +pub(crate) struct LoginCLIConfig { /// Username #[clap(long, env = "TUTANOTA_CLI_USERNAME")] username: NonEmptyString, @@ -34,16 +34,17 @@ pub struct LoginCLIConfig { /// User session #[derive(Debug)] -pub struct Session { - pub user_id: String, - pub access_token: Base64Url, - pub group_keys: Arc, - pub user_data: UserResponse, +pub(crate) struct Session { + #[allow(dead_code)] + pub(crate) user_id: String, + pub(crate) access_token: Base64Url, + pub(crate) group_keys: Arc, + pub(crate) user_data: UserResponse, } impl Session { /// Perform tutanota login. - pub async fn login(config: LoginCLIConfig, client: &Client) -> Result { + pub(crate) async fn login(config: LoginCLIConfig, client: &Client) -> Result { debug!("perform login"); let req = SaltServiceRequest { @@ -103,7 +104,7 @@ impl Session { }) } - pub async fn logout(self, client: &Client) -> Result<()> { + pub(crate) async fn logout(self, client: &Client) -> Result<()> { let session = &self.user_data.auth.sessions; debug!(session = session.as_str(), "performing logout",); @@ -129,7 +130,7 @@ impl Session { } #[derive(Debug)] -pub struct GroupKeys { +pub(crate) struct GroupKeys { keys: HashMap>, } @@ -150,7 +151,7 @@ impl GroupKeys { Ok(Self { keys: group_keys }) } - pub fn get(&self, group: &str) -> Result<&[u8]> { + pub(crate) fn get(&self, group: &str) -> Result<&[u8]> { let key = self.keys.get(group).context("group key not found")?; Ok(key) } diff --git a/tests/cli.rs b/tests/cli.rs index b0f6369..6c68829 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,3 +1,5 @@ +#![allow(unused_crate_dependencies)] + use assert_cmd::Command; #[test] From f1c56135df8ef4fee7d6e552df4fcd98f8dc7ef7 Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Sun, 18 Feb 2024 12:59:00 +0100 Subject: [PATCH 34/44] split off constants proto --- src/proto/constants.rs | 58 ++++++++++++++++++++++++++++++++++++++++++ src/proto/mod.rs | 55 ++++----------------------------------- 2 files changed, 63 insertions(+), 50 deletions(-) create mode 100644 src/proto/constants.rs diff --git a/src/proto/constants.rs b/src/proto/constants.rs new file mode 100644 index 0000000..da02109 --- /dev/null +++ b/src/proto/constants.rs @@ -0,0 +1,58 @@ +use anyhow::Result; +use serde::{de::Error, Deserializer, Serializer}; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) struct Format; + +impl serde::Serialize for Format { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&F.to_string()) + } +} + +impl<'de, const F: u8> serde::Deserialize<'de> for Format { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + let f: u8 = s.parse().map_err(D::Error::custom)?; + if f == F { + Ok(Self) + } else { + Err(D::Error::custom(format!("invalid format: {f}"))) + } + } +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) struct Null; + +impl serde::Serialize for Null { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_none() + } +} + +#[cfg(test)] +mod tests { + use crate::proto::testing::{assert_deser_error, assert_roundtrip}; + + use super::*; + + #[test] + fn test_roundtrip_format() { + assert_roundtrip(Format::<0>); + assert_roundtrip(Format::<1>); + assert_roundtrip(Format::<2>); + assert_roundtrip(Format::<255>); + + assert_deser_error::>(r#""0""#, "invalid format: 0"); + } +} diff --git a/src/proto/mod.rs b/src/proto/mod.rs index 054063b..74950d5 100644 --- a/src/proto/mod.rs +++ b/src/proto/mod.rs @@ -1,9 +1,13 @@ use anyhow::Result; use serde::{de::Error, Deserialize, Deserializer, Serialize, Serializer}; -use self::binary::{Base64String, Base64Url}; +use self::{ + binary::{Base64String, Base64Url}, + constants::{Format, Null}, +}; pub(crate) mod binary; +pub(crate) mod constants; #[cfg(test)] mod testing; @@ -12,33 +16,6 @@ pub(crate) trait Entity { fn id(&self) -> &str; } -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub(crate) struct Format; - -impl serde::Serialize for Format { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(&F.to_string()) - } -} - -impl<'de, const F: u8> serde::Deserialize<'de> for Format { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - let f: u8 = s.parse().map_err(D::Error::custom)?; - if f == F { - Ok(Self) - } else { - Err(D::Error::custom(format!("invalid format: {f}"))) - } - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) enum KdfVersion { Bcrypt, @@ -72,18 +49,6 @@ impl<'de> serde::Deserialize<'de> for KdfVersion { } } -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] -pub(crate) struct Null; - -impl serde::Serialize for Null { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_none() - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) enum GroupType { User, @@ -432,16 +397,6 @@ mod tests { use super::testing::{assert_deser_error, assert_roundtrip}; use super::*; - #[test] - fn test_roundtrip_format() { - assert_roundtrip(Format::<0>); - assert_roundtrip(Format::<1>); - assert_roundtrip(Format::<2>); - assert_roundtrip(Format::<255>); - - assert_deser_error::>(r#""0""#, "invalid format: 0"); - } - #[test] fn test_roundtrip_kdf_version() { assert_roundtrip(KdfVersion::Bcrypt); From df8122f3277c903a45d9bed9cd033e3768846b23 Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Sun, 18 Feb 2024 13:02:46 +0100 Subject: [PATCH 35/44] split off enums proto --- src/crypto/auth.rs | 2 +- src/folders.rs | 4 +- src/proto/enums.rs | 206 ++++++++++++++++++++++++++++++++++++++++++++ src/proto/mod.rs | 208 +-------------------------------------------- 4 files changed, 212 insertions(+), 208 deletions(-) create mode 100644 src/proto/enums.rs diff --git a/src/crypto/auth.rs b/src/crypto/auth.rs index 6beb58d..9aaba67 100644 --- a/src/crypto/auth.rs +++ b/src/crypto/auth.rs @@ -3,7 +3,7 @@ use std::ops::Deref; use anyhow::{bail, Context, Result}; use sha2::{Digest, Sha256}; -use crate::proto::{binary::Base64Url, KdfVersion}; +use crate::proto::{binary::Base64Url, enums::KdfVersion}; #[derive(Debug)] pub(crate) struct UserPassphraseKey(Box<[u8]>); diff --git a/src/folders.rs b/src/folders.rs index f49b580..5666531 100644 --- a/src/folders.rs +++ b/src/folders.rs @@ -12,8 +12,8 @@ use crate::{ client::Client, crypto::encryption::{decrypt_key, decrypt_value}, proto::{ - FolderResponse, GroupType, MailFolderType, MailboxGroupRootResponse, MailboxResponse, - UserMembership, + enums::{GroupType, MailFolderType}, + FolderResponse, MailboxGroupRootResponse, MailboxResponse, UserMembership, }, session::{GroupKeys, Session}, }; diff --git a/src/proto/enums.rs b/src/proto/enums.rs new file mode 100644 index 0000000..c8a50b4 --- /dev/null +++ b/src/proto/enums.rs @@ -0,0 +1,206 @@ +use anyhow::Result; +use serde::{de::Error, Deserializer, Serializer}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) enum KdfVersion { + Bcrypt, + Argon2id, +} + +impl serde::Serialize for KdfVersion { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let s = match self { + Self::Bcrypt => "0", + Self::Argon2id => "1", + }; + serializer.serialize_str(s) + } +} + +impl<'de> serde::Deserialize<'de> for KdfVersion { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + match s.as_str() { + "0" => Ok(Self::Bcrypt), + "1" => Ok(Self::Argon2id), + s => Err(D::Error::custom(format!("invalid KDF version: {s}"))), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) enum GroupType { + User, + Admin, + MailingList, + Customer, + External, + Mail, + Contact, + File, + LocalAdmin, + Calendar, + Template, + ContactList, +} + +impl serde::Serialize for GroupType { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let s = match self { + Self::User => "0", + Self::Admin => "1", + Self::MailingList => "2", + Self::Customer => "3", + Self::External => "4", + Self::Mail => "5", + Self::Contact => "6", + Self::File => "7", + Self::LocalAdmin => "8", + Self::Calendar => "9", + Self::Template => "10", + Self::ContactList => "11", + }; + serializer.serialize_str(s) + } +} + +impl<'de> serde::Deserialize<'de> for GroupType { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + match s.as_str() { + "0" => Ok(Self::User), + "1" => Ok(Self::Admin), + "2" => Ok(Self::MailingList), + "3" => Ok(Self::Customer), + "4" => Ok(Self::External), + "5" => Ok(Self::Mail), + "6" => Ok(Self::Contact), + "7" => Ok(Self::File), + "8" => Ok(Self::LocalAdmin), + "9" => Ok(Self::Calendar), + "10" => Ok(Self::Template), + "11" => Ok(Self::ContactList), + s => Err(D::Error::custom(format!("invalid group type: {s}"))), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) enum MailFolderType { + Custom, + Inbox, + Sent, + Trash, + Archive, + Spam, + Draft, +} + +impl MailFolderType { + pub(crate) fn name(&self) -> &'static str { + match self { + Self::Custom => "Custom", + Self::Inbox => "Inbox", + Self::Sent => "Sent", + Self::Trash => "Trash", + Self::Archive => "Archive", + Self::Spam => "Spam", + Self::Draft => "Draft", + } + } +} + +impl serde::Serialize for MailFolderType { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let s = match self { + Self::Custom => "0", + Self::Inbox => "1", + Self::Sent => "2", + Self::Trash => "3", + Self::Archive => "4", + Self::Spam => "5", + Self::Draft => "6", + }; + serializer.serialize_str(s) + } +} + +impl<'de> serde::Deserialize<'de> for MailFolderType { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + match s.as_str() { + "0" => Ok(Self::Custom), + "1" => Ok(Self::Inbox), + "2" => Ok(Self::Sent), + "3" => Ok(Self::Trash), + "4" => Ok(Self::Archive), + "5" => Ok(Self::Spam), + "6" => Ok(Self::Draft), + s => Err(D::Error::custom(format!("invalid mail folder type: {s}"))), + } + } +} + +#[cfg(test)] +mod tests { + use crate::proto::testing::{assert_deser_error, assert_roundtrip}; + + use super::*; + + #[test] + fn test_roundtrip_kdf_version() { + assert_roundtrip(KdfVersion::Bcrypt); + assert_roundtrip(KdfVersion::Argon2id); + + assert_deser_error::(r#""2""#, "invalid KDF version: 2"); + } + + #[test] + fn test_roundtrip_group_type() { + assert_roundtrip(GroupType::User); + assert_roundtrip(GroupType::Admin); + assert_roundtrip(GroupType::MailingList); + assert_roundtrip(GroupType::Customer); + assert_roundtrip(GroupType::External); + assert_roundtrip(GroupType::Mail); + assert_roundtrip(GroupType::Contact); + assert_roundtrip(GroupType::File); + assert_roundtrip(GroupType::LocalAdmin); + assert_roundtrip(GroupType::Calendar); + assert_roundtrip(GroupType::Template); + assert_roundtrip(GroupType::ContactList); + + assert_deser_error::(r#""20""#, "invalid group type: 20"); + } + + #[test] + fn test_roundtrip_mail_folder_type() { + assert_roundtrip(MailFolderType::Custom); + assert_roundtrip(MailFolderType::Inbox); + assert_roundtrip(MailFolderType::Sent); + assert_roundtrip(MailFolderType::Trash); + assert_roundtrip(MailFolderType::Archive); + assert_roundtrip(MailFolderType::Spam); + assert_roundtrip(MailFolderType::Draft); + + assert_deser_error::(r#""20""#, "invalid mail folder type: 20"); + } +} diff --git a/src/proto/mod.rs b/src/proto/mod.rs index 74950d5..46bf87d 100644 --- a/src/proto/mod.rs +++ b/src/proto/mod.rs @@ -1,13 +1,14 @@ -use anyhow::Result; -use serde::{de::Error, Deserialize, Deserializer, Serialize, Serializer}; +use serde::{Deserialize, Serialize}; use self::{ binary::{Base64String, Base64Url}, constants::{Format, Null}, + enums::{GroupType, KdfVersion, MailFolderType}, }; pub(crate) mod binary; pub(crate) mod constants; +pub(crate) mod enums; #[cfg(test)] mod testing; @@ -16,164 +17,6 @@ pub(crate) trait Entity { fn id(&self) -> &str; } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub(crate) enum KdfVersion { - Bcrypt, - Argon2id, -} - -impl serde::Serialize for KdfVersion { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - let s = match self { - Self::Bcrypt => "0", - Self::Argon2id => "1", - }; - serializer.serialize_str(s) - } -} - -impl<'de> serde::Deserialize<'de> for KdfVersion { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - match s.as_str() { - "0" => Ok(Self::Bcrypt), - "1" => Ok(Self::Argon2id), - s => Err(D::Error::custom(format!("invalid KDF version: {s}"))), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub(crate) enum GroupType { - User, - Admin, - MailingList, - Customer, - External, - Mail, - Contact, - File, - LocalAdmin, - Calendar, - Template, - ContactList, -} - -impl serde::Serialize for GroupType { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - let s = match self { - Self::User => "0", - Self::Admin => "1", - Self::MailingList => "2", - Self::Customer => "3", - Self::External => "4", - Self::Mail => "5", - Self::Contact => "6", - Self::File => "7", - Self::LocalAdmin => "8", - Self::Calendar => "9", - Self::Template => "10", - Self::ContactList => "11", - }; - serializer.serialize_str(s) - } -} - -impl<'de> serde::Deserialize<'de> for GroupType { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - match s.as_str() { - "0" => Ok(Self::User), - "1" => Ok(Self::Admin), - "2" => Ok(Self::MailingList), - "3" => Ok(Self::Customer), - "4" => Ok(Self::External), - "5" => Ok(Self::Mail), - "6" => Ok(Self::Contact), - "7" => Ok(Self::File), - "8" => Ok(Self::LocalAdmin), - "9" => Ok(Self::Calendar), - "10" => Ok(Self::Template), - "11" => Ok(Self::ContactList), - s => Err(D::Error::custom(format!("invalid group type: {s}"))), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub(crate) enum MailFolderType { - Custom, - Inbox, - Sent, - Trash, - Archive, - Spam, - Draft, -} - -impl MailFolderType { - pub(crate) fn name(&self) -> &'static str { - match self { - Self::Custom => "Custom", - Self::Inbox => "Inbox", - Self::Sent => "Sent", - Self::Trash => "Trash", - Self::Archive => "Archive", - Self::Spam => "Spam", - Self::Draft => "Draft", - } - } -} - -impl serde::Serialize for MailFolderType { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - let s = match self { - Self::Custom => "0", - Self::Inbox => "1", - Self::Sent => "2", - Self::Trash => "3", - Self::Archive => "4", - Self::Spam => "5", - Self::Draft => "6", - }; - serializer.serialize_str(s) - } -} - -impl<'de> serde::Deserialize<'de> for MailFolderType { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - match s.as_str() { - "0" => Ok(Self::Custom), - "1" => Ok(Self::Inbox), - "2" => Ok(Self::Sent), - "3" => Ok(Self::Trash), - "4" => Ok(Self::Archive), - "5" => Ok(Self::Spam), - "6" => Ok(Self::Draft), - s => Err(D::Error::custom(format!("invalid mail folder type: {s}"))), - } - } -} - #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct SaltServiceRequest { @@ -391,48 +234,3 @@ pub(crate) struct MailDetailsBlob { pub(crate) details: MailDetails, } - -#[cfg(test)] -mod tests { - use super::testing::{assert_deser_error, assert_roundtrip}; - use super::*; - - #[test] - fn test_roundtrip_kdf_version() { - assert_roundtrip(KdfVersion::Bcrypt); - assert_roundtrip(KdfVersion::Argon2id); - - assert_deser_error::(r#""2""#, "invalid KDF version: 2"); - } - - #[test] - fn test_roundtrip_group_type() { - assert_roundtrip(GroupType::User); - assert_roundtrip(GroupType::Admin); - assert_roundtrip(GroupType::MailingList); - assert_roundtrip(GroupType::Customer); - assert_roundtrip(GroupType::External); - assert_roundtrip(GroupType::Mail); - assert_roundtrip(GroupType::Contact); - assert_roundtrip(GroupType::File); - assert_roundtrip(GroupType::LocalAdmin); - assert_roundtrip(GroupType::Calendar); - assert_roundtrip(GroupType::Template); - assert_roundtrip(GroupType::ContactList); - - assert_deser_error::(r#""20""#, "invalid group type: 20"); - } - - #[test] - fn test_roundtrip_mail_folder_type() { - assert_roundtrip(MailFolderType::Custom); - assert_roundtrip(MailFolderType::Inbox); - assert_roundtrip(MailFolderType::Sent); - assert_roundtrip(MailFolderType::Trash); - assert_roundtrip(MailFolderType::Archive); - assert_roundtrip(MailFolderType::Spam); - assert_roundtrip(MailFolderType::Draft); - - assert_deser_error::(r#""20""#, "invalid mail folder type: 20"); - } -} From d21c5c8174e962283e91c8ff0442dcd30c3d1e5f Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Sun, 18 Feb 2024 13:06:25 +0100 Subject: [PATCH 36/44] split off messages proto --- src/blob.rs | 4 +- src/client.rs | 2 +- src/folders.rs | 2 +- src/mails.rs | 2 +- src/proto/messages.rs | 231 ++++++++++++++++++++++++++++++++++++++++++ src/proto/mod.rs | 231 +----------------------------------------- src/session.rs | 7 +- 7 files changed, 243 insertions(+), 236 deletions(-) create mode 100644 src/proto/messages.rs diff --git a/src/blob.rs b/src/blob.rs index 348d9b4..f3f3ae8 100644 --- a/src/blob.rs +++ b/src/blob.rs @@ -4,7 +4,9 @@ use serde::de::DeserializeOwned; use crate::{ client::Client, - proto::{BlobAccessTokenServiceRequest, BlobAccessTokenServiceResponse, BlobReadRequest}, + proto::messages::{ + BlobAccessTokenServiceRequest, BlobAccessTokenServiceResponse, BlobReadRequest, + }, session::Session, }; diff --git a/src/client.rs b/src/client.rs index e01adf7..5c30545 100644 --- a/src/client.rs +++ b/src/client.rs @@ -8,7 +8,7 @@ use tracing::debug; use crate::{ constants::APP_USER_AGENT, - proto::{binary::Base64Url, Entity}, + proto::{binary::Base64Url, messages::Entity}, }; const STREAM_BATCH_SIZE: u64 = 1000; diff --git a/src/folders.rs b/src/folders.rs index 5666531..cbc8805 100644 --- a/src/folders.rs +++ b/src/folders.rs @@ -13,7 +13,7 @@ use crate::{ crypto::encryption::{decrypt_key, decrypt_value}, proto::{ enums::{GroupType, MailFolderType}, - FolderResponse, MailboxGroupRootResponse, MailboxResponse, UserMembership, + messages::{FolderResponse, MailboxGroupRootResponse, MailboxResponse, UserMembership}, }, session::{GroupKeys, Session}, }; diff --git a/src/mails.rs b/src/mails.rs index 274a308..3c93c1e 100644 --- a/src/mails.rs +++ b/src/mails.rs @@ -9,7 +9,7 @@ use crate::{ compression::decompress_value, crypto::encryption::{decrypt_key, decrypt_value}, folders::Folder, - proto::{MailDetailsBlob, MailReponse}, + proto::messages::{MailDetailsBlob, MailReponse}, session::{GroupKeys, Session}, }; diff --git a/src/proto/messages.rs b/src/proto/messages.rs new file mode 100644 index 0000000..f676370 --- /dev/null +++ b/src/proto/messages.rs @@ -0,0 +1,231 @@ +use serde::{Deserialize, Serialize}; + +use crate::proto::constants::Format; + +use super::{ + binary::{Base64String, Base64Url}, + constants::Null, + enums::{GroupType, KdfVersion, MailFolderType}, +}; + +pub(crate) trait Entity { + fn id(&self) -> &str; +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SaltServiceRequest { + #[serde(rename = "_format")] + pub(crate) format: Format<0>, + + pub(crate) mail_address: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SaltServiceResponse { + #[serde(rename = "_format")] + pub(crate) _format: Format<0>, + + pub(crate) kdf_version: KdfVersion, + + pub(crate) salt: Base64String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SessionServiceRequest { + #[serde(rename = "_format")] + pub(crate) format: Format<0>, + + pub(crate) access_key: Null, + + pub(crate) auth_token: Null, + + pub(crate) auth_verifier: Base64Url, + + pub(crate) client_identifier: String, + + pub(crate) mail_address: String, + + pub(crate) recover_code_verifier: Null, + + pub(crate) user: Null, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SessionServiceResponse { + #[serde(rename = "_format")] + pub(crate) _format: Format<0>, + + pub(crate) access_token: Base64Url, + + pub(crate) challenges: Vec, + + pub(crate) user: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UserMembership { + pub(crate) group_type: GroupType, + pub(crate) group: String, + pub(crate) sym_enc_g_key: Base64String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UserAuth { + pub(crate) sessions: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UserResponse { + #[serde(rename = "_format")] + pub(crate) _format: Format<0>, + + pub(crate) memberships: Vec, + pub(crate) auth: UserAuth, + pub(crate) user_group: UserMembership, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MailboxGroupRootResponse { + #[serde(rename = "_format")] + pub(crate) _format: Format<0>, + + pub(crate) mailbox: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Folders { + pub(crate) folders: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MailboxResponse { + #[serde(rename = "_format")] + pub(crate) _format: Format<0>, + + pub(crate) folders: Folders, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct FolderResponse { + #[serde(rename = "_format")] + pub(crate) _format: Format<0>, + + #[serde(rename = "_id")] + pub(crate) id: [String; 2], + + #[serde(rename = "_ownerEncSessionKey")] + pub(crate) owner_enc_session_key: Base64String, + + #[serde(rename = "_ownerGroup")] + pub(crate) owner_group: String, + + pub(crate) folder_type: MailFolderType, + pub(crate) name: Base64String, + pub(crate) mails: String, +} + +impl Entity for FolderResponse { + fn id(&self) -> &str { + &self.id[1] + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MailReponse { + #[serde(rename = "_format")] + pub(crate) _format: Format<0>, + + #[serde(rename = "_ownerEncSessionKey")] + pub(crate) owner_enc_session_key: Base64String, + + #[serde(rename = "_ownerGroup")] + pub(crate) owner_group: String, + + #[serde(rename = "_id")] + pub(crate) id: [String; 2], + + pub(crate) mail_details: [String; 2], +} + +impl Entity for MailReponse { + fn id(&self) -> &str { + &self.id[1] + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct BlobReadRequest { + #[serde(rename = "_id")] + pub(crate) id: String, + + pub(crate) archive_id: String, + pub(crate) instance_ids: Vec<()>, + pub(crate) instance_list_id: Null, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct BlobAccessTokenServiceRequest { + #[serde(rename = "_format")] + pub(crate) format: Format<0>, + + pub(crate) archive_data_type: Null, + pub(crate) read: BlobReadRequest, + pub(crate) write: Null, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct BlobServer { + pub(crate) url: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct BlobAccessInfo { + pub(crate) blob_access_token: String, + pub(crate) servers: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct BlobAccessTokenServiceResponse { + #[serde(rename = "_format")] + pub(crate) _format: Format<0>, + + pub(crate) blob_access_info: BlobAccessInfo, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MailBody { + pub(crate) compressed_text: Base64String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MailDetails { + pub(crate) body: MailBody, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MailDetailsBlob { + #[serde(rename = "_format")] + pub(crate) _format: Format<0>, + + pub(crate) details: MailDetails, +} diff --git a/src/proto/mod.rs b/src/proto/mod.rs index 46bf87d..840d6d5 100644 --- a/src/proto/mod.rs +++ b/src/proto/mod.rs @@ -1,236 +1,7 @@ -use serde::{Deserialize, Serialize}; - -use self::{ - binary::{Base64String, Base64Url}, - constants::{Format, Null}, - enums::{GroupType, KdfVersion, MailFolderType}, -}; - pub(crate) mod binary; pub(crate) mod constants; pub(crate) mod enums; +pub(crate) mod messages; #[cfg(test)] mod testing; - -pub(crate) trait Entity { - fn id(&self) -> &str; -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct SaltServiceRequest { - #[serde(rename = "_format")] - pub(crate) format: Format<0>, - - pub(crate) mail_address: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct SaltServiceResponse { - #[serde(rename = "_format")] - pub(crate) _format: Format<0>, - - pub(crate) kdf_version: KdfVersion, - - pub(crate) salt: Base64String, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct SessionServiceRequest { - #[serde(rename = "_format")] - pub(crate) format: Format<0>, - - pub(crate) access_key: Null, - - pub(crate) auth_token: Null, - - pub(crate) auth_verifier: Base64Url, - - pub(crate) client_identifier: String, - - pub(crate) mail_address: String, - - pub(crate) recover_code_verifier: Null, - - pub(crate) user: Null, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct SessionServiceResponse { - #[serde(rename = "_format")] - pub(crate) _format: Format<0>, - - pub(crate) access_token: Base64Url, - - pub(crate) challenges: Vec, - - pub(crate) user: String, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct UserMembership { - pub(crate) group_type: GroupType, - pub(crate) group: String, - pub(crate) sym_enc_g_key: Base64String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct UserAuth { - pub(crate) sessions: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct UserResponse { - #[serde(rename = "_format")] - pub(crate) _format: Format<0>, - - pub(crate) memberships: Vec, - pub(crate) auth: UserAuth, - pub(crate) user_group: UserMembership, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct MailboxGroupRootResponse { - #[serde(rename = "_format")] - pub(crate) _format: Format<0>, - - pub(crate) mailbox: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct Folders { - pub(crate) folders: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct MailboxResponse { - #[serde(rename = "_format")] - pub(crate) _format: Format<0>, - - pub(crate) folders: Folders, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct FolderResponse { - #[serde(rename = "_format")] - pub(crate) _format: Format<0>, - - #[serde(rename = "_id")] - pub(crate) id: [String; 2], - - #[serde(rename = "_ownerEncSessionKey")] - pub(crate) owner_enc_session_key: Base64String, - - #[serde(rename = "_ownerGroup")] - pub(crate) owner_group: String, - - pub(crate) folder_type: MailFolderType, - pub(crate) name: Base64String, - pub(crate) mails: String, -} - -impl Entity for FolderResponse { - fn id(&self) -> &str { - &self.id[1] - } -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct MailReponse { - #[serde(rename = "_format")] - pub(crate) _format: Format<0>, - - #[serde(rename = "_ownerEncSessionKey")] - pub(crate) owner_enc_session_key: Base64String, - - #[serde(rename = "_ownerGroup")] - pub(crate) owner_group: String, - - #[serde(rename = "_id")] - pub(crate) id: [String; 2], - - pub(crate) mail_details: [String; 2], -} - -impl Entity for MailReponse { - fn id(&self) -> &str { - &self.id[1] - } -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct BlobReadRequest { - #[serde(rename = "_id")] - pub(crate) id: String, - - pub(crate) archive_id: String, - pub(crate) instance_ids: Vec<()>, - pub(crate) instance_list_id: Null, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct BlobAccessTokenServiceRequest { - #[serde(rename = "_format")] - pub(crate) format: Format<0>, - - pub(crate) archive_data_type: Null, - pub(crate) read: BlobReadRequest, - pub(crate) write: Null, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct BlobServer { - pub(crate) url: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct BlobAccessInfo { - pub(crate) blob_access_token: String, - pub(crate) servers: Vec, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct BlobAccessTokenServiceResponse { - #[serde(rename = "_format")] - pub(crate) _format: Format<0>, - - pub(crate) blob_access_info: BlobAccessInfo, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct MailBody { - pub(crate) compressed_text: Base64String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct MailDetails { - pub(crate) body: MailBody, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct MailDetailsBlob { - #[serde(rename = "_format")] - pub(crate) _format: Format<0>, - - pub(crate) details: MailDetails, -} diff --git a/src/session.rs b/src/session.rs index 6d81eac..8cdc5e9 100644 --- a/src/session.rs +++ b/src/session.rs @@ -15,8 +15,11 @@ use crate::{ }, non_empty_string::NonEmptyString, proto::{ - binary::Base64Url, SaltServiceRequest, SaltServiceResponse, SessionServiceRequest, - SessionServiceResponse, UserResponse, + binary::Base64Url, + messages::{ + SaltServiceRequest, SaltServiceResponse, SessionServiceRequest, SessionServiceResponse, + UserResponse, + }, }, }; From cc9904786ced6b2c13d5c142a98816c1e710ec36 Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Sun, 18 Feb 2024 13:59:42 +0100 Subject: [PATCH 37/44] rework key handling --- src/crypto/auth.rs | 20 ++-- src/crypto/encryption.rs | 236 +++++++++++++++++++-------------------- src/folders.rs | 4 +- src/mails.rs | 11 +- src/proto/binary.rs | 9 ++ src/proto/keys.rs | 130 +++++++++++++++++++++ src/proto/messages.rs | 7 +- src/proto/mod.rs | 1 + src/session.rs | 33 ++++-- 9 files changed, 300 insertions(+), 151 deletions(-) create mode 100644 src/proto/keys.rs diff --git a/src/crypto/auth.rs b/src/crypto/auth.rs index 9aaba67..88bc9a3 100644 --- a/src/crypto/auth.rs +++ b/src/crypto/auth.rs @@ -3,22 +3,22 @@ use std::ops::Deref; use anyhow::{bail, Context, Result}; use sha2::{Digest, Sha256}; -use crate::proto::{binary::Base64Url, enums::KdfVersion}; +use crate::proto::{binary::Base64Url, enums::KdfVersion, keys::Key}; -#[derive(Debug)] -pub(crate) struct UserPassphraseKey(Box<[u8]>); +#[derive(Debug, Clone, Copy)] +pub(crate) struct UserPassphraseKey(Key); -impl AsRef<[u8]> for UserPassphraseKey { - fn as_ref(&self) -> &[u8] { +impl AsRef for UserPassphraseKey { + fn as_ref(&self) -> &Key { &self.0 } } impl Deref for UserPassphraseKey { - type Target = [u8]; + type Target = Key; fn deref(&self) -> &Self::Target { - self.0.deref() + &self.0 } } @@ -37,7 +37,9 @@ pub(crate) fn derive_passkey( let hashed = bcrypt::bcrypt(8, salt, &passphrase); - Ok(UserPassphraseKey(hashed[..16].to_owned().into())) + Ok(UserPassphraseKey(Key::Aes128( + hashed[..16].try_into().expect("checked length"), + ))) } KdfVersion::Argon2id => bail!("not implemented: Argon2id"), } @@ -45,7 +47,7 @@ pub(crate) fn derive_passkey( pub(crate) fn encode_auth_verifier(passkey: &UserPassphraseKey) -> Base64Url { let mut hasher = Sha256::new(); - hasher.update(&passkey.0); + hasher.update(passkey.0); let hashed = hasher.finalize().to_vec(); Base64Url::from(hashed) diff --git a/src/crypto/encryption.rs b/src/crypto/encryption.rs index 9984052..95a52f4 100644 --- a/src/crypto/encryption.rs +++ b/src/crypto/encryption.rs @@ -1,3 +1,5 @@ +use std::ops::Deref; + use anyhow::{anyhow, bail, Context, Result}; use cbc::cipher::{ block_padding::{NoPadding, Pkcs7}, @@ -6,138 +8,116 @@ use cbc::cipher::{ use hmac::{Hmac, Mac}; use sha2::{Digest, Sha256, Sha512}; +use crate::proto::keys::{EncryptedKey, Key}; + type Aes128CbcDec = cbc::Decryptor; type Aes256CbcDec = cbc::Decryptor; type HmacSha256 = Hmac; -pub(crate) fn decrypt_key(encryption_key: &[u8], key_to_be_decrypted: &[u8]) -> Result> { - if let Ok(k) = TryInto::<[u8; 16]>::try_into(encryption_key) { - let iv: [u8; 16] = [128u8 + 8; 16]; - Aes128CbcDec::new(&k.into(), &iv.into()) - .decrypt_padded_vec_mut::(key_to_be_decrypted) - .map_err(|e| anyhow!("{e}")) - .context("AES decryption") - } else if let Ok(_k) = TryInto::<[u8; 32]>::try_into(encryption_key) { - bail!("not implemented: AES256") - } else { - bail!("invalid encryption key length: {}", encryption_key.len()) - } -} +pub(crate) fn decrypt_key(encryption_key: Key, key_to_be_decrypted: EncryptedKey) -> Result { + match encryption_key { + Key::Aes128(k) => { + let iv: [u8; 16] = [128u8 + 8; 16]; + let decrypted = Aes128CbcDec::new(&k.into(), &iv.into()) + .decrypt_padded_vec_mut::(key_to_be_decrypted.deref().deref()) + .map_err(|e| anyhow!("{e}")) + .context("AES decryption")?; -pub(crate) fn decrypt_value(encryption_key: &[u8], value: &[u8]) -> Result> { - if let Ok(k) = TryInto::<[u8; 16]>::try_into(encryption_key) { - let (k, value) = if value.len() % 2 == 1 { - // use mac - const MAC_LEN: usize = 32; - if value.len() < MAC_LEN + 1 { - bail!("mac missing") + match key_to_be_decrypted.deref() { + Key::Aes128(_) => Ok(Key::Aes128(decrypted.try_into().expect("checked length"))), + Key::Aes256(_) => Ok(Key::Aes256(decrypted.try_into().expect("checked length"))), } - let payload = &value[1..(value.len() - MAC_LEN)]; - let mac = &value[value.len() - MAC_LEN..]; - let subkeys = Aes128Subkeys::from(k); - - // check mac - let mut m = HmacSha256::new_from_slice(&subkeys.mac_key).expect("checked length"); - m.update(payload); - m.verify_slice(mac) - .map_err(|e| anyhow!("{e}")) - .context("HMAC verification")?; - - (subkeys.encryption_key, payload) - } else { - // technically this is - // (k, value) - // - // however we haven't seen this used yet so we just bail out for now - bail!("not implemented: value w/o MAC") - }; - - // get IV - const IV_LEN: usize = 16; - if value.len() < IV_LEN { - bail!("IV missing") } - let iv: [u8; IV_LEN] = value[..IV_LEN].try_into().expect("checked length"); - let value = &value[IV_LEN..]; - Aes128CbcDec::new(&k.into(), &iv.into()) - .decrypt_padded_vec_mut::(value) - .map_err(|e| anyhow!("{e}")) - .context("AES decryption") - } else if let Ok(k) = TryInto::<[u8; 32]>::try_into(encryption_key) { - let (k, value) = if value.len() % 2 == 1 { - // use mac - const MAC_LEN: usize = 32; - if value.len() < MAC_LEN + 1 { - bail!("mac missing") - } - let payload = &value[1..(value.len() - MAC_LEN)]; - let mac = &value[value.len() - MAC_LEN..]; - let subkeys = Aes256Subkeys::from(k); - - // check mac - let mut m = HmacSha256::new_from_slice(&subkeys.mac_key).expect("checked length"); - m.update(payload); - m.verify_slice(mac) - .map_err(|e| anyhow!("{e}")) - .context("HMAC verification")?; - - (subkeys.encryption_key, payload) - } else { - // technically this is - // (k, value) - // - // however we haven't seen this used yet so we just bail out for now - bail!("not implemented: value w/o MAC") - }; - - // get IV - const IV_LEN: usize = 16; - if value.len() < IV_LEN { - bail!("IV missing") + Key::Aes256(_) => { + bail!("not implemented: AES256") } - let iv: [u8; IV_LEN] = value[..IV_LEN].try_into().expect("checked length"); - let value = &value[IV_LEN..]; - Aes256CbcDec::new(&k.into(), &iv.into()) - .decrypt_padded_vec_mut::(value) - .map_err(|e| anyhow!("{e}")) - .context("AES decryption") - } else { - bail!("invalid encryption key length: {}", encryption_key.len()) } } -struct Aes128Subkeys { - encryption_key: [u8; 16], - mac_key: [u8; 16], -} - -impl From<[u8; 16]> for Aes128Subkeys { - fn from(k: [u8; 16]) -> Self { - let mut hasher = Sha256::new(); - hasher.update(k); - let hashed = hasher.finalize().to_vec(); +pub(crate) fn decrypt_value(encryption_key: Key, value: &[u8]) -> Result> { + let (encryption_key, value) = if value.len() % 2 == 1 { + // use mac + const MAC_LEN: usize = 32; + if value.len() < MAC_LEN + 1 { + bail!("mac missing") + } + let payload = &value[1..(value.len() - MAC_LEN)]; + let mac = &value[value.len() - MAC_LEN..]; + let subkeys = Subkeys::from(encryption_key); + + // check mac + let mut m = HmacSha256::new_from_slice(&subkeys.mac_key).expect("checked length"); + m.update(payload); + m.verify_slice(mac) + .map_err(|e| anyhow!("{e}")) + .context("HMAC verification")?; - Self { - encryption_key: hashed[..16].try_into().expect("check length"), - mac_key: hashed[16..].try_into().expect("check length"), + (subkeys.encryption_key, payload) + } else { + // technically this is + // (k, value) + // + // however we haven't seen this used yet so we just bail out for now + bail!("not implemented: value w/o MAC") + }; + + match encryption_key { + Key::Aes128(k) => { + // get IV + const IV_LEN: usize = 16; + if value.len() < IV_LEN { + bail!("IV missing") + } + let iv: [u8; IV_LEN] = value[..IV_LEN].try_into().expect("checked length"); + let value = &value[IV_LEN..]; + Aes128CbcDec::new(&k.into(), &iv.into()) + .decrypt_padded_vec_mut::(value) + .map_err(|e| anyhow!("{e}")) + .context("AES decryption") + } + Key::Aes256(k) => { + const IV_LEN: usize = 16; + if value.len() < IV_LEN { + bail!("IV missing") + } + let iv: [u8; IV_LEN] = value[..IV_LEN].try_into().expect("checked length"); + let value = &value[IV_LEN..]; + Aes256CbcDec::new(&k.into(), &iv.into()) + .decrypt_padded_vec_mut::(value) + .map_err(|e| anyhow!("{e}")) + .context("AES decryption") } } } -struct Aes256Subkeys { - encryption_key: [u8; 32], - mac_key: [u8; 32], +struct Subkeys { + encryption_key: Key, + mac_key: Key, } -impl From<[u8; 32]> for Aes256Subkeys { - fn from(k: [u8; 32]) -> Self { - let mut hasher = Sha512::new(); - hasher.update(k); - let hashed = hasher.finalize().to_vec(); - - Self { - encryption_key: hashed[..32].try_into().expect("check length"), - mac_key: hashed[32..].try_into().expect("check length"), +impl From for Subkeys { + fn from(k: Key) -> Self { + match k { + Key::Aes128(k) => { + let mut hasher = Sha256::new(); + hasher.update(k); + let hashed = hasher.finalize().to_vec(); + + Self { + encryption_key: Key::Aes128(hashed[..16].try_into().expect("check length")), + mac_key: Key::Aes128(hashed[16..].try_into().expect("check length")), + } + } + Key::Aes256(k) => { + let mut hasher = Sha512::new(); + hasher.update(k); + let hashed = hasher.finalize().to_vec(); + + Self { + encryption_key: Key::Aes256(hashed[..32].try_into().expect("check length")), + mac_key: Key::Aes256(hashed[32..].try_into().expect("check length")), + } + } } } } @@ -150,20 +130,34 @@ mod tests { fn test_decrypt_key() { assert_eq!( decrypt_key( - &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], - &[10u8, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160] + Key::Aes128([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]), + EncryptedKey(Key::Aes128([ + 10u8, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160 + ])), + ) + .unwrap(), + Key::Aes128([177u8, 11, 11, 117, 32, 75, 2, 15, 107, 230, 248, 94, 26, 11, 143, 0]), + ); + + assert_eq!( + decrypt_key( + Key::Aes128([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]), + EncryptedKey(Key::Aes256([42; 32])), ) .unwrap(), - vec![177u8, 11, 11, 117, 32, 75, 2, 15, 107, 230, 248, 94, 26, 11, 143, 0], + Key::Aes256([ + 167, 228, 240, 83, 0, 221, 168, 213, 118, 210, 12, 226, 248, 24, 227, 195, 5, 70, + 82, 241, 162, 127, 10, 119, 212, 112, 174, 64, 90, 186, 65, 97 + ]), ); } #[test] fn test_decrypt_value() { - let k = [ + let k = Key::Aes256([ 163, 52, 230, 134, 76, 199, 13, 61, 124, 69, 58, 80, 3, 1, 198, 219, 215, 51, 42, 8, 59, 76, 55, 188, 101, 165, 209, 167, 111, 205, 128, 60, - ]; + ]); let v = [ 1, 1, 221, 88, 186, 70, 178, 125, 28, 66, 245, 102, 7, 214, 121, 162, 88, 138, 118, @@ -172,13 +166,13 @@ mod tests { 146, 134, 10, 134, 81, 50, 252, 212, ]; - assert_eq!(decrypt_value(&k, &v,).unwrap(), b"fooooo".to_owned(),); + assert_eq!(decrypt_value(k, &v,).unwrap(), b"fooooo".to_owned(),); let mut v_broken = v; v_broken[1] = 0; assert_eq!( - decrypt_value(&k, &v_broken).unwrap_err().to_string(), + decrypt_value(k, &v_broken).unwrap_err().to_string(), "HMAC verification", ); } diff --git a/src/folders.rs b/src/folders.rs index cbc8805..779f0f7 100644 --- a/src/folders.rs +++ b/src/folders.rs @@ -76,13 +76,13 @@ impl Folder { group_keys .get(&resp.owner_group) .context("getting owner group key")?, - resp.owner_enc_session_key.as_ref(), + resp.owner_enc_session_key, ) .context("decrypting session key")?; let name = if resp.folder_type == MailFolderType::Custom { String::from_utf8( - decrypt_value(&session_key, resp.name.as_ref()).context("decrypt folder name")?, + decrypt_value(session_key, resp.name.as_ref()).context("decrypt folder name")?, ) .context("invalid UTF8 string")? } else { diff --git a/src/mails.rs b/src/mails.rs index 3c93c1e..89df452 100644 --- a/src/mails.rs +++ b/src/mails.rs @@ -9,7 +9,10 @@ use crate::{ compression::decompress_value, crypto::encryption::{decrypt_key, decrypt_value}, folders::Folder, - proto::messages::{MailDetailsBlob, MailReponse}, + proto::{ + keys::Key, + messages::{MailDetailsBlob, MailReponse}, + }, session::{GroupKeys, Session}, }; @@ -20,7 +23,7 @@ pub(crate) struct Mail { pub(crate) mail_id: String, pub(crate) archive_id: String, pub(crate) blob_id: String, - pub(crate) session_key: Vec, + pub(crate) session_key: Key, } impl Mail { @@ -46,7 +49,7 @@ impl Mail { group_keys .get(&resp.owner_group) .context("getting owner group key")?, - resp.owner_enc_session_key.as_ref(), + resp.owner_enc_session_key, ) .context("decrypting session key")?; @@ -71,7 +74,7 @@ impl Mail { .context("download mail details")?; let body = decrypt_value( - &self.session_key, + self.session_key, mail_details.details.body.compressed_text.as_ref(), ) .context("decrypt body")?; diff --git a/src/proto/binary.rs b/src/proto/binary.rs index a695e06..5605d3f 100644 --- a/src/proto/binary.rs +++ b/src/proto/binary.rs @@ -1,6 +1,7 @@ use anyhow::{bail, Result}; use base64::prelude::*; use serde::{de::Error, Deserializer, Serializer}; +use std::ops::Deref; #[derive(Clone, PartialEq, Eq, Hash)] pub(crate) struct Base64String(Box<[u8]>); @@ -58,6 +59,14 @@ impl AsRef<[u8]> for Base64String { } } +impl Deref for Base64String { + type Target = [u8]; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + impl serde::Serialize for Base64String { fn serialize(&self, serializer: S) -> Result where diff --git a/src/proto/keys.rs b/src/proto/keys.rs new file mode 100644 index 0000000..74fe808 --- /dev/null +++ b/src/proto/keys.rs @@ -0,0 +1,130 @@ +use serde::{de::Error, Deserializer, Serializer}; +use std::ops::Deref; + +use super::binary::Base64String; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Key { + Aes128([u8; 16]), + Aes256([u8; 32]), +} + +impl AsRef<[u8]> for Key { + fn as_ref(&self) -> &[u8] { + match self { + Self::Aes128(k) => k, + Self::Aes256(k) => k, + } + } +} + +impl Deref for Key { + type Target = [u8]; + + fn deref(&self) -> &Self::Target { + match self { + Self::Aes128(k) => k, + Self::Aes256(k) => k, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct EncryptedKey(pub(crate) Key); + +impl Deref for EncryptedKey { + type Target = Key; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl serde::Serialize for EncryptedKey { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + Base64String::from(self.deref().deref()).serialize(serializer) + } +} + +impl<'de> serde::Deserialize<'de> for EncryptedKey { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let k = OptionalEncryptedKey::deserialize(deserializer)?; + + match k.0 { + None => Err(D::Error::custom("key must not be empty")), + Some(k) => Ok(k), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct OptionalEncryptedKey(pub(crate) Option); + +impl serde::Serialize for OptionalEncryptedKey { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self.0 { + None => Base64String::from(&[]).serialize(serializer), + Some(k) => Base64String::from(k.0.deref()).serialize(serializer), + } + } +} + +impl<'de> serde::Deserialize<'de> for OptionalEncryptedKey { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = Base64String::deserialize(deserializer)?; + + if s.deref().is_empty() { + Ok(Self(None)) + } else if let Ok(k) = TryInto::<[u8; 16]>::try_into(s.deref()) { + Ok(Self(Some(EncryptedKey(Key::Aes128(k))))) + } else if let Ok(k) = TryInto::<[u8; 32]>::try_into(s.deref()) { + Ok(Self(Some(EncryptedKey(Key::Aes256(k))))) + } else { + Err(D::Error::custom(format!( + "invalid key length: {}", + s.deref().len() + ))) + } + } +} + +#[cfg(test)] +mod tests { + use crate::proto::testing::{assert_deser_error, assert_roundtrip}; + + use super::*; + + #[test] + fn test_roundtrip_encrypted_key() { + assert_roundtrip(EncryptedKey(Key::Aes128([42; 16]))); + assert_roundtrip(EncryptedKey(Key::Aes256([42; 32]))); + + assert_deser_error::(r#""""#, "key must not be empty"); + assert_deser_error::(r#""eAo=""#, "invalid key length: 2"); + } + + #[test] + fn test_roundtrip_optional_encrypted_key() { + assert_roundtrip(OptionalEncryptedKey(Some(EncryptedKey(Key::Aes128( + [42; 16], + ))))); + assert_roundtrip(OptionalEncryptedKey(Some(EncryptedKey(Key::Aes256( + [42; 32], + ))))); + assert_roundtrip(OptionalEncryptedKey(None)); + + assert_deser_error::(r#""eAo=""#, "invalid key length: 2"); + } +} diff --git a/src/proto/messages.rs b/src/proto/messages.rs index f676370..6740bee 100644 --- a/src/proto/messages.rs +++ b/src/proto/messages.rs @@ -6,6 +6,7 @@ use super::{ binary::{Base64String, Base64Url}, constants::Null, enums::{GroupType, KdfVersion, MailFolderType}, + keys::{EncryptedKey, OptionalEncryptedKey}, }; pub(crate) trait Entity { @@ -71,7 +72,7 @@ pub(crate) struct SessionServiceResponse { pub(crate) struct UserMembership { pub(crate) group_type: GroupType, pub(crate) group: String, - pub(crate) sym_enc_g_key: Base64String, + pub(crate) sym_enc_g_key: OptionalEncryptedKey, } #[derive(Debug, Deserialize)] @@ -125,7 +126,7 @@ pub(crate) struct FolderResponse { pub(crate) id: [String; 2], #[serde(rename = "_ownerEncSessionKey")] - pub(crate) owner_enc_session_key: Base64String, + pub(crate) owner_enc_session_key: EncryptedKey, #[serde(rename = "_ownerGroup")] pub(crate) owner_group: String, @@ -148,7 +149,7 @@ pub(crate) struct MailReponse { pub(crate) _format: Format<0>, #[serde(rename = "_ownerEncSessionKey")] - pub(crate) owner_enc_session_key: Base64String, + pub(crate) owner_enc_session_key: EncryptedKey, #[serde(rename = "_ownerGroup")] pub(crate) owner_group: String, diff --git a/src/proto/mod.rs b/src/proto/mod.rs index 840d6d5..f906857 100644 --- a/src/proto/mod.rs +++ b/src/proto/mod.rs @@ -1,6 +1,7 @@ pub(crate) mod binary; pub(crate) mod constants; pub(crate) mod enums; +pub(crate) mod keys; pub(crate) mod messages; #[cfg(test)] diff --git a/src/session.rs b/src/session.rs index 8cdc5e9..f643da2 100644 --- a/src/session.rs +++ b/src/session.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, sync::Arc}; +use std::{collections::HashMap, ops::Deref, sync::Arc}; use anyhow::{bail, Context, Result}; use clap::Parser; @@ -16,6 +16,7 @@ use crate::{ non_empty_string::NonEmptyString, proto::{ binary::Base64Url, + keys::Key, messages::{ SaltServiceRequest, SaltServiceResponse, SessionServiceRequest, SessionServiceResponse, UserResponse, @@ -134,29 +135,37 @@ impl Session { #[derive(Debug)] pub(crate) struct GroupKeys { - keys: HashMap>, + keys: HashMap, } impl GroupKeys { fn try_new(pk: &UserPassphraseKey, user_data: &UserResponse) -> Result { - let user_key = decrypt_key(pk, user_data.user_group.sym_enc_g_key.as_ref()) - .context("decrypt user group key")?; + let user_key = decrypt_key( + *pk.deref(), + user_data + .user_group + .sym_enc_g_key + .0 + .context("user key must be set")?, + ) + .context("decrypt user group key")?; let mut group_keys = HashMap::default(); - group_keys.insert(user_data.user_group.group.clone(), user_key.clone()); + group_keys.insert(user_data.user_group.group.clone(), user_key); for group in &user_data.memberships { - group_keys.insert( - group.group.clone(), - decrypt_key(&user_key, group.sym_enc_g_key.as_ref()) - .context("decrypt membership group key")?, - ); + if let Some(enc_g_key) = group.sym_enc_g_key.0 { + group_keys.insert( + group.group.clone(), + decrypt_key(user_key, enc_g_key).context("decrypt membership group key")?, + ); + } } Ok(Self { keys: group_keys }) } - pub(crate) fn get(&self, group: &str) -> Result<&[u8]> { + pub(crate) fn get(&self, group: &str) -> Result { let key = self.keys.get(group).context("group key not found")?; - Ok(key) + Ok(*key) } } From 43e1bc7e1991ecb2590a9f84cc8280fd53923cdf Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Sun, 18 Feb 2024 15:08:22 +0100 Subject: [PATCH 38/44] progress on download --- Cargo.lock | 71 +++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + src/file_output.rs | 40 ++++++++++++++++++++++++ src/mails.rs | 30 +++++++++++++++--- src/main.rs | 9 +++++- src/proto/date.rs | 49 +++++++++++++++++++++++++++++ src/proto/messages.rs | 15 +++++++++ src/proto/mod.rs | 1 + 8 files changed, 211 insertions(+), 5 deletions(-) create mode 100644 src/file_output.rs create mode 100644 src/proto/date.rs diff --git a/Cargo.lock b/Cargo.lock index 1ec052a..794e97f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,21 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.11" @@ -294,6 +309,20 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bc015644b92d5890fab7489e49d21f879d5c990186827d42ec511919404f38b" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-targets 0.52.0", +] + [[package]] name = "cipher" version = "0.4.4" @@ -744,6 +773,29 @@ dependencies = [ "tokio-rustls", ] +[[package]] +name = "iana-time-zone" +version = "0.1.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "idna" version = "0.4.0" @@ -939,6 +991,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-traits" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a" +dependencies = [ + "autocfg", +] + [[package]] name = "num_cpus" version = "1.16.0" @@ -1444,6 +1505,7 @@ dependencies = [ "base64", "bcrypt", "cbc", + "chrono", "clap", "dotenvy", "futures", @@ -1899,6 +1961,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.0", +] + [[package]] name = "windows-sys" version = "0.48.0" diff --git a/Cargo.toml b/Cargo.toml index fe32042..c3f7470 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ anyhow = "1.0.75" base64 = "0.21.7" bcrypt = "0.15.0" cbc = { version = "0.1.2", features = ["alloc"] } +chrono = "0.4.34" clap = { version = "4.4.6", features = ["derive", "env"] } dotenvy = "0.15.7" futures = "0.3.28" diff --git a/src/file_output.rs b/src/file_output.rs new file mode 100644 index 0000000..d7c7936 --- /dev/null +++ b/src/file_output.rs @@ -0,0 +1,40 @@ +use std::path::Path; + +use anyhow::{Context, Result}; +use tokio::{fs::OpenOptions, io::AsyncWriteExt}; + +pub(crate) async fn write_to_file(content: &[u8], path: &Path) -> Result<()> { + let tmp_path = path.with_extension(".part"); + let mut f = OpenOptions::new() + .write(true) + .truncate(true) + .create(true) + .open(&tmp_path) + .await + .context("open temp file")?; + + f.write_all(content).await.context("write to temp file")?; + f.shutdown().await.context("close temp file")?; + + tokio::fs::rename(tmp_path, path).await.context("rename")?; + + Ok(()) +} + +pub(crate) fn escape_file_string(s: &str) -> String { + s.chars() + .filter(|c| matches!(c, 'a'..='z' | 'A'..='Z' | '0'..='9' | ' ')) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_escape_file_string() { + assert_eq!(escape_file_string(""), ""); + assert_eq!(escape_file_string("azaZ09 "), "azaZ09 "); + assert_eq!(escape_file_string("fOo1!@/\\bar19"), "fOo1bar19"); + } +} diff --git a/src/mails.rs b/src/mails.rs index 89df452..e2d8f7e 100644 --- a/src/mails.rs +++ b/src/mails.rs @@ -1,6 +1,7 @@ use std::{path::Path, sync::Arc}; use anyhow::{Context, Result}; +use chrono::NaiveDateTime; use futures::{Stream, TryStreamExt}; use crate::{ @@ -8,6 +9,7 @@ use crate::{ client::Client, compression::decompress_value, crypto::encryption::{decrypt_key, decrypt_value}, + file_output::write_to_file, folders::Folder, proto::{ keys::Key, @@ -24,6 +26,8 @@ pub(crate) struct Mail { pub(crate) archive_id: String, pub(crate) blob_id: String, pub(crate) session_key: Key, + pub(crate) date: NaiveDateTime, + pub(crate) subject: String, } impl Mail { @@ -53,12 +57,17 @@ impl Mail { ) .context("decrypting session key")?; + let subject = decrypt_value(session_key, &resp.subject).context("decrypt subject")?; + let subject = String::from_utf8(subject).context("decode string")?; + Ok(Self { folder_id: resp.id[0].clone(), mail_id: resp.id[1].clone(), archive_id: resp.mail_details[0].clone(), blob_id: resp.mail_details[1].clone(), session_key, + date: resp.received_date.0, + subject, }) } @@ -66,21 +75,34 @@ impl Mail { &self, client: &Client, session: &Session, - _target_file: &Path, + target_file: &Path, ) -> Result<()> { let mail_details: MailDetailsBlob = get_blob(client, session, &self.archive_id, &self.blob_id) .await .context("download mail details")?; + let mut out = Vec::new(); + + if let Some(headers) = mail_details.details.headers { + let headers = decrypt_value(self.session_key, headers.compressed_headers.as_ref()) + .context("decrypt headers")?; + let mut headers = decompress_value(&headers).context("decompress headers")?; + + out.append(&mut headers); + } + let body = decrypt_value( self.session_key, mail_details.details.body.compressed_text.as_ref(), ) .context("decrypt body")?; - let body = decompress_value(&body).context("decompress body")?; - let body = String::from_utf8(body).context("decode body")?; - println!("{}", body); + let mut body = decompress_value(&body).context("decompress body")?; + out.append(&mut body); + + write_to_file(&out, target_file) + .await + .context("write to output file")?; Ok(()) } diff --git a/src/main.rs b/src/main.rs index 8cc58e6..ccbb62a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; use crate::{ client::Client, + file_output::escape_file_string, mails::Mail, session::{LoginCLIConfig, Session}, }; @@ -23,6 +24,7 @@ mod client; mod compression; mod constants; mod crypto; +mod file_output; mod folders; mod logging; mod mails; @@ -125,7 +127,12 @@ async fn exec_cmd(client: &Client, session: &Session, cmd: Command) -> Result<() let mails = Mail::list(client, session, &folder); let mut mails = std::pin::pin!(mails); while let Some(mail) = mails.try_next().await.context("list mails")? { - let target_file = cfg.path.join(&mail.mail_id).with_extension(".eml"); + let target_file = cfg.path.join(format!( + "{}-{}.eml", + mail.date.format("%Y-%m-%d-%Hh%Mm%Ss"), + escape_file_string(&mail.subject), + )); + if tokio::fs::try_exists(&target_file) .await .context("check file existence")? diff --git a/src/proto/date.rs b/src/proto/date.rs new file mode 100644 index 0000000..d34adb9 --- /dev/null +++ b/src/proto/date.rs @@ -0,0 +1,49 @@ +use chrono::NaiveDateTime; +use serde::{de::Error, Deserializer, Serializer}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct UnixDate(pub(crate) NaiveDateTime); + +impl serde::Serialize for UnixDate { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.0.timestamp_millis().to_string()) + } +} + +impl<'de> serde::Deserialize<'de> for UnixDate { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let millis = String::deserialize(deserializer)?; + let millis = millis + .parse() + .map_err(|e| D::Error::custom(format!("invalid time: {e}")))?; + match NaiveDateTime::from_timestamp_millis(millis) { + Some(dt) => Ok(Self(dt)), + None => Err(D::Error::custom(format!("invalid time: {millis}"))), + } + } +} + +#[cfg(test)] +mod tests { + use crate::proto::testing::{assert_deser_error, assert_roundtrip}; + + use super::*; + + #[test] + fn test_unix_date_roundtrip() { + assert_roundtrip(UnixDate( + NaiveDateTime::from_timestamp_millis(1337).unwrap(), + )); + + assert_deser_error::( + &format!(r#""{}""#, i64::MIN), + "invalid time: -9223372036854775808", + ); + } +} diff --git a/src/proto/messages.rs b/src/proto/messages.rs index 6740bee..f0a33de 100644 --- a/src/proto/messages.rs +++ b/src/proto/messages.rs @@ -5,6 +5,7 @@ use crate::proto::constants::Format; use super::{ binary::{Base64String, Base64Url}, constants::Null, + date::UnixDate, enums::{GroupType, KdfVersion, MailFolderType}, keys::{EncryptedKey, OptionalEncryptedKey}, }; @@ -158,6 +159,9 @@ pub(crate) struct MailReponse { pub(crate) id: [String; 2], pub(crate) mail_details: [String; 2], + + pub(crate) received_date: UnixDate, + pub(crate) subject: Base64String, } impl Entity for MailReponse { @@ -216,10 +220,21 @@ pub(crate) struct MailBody { pub(crate) compressed_text: Base64String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MailHeaders { + pub(crate) compressed_headers: Base64String, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct MailDetails { pub(crate) body: MailBody, + + /// Mail headers. + /// + /// These only appear for true emails, not for internal messages. + pub(crate) headers: Option, } #[derive(Debug, Deserialize)] diff --git a/src/proto/mod.rs b/src/proto/mod.rs index f906857..f7a84c9 100644 --- a/src/proto/mod.rs +++ b/src/proto/mod.rs @@ -1,5 +1,6 @@ pub(crate) mod binary; pub(crate) mod constants; +pub(crate) mod date; pub(crate) mod enums; pub(crate) mod keys; pub(crate) mod messages; From a6679d45a4c30f23bc5e11fa47d8a00d13c6fdb6 Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Sun, 18 Feb 2024 18:16:41 +0100 Subject: [PATCH 39/44] progress on download --- Cargo.lock | 17 +++++ Cargo.toml | 2 + src/mails.rs | 149 ++++++++++++++++++++++++++++++++++++++++-- src/proto/messages.rs | 9 +++ 4 files changed, 171 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 794e97f..7409cd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -470,6 +470,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "either" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11157ac094ffbdde99aa67b23417ebdd801842852b500e395a45a9c0aac03e4a" + [[package]] name = "encode_unicode" version = "0.3.6" @@ -867,6 +873,15 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.10" @@ -1511,7 +1526,9 @@ dependencies = [ "futures", "hmac", "insta", + "itertools", "lz4_flex", + "regex", "reqwest", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index c3f7470..83a9933 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,9 @@ clap = { version = "4.4.6", features = ["derive", "env"] } dotenvy = "0.15.7" futures = "0.3.28" hmac = "0.12.1" +itertools = "0.12.1" lz4_flex = "0.11.2" +regex = "1.10.3" reqwest = { version = "0.11", default-features = false, features = ["brotli", "deflate", "gzip", "json", "rustls-tls-webpki-roots", "trust-dns"] } serde = { version = "1.0", features = ["derive"] } sha2 = "0.10.8" diff --git a/src/mails.rs b/src/mails.rs index e2d8f7e..017df8b 100644 --- a/src/mails.rs +++ b/src/mails.rs @@ -1,8 +1,12 @@ -use std::{path::Path, sync::Arc}; +use std::{ + path::Path, + sync::{Arc, OnceLock}, +}; use anyhow::{Context, Result}; use chrono::NaiveDateTime; use futures::{Stream, TryStreamExt}; +use itertools::Itertools; use crate::{ blob::get_blob, @@ -12,12 +16,17 @@ use crate::{ file_output::write_to_file, folders::Folder, proto::{ + binary::Base64String, keys::Key, messages::{MailDetailsBlob, MailReponse}, }, session::{GroupKeys, Session}, }; +static LINE_ENDING_RE: OnceLock = OnceLock::new(); +static BOUNDARY_RE: OnceLock = OnceLock::new(); +const NEWLINE: &[u8] = b"\r\n"; + #[derive(Debug)] pub(crate) struct Mail { #[allow(dead_code)] @@ -28,6 +37,9 @@ pub(crate) struct Mail { pub(crate) session_key: Key, pub(crate) date: NaiveDateTime, pub(crate) subject: String, + pub(crate) sender_mail: String, + pub(crate) sender_name: String, + pub(crate) attachments: Vec<[String; 2]>, } impl Mail { @@ -60,6 +72,10 @@ impl Mail { let subject = decrypt_value(session_key, &resp.subject).context("decrypt subject")?; let subject = String::from_utf8(subject).context("decode string")?; + let sender_name = + decrypt_value(session_key, &resp.sender.name).context("decrypt subject")?; + let sender_name = String::from_utf8(sender_name).context("decode sender name")?; + Ok(Self { folder_id: resp.id[0].clone(), mail_id: resp.id[1].clone(), @@ -68,6 +84,9 @@ impl Mail { session_key, date: resp.received_date.0, subject, + sender_mail: resp.sender.address, + sender_name, + attachments: resp.attachments, }) } @@ -82,23 +101,44 @@ impl Mail { .await .context("download mail details")?; + self.emit_eml(mail_details, target_file) + .await + .context("emit EML") + } + + async fn emit_eml(&self, mail_details: MailDetailsBlob, target_file: &Path) -> Result<()> { let mut out = Vec::new(); - if let Some(headers) = mail_details.details.headers { + let boundary = if let Some(headers) = mail_details.details.headers { let headers = decrypt_value(self.session_key, headers.compressed_headers.as_ref()) .context("decrypt headers")?; - let mut headers = decompress_value(&headers).context("decompress headers")?; + let headers = decompress_value(&headers).context("decompress headers")?; + let mut headers = fix_header_line_endings(&headers); + let boundary = get_boundary(&headers).context("get boundary")?; out.append(&mut headers); - } + boundary + } else { + self.synthesize_headers(&mut out) + }; + + write_intermediate_delimiter(&mut out, &boundary); let body = decrypt_value( self.session_key, mail_details.details.body.compressed_text.as_ref(), ) .context("decrypt body")?; - let mut body = decompress_value(&body).context("decompress body")?; - out.append(&mut body); + let body = decompress_value(&body).context("decompress body")?; + let body = Base64String::from(body); + out.extend(b"Content-Type: text/html; charset=UTF-8"); + out.extend(NEWLINE); + out.extend(b"Content-Transfer-Encoding: base64"); + out.extend(NEWLINE); + out.extend(NEWLINE); + write_chunked(&mut out, body.to_string().as_bytes()); + + write_final_delimiter(&mut out, &boundary); write_to_file(&out, target_file) .await @@ -106,4 +146,101 @@ impl Mail { Ok(()) } + + /// Create headers from metadata and return multipart boundary. + fn synthesize_headers(&self, out: &mut Vec) -> Vec { + let boundary = b"----------79Bu5A16qPEYcVIZL@tutanota".to_vec(); + + out.extend(b"From: "); + out.extend(self.sender_name.as_bytes()); + out.extend(b" <"); + out.extend(self.sender_mail.as_bytes()); + out.extend(b">"); + out.extend(NEWLINE); + + out.extend(b"MIME-Version: 1.0"); + out.extend(NEWLINE); + + if self.subject.is_empty() { + out.extend(b"Subject: "); + out.extend(NEWLINE); + } else { + out.extend(b"Subject: =?UTF-8?B?"); + out.extend( + Base64String::from(self.subject.as_bytes()) + .to_string() + .as_bytes(), + ); + out.extend(b"?="); + }; + + out.extend(b"Content-Type: multipart/related; boundary=\""); + out.extend(&boundary); + out.extend(b"\""); + out.extend(NEWLINE); + + boundary + } +} + +fn line_ending_re() -> &'static regex::bytes::Regex { + LINE_ENDING_RE.get_or_init(|| regex::bytes::Regex::new(r#"\r?\n"#).expect("valid regex")) +} + +fn boundary_re() -> &'static regex::bytes::Regex { + BOUNDARY_RE.get_or_init(|| { + regex::bytes::Regex::new(r#"Content-Type: .*boundary="(?[^"]*)""#) + .expect("valid regex") + }) +} + +/// Upstream provides `\n` line endings for headers but we need `\r\n` +#[allow(unstable_name_collisions)] +fn fix_header_line_endings(headers: &[u8]) -> Vec { + line_ending_re() + .split(headers) + .map(|s| s.to_vec()) + .intersperse(b"\r\n".to_vec()) + .concat() +} + +/// Extract boundery from headers +fn get_boundary(headers: &[u8]) -> Result> { + let boundary_re = boundary_re(); + + line_ending_re() + .split(headers) + .find_map(|line| boundary_re.captures(line).and_then(|c| c.name("boundary"))) + .map(|s| s.as_bytes().to_owned()) + .context("boundary not found") +} + +/// See . +fn write_delimiter(out: &mut Vec, boundary: &[u8]) { + out.extend(NEWLINE); + out.extend(NEWLINE); + out.extend(b"--"); + out.extend(boundary); +} + +fn write_intermediate_delimiter(out: &mut Vec, boundary: &[u8]) { + write_delimiter(out, boundary); + out.extend(NEWLINE); +} + +/// See . +fn write_final_delimiter(out: &mut Vec, boundary: &[u8]) { + write_delimiter(out, boundary); + out.extend(b"--"); +} + +fn write_chunked(out: &mut Vec, s: &[u8]) { + let mut first = false; + for chunk in s.chunks(78) { + if !first { + out.extend(NEWLINE); + } + out.extend(chunk); + first = false; + } } diff --git a/src/proto/messages.rs b/src/proto/messages.rs index f0a33de..0c12cab 100644 --- a/src/proto/messages.rs +++ b/src/proto/messages.rs @@ -143,6 +143,13 @@ impl Entity for FolderResponse { } } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MailSender { + pub(crate) address: String, + pub(crate) name: Base64String, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub(crate) struct MailReponse { @@ -162,6 +169,8 @@ pub(crate) struct MailReponse { pub(crate) received_date: UnixDate, pub(crate) subject: Base64String, + pub(crate) sender: MailSender, + pub(crate) attachments: Vec<[String; 2]>, } impl Entity for MailReponse { From 936c94812b2db8888f3adaddd99e8c0026d4b9e1 Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Sun, 31 Mar 2024 15:30:17 +0200 Subject: [PATCH 40/44] clean up --- src/blob.rs | 56 +++++++---- src/client.rs | 23 ++++- src/eml.rs | 109 +++++++++++++++++++++ src/mails.rs | 219 +++++++++++++++--------------------------- src/main.rs | 11 ++- src/proto/messages.rs | 25 +++++ 6 files changed, 277 insertions(+), 166 deletions(-) create mode 100644 src/eml.rs diff --git a/src/blob.rs b/src/blob.rs index f3f3ae8..cd4ceb9 100644 --- a/src/blob.rs +++ b/src/blob.rs @@ -1,24 +1,44 @@ use anyhow::{bail, Context, Result}; use reqwest::Method; -use serde::de::DeserializeOwned; use crate::{ client::Client, proto::messages::{ BlobAccessTokenServiceRequest, BlobAccessTokenServiceResponse, BlobReadRequest, + MailDetailsBlob, }, session::Session, }; -pub(crate) async fn get_blob( +pub(crate) async fn get_mail_blob( client: &Client, session: &Session, archive_id: &str, blob_id: &str, -) -> Result -where - Resp: DeserializeOwned, -{ +) -> Result { + let access = get_access(client, session, archive_id) + .await + .context("get blob access")?; + + let resp: Vec = client + .mail_blob_request( + &access.server_url, + &format!("maildetailsblob/{archive_id}"), + &session.access_token, + &[blob_id], + &access.blob_access_token, + ) + .await + .context("blob download")?; + + if resp.len() != 1 { + bail!("invalid reponse length") + } + + Ok(resp.into_iter().next().expect("checked length")) +} + +async fn get_access(client: &Client, session: &Session, archive_id: &str) -> Result { let req = BlobAccessTokenServiceRequest { format: Default::default(), archive_data_type: Default::default(), @@ -44,20 +64,14 @@ where bail!("no blob servers provided") }; - let resp: Vec = client - .blob_request( - &server.url, - &format!("maildetailsblob/{archive_id}"), - &session.access_token, - &[blob_id], - &resp.blob_access_info.blob_access_token, - ) - .await - .context("blob download")?; - - if resp.len() != 1 { - bail!("invalid reponse length") - } + Ok(BlobAccess { + server_url: server.url.clone(), + blob_access_token: resp.blob_access_info.blob_access_token, + }) +} - Ok(resp.into_iter().next().expect("checked length")) +#[derive(Debug)] +pub(crate) struct BlobAccess { + pub(crate) server_url: String, + pub(crate) blob_access_token: String, } diff --git a/src/client.rs b/src/client.rs index 5c30545..e7bf86d 100644 --- a/src/client.rs +++ b/src/client.rs @@ -100,7 +100,28 @@ impl Client { .await } - pub(crate) async fn blob_request( + pub(crate) async fn file_request( + &self, + access_token: &Base64Url, + group_id: &str, + ids: &[&str], + ) -> Result + where + Resp: DeserializeOwned, + { + self.do_json(Request { + method: Method::GET, + host: DEFAULT_HOST, + prefix: "tutanota", + path: &format!("file/{group_id}"), + data: &(), + access_token: Some(access_token), + query: &[("ids", &ids.join(","))], + }) + .await + } + + pub(crate) async fn mail_blob_request( &self, host: &str, path: &str, diff --git a/src/eml.rs b/src/eml.rs new file mode 100644 index 0000000..b2ec0a6 --- /dev/null +++ b/src/eml.rs @@ -0,0 +1,109 @@ +use std::sync::OnceLock; + +use anyhow::{Context, Result}; +use itertools::Itertools; + +use crate::{ + mails::{DownloadedMail, Mail}, + proto::binary::Base64String, +}; + +static LINE_ENDING_RE: OnceLock = OnceLock::new(); +static BOUNDARY_RE: OnceLock = OnceLock::new(); +const NEWLINE: &str = "\r\n"; + +pub(crate) fn emit_eml(mail: &DownloadedMail) -> Result { + let mut lines = Vec::new(); + + let boundary = if let Some(headers) = &mail.headers { + let mut headers = split_header_lines(&headers); + let boundary = get_boundary(&headers).context("get boundary")?; + + lines.append(&mut headers); + boundary + } else { + let boundary = "----------79Bu5A16qPEYcVIZL@tutanota".to_owned(); + synthesize_headers(&mail.mail, &boundary, &mut lines); + boundary + }; + + write_intermediate_delimiter(&mut lines, &boundary); + + let body = Base64String::from(mail.body.clone()); + lines.push("Content-Type: text/html; charset=UTF-8".to_owned()); + lines.push("Content-Transfer-Encoding: base64".to_owned()); + lines.push("".to_owned()); + write_chunked(&mut lines, &body.to_string()); + + write_final_delimiter(&mut lines, &boundary); + + Ok(lines.join(NEWLINE)) +} + +/// Create headers from metadata and return multipart boundary. +fn synthesize_headers(mail: &Mail, boundary: &str, lines: &mut Vec) { + lines.push(format!("From: {} <{}>", mail.sender_name, mail.sender_mail)); + + lines.push("MIME-Version: 1.0".to_owned()); + + if mail.subject.is_empty() { + lines.push(format!("Subject: ")); + } else { + lines.push(format!( + "Subject: =?UTF-8?B?{}?=", + Base64String::from(mail.subject.as_bytes()) + )); + }; + + lines.push(format!( + "Content-Type: multipart/related; boundary=\"{}\"", + boundary + )); +} + +fn line_ending_re() -> &'static regex::Regex { + LINE_ENDING_RE.get_or_init(|| regex::Regex::new(r#"\r?\n"#).expect("valid regex")) +} + +fn boundary_re() -> &'static regex::Regex { + BOUNDARY_RE.get_or_init(|| { + regex::Regex::new(r#"Content-Type: .*boundary="(?[^"]*)""#).expect("valid regex") + }) +} + +/// Upstream provides `\n` line endings for headers but we need `\r\n` +fn split_header_lines(headers: &str) -> Vec { + line_ending_re() + .split(headers) + .map(|s| s.to_owned()) + .collect() +} + +/// Extract boundary from headers +fn get_boundary(headers: &[String]) -> Result { + let boundary_re = boundary_re(); + + headers + .iter() + .find_map(|line| boundary_re.captures(line).and_then(|c| c.name("boundary"))) + .map(|s| s.as_str().to_owned()) + .context("boundary not found") +} + +/// See . +fn write_intermediate_delimiter(lines: &mut Vec, boundary: &str) { + lines.push("".to_owned()); + lines.push(format!("--{}", boundary)); +} + +/// See . +fn write_final_delimiter(lines: &mut Vec, boundary: &str) { + lines.push("".to_owned()); + lines.push(format!("--{}--", boundary)); +} + +fn write_chunked(lines: &mut Vec, s: &str) { + for chunk in &s.chars().chunks(78) { + lines.push(chunk.collect()); + } +} diff --git a/src/mails.rs b/src/mails.rs index 017df8b..00c6f78 100644 --- a/src/mails.rs +++ b/src/mails.rs @@ -1,24 +1,18 @@ -use std::{ - path::Path, - sync::{Arc, OnceLock}, -}; +use std::sync::{Arc, OnceLock}; -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use chrono::NaiveDateTime; use futures::{Stream, TryStreamExt}; -use itertools::Itertools; use crate::{ - blob::get_blob, + blob::get_mail_blob, client::Client, compression::decompress_value, crypto::encryption::{decrypt_key, decrypt_value}, - file_output::write_to_file, folders::Folder, proto::{ - binary::Base64String, keys::Key, - messages::{MailDetailsBlob, MailReponse}, + messages::{FileReponse, MailReponse}, }, session::{GroupKeys, Session}, }; @@ -91,156 +85,97 @@ impl Mail { } pub(crate) async fn download( - &self, + self, client: &Client, session: &Session, - target_file: &Path, - ) -> Result<()> { - let mail_details: MailDetailsBlob = - get_blob(client, session, &self.archive_id, &self.blob_id) - .await - .context("download mail details")?; - - self.emit_eml(mail_details, target_file) + ) -> Result { + let mail_details = get_mail_blob(client, session, &self.archive_id, &self.blob_id) .await - .context("emit EML") - } - - async fn emit_eml(&self, mail_details: MailDetailsBlob, target_file: &Path) -> Result<()> { - let mut out = Vec::new(); - - let boundary = if let Some(headers) = mail_details.details.headers { - let headers = decrypt_value(self.session_key, headers.compressed_headers.as_ref()) - .context("decrypt headers")?; - let headers = decompress_value(&headers).context("decompress headers")?; - let mut headers = fix_header_line_endings(&headers); - let boundary = get_boundary(&headers).context("get boundary")?; - - out.append(&mut headers); - boundary - } else { - self.synthesize_headers(&mut out) - }; - - write_intermediate_delimiter(&mut out, &boundary); - + .context("download mail details")?; let body = decrypt_value( self.session_key, mail_details.details.body.compressed_text.as_ref(), ) .context("decrypt body")?; let body = decompress_value(&body).context("decompress body")?; - let body = Base64String::from(body); - out.extend(b"Content-Type: text/html; charset=UTF-8"); - out.extend(NEWLINE); - out.extend(b"Content-Transfer-Encoding: base64"); - out.extend(NEWLINE); - out.extend(NEWLINE); - write_chunked(&mut out, body.to_string().as_bytes()); - - write_final_delimiter(&mut out, &boundary); - - write_to_file(&out, target_file) - .await - .context("write to output file")?; - Ok(()) - } - - /// Create headers from metadata and return multipart boundary. - fn synthesize_headers(&self, out: &mut Vec) -> Vec { - let boundary = b"----------79Bu5A16qPEYcVIZL@tutanota".to_vec(); - - out.extend(b"From: "); - out.extend(self.sender_name.as_bytes()); - out.extend(b" <"); - out.extend(self.sender_mail.as_bytes()); - out.extend(b">"); - out.extend(NEWLINE); - - out.extend(b"MIME-Version: 1.0"); - out.extend(NEWLINE); + let headers = if let Some(headers) = mail_details.details.headers { + let headers = decrypt_value(self.session_key, headers.compressed_headers.as_ref()) + .context("decrypt headers")?; + let headers = decompress_value(&headers).context("decompress headers")?; + let headers = String::from_utf8(headers).context("decode headers")?; - if self.subject.is_empty() { - out.extend(b"Subject: "); - out.extend(NEWLINE); + Some(headers) } else { - out.extend(b"Subject: =?UTF-8?B?"); - out.extend( - Base64String::from(self.subject.as_bytes()) - .to_string() - .as_bytes(), - ); - out.extend(b"?="); + None }; - out.extend(b"Content-Type: multipart/related; boundary=\""); - out.extend(&boundary); - out.extend(b"\""); - out.extend(NEWLINE); + let mut attachements = vec![]; + if !self.attachments.is_empty() { + let group = &self.attachments[0][0]; + if self.attachments.iter().any(|[g_id, _id]| g_id != group) { + bail!("inconsistent attachement group IDs") + } + let ids = self + .attachments + .iter() + .map(|[_g_id, id]| id.as_str()) + .collect::>(); + let files: Vec = client + .file_request(&session.access_token, group, &ids) + .await + .context("get file infos")?; + + for file in files { + let session_key = decrypt_key( + session + .group_keys + .get(&file.owner_group) + .context("getting file owner group key")?, + file.owner_enc_session_key, + ) + .context("decrypting file session key")?; + + let cid = decrypt_value(session_key, file.cid.as_ref()) + .context("decrypt file content ID")?; + let cid = String::from_utf8(cid).context("decode cid")?; + + let mime_type = decrypt_value(session_key, file.mime_type.as_ref()) + .context("decrypt file mime type")?; + let mime_type = String::from_utf8(mime_type).context("decode mime_type")?; + + let name = + decrypt_value(session_key, file.name.as_ref()).context("decrypt file name")?; + let name = String::from_utf8(name).context("decode name")?; + + attachements.push(Attachment { + cid, + mime_type, + name, + }); + } + } - boundary + Ok(DownloadedMail { + mail: self, + headers, + body, + attachements, + }) } } -fn line_ending_re() -> &'static regex::bytes::Regex { - LINE_ENDING_RE.get_or_init(|| regex::bytes::Regex::new(r#"\r?\n"#).expect("valid regex")) -} - -fn boundary_re() -> &'static regex::bytes::Regex { - BOUNDARY_RE.get_or_init(|| { - regex::bytes::Regex::new(r#"Content-Type: .*boundary="(?[^"]*)""#) - .expect("valid regex") - }) -} - -/// Upstream provides `\n` line endings for headers but we need `\r\n` -#[allow(unstable_name_collisions)] -fn fix_header_line_endings(headers: &[u8]) -> Vec { - line_ending_re() - .split(headers) - .map(|s| s.to_vec()) - .intersperse(b"\r\n".to_vec()) - .concat() -} - -/// Extract boundery from headers -fn get_boundary(headers: &[u8]) -> Result> { - let boundary_re = boundary_re(); - - line_ending_re() - .split(headers) - .find_map(|line| boundary_re.captures(line).and_then(|c| c.name("boundary"))) - .map(|s| s.as_bytes().to_owned()) - .context("boundary not found") -} - -/// See . -fn write_delimiter(out: &mut Vec, boundary: &[u8]) { - out.extend(NEWLINE); - out.extend(NEWLINE); - out.extend(b"--"); - out.extend(boundary); -} - -fn write_intermediate_delimiter(out: &mut Vec, boundary: &[u8]) { - write_delimiter(out, boundary); - out.extend(NEWLINE); -} - -/// See . -fn write_final_delimiter(out: &mut Vec, boundary: &[u8]) { - write_delimiter(out, boundary); - out.extend(b"--"); +#[derive(Debug)] +pub(crate) struct DownloadedMail { + pub(crate) mail: Mail, + pub(crate) headers: Option, + pub(crate) body: Vec, + pub(crate) attachements: Vec, } -fn write_chunked(out: &mut Vec, s: &[u8]) { - let mut first = false; - for chunk in s.chunks(78) { - if !first { - out.extend(NEWLINE); - } - out.extend(chunk); - first = false; - } +#[derive(Debug)] +pub(crate) struct Attachment { + pub(crate) cid: String, + pub(crate) mime_type: String, + pub(crate) name: String, } diff --git a/src/main.rs b/src/main.rs index ccbb62a..6230419 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,8 @@ use std::path::PathBuf; use crate::{ client::Client, - file_output::escape_file_string, + eml::emit_eml, + file_output::{escape_file_string, write_to_file}, mails::Mail, session::{LoginCLIConfig, Session}, }; @@ -24,6 +25,7 @@ mod client; mod compression; mod constants; mod crypto; +mod eml; mod file_output; mod folders; mod logging; @@ -140,9 +142,14 @@ async fn exec_cmd(client: &Client, session: &Session, cmd: Command) -> Result<() info!(id = mail.mail_id.as_str(), "already exists"); } else { info!(id = mail.mail_id.as_str(), "download"); - mail.download(client, session, &target_file) + let mail = mail + .download(client, session) .await .context("download mail")?; + let eml = emit_eml(&mail).context("emit eml")?; + write_to_file(eml.as_bytes(), &target_file) + .await + .context("write output file")?; } } diff --git a/src/proto/messages.rs b/src/proto/messages.rs index 0c12cab..b768929 100644 --- a/src/proto/messages.rs +++ b/src/proto/messages.rs @@ -254,3 +254,28 @@ pub(crate) struct MailDetailsBlob { pub(crate) details: MailDetails, } + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct FileBlob { + pub(crate) archive_id: String, + pub(crate) blob_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct FileReponse { + #[serde(rename = "_format")] + pub(crate) _format: Format<0>, + + #[serde(rename = "_ownerEncSessionKey")] + pub(crate) owner_enc_session_key: EncryptedKey, + + #[serde(rename = "_ownerGroup")] + pub(crate) owner_group: String, + + pub(crate) cid: Base64String, + pub(crate) mime_type: Base64String, + pub(crate) name: Base64String, + pub(crate) blobs: [FileBlob; 1], +} From 4287cc006e7d05568b80268064882601f8052c82 Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Sun, 31 Mar 2024 16:00:39 +0200 Subject: [PATCH 41/44] clean up client --- src/blob.rs | 37 +++++---- src/client.rs | 201 ++++++++++++------------------------------------- src/folders.rs | 32 ++++---- src/mails.rs | 19 +++-- src/session.rs | 34 +++++---- 5 files changed, 121 insertions(+), 202 deletions(-) diff --git a/src/blob.rs b/src/blob.rs index cd4ceb9..9295f07 100644 --- a/src/blob.rs +++ b/src/blob.rs @@ -2,7 +2,7 @@ use anyhow::{bail, Context, Result}; use reqwest::Method; use crate::{ - client::Client, + client::{Client, Prefix, Request, DEFAULT_HOST}, proto::messages::{ BlobAccessTokenServiceRequest, BlobAccessTokenServiceResponse, BlobReadRequest, MailDetailsBlob, @@ -21,13 +21,19 @@ pub(crate) async fn get_mail_blob( .context("get blob access")?; let resp: Vec = client - .mail_blob_request( - &access.server_url, - &format!("maildetailsblob/{archive_id}"), - &session.access_token, - &[blob_id], - &access.blob_access_token, - ) + .do_json(Request { + method: Method::GET, + host: &access.server_url, + prefix: Prefix::Tutanota, + path: &format!("maildetailsblob/{archive_id}"), + data: &(), + access_token: None, + query: &[ + ("accessToken", &session.access_token.to_string()), + ("ids", &[blob_id].join(",")), + ("blobAccessToken", &access.blob_access_token), + ], + }) .await .context("blob download")?; @@ -51,12 +57,15 @@ async fn get_access(client: &Client, session: &Session, archive_id: &str) -> Res write: Default::default(), }; let resp: BlobAccessTokenServiceResponse = client - .service_request_storage( - Method::POST, - "blobaccesstokenservice", - &req, - Some(&session.access_token), - ) + .do_json(Request { + method: Method::POST, + host: DEFAULT_HOST, + prefix: Prefix::Storage, + path: "blobaccesstokenservice", + data: &req, + access_token: Some(&session.access_token), + query: &[], + }) .await .context("blob service access request")?; diff --git a/src/client.rs b/src/client.rs index e7bf86d..c571a36 100644 --- a/src/client.rs +++ b/src/client.rs @@ -12,7 +12,7 @@ use crate::{ }; const STREAM_BATCH_SIZE: u64 = 1000; -const DEFAULT_HOST: &str = "https://app.tuta.com"; +pub(crate) const DEFAULT_HOST: &str = "https://app.tuta.com"; #[derive(Debug, Clone)] pub(crate) struct Client { @@ -31,145 +31,6 @@ impl Client { Ok(Self { inner }) } - pub(crate) async fn service_request( - &self, - method: Method, - path: &str, - data: &Req, - access_token: Option<&Base64Url>, - ) -> Result - where - Req: serde::Serialize + Sync, - Resp: DeserializeOwned, - { - self.do_json(Request { - method, - host: DEFAULT_HOST, - prefix: "sys", - path, - data, - access_token, - query: &[], - }) - .await - } - - pub(crate) async fn service_request_tutanota( - &self, - method: Method, - path: &str, - data: &Req, - access_token: Option<&Base64Url>, - ) -> Result - where - Req: serde::Serialize + Sync, - Resp: DeserializeOwned, - { - self.do_json(Request { - method, - host: DEFAULT_HOST, - prefix: "tutanota", - path, - data, - access_token, - query: &[], - }) - .await - } - - pub(crate) async fn service_request_storage( - &self, - method: Method, - path: &str, - data: &Req, - access_token: Option<&Base64Url>, - ) -> Result - where - Req: serde::Serialize + Sync, - Resp: DeserializeOwned, - { - self.do_json(Request { - method, - host: DEFAULT_HOST, - prefix: "storage", - path, - data, - access_token, - query: &[], - }) - .await - } - - pub(crate) async fn file_request( - &self, - access_token: &Base64Url, - group_id: &str, - ids: &[&str], - ) -> Result - where - Resp: DeserializeOwned, - { - self.do_json(Request { - method: Method::GET, - host: DEFAULT_HOST, - prefix: "tutanota", - path: &format!("file/{group_id}"), - data: &(), - access_token: Some(access_token), - query: &[("ids", &ids.join(","))], - }) - .await - } - - pub(crate) async fn mail_blob_request( - &self, - host: &str, - path: &str, - access_token: &Base64Url, - ids: &[&str], - blob_access_token: &str, - ) -> Result - where - Resp: DeserializeOwned, - { - self.do_json(Request { - method: Method::GET, - host, - prefix: "tutanota", - path, - data: &(), - access_token: None, - query: &[ - ("accessToken", &access_token.to_string()), - ("ids", &ids.join(",")), - ("blobAccessToken", blob_access_token), - ], - }) - .await - } - - pub(crate) async fn service_request_no_response( - &self, - method: Method, - path: &str, - data: &Req, - access_token: Option<&Base64Url>, - ) -> Result - where - Req: serde::Serialize + Sync, - { - self.do_request(Request { - method, - host: DEFAULT_HOST, - prefix: "sys", - path, - data, - access_token, - query: &[], - }) - .await - } - pub(crate) fn stream( &self, path: &str, @@ -206,7 +67,7 @@ impl Client { .do_json::<(), Vec>(Request { method: Method::GET, host: DEFAULT_HOST, - prefix: "tutanota", + prefix: Prefix::Tutanota, path: &path, data: &(), access_token: access_token.as_ref().as_ref(), @@ -233,7 +94,7 @@ impl Client { }) } - async fn do_json(&self, r: Request<'_, Req>) -> Result + pub(crate) async fn do_json(&self, r: Request<'_, Req>) -> Result where Req: serde::Serialize + Sync, Resp: DeserializeOwned, @@ -248,7 +109,7 @@ impl Client { Ok(resp) } - async fn do_request(&self, r: Request<'_, Req>) -> Result + pub(crate) async fn do_request(&self, r: Request<'_, Req>) -> Result where Req: serde::Serialize + Sync, { @@ -261,11 +122,11 @@ impl Client { access_token, query, } = r; - debug!(%method, prefix, path, "service request",); + debug!(%method, prefix=prefix.str(), path, "service request",); let mut req = self .inner - .request(method, format!("{host}/rest/{prefix}/{path}")); + .request(method, format!("{}/rest/{}/{}", host, prefix.str(), path)); if let Some(access_token) = access_token { req = req.header("accessToken", access_token.to_string()); @@ -289,15 +150,49 @@ struct StreamState { next_start: String, } -struct Request<'a, Req> +#[derive(Debug, Clone, Copy)] +pub(crate) enum Prefix { + Tutanota, + Storage, + Sys, +} + +impl Prefix { + fn str(&self) -> &'static str { + match self { + Self::Tutanota => "tutanota", + Self::Storage => "storage", + Self::Sys => "sys", + } + } +} + +pub(crate) struct Request<'a, Req> +where + Req: serde::Serialize + Sync, +{ + pub(crate) method: Method, + pub(crate) host: &'a str, + pub(crate) prefix: Prefix, + pub(crate) path: &'a str, + pub(crate) data: &'a Req, + pub(crate) access_token: Option<&'a Base64Url>, + pub(crate) query: &'a [(&'a str, &'a str)], +} + +impl<'a, Req> Request<'a, Req> where Req: serde::Serialize + Sync, { - method: Method, - host: &'a str, - prefix: &'a str, - path: &'a str, - data: &'a Req, - access_token: Option<&'a Base64Url>, - query: &'a [(&'a str, &'a str)], + pub(crate) fn new(prefix: Prefix, path: &'a str, data: &'a Req) -> Self { + Self { + method: Method::GET, + host: DEFAULT_HOST, + prefix, + path, + data, + access_token: None, + query: &[], + } + } } diff --git a/src/folders.rs b/src/folders.rs index 779f0f7..4f8799b 100644 --- a/src/folders.rs +++ b/src/folders.rs @@ -9,7 +9,7 @@ use reqwest::Method; use tracing::debug; use crate::{ - client::Client, + client::{Client, Prefix, Request, DEFAULT_HOST}, crypto::encryption::{decrypt_key, decrypt_value}, proto::{ enums::{GroupType, MailFolderType}, @@ -32,12 +32,15 @@ impl Folder { let mail_group = get_mail_membership(session).context("get mail group")?; let resp: MailboxGroupRootResponse = client - .service_request_tutanota( - Method::GET, - &format!("mailboxgrouproot/{}", mail_group.group), - &(), - Some(&session.access_token), - ) + .do_json(Request { + method: Method::GET, + host: DEFAULT_HOST, + prefix: Prefix::Tutanota, + path: &format!("mailboxgrouproot/{}", mail_group.group), + data: &(), + access_token: Some(&session.access_token), + query: &[], + }) .await .context("get mailbox group root")?; let mailbox = resp.mailbox; @@ -45,12 +48,15 @@ impl Folder { debug!(mailbox = mailbox.as_str(), "mailbox found"); let resp: MailboxResponse = client - .service_request_tutanota( - Method::GET, - &format!("mailbox/{mailbox}"), - &(), - Some(&session.access_token), - ) + .do_json(Request { + method: Method::GET, + host: DEFAULT_HOST, + prefix: Prefix::Tutanota, + path: &format!("mailbox/{mailbox}"), + data: &(), + access_token: Some(&session.access_token), + query: &[], + }) .await .context("get mailbox")?; let folders = resp.folders.folders; diff --git a/src/mails.rs b/src/mails.rs index 00c6f78..6aa9f7b 100644 --- a/src/mails.rs +++ b/src/mails.rs @@ -1,12 +1,13 @@ -use std::sync::{Arc, OnceLock}; +use std::sync::Arc; use anyhow::{bail, Context, Result}; use chrono::NaiveDateTime; use futures::{Stream, TryStreamExt}; +use reqwest::Method; use crate::{ blob::get_mail_blob, - client::Client, + client::{Client, Prefix, Request, DEFAULT_HOST}, compression::decompress_value, crypto::encryption::{decrypt_key, decrypt_value}, folders::Folder, @@ -17,10 +18,6 @@ use crate::{ session::{GroupKeys, Session}, }; -static LINE_ENDING_RE: OnceLock = OnceLock::new(); -static BOUNDARY_RE: OnceLock = OnceLock::new(); -const NEWLINE: &[u8] = b"\r\n"; - #[derive(Debug)] pub(crate) struct Mail { #[allow(dead_code)] @@ -122,7 +119,15 @@ impl Mail { .map(|[_g_id, id]| id.as_str()) .collect::>(); let files: Vec = client - .file_request(&session.access_token, group, &ids) + .do_json(Request { + method: Method::GET, + host: DEFAULT_HOST, + prefix: Prefix::Tutanota, + path: &format!("file/{group}"), + data: &(), + access_token: Some(&session.access_token), + query: &[("ids", &ids.join(","))], + }) .await .context("get file infos")?; diff --git a/src/session.rs b/src/session.rs index f643da2..8bcdf3e 100644 --- a/src/session.rs +++ b/src/session.rs @@ -7,7 +7,7 @@ use sha2::{Digest, Sha256}; use tracing::debug; use crate::{ - client::Client, + client::{Client, Prefix, Request, DEFAULT_HOST}, constants::APP_USER_AGENT, crypto::{ auth::{derive_passkey, encode_auth_verifier, UserPassphraseKey}, @@ -56,7 +56,7 @@ impl Session { mail_address: config.username.to_string(), }; let resp: SaltServiceResponse = client - .service_request(Method::GET, "saltservice", &req, None) + .do_json(Request::new(Prefix::Sys, "saltservice", &req)) .await .context("get salt")?; @@ -75,7 +75,10 @@ impl Session { user: Default::default(), }; let resp: SessionServiceResponse = client - .service_request(Method::POST, "sessionservice", &req, None) + .do_json(Request { + method: Method::POST, + ..Request::new(Prefix::Sys, "sessionservice", &req) + }) .await .context("get session")?; let user_id = resp.user; @@ -88,12 +91,10 @@ impl Session { } let user_data: UserResponse = client - .service_request( - Method::GET, - &format!("user/{}", user_id), - &(), - Some(&access_token), - ) + .do_json(Request { + access_token: Some(&access_token), + ..Request::new(Prefix::Sys, &format!("user/{}", user_id), &()) + }) .await .context("get user")?; @@ -114,16 +115,19 @@ impl Session { debug!(session = session.as_str(), "performing logout",); client - .service_request_no_response( - Method::DELETE, - &format!( + .do_request(Request { + method: Method::DELETE, + host: DEFAULT_HOST, + prefix: Prefix::Sys, + path: &format!( "session/{}/{}", session, session_element_id(&self.access_token) ), - &(), - Some(&self.access_token), - ) + data: &(), + access_token: Some(&self.access_token), + query: &[], + }) .await .context("session deletion")?; From 241e4135e649e5235ccf989d73c8e610ce363d54 Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Sun, 31 Mar 2024 17:59:19 +0200 Subject: [PATCH 42/44] attachements work --- Cargo.lock | 96 +- Cargo.toml | 4 +- src/blob.rs | 91 +- src/eml.rs | 38 +- src/mails.rs | 27 +- src/main.rs | 4 + src/proto/enums.rs | 45 + src/proto/messages.rs | 28 +- tests/cli.rs | 54 + ...ta is now Tuta su fr deutsche Version.eml | 121 + ...y for Everyone su fr deutsche Version.eml | 186 ++ .../2024-02-14-17h34m30s-Test Mail 1.eml | 59 + .../2024-02-14-17h38m34s-Test Mail 2.eml | 2022 +++++++++++++++++ tests/reference/2024-02-18-16h54m17s-Test.eml | 15 + 14 files changed, 2756 insertions(+), 34 deletions(-) create mode 100644 tests/reference/2023-11-07-14h57m21s-Tutanota is now Tuta su fr deutsche Version.eml create mode 100644 tests/reference/2023-12-22-09h49m57s-Privacy for Everyone su fr deutsche Version.eml create mode 100644 tests/reference/2024-02-14-17h34m30s-Test Mail 1.eml create mode 100644 tests/reference/2024-02-14-17h38m34s-Test Mail 2.eml create mode 100644 tests/reference/2024-02-18-16h54m17s-Test.eml diff --git a/Cargo.lock b/Cargo.lock index 7409cd3..1443d4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -128,7 +128,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00ad3f3a942eee60335ab4342358c161ee296829e0d16ff42fc1d6cb07815467" dependencies = [ "anstyle", - "bstr", + "bstr 1.9.0", "doc-comment", "predicates", "predicates-core", @@ -207,6 +207,12 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "bitflags" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" + [[package]] name = "block-buffer" version = "0.10.4" @@ -256,6 +262,17 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bstr" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3569f383e8f1598449f1a423e72e99569137b47740b1da11ef19af3d5c3223" +dependencies = [ + "lazy_static", + "memchr", + "regex-automata 0.1.10", +] + [[package]] name = "bstr" version = "1.9.0" @@ -509,6 +526,22 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "errno" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "fastrand" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bd65b1cf4c852a3cc96f18a8ce7b5640f6b703f905c7d74532294c2a63984" + [[package]] name = "flate2" version = "1.0.28" @@ -915,6 +948,12 @@ version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +[[package]] +name = "linux-raw-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" + [[package]] name = "lock_api" version = "0.4.11" @@ -1180,7 +1219,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" dependencies = [ - "bitflags", + "bitflags 1.3.2", ] [[package]] @@ -1301,6 +1340,19 @@ version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" +[[package]] +name = "rustix" +version = "0.38.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65e04861e65f21776e67888bfbea442b3642beaa0138fdb1dd7a84a52dffdb89" +dependencies = [ + "bitflags 2.5.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + [[package]] name = "rustls" version = "0.21.10" @@ -1419,9 +1471,23 @@ dependencies = [ [[package]] name = "similar" -version = "2.4.0" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa42c91313f1d05da9b26f267f931cf178d4aba455b4c4622dd7355eb80c6640" +dependencies = [ + "bstr 0.2.17", + "unicode-segmentation", +] + +[[package]] +name = "similar-asserts" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32fea41aca09ee824cc9724996433064c89f7777e60762749a4170a14abbfa21" +checksum = "e041bb827d1bfca18f213411d51b665309f1afb37a04a5d1464530e13779fc0f" +dependencies = [ + "console", + "similar", +] [[package]] name = "slab" @@ -1495,7 +1561,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" dependencies = [ - "bitflags", + "bitflags 1.3.2", "core-foundation", "system-configuration-sys", ] @@ -1533,12 +1599,26 @@ dependencies = [ "serde", "serde_json", "sha2", + "similar-asserts", + "tempfile", "tokio", "tracing", "tracing-log 0.1.4", "tracing-subscriber", ] +[[package]] +name = "tempfile" +version = "3.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" +dependencies = [ + "cfg-if", + "fastrand", + "rustix", + "windows-sys 0.52.0", +] + [[package]] name = "termtree" version = "0.4.1" @@ -1809,6 +1889,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-segmentation" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 83a9933..67b0a2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ lz4_flex = "0.11.2" regex = "1.10.3" reqwest = { version = "0.11", default-features = false, features = ["brotli", "deflate", "gzip", "json", "rustls-tls-webpki-roots", "trust-dns"] } serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" sha2 = "0.10.8" tokio = { version = "1.32.0", features = ["fs", "macros", "rt-multi-thread"] } tracing = "0.1.38" @@ -29,7 +30,8 @@ tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } [dev-dependencies] assert_cmd = "2.0.12" insta = "1.34.0" -serde_json = "1.0" +similar-asserts = "1.5.0" +tempfile = "3" [lints.rust] missing_copy_implementations = "deny" diff --git a/src/blob.rs b/src/blob.rs index 9295f07..9fd63ac 100644 --- a/src/blob.rs +++ b/src/blob.rs @@ -3,9 +3,12 @@ use reqwest::Method; use crate::{ client::{Client, Prefix, Request, DEFAULT_HOST}, - proto::messages::{ - BlobAccessTokenServiceRequest, BlobAccessTokenServiceResponse, BlobReadRequest, - MailDetailsBlob, + proto::{ + enums::ArchiveDataType, + messages::{ + BlobAccessTokenServiceRequest, BlobAccessTokenServiceResponse, BlobReadRequest, + BlobReadRequestInstanceId, BlobServiceRequest, MailDetailsBlob, + }, }, session::Session, }; @@ -16,9 +19,15 @@ pub(crate) async fn get_mail_blob( archive_id: &str, blob_id: &str, ) -> Result { - let access = get_access(client, session, archive_id) - .await - .context("get blob access")?; + let access = get_access( + client, + session, + archive_id, + ArchiveDataType::MailDetails, + None, + ) + .await + .context("get blob access")?; let resp: Vec = client .do_json(Request { @@ -44,15 +53,77 @@ pub(crate) async fn get_mail_blob( Ok(resp.into_iter().next().expect("checked length")) } -async fn get_access(client: &Client, session: &Session, archive_id: &str) -> Result { +pub(crate) async fn get_attachment_blob( + client: &Client, + session: &Session, + archive_id: &str, + blob_id: &str, + instance_list_id: &str, + instance_id: &str, +) -> Result> { + let access = get_access( + client, + session, + archive_id, + ArchiveDataType::Attachments, + Some((instance_list_id, instance_id)), + ) + .await + .context("get blob access")?; + + let data = client + .do_request(Request { + method: Method::GET, + host: &access.server_url, + prefix: Prefix::Storage, + path: "blobservice", + data: &(), + access_token: None, + query: &[ + ("accessToken", &session.access_token.to_string()), + ("blobAccessToken", &access.blob_access_token), + ( + "_body", + &serde_json::to_string(&BlobServiceRequest { + format: Default::default(), + archive_id: archive_id.to_owned(), + blob_id: blob_id.to_owned(), + blob_ids: vec![], + }) + .expect("serde should always work"), + ), + ], + }) + .await + .context("blob download")? + .bytes() + .await + .context("download blob")?; + + Ok(data.to_vec()) +} + +async fn get_access( + client: &Client, + session: &Session, + archive_id: &str, + archive_data_type: ArchiveDataType, + instance: Option<(&str, &str)>, +) -> Result { let req = BlobAccessTokenServiceRequest { format: Default::default(), - archive_data_type: Default::default(), + archive_data_type, read: BlobReadRequest { id: "MR9cbw".to_owned(), archive_id: archive_id.to_owned(), - instance_ids: vec![], - instance_list_id: Default::default(), + instance_ids: instance + .iter() + .map(|(_l, i)| BlobReadRequestInstanceId { + id: "MR9cbw".to_owned(), + instance_id: (*i).to_owned(), + }) + .collect(), + instance_list_id: instance.as_ref().map(|(l, _i)| (*l).to_owned()), }, write: Default::default(), }; diff --git a/src/eml.rs b/src/eml.rs index b2ec0a6..1c5b2a2 100644 --- a/src/eml.rs +++ b/src/eml.rs @@ -15,8 +15,9 @@ const NEWLINE: &str = "\r\n"; pub(crate) fn emit_eml(mail: &DownloadedMail) -> Result { let mut lines = Vec::new(); + // headers let boundary = if let Some(headers) = &mail.headers { - let mut headers = split_header_lines(&headers); + let mut headers = split_header_lines(headers); let boundary = get_boundary(&headers).context("get boundary")?; lines.append(&mut headers); @@ -27,16 +28,36 @@ pub(crate) fn emit_eml(mail: &DownloadedMail) -> Result { boundary }; + // body write_intermediate_delimiter(&mut lines, &boundary); - let body = Base64String::from(mail.body.clone()); lines.push("Content-Type: text/html; charset=UTF-8".to_owned()); lines.push("Content-Transfer-Encoding: base64".to_owned()); lines.push("".to_owned()); write_chunked(&mut lines, &body.to_string()); - write_final_delimiter(&mut lines, &boundary); + // attachments + for attachment in &mail.attachments { + write_intermediate_delimiter(&mut lines, &boundary); + lines.push(format!( + "Content-Type: {}; name={}", + attachment.mime_type, + utf8_header_value(&attachment.name) + )); + lines.push("Content-Transfer-Encoding: base64".to_owned()); + lines.push(format!( + "Content-Disposition: attachment; filename={}", + utf8_header_value(&attachment.name) + )); + lines.push(format!("Content-Id: <{}>", attachment.cid)); + lines.push("".to_owned()); + write_chunked( + &mut lines, + &Base64String::from(attachment.data.clone()).to_string(), + ); + } + write_final_delimiter(&mut lines, &boundary); Ok(lines.join(NEWLINE)) } @@ -47,12 +68,9 @@ fn synthesize_headers(mail: &Mail, boundary: &str, lines: &mut Vec) { lines.push("MIME-Version: 1.0".to_owned()); if mail.subject.is_empty() { - lines.push(format!("Subject: ")); + lines.push("Subject: ".to_owned()); } else { - lines.push(format!( - "Subject: =?UTF-8?B?{}?=", - Base64String::from(mail.subject.as_bytes()) - )); + lines.push(format!("Subject: {}", utf8_header_value(&mail.subject),)); }; lines.push(format!( @@ -107,3 +125,7 @@ fn write_chunked(lines: &mut Vec, s: &str) { lines.push(chunk.collect()); } } + +fn utf8_header_value(s: &str) -> String { + format!("=?UTF-8?B?{}?=", Base64String::from(s.as_bytes())) +} diff --git a/src/mails.rs b/src/mails.rs index 6aa9f7b..fdf0cfe 100644 --- a/src/mails.rs +++ b/src/mails.rs @@ -6,7 +6,7 @@ use futures::{Stream, TryStreamExt}; use reqwest::Method; use crate::{ - blob::get_mail_blob, + blob::{get_attachment_blob, get_mail_blob}, client::{Client, Prefix, Request, DEFAULT_HOST}, compression::decompress_value, crypto::encryption::{decrypt_key, decrypt_value}, @@ -107,7 +107,7 @@ impl Mail { None }; - let mut attachements = vec![]; + let mut attachments = vec![]; if !self.attachments.is_empty() { let group = &self.attachments[0][0]; if self.attachments.iter().any(|[g_id, _id]| g_id != group) { @@ -131,7 +131,7 @@ impl Mail { .await .context("get file infos")?; - for file in files { + for (id, file) in ids.into_iter().zip(files) { let session_key = decrypt_key( session .group_keys @@ -153,10 +153,24 @@ impl Mail { decrypt_value(session_key, file.name.as_ref()).context("decrypt file name")?; let name = String::from_utf8(name).context("decode name")?; - attachements.push(Attachment { + let [blob] = file.blobs; + let data = get_attachment_blob( + client, + session, + &blob.archive_id, + &blob.blob_id, + group, + id, + ) + .await + .context("download attachment")?; + let data = decrypt_value(session_key, &data).context("decrypt attachment data")?; + + attachments.push(Attachment { cid, mime_type, name, + data, }); } } @@ -165,7 +179,7 @@ impl Mail { mail: self, headers, body, - attachements, + attachments, }) } } @@ -175,7 +189,7 @@ pub(crate) struct DownloadedMail { pub(crate) mail: Mail, pub(crate) headers: Option, pub(crate) body: Vec, - pub(crate) attachements: Vec, + pub(crate) attachments: Vec, } #[derive(Debug)] @@ -183,4 +197,5 @@ pub(crate) struct Attachment { pub(crate) cid: String, pub(crate) mime_type: String, pub(crate) name: String, + pub(crate) data: Vec, } diff --git a/src/main.rs b/src/main.rs index 6230419..534c720 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,6 +19,10 @@ use tracing::{debug, info}; use assert_cmd as _; #[cfg(test)] use insta as _; +#[cfg(test)] +use similar_asserts as _; +#[cfg(test)] +use tempfile as _; mod blob; mod client; diff --git a/src/proto/enums.rs b/src/proto/enums.rs index c8a50b4..15b6041 100644 --- a/src/proto/enums.rs +++ b/src/proto/enums.rs @@ -159,6 +159,42 @@ impl<'de> serde::Deserialize<'de> for MailFolderType { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) enum ArchiveDataType { + AuthorityRequests, + Attachments, + MailDetails, +} + +impl serde::Serialize for ArchiveDataType { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let s = match self { + Self::AuthorityRequests => "0", + Self::Attachments => "1", + Self::MailDetails => "2", + }; + serializer.serialize_str(s) + } +} + +impl<'de> serde::Deserialize<'de> for ArchiveDataType { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + match s.as_str() { + "0" => Ok(Self::AuthorityRequests), + "1" => Ok(Self::Attachments), + "2" => Ok(Self::MailDetails), + s => Err(D::Error::custom(format!("invalid archive data type: {s}"))), + } + } +} + #[cfg(test)] mod tests { use crate::proto::testing::{assert_deser_error, assert_roundtrip}; @@ -203,4 +239,13 @@ mod tests { assert_deser_error::(r#""20""#, "invalid mail folder type: 20"); } + + #[test] + fn test_roundtrip_archive_data_type() { + assert_roundtrip(ArchiveDataType::AuthorityRequests); + assert_roundtrip(ArchiveDataType::Attachments); + assert_roundtrip(ArchiveDataType::MailDetails); + + assert_deser_error::(r#""20""#, "invalid archive data type: 20"); + } } diff --git a/src/proto/messages.rs b/src/proto/messages.rs index b768929..0318893 100644 --- a/src/proto/messages.rs +++ b/src/proto/messages.rs @@ -6,7 +6,7 @@ use super::{ binary::{Base64String, Base64Url}, constants::Null, date::UnixDate, - enums::{GroupType, KdfVersion, MailFolderType}, + enums::{ArchiveDataType, GroupType, KdfVersion, MailFolderType}, keys::{EncryptedKey, OptionalEncryptedKey}, }; @@ -179,6 +179,15 @@ impl Entity for MailReponse { } } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct BlobReadRequestInstanceId { + #[serde(rename = "_id")] + pub(crate) id: String, + + pub(crate) instance_id: String, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct BlobReadRequest { @@ -186,8 +195,8 @@ pub(crate) struct BlobReadRequest { pub(crate) id: String, pub(crate) archive_id: String, - pub(crate) instance_ids: Vec<()>, - pub(crate) instance_list_id: Null, + pub(crate) instance_ids: Vec, + pub(crate) instance_list_id: Option, } #[derive(Debug, Serialize)] @@ -196,7 +205,7 @@ pub(crate) struct BlobAccessTokenServiceRequest { #[serde(rename = "_format")] pub(crate) format: Format<0>, - pub(crate) archive_data_type: Null, + pub(crate) archive_data_type: ArchiveDataType, pub(crate) read: BlobReadRequest, pub(crate) write: Null, } @@ -279,3 +288,14 @@ pub(crate) struct FileReponse { pub(crate) name: Base64String, pub(crate) blobs: [FileBlob; 1], } + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct BlobServiceRequest { + #[serde(rename = "_format")] + pub(crate) format: Format<0>, + + pub(crate) archive_id: String, + pub(crate) blob_id: String, + pub(crate) blob_ids: Vec<()>, +} diff --git a/tests/cli.rs b/tests/cli.rs index 6c68829..0ed21e3 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,6 +1,12 @@ #![allow(unused_crate_dependencies)] +use std::{ + collections::HashMap, + path::{Path, PathBuf}, +}; + use assert_cmd::Command; +use tempfile::TempDir; #[test] fn test_help() { @@ -25,6 +31,54 @@ fn test_list_folders() { "###); } +#[test] +fn test_download() { + let actual_path = TempDir::new().unwrap(); + + let mut expected_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + expected_path.push("tests"); + expected_path.push("reference"); + + let mut cmd = cmd(); + cmd.arg("-vv") + .arg("download") + .arg("--folder=fooooo") + .arg("--path") + .arg(actual_path.path()) + .assert() + .success(); + + let actual = read_files(actual_path.path()); + let expected = read_files(&expected_path); + + let mut actual_files = actual.keys().collect::>(); + actual_files.sort(); + let mut expected_files = expected.keys().collect::>(); + expected_files.sort(); + assert_eq!(actual_files, expected_files); + + for fname in actual_files { + let actual_content = actual.get(fname).unwrap(); + let expected_content = expected.get(fname).unwrap(); + similar_asserts::assert_eq!(actual_content, expected_content); + } +} + fn cmd() -> Command { Command::cargo_bin(env!("CARGO_PKG_NAME")).unwrap() } + +fn read_files(path: &Path) -> HashMap { + let mut out = HashMap::default(); + + for f in std::fs::read_dir(path).unwrap() { + let f = f.unwrap(); + assert!(f.file_type().unwrap().is_file()); + out.insert( + f.path().file_name().unwrap().to_str().unwrap().to_owned(), + std::fs::read_to_string(f.path()).unwrap(), + ); + } + + out +} diff --git a/tests/reference/2023-11-07-14h57m21s-Tutanota is now Tuta su fr deutsche Version.eml b/tests/reference/2023-11-07-14h57m21s-Tutanota is now Tuta su fr deutsche Version.eml new file mode 100644 index 0000000..d162bb2 --- /dev/null +++ b/tests/reference/2023-11-07-14h57m21s-Tutanota is now Tuta su fr deutsche Version.eml @@ -0,0 +1,121 @@ +From: +MIME-Version: 1.0 +Subject: =?UTF-8?B?VHV0YW5vdGEgaXMgbm93IFR1dGEhIC8gcy51LiBmw7xyIGRldXRzY2hlIFZlcnNpb24=?= +Content-Type: multipart/related; boundary="----------79Bu5A16qPEYcVIZL@tutanota" + +------------79Bu5A16qPEYcVIZL@tutanota +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: base64 + +PGRpdj5EZWFyIHByaXZhY3kgZmFuLDwvZGl2PiAgIDxkaXY+PGJyPjwvZGl2PiAgPGRpdj5XZSBhcm +UgZXhjaXRlZCB0byBhbm5vdW5jZSB0aGF0IFR1dGFub3RhIGlzIG5vdyBUdXRhISBTaW5jZSB0aGUg +bGF1bmNoIG9mIFR1dGFub3RhIGluIDIwMTQgd2UgbG92ZWQgb3VyIG5hbWUgd2l0aCBpdHMgZGVlcC +BhbmQgc21hcnQgbWVhbmluZyAoInNlY3VyZSBtZXNzYWdlIiBpbiBMYXRpbikuIEhvd2V2ZXIsIHRv +ZGF5IHdlIGFyZSBoYXBweSB0aGF0IHdlIGNhbiBtZWV0IG9uZSBtYWluIHdpc2ggb2Ygb3VyIGNvbW +11bml0eTogYSBzaG9ydGVyIG5hbWUuIE5vdyBUdXRhIGlzIHRoZSB3b3JsZCdzIG9ubHkgc2VjdXJl +IGVtYWlsIHBsYXRmb3JtIG9mZmVyaW5nIGEgZm91ci1sZXR0ZXIgY29tIGRvbWFpbiAtIHR1dGEuY2 +9tLiBUaGlzIGJyYW5kLW5ldyBkb21haW4gaXMgcmVzZXJ2ZWQgZXhjbHVzaXZlbHkgZm9yIG1lbWJl +cnMgd2hvIGFyZSB0YWtpbmcgYWR2YW50YWdlIG9mIGEgbmV3IHN1YnNjcmlwdGlvbiBwbGFuLiBJbi +BjYXNlLCB5b3UncmUgc3RpbGwgdXNpbmcgYSBsZWdhY3kgc3Vic2NyaXB0aW9uIChlLmcuIFByZW1p +dW0pLCBub3cgaXMgdGhlIG1vbWVudCB0byBtYWtlIHRoZSBqdW1wIHRvIFJldm9sdXRpb25hcnkgb3 +IgTGVnZW5kIGFuZCBncmFiIHlvdXIgbmFtZSBAIHR1dGEuY29tITwvZGl2PiAgPGRpdj48YnI+PC9k +aXY+ICA8ZGl2PkFsbCBUdXRhIHVzZXJzIG9uIG9uZSBvZiB0aGUgbmV3IHBsYW5zIChSZXZvbHV0aW +9uYXJ5LCBMZWdlbmQsIEVzc2VudGlhbCwgQWR2YW5jZWQgJiBVbmxpbWl0ZWQpIHdpbGwgYmUgYWJs +ZSB0byByZWdpc3RlciBAIHR1dGEuY29tIGVtYWlsIGFkZHJlc3NlcyBpbiBhIGZldyBkYXlzLiA8YS +BocmVmPSIvc2V0dGluZ3Mvc3Vic2NyaXB0aW9uIj5Td2l0Y2ggbm93IHRvIGJlIHJlYWR5IHRoZSBt +b21lbnQgdGhpcyBhd2Vzb21lIGRvbWFpbiBiZWNvbWVzIGF2YWlsYWJsZSE8L2E+IE9mIGNvdXJzZS +wgbm90aGluZyB3aWxsIGNoYW5nZSB3aXRoIHlvdXIgZXhpc3RpbmcgVHV0YW5vdGEgZW1haWwgYWRk +cmVzc2VzLjwvZGl2PiAgPGRpdj48YnI+PC9kaXY+ICA8aDM+TW9yZSB0aGFuIGp1c3QgZW1haWw8L2 +gzPiA8ZGl2Pjxicj48L2Rpdj4gICAgPGRpdj5UaGUgbmV3IG5hbWUgVHV0YSBhbHNvIHJlZmxlY3Rz +IHRoYXQgVHV0YSBpcyBncm93aW5nIGJleW9uZCB0aGUgbGltaXRzIG9mIGVtYWlsLiBZb3VyIFR1dG +EgYWNjb3VudCBjb21lcyBzdGFuZGFyZCB3aXRoIGFuIGVuY3J5cHRlZCBjYWxlbmRhciwgZW5jcnlw +dGVkIGFkZHJlc3MgYm9vayAtIGFuZCBpbiB0aGUgZnV0dXJlIGVuY3J5cHRlZCBjbG91ZCBzdG9yYW +dlLiBJbiBhZGRpdGlvbiB3ZSBhcmUgYWxyZWFkeSB3b3JraW5nIG9uIHBvc3QtcXVhbnR1bSBzZWN1 +cmUgZW5jcnlwdGlvbiB0byBzdGF5IGFoZWFkIGluIHRoZSBxdWFudHVtIHJhY2UgYW5kIG1ha2Ugc3 +VyZSB5b3VyIGRhdGEgc3RheXMgc2FmZSBmb3IgZGVjYWRlcyB0byBjb21lLiBSZWFkIG1vcmUgb24g +b3VyIDxhIGhyZWY9Imh0dHBzOi8vdHV0YS5jb20vYmxvZy90dXRhbm90YS1pcy1ub3ctdHV0YSI+Ym +xvZzwvYT4gb24gd2h5IHdlIGNoYW5nZWQgb3VyIG5hbWUgdG8gVHV0YSBhbmQgd2hhdCB0byBleHBl +Y3QgZnJvbSB1cyBpbiB0aGUgZnV0dXJlLjwvZGl2PiAgPGRpdj48YnI+PC9kaXY+ICA8aDM+RG8gSS +BuZWVkIHRvIGRvIGFueXRoaW5nIG5vdz88L2gzPiAgPGRpdj48YnI+PC9kaXY+IDxkaXY+RnJvbSBu +b3cgb24geW91IHdpbGwgYWNjZXNzIHlvdXIgZW5jcnlwdGVkIG1haWxib3ggdmlhIDxhIGhyZWY9Im +h0dHBzOi8vYXBwLnR1dGEuY29tIj5hcHAudHV0YS5jb208L2E+LiBZb3UgY2FuIGVpdGhlciBsb2dp +biBmcmVzaCBvbiB0aGlzIHBhZ2UsIG9yIG1pZ3JhdGUgc2F2ZWQgY3JlZGVudGlhbHMgZnJvbSBvdX +Igb2xkIGRvbWFpbiB0byB0aGUgbmV3IG9uZS4gVG8gbWlncmF0ZSB5b3VyIHNhdmVkIGNyZWRlbnRp +YWxzLCBqdXN0IGdvIHRvIG91ciA8YSBocmVmPSJodHRwczovL21haWwudHV0YW5vdGEuY29tIj5vbG +QgbG9naW4gcGFnZSBhbmQgZm9sbG93IHRoZSBwcm9tcHRzIHRoZXJlPC9hPi4gT24gYWxsIG90aGVy +IGNsaWVudHMgKG1vYmlsZSBhcHBzLCBkZXNrdG9wIGNsaWVudHMpIHN0b3JlZCBjcmVkZW50aWFscy +B3aWxsIHN0aWxsIHdvcmsuIElmIHlvdSB1c2UgVTJGIGhhcmR3YXJlIGtleXMsIHBsZWFzZSBtYWtl +IHN1cmUgdG8gYWxzbyByZWdpc3RlciB0aGVzZSB3aXRoIHRoZSBuZXcgZG9tYWluLjwvZGl2PjxkaX +Y+PGJyPjwvZGl2PiAgPGRpdj5TdGF5IHNlY3VyZSw8YnI+WW91ciBUdXRhIFRlYW08L2Rpdj48ZGl2 +Pjxicj48L2Rpdj48ZGl2PlN0YXkgaW4gdGhlIGxvb3AgYWJvdXQgdXBjb21pbmcgVHV0YSBmZWF0dX +Jlczo8L2Rpdj4gPGRpdj48YnI+PC9kaXY+PGRpdj48YSBocmVmPSJodHRwczovL21hc3RvZG9uLnNv +Y2lhbC9AVHV0YW5vdGEiPkZvbGxvdyB1cyBvbiBNYXN0b2RvbjwvYT48L2Rpdj48ZGl2PjxhIGhyZW +Y9Imh0dHBzOi8vdHdpdHRlci5jb20vVHV0YVByaXZhY3kiPkZvbGxvdyB1cyBvbiBUd2l0dGVyPC9h +PjwvZGl2PjxkaXY+PGEgaHJlZj0iaHR0cHM6Ly9mYWNlYm9vay5jb20vdHV0YXByaXZhY3kiPkZvbG +xvdyB1cyBvbiBGYWNlYm9vazwvYT48L2Rpdj48ZGl2PjxhIGhyZWY9Imh0dHBzOi8vd3d3Lmxpbmtl +ZGluLmNvbS9jb21wYW55L3R1dGFub3RhLyI+Rm9sbG93IHVzIG9uIExpbmtlZEluPC9hPjwvZGl2Pj +xkaXY+PGEgaHJlZj0iaHR0cHM6Ly93d3cucmVkZGl0LmNvbS9yL3R1dGFub3RhLyI+Rm9sbG93IHVz +IG9uIFJlZGRpdDwvYT48L2Rpdj48ZGl2PjxhIGhyZWY9Imh0dHBzOi8vd3d3Lmluc3RhZ3JhbS5jb2 +0vdHV0YXByaXZhY3kvIj5Gb2xsb3cgdXMgb24gSW5zdGFncmFtPC9hPjwvZGl2PjxkaXY+PGEgaHJl +Zj0iaHR0cHM6Ly93d3cudGlrdG9rLmNvbS9AdHV0YXByaXZhY3kiPkZvbGxvdyB1cyBvbiBUaWtUb2 +s8L2E+PC9kaXY+PGRpdj48YSBocmVmPSJodHRwczovL3d3dy55b3V0dWJlLmNvbS9jL1R1dGFQcml2 +YWN5LyI+Rm9sbG93IG91ciBZb3VUdWJlIGNoYW5uZWw8L2E+PC9kaXY+PGRpdj48YnI+PC9kaXY+PG +gzPkdlcm1hbiB2ZXJzaW9uPC9oMz48ZGl2Pjxicj48L2Rpdj4gICAgPGRpdj5XaXIgZnJldWVuIHVu +cyBzZWhyLCBoZXV0ZSBhbnp1a8O8bmRpZ2VuLCBkYXNzIFR1dGFub3RhIGpldHp0IFR1dGEgaGVpw5 +90ISBTZWl0IGRlbSBTdGFydCB2b24gVHV0YW5vdGEgaW0gSmFociAyMDE0IHdhcmVuIHdpciBncm/D +n2UgRmFucyB1bnNlcmVzIE5hbWVucyBtaXQgc2VpbmVyIHRpZWZncsO8bmRpZ2VuIHVuZCBpbnRlbG +xpZ2VudGVuIEJlZGV1dHVuZyAoInNpY2hlcmUgTmFjaHJpY2h0IiBhdWYgTGF0ZWluKS4gSGV1dGUg +ZnJldWVuIHdpciB1bnMgamVkb2NoLCBkYXNzIHdpciBlaW5lIHdpY2h0aWdlIEFuZm9yZGVydW5nIH +Vuc2VyZXIgQ29tbXVuaXR5IGVyZsO8bGxlbiBrw7ZubmVuOiBlaW5lbiBrw7xyemVyZW4gTmFtZW4u +IFR1dGEgaXN0IGpldHp0IGRlciBlaW56aWdlIHNpY2hlcmUgRS1NYWlsLUFuYmlldGVyLCBkZXIgZW +luZSBjb20tRG9tYWluIG1pdCB2aWVyIEJ1Y2hzdGFiZW4gYW5iaWV0ZXQgLSB0dXRhLmNvbS4gRGll +c2UgYnJhbmRuZXVlIERvbWFpbiBpc3QgZsO8ciBkaWVqZW5pZ2VuIHJlc2VydmllcnQsIGRpZSBlaW +4gbmV1ZXMgQWJvbm5lbWVudCBudXR6ZW4uIEZhbGxzIGR1IG5vY2ggZWluIGFsdGVzIEFib25uZW1l +bnQgbnV0enQgKHouQi4gUHJlbWl1bSksIGlzdCBqZXR6dCBkZXIgcmljaHRpZ2UgWmVpdHB1bmt0LC +B1bSBhdWYgUmV2b2x1dGlvbmFyeSBvZGVyIExlZ2VuZCB1bXp1c3RlaWdlbiB1bmQgZGlyIGRlaW5l +biBOYW1lbiBAIHR1dGEuY29tIHp1IHNpY2hlcm4hPC9kaXY+ICA8ZGl2Pjxicj48L2Rpdj4gIDxkaX +Y+V2VubiBkdSBlaW5lbiBkZXIgbmV1ZW4gVGFyaWZlIChSZXZvbHV0aW9uYXJ5LCBMZWdlbmQsIEVz +c2VudGlhbCwgQWR2YW5jZWQgJiBVbmxpbWl0ZWQpIG51dHp0LCBrYW5uc3QgZHUgZGlyIGluIHdlbm +lnZW4gVGFnZW4gZGVpbmUgZWlnZW5lIEAgdHV0YS5jb20tRS1NYWlsLUFkcmVzc2UgenVsZWdlbi4g +PGEgaHJlZj0iL3NldHRpbmdzL3N1YnNjcmlwdGlvbiI+V2VjaHNlbCBqZXR6dCwgdW0gZsO8ciBkZW +4gTW9tZW50IGJlcmVpdCB6dSBzZWluLCB3ZW5uIGRpZSBuZXVlIERvbWFpbiB2ZXJmw7xnYmFyIHdp +cmQhPC9hPiBOYXTDvHJsaWNoIMOkbmRlcnQgc2ljaCBuaWNodHMgZsO8ciBkZWluZSBleGlzdGllcm +VuZGUgVHV0YW5vdGEgRS1NYWlsLUFkcmVzc2UuPC9kaXY+ICA8ZGl2Pjxicj48L2Rpdj4gIDxoMz5N +ZWhyIGFscyBudXIgRS1NYWlsPC9oMz4gPGRpdj48YnI+PC9kaXY+ICAgIDxkaXY+RGVyIG5ldWUgTm +FtZSBUdXRhIHNwaWVnZWx0IGF1Y2ggd2lkZXIsIGRhc3MgVHV0YSBpbnp3aXNjaGVuIG1laHIgYWxz +IG51ciBFLU1haWwgaXN0LiBEZWluIFR1dGEtS29udG8gdmVyZsO8Z3Qgc3RhbmRhcmRtw6TDn2lnIM +O8YmVyIGVpbmVuIHZlcnNjaGzDvHNzZWx0ZW4gS2FsZW5kZXIsIGVpbiB2ZXJzY2hsw7xzc2VsdGVz +IEFkcmVzc2J1Y2ggLSB1bmQgaW4gWnVrdW5mdCBhdWNoIMO8YmVyIGVpbmVuIHZlcnNjaGzDvHNzZW +x0ZW4gQ2xvdWQtU3BlaWNoZXIuIERhcsO8YmVyIGhpbmF1cyBhcmJlaXRlbiB3aXIgYmVyZWl0cyBh +biBlaW5lciBzaWNoZXJlbiBQb3N0LVF1YW50ZW4tVmVyc2NobMO8c3NlbHVuZywgdW0gaW0gVmVyc2 +NobMO8c3NlbHVuZ3N3ZXR0bGF1ZiBkaWUgTmFzZSB2b3JuIHp1IGhhYmVuIHVuZCBzaWNoZXJ6dXN0 +ZWxsZW4sIGRhc3MgZGVpbmUgRGF0ZW4gYXVjaCBpbiBkZW4gbsOkY2hzdGVuIEphaHJ6ZWhudGVuIH +NpY2hlciBzaW5kLiBMaWVzIG1laHIgYXVmIHVuc2VyZW0gPGEgaHJlZj0iaHR0cHM6Ly90dXRhLmNv +bS9ibG9nL3R1dGFub3RhLWlzLW5vdy10dXRhIj5CbG9nPC9hPiwgd2FydW0gd2lyIHVuc2VyZW4gTm +FtZW4gZ2XDpG5kZXJ0IGhhYmVuIHVuZCB3YXMgZHUgaW4gZGVyIFp1a3VuZnQgdm9uIHVucyBlcndh +cnRlbiBrYW5uc3QuPC9kaXY+ICA8ZGl2Pjxicj48L2Rpdj4gIDxoMz5NdXNzIGljaCBqZXR6dCBldH +dhcyB0dW4/PC9oMz4gIDxkaXY+PGJyPjwvZGl2PiA8ZGl2PlZvbiBqZXR6dCBhbiBrYW5uc3QgZHUg +ZGljaCB1bnRlciA8YSBocmVmPSJodHRwczovL2FwcC50dXRhLmNvbSI+YXBwLnR1dGEuY29tPC9hPi +BpbiBkZWluZSB2ZXJzY2hsw7xzc2VsdGUgTWFpbGJveCBlaW5sb2dnZW4uIER1IGthbm5zdCBkaWNo +IGVudHdlZGVyIG5ldSBlaW5sb2dnZW4gb2RlciBkZWluZSBnZXNwZWljaGVydGVuIFp1Z8OkbmdlIG +1pZ3JpZXJlbi4gVW0gZGVpbmUgZ2VzcGVpY2hlcnRlbiBadWfDpG5nZSB6dSBtaWdyaWVyZW4sIGdl +aGUgZWluZmFjaCB6dSB1bnNlcmVyIDxhIGhyZWY9Imh0dHBzOi8vbWFpbC50dXRhbm90YS5jb20iPm +FsdGVuIExvZ2luLVNlaXRlIHVuZCBiZWZvbGdlIGRpZSBBbndlaXN1bmdlbiBkb3J0PC9hPi4gSW4g +YWxsZW4gYW5kZXJlbiBDbGllbnRzIChtb2JpbGUgQXBwcywgRGVza3RvcCBDbGllbnRzKSBmdW5rdG +lvbmllcmVuIGdlc3BlaWNoZXJ0ZSBadWfDpG5nZSB3ZWl0ZXJoaW4uIFdlbm4gZHUgVTJGLVNjaGzD +vHNzZWwgbnV0enQsIGRlbmtlIGRhcmFuLCBkaWVzZSBhdWNoIGF1ZiBkZXIgbmV1ZW4gRG9tYWluIH +p1IHJlZ2lzdHJpZXJlbi48L2Rpdj4gPGRpdj48YnI+PC9kaXY+ICAgICAgIDxkaXY+VmllbGUgR3LD +vMOfZSw8YnI+ICAgICBkZWluIFR1dGFub3RhLVRlYW08L2Rpdj48ZGl2Pjxicj48L2Rpdj48ZGl2Pk +JsZWliIGF1ZiBkZW0gTGF1ZmVuZGVuIMO8YmVyIGtvbW1lbmRlIFR1dGFub3RhLUZlYXR1cmVzOjwv +ZGl2PjxkaXY+PGJyPjwvZGl2PiA8ZGl2PjxhIGhyZWY9Imh0dHBzOi8vbWFzdG9kb24uc29jaWFsL0 +BUdXRhbm90YSI+TWFzdG9kb248L2E+PC9kaXY+PGRpdj48YSBocmVmPSJodHRwczovL3R3aXR0ZXIu +Y29tL1R1dGFQcml2YWN5Ij5Ud2l0dGVyPC9hPjwvZGl2PjxkaXY+PGEgaHJlZj0iaHR0cHM6Ly9mYW +NlYm9vay5jb20vdHV0YXByaXZhY3kiPkZhY2Vib29rPC9hPjwvZGl2PjxkaXY+PGEgaHJlZj0iaHR0 +cHM6Ly93d3cubGlua2VkaW4uY29tL2NvbXBhbnkvdHV0YW5vdGEvIj5MaW5rZWRJbjwvYT48L2Rpdj +48ZGl2PjxhIGhyZWY9Imh0dHBzOi8vd3d3LnJlZGRpdC5jb20vci90dXRhbm90YS8iPlJlZGRpdDwv +YT48L2Rpdj48ZGl2PjxhIGhyZWY9Imh0dHBzOi8vd3d3Lmluc3RhZ3JhbS5jb20vdHV0YXByaXZhY3 +kvIj5JbnN0YWdyYW08L2E+PC9kaXY+PGRpdj48YSBocmVmPSJodHRwczovL3d3dy50aWt0b2suY29t +L0B0dXRhcHJpdmFjeSI+VGlrVG9rPC9hPjwvZGl2PjxkaXY+PGEgaHJlZj0iaHR0cHM6Ly93d3cueW +91dHViZS5jb20vYy9UdXRhUHJpdmFjeS8iPllvdVR1YmU8L2E+PC9kaXY+ICA= + +------------79Bu5A16qPEYcVIZL@tutanota-- \ No newline at end of file diff --git a/tests/reference/2023-12-22-09h49m57s-Privacy for Everyone su fr deutsche Version.eml b/tests/reference/2023-12-22-09h49m57s-Privacy for Everyone su fr deutsche Version.eml new file mode 100644 index 0000000..ee404f1 --- /dev/null +++ b/tests/reference/2023-12-22-09h49m57s-Privacy for Everyone su fr deutsche Version.eml @@ -0,0 +1,186 @@ +From: +MIME-Version: 1.0 +Subject: =?UTF-8?B?UHJpdmFjeSBmb3IgRXZlcnlvbmUhIC8gcy51LiBmw7xyIGRldXRzY2hlIFZlcnNpb24=?= +Content-Type: multipart/related; boundary="----------79Bu5A16qPEYcVIZL@tutanota" + +------------79Bu5A16qPEYcVIZL@tutanota +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: base64 + +PGRpdj5EZWFyIFByaXZhY3kgRmFuLDwvZGl2PjxkaXY+PGJyPjwvZGl2PiA8ZGl2PkNocmlzdG1hcy +BpcyBhcHByb2FjaGluZyBxdWlja2x5LCBhbmQgd2l0aCBpdCB0aGUgTmV3IFllYXIuIEZvciB0aGlz +IE5ldyBZZWFyJ3MgUmVzb2x1dGlvbiBoZWxwIHlvdXIgZnJpZW5kcyBhbmQgZmFtaWx5IHRvIGdvIH +ByaXZhdGU6IEl0J3Mgc28gZWFzeSB3aXRoIFR1dGEgZ2lmdCBjYXJkcy4g4p2k77iPIEp1c3QgdXBn +cmFkZSB5b3VyIG93biBhY2NvdW50IGFuZCBwdXJjaGFzZSBnaWZ0IGNhcmRzIHVuZGVyIFNldHRpbm +dzIC0+IFBsYW4gLT4gR2lmdCBjYXJkcy4gSXQncyBvbmx5IDM2IGV1cm9zIHBlciBwZXJzb24gdG8g +Z2l2ZSBwcml2YWN5IGZvciBhbiBlbnRpcmUgeWVhciEgQW5kLCBpbiBjb250cmFzdCB0byBvdGhlci +BnaWZ0IGNhcmRzIHRoYXQgbWlnaHQgZXhwaXJlLCB5b3UgY2FuIGFsd2F5cyB1c2UgdGhlIGdpZnQg +Y2FyZCB0byB0b3AgdXAgeW91ciBvd24gYWNjb3VudC4gSW4gY2FzZSB5b3UgcHJlZmVyIHRvIHVwZ3 +JhZGUgYW5vbnltb3VzbHksIHlvdSBjYW4gYWxzbyBwYXkgZm9yIHRoZSBnaWZ0IGNhcmRzIHdpdGgg +PGEgaHJlZj0iaHR0cHM6Ly90dXRhbm90YS5jb20vZmFxI2NyeXB0b2N1cnJlbmN5Ij5jYXNoIG9yIG +NyeXB0b2N1cnJlbmN5PC9hPi48L2Rpdj4gIDxkaXY+PGJyPjwvZGl2PiA8aDM+V2hhdCBZb3UgR2V0 +IFdpdGggUmV2b2x1dGlvbmFyeTwvaDM+PGRpdj48YnI+PC9kaXY+PGRpdj5XZSBhcmUgY2VydGFpbi +B0aGF0IHlvdSBhbmQgeW91ciBmcmllbmRzIHdpbGwgbG92ZSBUdXRhIFJldm9sdXRpb25hcnkuIFdp +dGggdGhlIHVwZ3JhZGUgeW91IG5vdCBvbmx5IGdldCBhIHNlY3VyZSwgZ3JlZW4gYW5kIGFkLWZyZW +UgbWFpbGJveCwgYnV0IGFsc28gMTUgYWRkaXRpb25hbCBlbWFpbCBhZGRyZXNzZXMgKGluY2x1ZGlu +ZyBhZGRyZXNzZXMgd2l0aCBvdXIgbmV3IHR1dGEuY29tIGRvbWFpbiBmb3Igd2hpY2ggbG90cyBvZi +BuYW1lcyBhcmUgc3RpbGwgYXZhaWxhYmxlISksIDIwIEdCIG9mIHN0b3JhZ2UsIGluYm94IHJ1bGVz +LCB1bmxpbWl0ZWQgc2VhcmNoIGFuZCB1bmxpbWl0ZWQgb2ZmbGluZSBhY2Nlc3MgdG8geW91ciBlbm +NyeXB0ZWQgZGF0YSwgc3VwcG9ydCBmb3IgMyBjdXN0b20gZG9tYWlucywgbXVsdGlwbGUgY2FsZW5k +YXJzIGFuZCBtb3JlLiA8Yj5UaGUgbW9zdCBsb3ZlZCBuZXcgZmVhdHVyZXMgZm9yIFJldm9sdXRpb2 +5hcnkgYW5kIExlZ2VuZCBpbiAyMDIzIHdlcmUgdGhlIG5ldyB0dXRhLmNvbSBEb21haW4sIHVubGlt +aXRlZCBlbWFpbCBhZGRyZXNzZXMgZm9yIGN1c3RvbSBkb21haW5zIGFuZCBzaGFyZWQgbWFpbGJveG +VzIGZvciBwZW9wbGUgb24gdGhlIGZhbWlseSBwbGFuLjwvYj48L2Rpdj48ZGl2Pjxicj48L2Rpdj48 +aDM+SHVnZSBDYWxlbmRhciBJbXByb3ZlbWVudHMgJiBTcG9pbGVyPC9oMz48ZGl2Pjxicj48L2Rpdj +48ZGl2PllvdSBtaWdodCBoYXZlIG5vdGljZWQgdGhhdCB3ZSBhcmUgY3VycmVudGx5IHB1dHRpbmcg +YSBsb3Qgb2YgZWZmb3J0IGludG8gZ2V0dGluZyBvdXIgZW5jcnlwdGVkIGNhbGVuZGFyIG91dCBvZi +BiZXRhLiBXaGlsZSB5b3UgY2FuIGFscmVhZHkgc2VuZCBhbmQgcmVjZWl2ZSBjYWxlbmRhciBpbnZp +dGF0aW9ucyB2aWEgZW1haWwgYW5kIHNoYXJlIGVudGlyZSBjYWxlbmRhcnMgd2l0aCBhbGwgb3VyIH +BhaWQgcGxhbnMsIHRoZSBjYWxlbmRhciBoYXMgbm93IGFsc28gc2VlbiBncmVhdCBpbXByb3ZlbWVu +dHMgZm9yIGFsbCB1c2VyczogV2UgYWRhcHRlZCB0aGUgbGF5b3V0IG9mIHRoZSBjYWxlbmRhciB2aW +V3cyB0byBpbXByb3ZlIHVzYWJpbGl0eSwgYWNjZXNzaWJpbGl0eSBhbmQgcHJvZHVjdGl2aXR5IGlu +IHRoZSB3ZWIvZGVza3RvcCBjbGllbnRzIGFuZCBzcGVjaWZpY2FsbHkgaW4gdGhlIG1vYmlsZSBhcH +BzLiBUaGVyZSBpcyBhIGRheS1vZi10aGUtd2VlayBzZWxlY3RvciBmb3IgdGhlIGRheSBhbmQgYWdl +bmRhIHZpZXdzLiBUaGUgd2VlayBjYW4gYmUgc3dpcGVkIHRvIHNob3cgdGhlIHByZXZpb3VzIG9yIG +5leHQgd2Vlay4gQWxsIG9mIHRoZXNlIGNoYW5nZXMgbWFrZSB0aGUgbmF2aWdhdGlvbiBtdWNoIGVh +c2llciBhbmQgZmFzdGVyLiBBbmQgLSBzcG9pbGVyIGFsZXJ0ISAtIHdlIGFyZSBjdXJyZW50bHkgd2 +9ya2luZyBvbiBhIHNlYXJjaCBmZWF0dXJlIGZvciB0aGUgY2FsZW5kYXIgdGhhdCB3ZSBhcmUgcGxh +bm5pbmcgdG8gcmVsZWFzZSBiZWdpbm5pbmcgbmV4dCB5ZWFyLiBTdGF5IHR1bmVkISDwn46JIDwvZG +l2PjxkaXY+PGJyPjwvZGl2PiA8aDM+VHV0YW5vdGEgRmFuIFNob3AgQWJvdXQgVG8gQ2xvc2U8L2gz +PjxkaXY+PGJyPjwvZGl2PjxkaXY+V2UgaGF2ZSByZWNlbnRseSByZWJyYW5kZWQgVHV0YW5vdGEgdG +8gVHV0YS4gVGhpcyBjaGFuZ2Ugd2FzIGV4dHJlbWVseSB3ZWxsIHJlY2VpdmVkIGJ5IG91ciBjb21t +dW5pdHksIHBhcnRpY3VsYXJseSBzaW5jZSB3ZSBub3cgb2ZmZXIgPGEgaHJlZj0iaHR0cHM6Ly90dX +RhLmNvbS9ibG9nL3R1dGFub3RhLWlzLW5vdy10dXRhIj50aGUgc2hvcnQgZG9tYWluIHR1dGEuY29t +PC9hPi4gQnV0IHNvbWUgd2VyZSBhbHNvIGEgbGl0dGxlIG1lbGFuY2hvbGljIGFib3V0IHRoaXMgY2 +hhbmdlIGFuZCB3b3VsZCBsaWtlIHRvIHByZXNlcnZlIFR1dGFub3RhLiBOb3cgaXMgeW91ciBjaGFu +Y2UgdG8gZ3JhYiB5b3VyIGZhdm9yaXRlIFR1dGFub3RhIG1lcmNoIHdoaWxlIGl0IHN0aWxsIGxhc3 +RzISBCZXR0ZXIgYmUgZmFzdCBhcyBvdXIgc2hvcCB3aWxsIHNvb24gaGF2ZSBuZXcgVHV0YSBtZXJj +aDo8L2Rpdj48dWw+PGxpPkNoZWNrIG91dCBvdXIgPGEgaHJlZj0iaHR0cHM6Ly9zaG9wLnNwcmVhZH +NoaXJ0LmRlL3R1dGFub3Rhc2hvcCI+VHV0YW5vdGEgZmFuIHNob3AgKEV1cm9wZTsgc3dpdGNoIGRv +bWFpbiBlbmRpbmcgZm9yIHlvdXIgY291bnRyeSk8L2E+LjwvbGk+PGxpPkNoZWNrIG91dCBvdXIgPG +EgaHJlZj0iaHR0cHM6Ly9zaG9wLnNwcmVhZHNoaXJ0LmNvbS90dXRhbm90YXNob3AiPlR1dGFub3Rh +IGZhbiBzaG9wIChVU0EgJiB3b3JsZCk8L2E+LjwvbGk+PC91bD48ZGl2PjwvZGl2PjxoMz5Qcml2YW +N5IG1hdHRlcnM8L2gzPjxkaXY+PGJyPjwvZGl2PjxkaXY+V2UgYXJlIHZlcnkgaGFwcHkgdGhhdCB0 +aGlzIGhvbGlkYXkgc2Vhc29uIHdpbGwgZ2l2ZSB1cyAtIGFuZCB5b3UgLSBhIHdlbGwgZGVzZXJ2ZW +QgYnJlYWsgYWZ0ZXIgYSBsb25nIGFuZCBkZW1hbmRpbmcgeWVhci4gV2l0aCBhIHNtaWxlIG9uIG91 +ciBmYWNlcyB3ZSBhcmUgbG9va2luZyBmb3J3YXJkIHRvIGNhbG0gbW9tZW50cyB1bmRlciB0aGUgQ2 +hyaXN0bWFzIHRyZWUsIGxvdmluZyByZXVuaW9ucyB3aXRoIGZhbWlseSBhbmQgZnJpZW5kcywgYW5k +IHBvc3NpYmx5IHNvbWUgbmljZSBwcmVzZW50cyB0aGF0IHdlIHdlcmVuJ3QgZXhwZWN0aW5nLiBJdC +BpcyB0aGUgcGVyZmVjdCBtb21lbnQgdG8gdGhpbmsgYWJvdXQgd2h5IHByaXZhY3kgbWF0dGVycyBh +bmQgdG8gc2hhcmUgdGhpcyBpZGVhIHdpdGggb3VyIGxvdmVkIG9uZXMuIFdoaWxlIGVhY2ggb2YgdX +Mga25vd3Mgd2h5IHByaXZhY3kgbWF0dGVycywgb3RoZXJzIG5lZWQgbW9yZSBleHBsYWluaW5nLiBX +ZSBhcmUgdGhyaWxsZWQgdGhhdCBtb3JlIHRoYW4gdGVuIG1pbGxpb24gcGVvcGxlIGFscmVhZHkgdm +FsdWUgdGhlaXIgcHJpdmFjeSBhbmQgaGF2ZSB0dXJuZWQgdGhlaXIgYmFja3Mgb24gR21haWwgYW5k +IENvLCBhbmQgd2UgYXJlIGNlcnRhaW4gdGhhdCBtYW55IG1vcmUgd2lsbCBmb2xsb3cgb3VyIG1vdm +VtZW50ISBSZWFkIG91ciA8YSBocmVmPSJodHRwczovL3R1dGEuY29tL2Jsb2cvdHV0YS0yMDIzLWZl +YXR1cmUtcmV2aWV3Ij5yZXZpZXcgb2YgdGhlIHllYXIgMjAyMzwvYT4gdG8gZmluZCBvdXQgd2hhdC +BmZWF0dXJlcyB3ZSBoYXZlIGFkZGVkIHRoaXMgeWVhciBhbmQgd2hhdCB5b3UgY2FuIGV4cGVjdCB0 +byBjb21lIGluIDIwMjQhIPCfmI0g8J+UkiA8L2Rpdj48ZGl2Pjxicj48L2Rpdj4gIDxkaXY+RW5qb3 +kgdGhlIGhvbGlkYXlzLDxicj5Zb3VyIFR1dGEgVGVhbTwvZGl2PjxkaXY+PGJyPjwvZGl2PjxkaXY+ +U3RheSBpbiB0aGUgbG9vcCBhYm91dCB1cGNvbWluZyBUdXRhIGZlYXR1cmVzOjwvZGl2PiA8ZGl2Pj +xicj48L2Rpdj48ZGl2PjxhIGhyZWY9Imh0dHBzOi8vbWFzdG9kb24uc29jaWFsL0BUdXRhbm90YSI+ +Rm9sbG93IHVzIG9uIE1hc3RvZG9uPC9hPjwvZGl2PjxkaXY+PGEgaHJlZj0iaHR0cHM6Ly90d2l0dG +VyLmNvbS9UdXRhUHJpdmFjeSI+Rm9sbG93IHVzIG9uIFR3aXR0ZXI8L2E+PC9kaXY+PGRpdj48YSBo +cmVmPSJodHRwczovL2ZhY2Vib29rLmNvbS90dXRhcHJpdmFjeSI+Rm9sbG93IHVzIG9uIEZhY2Vib2 +9rPC9hPjwvZGl2PjxkaXY+PGEgaHJlZj0iaHR0cHM6Ly93d3cubGlua2VkaW4uY29tL2NvbXBhbnkv +dHV0YW5vdGEvIj5Gb2xsb3cgdXMgb24gTGlua2VkSW48L2E+PC9kaXY+PGRpdj48YSBocmVmPSJodH +RwczovL3d3dy5yZWRkaXQuY29tL3IvdHV0YW5vdGEvIj5Gb2xsb3cgdXMgb24gUmVkZGl0PC9hPjwv +ZGl2PjxkaXY+PGEgaHJlZj0iaHR0cHM6Ly93d3cuaW5zdGFncmFtLmNvbS90dXRhcHJpdmFjeS8iPk +ZvbGxvdyB1cyBvbiBJbnN0YWdyYW08L2E+PC9kaXY+PGRpdj48YSBocmVmPSJodHRwczovL3d3dy50 +aWt0b2suY29tL0B0dXRhcHJpdmFjeSI+Rm9sbG93IHVzIG9uIFRpa1RvazwvYT48L2Rpdj48ZGl2Pj +xhIGhyZWY9Imh0dHBzOi8vd3d3LnlvdXR1YmUuY29tL0BUdXRhUHJpdmFjeS8/c3ViX2NvbmZpcm1h +dGlvbj0xIj5Gb2xsb3cgb3VyIFlvdVR1YmUgY2hhbm5lbDwvYT48L2Rpdj48ZGl2Pjxicj48L2Rpdj +48aDM+R2VybWFuIHZlcnNpb248L2gzPjxkaXY+PGJyPjwvZGl2PiDCoCDCoCAgPGRpdj5XZWlobmFj +aHRlbiBzdGVodCB2b3IgZGVyIFTDvHIsIHVuZCBkYW1pdCBhdWNoIGRhcyBuZXVlIEphaHIuIEVpbm +Ugc3VwZXIgR2VsZWdlbmhlaXQsIHVtIGRlaW5lbiBGcmV1bmRlbiB1bmQgVmVyd2FuZHRlbiBtaXQg +ZGVtIE5ldWphaHJzdm9yc2F0eiBwcml2YXRlciBpbSBJbnRlcm5ldCB1bnRlcndlZ3MgenUgc2Vpbi +wgenUgaGVsZmVuISBNaXQgR3V0c2NoZWluZW4gdm9uIFR1dGEgaXN0IGRhcyBnYW56IGVpbmZhY2gu +IOKdpO+4jyBXZWNoc2VsIGpldHp0IHp1IFJldm9sdXRpb25hcnkgdW5kIGJ1Y2hlIHVudGVyIEVpbn +N0ZWxsdW5nZW4gLT4gQWJvbm5lbWVudCAtPiBHdXRzY2hlaW5lIGbDvHIgbnVyIDM2IEV1cm8gcHJv +IFBlcnNvbiwgdW5kIHNwZW5kaWVyZSBzbyBlaW4gZ2FuemVzIEphaHIgcHJpdmF0ZSBFLU1haWwhIF +VuZCBpbSBHZWdlbnNhdHogenUgYW5kZXJlbiBHZXNjaGVua2thcnRlbiwgZGllIHZlcmZhbGxlbiBr +w7ZubmVuLCBrYW5uc3QgZHUgdW5zZXJlIEd1dHNjaGVpbmUgamVkZXJ6ZWl0IHp1bSBBdWZsYWRlbi +BkZWluZXMgZWlnZW5lbiBLb250b3MgdmVyd2VuZGVuLiBGYWxscyBkdSBsaWViZXIgYW5vbnltIHVw +Z3JhZGVuIG3DtmNodGVuLCBrYW5uc3QgZHUgZGllIEd1dHNjaGVpbmUgYXVjaCA8YSBocmVmPSJodH +RwczovL3R1dGFub3RhLmNvbS9mYXEjY3J5cHRvY3VycmVuY3kiPmluIEJhciBvZGVyIG1pdCBCaXRj +b2luIGthdWZlbjwvYT4uPC9kaXY+ICA8ZGl2Pjxicj48L2Rpdj4gPGgzPldhcyBkdSBtaXQgUmV2b2 +x1dGlvbmFyeSBiZWtvbW1zdDwvaDM+PGRpdj48YnI+PC9kaXY+PGRpdj5XaXIgc2luZCBzaWNoZXIs +IGRhc3MgZHUgdW5kIGRlaW5lIEZyZXVuZGUgVHV0YSBSZXZvbHV0aW9uYXJ5IGxpZWJlbiB3ZXJkZW +4uIE1pdCBkZW0gVXBncmFkZSBlcmjDpGx0c3QgZHUgbmljaHQgbnVyIGVpbiBzaWNoZXJlcywgZ3LD +vG5lcyB1bmQgd2VyYmVmcmVpZXMgUG9zdGZhY2gsIHNvbmRlcm4gYXVjaCAxNSB6dXPDpHR6bGljaG +UgRS1NYWlsLUFkcmVzc2VuIChlaW5zY2hsaWXDn2xpY2ggQWRyZXNzZW4gbWl0IHVuc2VyZXIgbmV1 +ZW4gdHV0YS5jb20tRG9tYWluLCBmw7xyIGRpZSBub2NoIHZpZWxlIE5hbWVuIHZlcmbDvGdiYXIgc2 +luZCEpLCAyMCBHQiBTcGVpY2hlcnBsYXR6LCBQb3N0ZWluZ2FuZ3NyZWdlbG4sIHVuYmVncmVuenRl +IFN1Y2hlIHVuZCB1bmJlZ3Jlbnp0ZW4gT2ZmbGluZS1adWdyaWZmIGF1ZiBkZWluZSB2ZXJzY2hsw7 +xzc2VsdGVuIERhdGVuLCBVbnRlcnN0w7x0enVuZyBmw7xyIDMgZWlnZW5lIERvbWFpbnMsIG1laHJl +cmUgS2FsZW5kZXIgdW5kIG1laHIuIDxiPkRpZSBiZWxpZWJ0ZXN0ZW4gbmV1ZW4gRnVua3Rpb25lbi +Bmw7xyIFJldm9sdXRpb25hcnkgdW5kIExlZ2VuZCBpbSBKYWhyIDIwMjMgd2FyZW4gZGllIG5ldWUg +VHV0YS5jb20tRG9tYWluLCB1bmJlZ3Jlbnp0ZSBFLU1haWwtQWRyZXNzZW4gZsO8ciBlaWdlbmUgRG +9tYWlucyB1bmQgZ2V0ZWlsdGUgUG9zdGbDpGNoZXIgZsO8ciBQZXJzb25lbiBtaXQgZGVtIEZhbWls +aWVudGFyaWYuPC9iPjwvZGl2PjxkaXY+PGJyPjwvZGl2PjxoMz5Fbm9ybWUgS2FsZW5kZXJ2ZXJiZX +NzZXJ1bmdlbiAmIFNwb2lsZXI8L2gzPjxkaXY+PGJyPjwvZGl2PjxkaXY+RHUgaGFzdCB2aWVsbGVp +Y2h0IHNjaG9uIGJlbWVya3QsIGRhc3Mgd2lyIGRlcnplaXQgaW50ZW5zaXYgZGFyYW4gYXJiZWl0ZW +4sIHVuc2VyZW4gdmVyc2NobMO8c3NlbHRlbiBLYWxlbmRlciBhdXMgZGVyIEJldGEtUGhhc2UgaGVy +YXVzenVicmluZ2VuLiBXw6RocmVuZCBkdSBtaXQgZWluZW0ga29zdGVucGZsaWNodGlnZW4gQWJvIG +JlcmVpdHMgS2FsZW5kZXJlaW5sYWR1bmdlbiBwZXIgRS1NYWlsIHNlbmRlbiB1bmQgZW1wZmFuZ2Vu +IHVuZCB2b2xsc3TDpG5kaWdlIEthbGVuZGVyIHRlaWxlbiBrYW5uc3QsIGhhdCBkZXIgS2FsZW5kZX +IgbnVuIGF1Y2ggc3VwZXIgVmVyYmVzc2VydW5nZW4gZsO8ciBhbGxlIE51dHplciBiZWtvbW1lbjog +V2lyIGhhYmVuIGRhcyBMYXlvdXQgZGVyIEthbGVuZGVyYW5zaWNodCBhbmdlcGFzc3QsIHVtIGRpZS +BCZW51dHplcmZyZXVuZGxpY2hrZWl0IHVuZCBQcm9kdWt0aXZpdMOkdCBpbiBkZW4gV2ViLSBzb3dp +ZSBEZXNrdG9wLUNsaWVudHMgdW5kIHNwZXppZWxsIGluIGRlbiBtb2JpbGVuIEFwcHMgenUgdmVyYm +Vzc2Vybi4gRXMgZ2lidCBqZXR6dCBlaW5lIFdvY2hlbnRhZ3NhdXN3YWhsIGbDvHIgZGllIFRhZ2Vz +LSB1bmQgQWdlbmRhLUFuc2ljaHRlbi4gRHVyY2ggU3dpcGVuIGthbm4gbWFuIHp1ciB2b3JoZXJpZ2 +VuIG9kZXIgbsOkY2hzdGVuIFdvY2hlIHdlY2hzZWxuLiBBbGwgZGllc2Ugw4RuZGVydW5nZW4gbWFj +aGVuIGRpZSBOYXZpZ2F0aW9uIHZpZWwgZWluZmFjaGVyIHVuZCBzY2huZWxsZXIuIFVuZCAtIFNwb2 +lsZXItQWxhcm0hIC0gd2lyIGFyYmVpdGVuIGRlcnplaXQgYW4gZWluZXIgU3VjaGZ1bmt0aW9uIGbD +vHIgZGVuIEthbGVuZGVyLCBkaWUgd2lyIEFuZmFuZyBuw6RjaHN0ZW4gSmFocmVzIHZlcsO2ZmZlbn +RsaWNoZW4gd29sbGVuLiBCbGVpYiBnZXNwYW5udCEg8J+OiSA8L2Rpdj48ZGl2Pjxicj48L2Rpdj4g +PGgzPlR1dGFub3RhIEZhbiBTaG9wIHNjaGxpZcOfdDwvaDM+PGRpdj48YnI+PC9kaXY+PGRpdj5XaX +IgaGFiZW4gVHV0YW5vdGEga8O8cnpsaWNoIGluIFR1dGEgdW1iZW5hbm50LiBEaWVzZSDDhG5kZXJ1 +bmcgd3VyZGUgdm9uIHVuc2VyZXIgQ29tbXVuaXR5IHNlaHIgcG9zaXRpdiBhdWZnZW5vbW1lbiwgen +VtYWwgd2lyIG51biA8YSBocmVmPSJodHRwczovL3R1dGEuY29tL2Jsb2cvdHV0YW5vdGEtaXMtbm93 +LXR1dGEiPmRpZSBrdXJ6ZSBEb21haW4gVHV0YS5jb208L2E+IGFuYmlldGVuLiBBYmVyIGVpbmlnZS +B3YXJlbiBhdWNoIGVpbiB3ZW5pZyBub3N0YWxnaXNjaCDDvGJlciBkaWVzZSBWZXLDpG5kZXJ1bmcg +dW5kIG3DtmNodGVuIFR1dGFub3RhIGVyaGFsdGVuLiBKZXR6dCBoYXN0IGR1IGRpZSBlaW5tYWxpZ2 +UgQ2hhbmNlLCBkaXIgZGVpbiBMaWVibGluZ3MtVHV0YW5vdGEtTWVyY2hhbmRpc2UgenUgc2ljaGVy +biwgc29sYW5nZSBlcyBub2NoIHZlcmbDvGdiYXIgaXN0ISBTZWkgbGllYmVyIHNjaG5lbGwsIGRlbm +4gaW4gdW5zZXJlbSBTaG9wIHdpcmQgZXMgYmFsZCBuZXVlIFR1dGEtQXJ0aWtlbCBnZWJlbiE8L2Rp +dj48dWw+PGxpPkhpZXIgZ2VodCBlcyB6dSB1bnNlcmVtIDxhIGhyZWY9Imh0dHBzOi8vc2hvcC5zcH +JlYWRzaGlydC5kZS90dXRhbm90YXNob3AiPlR1dGFub3RhIEZhbi1TaG9wIChFdXJvcGE7IHdlY2hz +ZWwgZWluZmFjaCBkaWUgRG9tYWluZW5kdW5nIGbDvHIgZGVpbiBMYW5kKTwvYT4uPC9saT48bGk+SG +llciBnZWh0IGVzIHp1IHVuc2VyZW0gPGEgaHJlZj0iaHR0cHM6Ly9zaG9wLnNwcmVhZHNoaXJ0LmNv +bS90dXRhbm90YXNob3AiPlR1dGFub3RhIEZhbi1TaG9wIChVU0EgJiB3ZWx0d2VpdCk8L2E+LjwvbG +k+PC91bD48ZGl2PjwvZGl2PjxoMz5Qcml2YWN5IG1hdHRlcnM8L2gzPjxkaXY+PGJyPjwvZGl2Pjxk +aXY+V2lyIHNpbmQgc2VociBmcm9oLCBkYXNzIGRpZSBXZWlobmFjaHRzemVpdCB1bnMgLSB1bmQgZG +lyIC0gbmFjaCBlaW5lbSBsYW5nZW4gdW5kIGFuc3RyZW5nZW5kZW4gSmFociBlaW5lIHdvaGx2ZXJk +aWVudGUgUGF1c2UgdmVyc2NoYWZmdC4gTWl0IGVpbmVtIEzDpGNoZWxuIGltIEdlc2ljaHQgZnJldW +VuIHdpciB1bnMgYXVmIHJ1aGlnZSBNb21lbnRlIHVudGVyIGRlbSBXZWlobmFjaHRzYmF1bSwgbGll +YmV2b2xsZSBXaWVkZXJzZWhlbiBtaXQgRmFtaWxpZSB1bmQgRnJldW5kZW4gdW5kIHZpZWxsZWljaH +QgZWluIHBhYXIgc2Now7ZuZSBHZXNjaGVua2UsIG1pdCBkZW5lbiB3aXIgbmljaHQgZ2VyZWNobmV0 +IGhhYmVuLiBEYXMgaXN0IGRlciBwZXJmZWt0ZSBNb21lbnQsIHVtIGRhcsO8YmVyIG5hY2h6dWRlbm +tlbiwgd2FydW0gZGllIFByaXZhdHNwaMOkcmUgc28gd2ljaHRpZyBpc3QsIHVuZCB1bSBkaWVzZSBJ +ZGVlIG1pdCB1bnNlcmVuIExpZWJlbiB6dSB0ZWlsZW4uIFfDpGhyZW5kIGplZGVyIHZvbiB1bnMgd2 +Vpw58sIHdhcnVtIGRpZSBQcml2YXRzcGjDpHJlIHdpY2h0aWcgaXN0LCBicmF1Y2hlbiBhbmRlcmUg +bWVociBFcmtsw6RydW5nZW4uIFdpciBzaW5kIGJlZ2Vpc3RlcnQsIGRhc3MgYmVyZWl0cyBtZWhyIG +FscyB6ZWhuIE1pbGxpb25lbiBNZW5zY2hlbiBpaHJlIFByaXZhdHNwaMOkcmUgc2Now6R0emVuIHVu +ZCBHbWFpbCB1bmQgQ28uIGRlbiBSw7xja2VuIGdla2VocnQgaGFiZW4sIHVuZCB3aXIgc2luZCBzaW +NoZXIsIGRhc3MgdmllbGUgd2VpdGVyZSB1bnNlcmVyIEJld2VndW5nIGZvbGdlbiB3ZXJkZW4hIExp +ZXMgdW5zZXJlbiA8YSBocmVmPSJodHRwczovL3R1dGEuY29tL2Jsb2cvdHV0YS0yMDIzLWZlYXR1cm +UtcmV2aWV3Ij5Sw7xja2JsaWNrIGF1ZiBkYXMgSmFociAyMDIzPC9hPiB1bSBoZXJhdXN6dWZpbmRl +biwgd2VsY2hlIEZ1bmt0aW9uZW4gd2lyIGRpZXNlcyBKYWhyIGhpbnp1Z2Vmw7xndCBoYWJlbiB1bm +Qgd2VsY2hlIGR1IDIwMjQgZXJ3YXJ0ZW4ga2FubnN0ISDwn5iNIPCflJIgPC9kaXY+IDxkaXY+PGJy +PjwvZGl2PiAgIDxkaXY+VmllbGVuIERhbmssPGJyPiDCoCDCoCBkZWluIFR1dGFub3RhLVRlYW08L2 +Rpdj48ZGl2Pjxicj48L2Rpdj48ZGl2PkJsZWliIGF1ZiBkZW0gTGF1ZmVuZGVuIMO8YmVyIGtvbW1l +bmRlIFR1dGFub3RhLUZlYXR1cmVzOjwvZGl2PjxkaXY+PGJyPjwvZGl2PiA8ZGl2PjxhIGhyZWY9Im +h0dHBzOi8vbWFzdG9kb24uc29jaWFsL0BUdXRhbm90YSI+TWFzdG9kb248L2E+PC9kaXY+PGRpdj48 +YSBocmVmPSJodHRwczovL3R3aXR0ZXIuY29tL1R1dGFQcml2YWN5Ij5Ud2l0dGVyPC9hPjwvZGl2Pj +xkaXY+PGEgaHJlZj0iaHR0cHM6Ly9mYWNlYm9vay5jb20vdHV0YXByaXZhY3kiPkZhY2Vib29rPC9h +PjwvZGl2PjxkaXY+PGEgaHJlZj0iaHR0cHM6Ly93d3cubGlua2VkaW4uY29tL2NvbXBhbnkvdHV0YW +5vdGEvIj5MaW5rZWRJbjwvYT48L2Rpdj48ZGl2PjxhIGhyZWY9Imh0dHBzOi8vd3d3LnJlZGRpdC5j +b20vci90dXRhbm90YS8iPlJlZGRpdDwvYT48L2Rpdj48ZGl2PjxhIGhyZWY9Imh0dHBzOi8vd3d3Lm +luc3RhZ3JhbS5jb20vdHV0YXByaXZhY3kvIj5JbnN0YWdyYW08L2E+PC9kaXY+PGRpdj48YSBocmVm +PSJodHRwczovL3d3dy50aWt0b2suY29tL0B0dXRhcHJpdmFjeSI+VGlrVG9rPC9hPjwvZGl2PjxkaX +Y+PGEgaHJlZj0iaHR0cHM6Ly93d3cueW91dHViZS5jb20vQFR1dGFQcml2YWN5Lz9zdWJfY29uZmly +bWF0aW9uPTEiPllvdVR1YmU8L2E+PC9kaXY+ICA= + +------------79Bu5A16qPEYcVIZL@tutanota-- \ No newline at end of file diff --git a/tests/reference/2024-02-14-17h34m30s-Test Mail 1.eml b/tests/reference/2024-02-14-17h34m30s-Test Mail 1.eml new file mode 100644 index 0000000..5fe8b5a --- /dev/null +++ b/tests/reference/2024-02-14-17h34m30s-Test Mail 1.eml @@ -0,0 +1,59 @@ +Authentication-Results: w10.tutanota.de (dis=neutral; info=dmarc default policy); + dmarc=pass (dis=neutral p=quarantine; aspf=r; adkim=r; pSrc=config) header.from=gmail.com; + dkim=pass header.d=gmail.com header.s=20230601 header.b=edoBn9eF +Received: from w1.tutanota.de ([192.168.1.162]) + by tutadb.w10.tutanota.de + with SMTP (SubEthaSMTP 3.1.7) id LSM2NHGE + for fritz.hutmacher@tutanota.com; + Wed, 14 Feb 2024 18:34:30 +0100 (CET) +Received-SPF: Pass (mailfrom) identity=mailfrom; client-ip=209.85.128.44; helo=mail-wm1-f44.google.com; envelope-from=marco.riesa@gmail.com; receiver= +Received: from mail-wm1-f44.google.com (mail-wm1-f44.google.com [209.85.128.44]) + by w1.tutanota.de (Postfix) with ESMTPS id A6805FBFB61 + for ; Wed, 14 Feb 2024 17:34:30 +0000 (UTC) +Received: by mail-wm1-f44.google.com with SMTP id 5b1f17b1804b1-411d715c401so10647515e9.1 + for ; Wed, 14 Feb 2024 09:34:30 -0800 (PST) +DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; + d=gmail.com; s=20230601; t=1707932070; x=1708536870; darn=tutanota.com; + h=to:subject:message-id:date:from:mime-version:from:to:cc:subject + :date:message-id:reply-to; + bh=1FMOce3XlhDnbuU/D4w6qoja3PAFHNbt6ySK0Le/s68=; + b=edoBn9eFJTEEpUNgv7G9Z3QwPyGA+qM+1ov0tWUAgTgukBypy4NAVUnLGpESgnH0Qc + QLjTeUEF48QYY8C8Tl/wvH+AKdYEpQEPmW4Su+NjFt1l1W2/uh3+rTmbjlVMEKxrFUrZ + f+FKA7BTOuFY5pie9kbwKpB9iZCLpkjIvqAxEvYFDKI0XLy9M426nZEGAjNaHBtLYeIh + YcbPlW2XbpMGzSjgwPXxD6sYG/+8wdScGzmE2l/W86Qt6fTTEqNxKgfP97Dsus401hTw + xSO0Si370E/W6YHozsk4Q5SCxeYqXlpr51rXOGoyMA6mjnOczM1MxcOqWwn/CQ+jAAtW + r25g== +X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; + d=1e100.net; s=20230601; t=1707932070; x=1708536870; + h=to:subject:message-id:date:from:mime-version:x-gm-message-state + :from:to:cc:subject:date:message-id:reply-to; + bh=1FMOce3XlhDnbuU/D4w6qoja3PAFHNbt6ySK0Le/s68=; + b=VJ+u1T2U9LukqEgfm40NdL2KRz2iFK/e52B3/FakH69lH7Oi5RZqL7ojAkHbmBBlP2 + OZRZUZ7dEUh3wP35zX0ldxxvVyz8zFeSHcLlwW8y5hd3c74Gwa61VdsXQwmKHw4jRhAi + 5OdJNl+/2xnPkRVM5NfFIV/cqZpmzxxJrj5BbjQRTCMxyPwVsojHL281fOJRjwQqZcbr + nBpu+B6qywbyVtL2XbEx0VklE6N037RXXtknWnOI/9LROsNDTYMlxBuNsOqaEThiBRZf + 2qJkLsScw+nU/1nGN38dKXHIHPdBEjRZXf3+FX4IizIy2/Fi0sDCfdvm9OS8l6xflQtT + t9rw== +X-Gm-Message-State: AOJu0Yynqcf2UnJXgdKgIFEJIXFsyV6Bi6nxwUTifCOWU8E5Lxz1xhkO + d4agnCd7qgIuMW0+Ia+edj3056Em7Fytor7euHdbOkdLY41LQ/ctN06F4hJY3WTTHaR5QfDZD85 + rU2o9FHuMfXRArQ4kyZbC5Zi5n0KTXFuF +X-Google-Smtp-Source: AGHT+IEThXqTWt03nPDGRK/WwQxD3d433TQshr/qcHWTuG5zBIX2xY+Ece7FqNFXDrZMnhuv4/RG45Yn8J129LIcCB0= +X-Received: by 2002:adf:f243:0:b0:33b:87a0:3af with SMTP id + b3-20020adff243000000b0033b87a003afmr2024372wrp.67.1707932069953; Wed, 14 Feb + 2024 09:34:29 -0800 (PST) +MIME-Version: 1.0 +From: Marco Neumann +Date: Wed, 14 Feb 2024 18:34:18 +0100 +Message-ID: +Subject: Test Mail 1 +To: fritz.hutmacher@tutanota.com +Content-Type: multipart/alternative; boundary="00000000000054847d06115aec07" + +--00000000000054847d06115aec07 +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: base64 + +PGRpdiBkaXI9Imx0ciI+PGRpdj5IZWxsbyBXb3JsZCE8L2Rpdj48ZGl2Pjxicj48L2Rpdj48ZGl2Pl +RoaXMgaXMgYSB0ZXN0Ljxicj48L2Rpdj48L2Rpdj4NCg== + +--00000000000054847d06115aec07-- \ No newline at end of file diff --git a/tests/reference/2024-02-14-17h38m34s-Test Mail 2.eml b/tests/reference/2024-02-14-17h38m34s-Test Mail 2.eml new file mode 100644 index 0000000..4a6f6f3 --- /dev/null +++ b/tests/reference/2024-02-14-17h38m34s-Test Mail 2.eml @@ -0,0 +1,2022 @@ +Authentication-Results: w10.tutanota.de (dis=neutral; info=dmarc default policy); + dmarc=pass (dis=neutral p=quarantine; aspf=r; adkim=r; pSrc=config) header.from=gmail.com; + dkim=pass header.d=gmail.com header.s=20230601 header.b=N+IFdeCJ +Received: from mail.w11.tutanota.de ([192.168.1.211]) + by tutadb.w10.tutanota.de + with SMTP (SubEthaSMTP 3.1.7) id LSM2SU93 + for fritz.hutmacher@tutanota.com; + Wed, 14 Feb 2024 18:38:34 +0100 (CET) +Received-SPF: Pass (mailfrom) identity=mailfrom; client-ip=209.85.128.50; helo=mail-wm1-f50.google.com; envelope-from=marco.riesa@gmail.com; receiver= +Received: from mail-wm1-f50.google.com (mail-wm1-f50.google.com [209.85.128.50]) + by mail.w11.tutanota.de (Postfix) with ESMTPS id 77CE4B0149C8 + for ; Wed, 14 Feb 2024 18:38:34 +0100 (CET) +Received: by mail-wm1-f50.google.com with SMTP id 5b1f17b1804b1-412078e983aso664435e9.1 + for ; Wed, 14 Feb 2024 09:38:34 -0800 (PST) +DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; + d=gmail.com; s=20230601; t=1707932314; x=1708537114; darn=tutanota.com; + h=to:subject:message-id:date:from:mime-version:from:to:cc:subject + :date:message-id:reply-to; + bh=U/hc+aodWZDHzeAB7RUs4wd0zt/VnQC6HlFkFuCatpg=; + b=N+IFdeCJh1SdhNBOv1zQY8sFWo7mRsUa6sJycSXfPVqkR27OYNSXD6eibOxTCHgN5q + 3KX1WomCmnp6N+0cTFmwe8kAgtrBZLn1h2ayjzI5jQIuOlfnk6WL/wvAXHgYnlzku33i + pErHn9X1oXCl9DIutg8571ee158OsWR5KZ/4abvI65JeKE4Sden4kNPLnk7n0YmoJpz9 + c+viZs0iVsPVpTn1EEu1MeSc4w5cFaQtn010qr25lWnTp5xPQQf9ZVYl8PYn10O+CGPd + tUfVrURwZEnswjqZIqejwxIeQMPHxF9B0aVWBgTtB5/XUL7DI+5yuvWzznOszASnxgu4 + epog== +X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; + d=1e100.net; s=20230601; t=1707932314; x=1708537114; + h=to:subject:message-id:date:from:mime-version:x-gm-message-state + :from:to:cc:subject:date:message-id:reply-to; + bh=U/hc+aodWZDHzeAB7RUs4wd0zt/VnQC6HlFkFuCatpg=; + b=VDkoFdGusAIxodscAHKDjqWf5eQqnIuWYKUsTCT5nemdJd+rrOzaU8Kui9h5NTx4Ms + CBIeDqtfkp27ofv8vWj1ZWRt0nftcNPdlyLwVEOF1m0N4+KbRIqz9uXizWVhQ45qUCQb + CKTeJ7ziK70pUUliTGgtBM7uUl+NpNq3MnQ2ATxSeTBlTkPEH0tF4Snk8u5a/RoXh7Xc + 1jqfIQ92iJ9j7uGmMx+L+ywvIYHEpTy8qWYXRfQ26pODcJAsTltz8QyVJ9dotCYN3mM0 + 69sg1gPuSRN6+wtTKnOsYn9r58tp3ajw60LcNG0QbOrePM0bnsLpPyxW3h9BMMCImdZ5 + mc8g== +X-Gm-Message-State: AOJu0YwtN/OD3RzHLwSA1Ix1T/4Lpi1O/XmW9/jUxRutMkPytgtX7bDF + SNdxt2y6MDolF3eUwSi7rLyvwoziOJ8t8bPVAkOVMZ7b9Gs5kr6QPhahCneMX/D77Rxg2JxqFSL + ZQ0PE2f40Zx+1egiRbXSPDumzNFXwrRJe +X-Google-Smtp-Source: AGHT+IE6xIvlPuhbk9fHEIenUAKN37yxBkdFAC/Vw1c+dz+pBd+lsaG9N/ps2vGrCpO1dMKXdFjlZtjViedUKRVdans= +X-Received: by 2002:adf:f9c6:0:b0:33b:5979:b92b with SMTP id + w6-20020adff9c6000000b0033b5979b92bmr2371502wrr.1.1707932313716; Wed, 14 Feb + 2024 09:38:33 -0800 (PST) +MIME-Version: 1.0 +From: Marco Neumann +Date: Wed, 14 Feb 2024 18:38:22 +0100 +Message-ID: +Subject: Test Mail 2 +To: fritz.hutmacher@tutanota.com +Content-Type: multipart/mixed; boundary="000000000000dc325006115afa66" + +--000000000000dc325006115afa66 +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: base64 + +PGRpdiBkaXI9Imx0ciI+VGhpcyBoYXMgc29tZSBhdHRhY2hlbWVudHMuPGJyPjwvZGl2Pg0K + +--000000000000dc325006115afa66 +Content-Type: image/jpeg; name==?UTF-8?B?Ym9vay5qcGc=?= +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; filename==?UTF-8?B?Ym9vay5qcGc=?= +Content-Id: + +/9j/4AAQSkZJRgABAQEASABIAAD/4QCuRXhpZgAASUkqAAgAAAAHABIBAwABAAAAAQAAABoBBQABAA +AAYgAAABsBBQABAAAAagAAACgBAwABAAAAAgAAADEBAgANAAAAcgAAADIBAgAUAAAAgAAAAGmHBAAB +AAAAlAAAAAAAAABIAAAAAQAAAEgAAAABAAAAR0lNUCAyLjEwLjM2AAAyMDI0OjAyOjE0IDE4OjM3Oj +IyAAEAAaADAAEAAAABAAAAAAAAAP/hDM9odHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvADw/eHBh +Y2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldG +EgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDQuNC4wLUV4aXYyIj4g +PHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YX +gtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9u +cy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0RXZ0PSJodHRwOi8vbnMuYWRvYmUuY29tL3 +hhcC8xLjAvc1R5cGUvUmVzb3VyY2VFdmVudCMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMv +ZWxlbWVudHMvMS4xLyIgeG1sbnM6R0lNUD0iaHR0cDovL3d3dy5naW1wLm9yZy94bXAvIiB4bWxucz +p4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOkRvY3VtZW50SUQ9ImdpbXA6 +ZG9jaWQ6Z2ltcDpjZGE2MjJlYi00Nzk5LTRmN2MtOTNhMC02YTViMzBlODBjNGEiIHhtcE1NOkluc3 +RhbmNlSUQ9InhtcC5paWQ6MjdiZTg5NWQtMTI2Zi00ODFlLTllZjQtNmE2NWE3YjA2OWMxIiB4bXBN +TTpPcmlnaW5hbERvY3VtZW50SUQ9InhtcC5kaWQ6MWY2MDI4NWYtNzVlZi00NzBkLWIzMWYtYTdiYm +RhODkzOTFhIiBkYzpGb3JtYXQ9ImltYWdlL2pwZWciIEdJTVA6QVBJPSIyLjAiIEdJTVA6UGxhdGZv +cm09IkxpbnV4IiBHSU1QOlRpbWVTdGFtcD0iMTcwNzkzMjI1MjU0MzE0MiIgR0lNUDpWZXJzaW9uPS +IyLjEwLjM2IiB4bXA6Q3JlYXRvclRvb2w9IkdJTVAgMi4xMCIgeG1wOk1ldGFkYXRhRGF0ZT0iMjAy +NDowMjoxNFQxODozNzoyMiswMTowMCIgeG1wOk1vZGlmeURhdGU9IjIwMjQ6MDI6MTRUMTg6Mzc6Mj +IrMDE6MDAiPiA8eG1wTU06SGlzdG9yeT4gPHJkZjpTZXE+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJz +YXZlZCIgc3RFdnQ6Y2hhbmdlZD0iLyIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDpjYjAzMjJjZC +0wYzhmLTQ2MmMtYTgwMS0xNjJkZTJhYjlhZjgiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkdpbXAgMi4x +MCAoTGludXgpIiBzdEV2dDp3aGVuPSIyMDI0LTAyLTE0VDE4OjM3OjMyKzAxOjAwIi8+IDwvcmRmOl +NlcT4gPC94bXBNTTpIaXN0b3J5PiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1w +bWV0YT4gICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICA8P3hwYWNrZXQgZW5kPSJ3Ij8+/+ICsElDQ19QUk9GSUxFAAEBAAACoGxjbXMEMAAAbW50 +clJHQiBYWVogB+gAAgAOABEAJAAoYWNzcEFQUEwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPbWAA +EAAAAA0y1sY21zAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAN +ZGVzYwAAASAAAABAY3BydAAAAWAAAAA2d3RwdAAAAZgAAAAUY2hhZAAAAawAAAAsclhZWgAAAdgAAA +AUYlhZWgAAAewAAAAUZ1hZWgAAAgAAAAAUclRSQwAAAhQAAAAgZ1RSQwAAAhQAAAAgYlRSQwAAAhQA +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 +/Z + +--000000000000dc325006115afa66 +Content-Type: image/jpeg; name==?UTF-8?B?c3VuLmpwZw==?= +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; filename==?UTF-8?B?c3VuLmpwZw==?= +Content-Id: + +/9j/4AAQSkZJRgABAQEASABIAAD/4QCuRXhpZgAASUkqAAgAAAAHABIBAwABAAAAAQAAABoBBQABAA +AAYgAAABsBBQABAAAAagAAACgBAwABAAAAAgAAADEBAgANAAAAcgAAADIBAgAUAAAAgAAAAGmHBAAB +AAAAlAAAAAAAAABIAAAAAQAAAEgAAAABAAAAR0lNUCAyLjEwLjM2AAAyMDI0OjAyOjE0IDE4OjM3Oj +U0AAEAAaADAAEAAAABAAAAAAAAAP/hDM9odHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvADw/eHBh +Y2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldG +EgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDQuNC4wLUV4aXYyIj4g +PHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YX +gtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9u +cy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0RXZ0PSJodHRwOi8vbnMuYWRvYmUuY29tL3 +hhcC8xLjAvc1R5cGUvUmVzb3VyY2VFdmVudCMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMv +ZWxlbWVudHMvMS4xLyIgeG1sbnM6R0lNUD0iaHR0cDovL3d3dy5naW1wLm9yZy94bXAvIiB4bWxucz +p4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOkRvY3VtZW50SUQ9ImdpbXA6 +ZG9jaWQ6Z2ltcDphNjhlYmZlYS0zOGQ4LTQ1ZDQtOTZhYy02NjM5MmYwNTE2ZjIiIHhtcE1NOkluc3 +RhbmNlSUQ9InhtcC5paWQ6YjQ2YTMwNzgtODllNy00ZjY1LTk5MjYtYmY5NzNkMzk2MTQxIiB4bXBN +TTpPcmlnaW5hbERvY3VtZW50SUQ9InhtcC5kaWQ6M2NkYTlmZTMtNDEyZi00MmVlLWJhMDctOTcxMT +UyYmE4NDRmIiBkYzpGb3JtYXQ9ImltYWdlL2pwZWciIEdJTVA6QVBJPSIyLjAiIEdJTVA6UGxhdGZv +cm09IkxpbnV4IiBHSU1QOlRpbWVTdGFtcD0iMTcwNzkzMjI3OTAzMzA1OCIgR0lNUDpWZXJzaW9uPS +IyLjEwLjM2IiB4bXA6Q3JlYXRvclRvb2w9IkdJTVAgMi4xMCIgeG1wOk1ldGFkYXRhRGF0ZT0iMjAy +NDowMjoxNFQxODozNzo1NCswMTowMCIgeG1wOk1vZGlmeURhdGU9IjIwMjQ6MDI6MTRUMTg6Mzc6NT +QrMDE6MDAiPiA8eG1wTU06SGlzdG9yeT4gPHJkZjpTZXE+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJz +YXZlZCIgc3RFdnQ6Y2hhbmdlZD0iLyIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDpmN2I2MzFiZS +0wMzdkLTQ0ZTEtOGQ0NS1hMjYxZGJiM2FiOTQiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkdpbXAgMi4x +MCAoTGludXgpIiBzdEV2dDp3aGVuPSIyMDI0LTAyLTE0VDE4OjM3OjU5KzAxOjAwIi8+IDwvcmRmOl +NlcT4gPC94bXBNTTpIaXN0b3J5PiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1w +bWV0YT4gICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg +ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC +AgICAgICA8P3hwYWNrZXQgZW5kPSJ3Ij8+/+ICsElDQ19QUk9GSUxFAAEBAAACoGxjbXMEMAAAbW50 +clJHQiBYWVogB+gAAgAOABEAJAAoYWNzcEFQUEwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPbWAA +EAAAAA0y1sY21zAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAN +ZGVzYwAAASAAAABAY3BydAAAAWAAAAA2d3RwdAAAAZgAAAAUY2hhZAAAAawAAAAsclhZWgAAAdgAAA +AUYlhZWgAAAewAAAAUZ1hZWgAAAgAAAAAUclRSQwAAAhQAAAAgZ1RSQwAAAhQAAAAgYlRSQwAAAhQA +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 +gxlMm8l+//AEzWT/4T/wD41//Z + +--000000000000dc325006115afa66-- \ No newline at end of file diff --git a/tests/reference/2024-02-18-16h54m17s-Test.eml b/tests/reference/2024-02-18-16h54m17s-Test.eml new file mode 100644 index 0000000..ace4afc --- /dev/null +++ b/tests/reference/2024-02-18-16h54m17s-Test.eml @@ -0,0 +1,15 @@ +From: +MIME-Version: 1.0 +Subject: =?UTF-8?B?VGVzdA==?= +Content-Type: multipart/related; boundary="----------79Bu5A16qPEYcVIZL@tutanota" + +------------79Bu5A16qPEYcVIZL@tutanota +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: base64 + +PGRpdiBkaXI9ImF1dG8iPkhlbGxvITxicj48L2Rpdj48ZGl2IGRpcj0iYXV0byI+PGJyPjwvZGl2Pj +xkaXYgZGlyPSJhdXRvIj4tLSA8YnI+PC9kaXY+PGRpdiBkaXI9ImF1dG8iPiBTZW50IHdpdGggVHV0 +YTsgZW5qb3kgc2VjdXJlICZhbXA7IGFkLWZyZWUgZW1haWxzOiA8YnI+PC9kaXY+PGRpdiBkaXI9Im +F1dG8iPiBodHRwczovL3R1dGEuY29tPGJyPjwvZGl2Pg== + +------------79Bu5A16qPEYcVIZL@tutanota-- \ No newline at end of file From 7e74198f46c84b14f1210a3c7467bfb7580925af Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Sun, 31 Mar 2024 18:12:28 +0200 Subject: [PATCH 43/44] concurrent downloads --- src/main.rs | 68 ++++++++++++++++++++++++++++++++--------------------- 1 file changed, 41 insertions(+), 27 deletions(-) diff --git a/src/main.rs b/src/main.rs index 534c720..cbbbb8e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,7 +10,7 @@ use crate::{ use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; use folders::Folder; -use futures::TryStreamExt; +use futures::{StreamExt, TryStreamExt}; use logging::{setup_logging, LoggingCLIConfig}; use tracing::{debug, info}; @@ -56,6 +56,10 @@ struct Args { #[derive(Debug, Parser)] struct DownloadCLIConfig { + /// Concurrent downloads. + #[clap(long, action, default_value_t = 5)] + concurrent_downloads: usize, + /// Folder name. #[clap(long, action)] folder: String, @@ -130,32 +134,42 @@ async fn exec_cmd(client: &Client, session: &Session, cmd: Command) -> Result<() .context("folder not found")?; debug!(mails = folder.mails.as_str(), "download mails from folder"); - let mails = Mail::list(client, session, &folder); - let mut mails = std::pin::pin!(mails); - while let Some(mail) = mails.try_next().await.context("list mails")? { - let target_file = cfg.path.join(format!( - "{}-{}.eml", - mail.date.format("%Y-%m-%d-%Hh%Mm%Ss"), - escape_file_string(&mail.subject), - )); - - if tokio::fs::try_exists(&target_file) - .await - .context("check file existence")? - { - info!(id = mail.mail_id.as_str(), "already exists"); - } else { - info!(id = mail.mail_id.as_str(), "download"); - let mail = mail - .download(client, session) - .await - .context("download mail")?; - let eml = emit_eml(&mail).context("emit eml")?; - write_to_file(eml.as_bytes(), &target_file) - .await - .context("write output file")?; - } - } + Mail::list(client, session, &folder) + .map(|mail| { + let cfg = &cfg; + + async move { + let mail = mail.context("list mail")?; + + let target_file = cfg.path.join(format!( + "{}-{}.eml", + mail.date.format("%Y-%m-%d-%Hh%Mm%Ss"), + escape_file_string(&mail.subject), + )); + + if tokio::fs::try_exists(&target_file) + .await + .context("check file existence")? + { + info!(id = mail.mail_id.as_str(), "already exists"); + } else { + info!(id = mail.mail_id.as_str(), "download"); + let mail = mail + .download(client, session) + .await + .context("download mail")?; + let eml = emit_eml(&mail).context("emit eml")?; + write_to_file(eml.as_bytes(), &target_file) + .await + .context("write output file")?; + } + + Ok(()) as Result<()> + } + }) + .buffer_unordered(cfg.concurrent_downloads) + .try_collect::<()>() + .await?; Ok(()) } From 98d8a02e08593aba8463e321857e9b2f542bb7fa Mon Sep 17 00:00:00 2001 From: Marco Neumann Date: Sun, 31 Mar 2024 18:13:26 +0200 Subject: [PATCH 44/44] clean up --- .gitignore | 4 - Cargo.lock | 1794 ---------------------------------- Cargo.toml | 21 - README.md | 27 +- bors.toml | 5 - src/commands/export.rs | 305 ------ src/commands/list_folders.rs | 65 -- src/commands/mod.rs | 2 - src/error.rs | 73 -- src/logging.rs | 36 - src/login.rs | 133 --- src/main.rs | 96 -- src/non_empty_string.rs | 43 - src/storage.rs | 50 - src/thirtyfour_util.rs | 75 -- src/webdriver.rs | 84 -- tests/cli.rs | 54 - 17 files changed, 3 insertions(+), 2864 deletions(-) delete mode 100644 .gitignore delete mode 100644 Cargo.lock delete mode 100644 Cargo.toml delete mode 100644 bors.toml delete mode 100644 src/commands/export.rs delete mode 100644 src/commands/list_folders.rs delete mode 100644 src/commands/mod.rs delete mode 100644 src/error.rs delete mode 100644 src/logging.rs delete mode 100644 src/login.rs delete mode 100644 src/main.rs delete mode 100644 src/non_empty_string.rs delete mode 100644 src/storage.rs delete mode 100644 src/thirtyfour_util.rs delete mode 100644 src/webdriver.rs delete mode 100644 tests/cli.rs diff --git a/.gitignore b/.gitignore deleted file mode 100644 index dfa432b..0000000 --- a/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -/target -.env -*.png -out/ diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index 8015b91..0000000 --- a/Cargo.lock +++ /dev/null @@ -1,1794 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 3 - -[[package]] -name = "addr2line" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a76fd60b23679b7d19bd066031410fb7e458ccc5e958eb5c325888ce4baedc97" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" - -[[package]] -name = "aho-corasick" -version = "0.7.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac" -dependencies = [ - "memchr", -] - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anstream" -version = "0.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd2405b3ac1faab2990b74d728624cd9fd115651fcecc7c2d8daf01376275ba" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41ed9a86bf92ae6580e0a31281f65a1b1d867c0cc68d5346e2ae128dddfa6a7d" - -[[package]] -name = "anstyle-parse" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e765fd216e48e067936442276d1d57399e37bce53c264d6fefbe298080cb57ee" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b" -dependencies = [ - "windows-sys 0.48.0", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0699d10d2f4d628a98ee7b57b289abbc98ff3bad977cb3152709d4bf2330628" -dependencies = [ - "anstyle", - "windows-sys 0.48.0", -] - -[[package]] -name = "anyhow" -version = "1.0.79" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca" - -[[package]] -name = "assert_cmd" -version = "2.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00ad3f3a942eee60335ab4342358c161ee296829e0d16ff42fc1d6cb07815467" -dependencies = [ - "anstyle", - "bstr", - "doc-comment", - "predicates", - "predicates-core", - "predicates-tree", - "wait-timeout", -] - -[[package]] -name = "async-trait" -version = "0.1.77" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c980ee35e870bd1a4d2c8294d4c04d0499e67bca1e4b5cefcc693c2fa00caea9" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.48", -] - -[[package]] -name = "autocfg" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" - -[[package]] -name = "backtrace" -version = "0.3.67" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233d376d6d185f2a3093e58f283f60f880315b6c60075b01f36b3b85154564ca" -dependencies = [ - "addr2line", - "cc", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", -] - -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - -[[package]] -name = "base64" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a4ddaa51a5bc52a6948f74c06d20aaaddb71924eab79b8c97a8c556e942d6a" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bstr" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6798148dccfbff0fae41c7574d2fa8f1ef3492fba0face179de5d8d447d67b05" -dependencies = [ - "memchr", - "regex-automata 0.3.6", - "serde", -] - -[[package]] -name = "bumpalo" -version = "3.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "572f695136211188308f16ad2ca5c851a712c464060ae6974944458eb83880ba" - -[[package]] -name = "bytes" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfb24e866b15a1af2a1b663f10c6b6b8f397a84aadb828f12e5b289ec23a3a3c" - -[[package]] -name = "cc" -version = "1.0.78" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a20104e2335ce8a659d6dd92a51a767a0c062599c73b343fd152cb401e828c3d" - -[[package]] -name = "cfg-if" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" - -[[package]] -name = "chrono" -version = "0.4.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b0a3d9ed01224b22057780a37bb8c5dbfe1be8ba48678e7bf57ec4b385411f" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-integer", - "num-traits", - "serde", - "time 0.1.45", - "wasm-bindgen", - "winapi", -] - -[[package]] -name = "clap" -version = "4.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e578d6ec4194633722ccf9544794b71b1385c3c027efe0c55db226fc880865c" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4df4df40ec50c46000231c914968278b1eb05098cf8f1b3a518a95030e71d1c7" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf9804afaaf59a91e75b022a30fb7229a7901f60c755489cc61c9b423b836442" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.48", -] - -[[package]] -name = "clap_lex" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1" - -[[package]] -name = "codespan-reporting" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" -dependencies = [ - "termcolor", - "unicode-width", -] - -[[package]] -name = "colorchoice" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" - -[[package]] -name = "cookie" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" -dependencies = [ - "percent-encoding", - "time 0.3.17", - "version_check", -] - -[[package]] -name = "core-foundation" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" - -[[package]] -name = "cxx" -version = "1.0.86" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d1075c37807dcf850c379432f0df05ba52cc30f279c5cfc43cc221ce7f8579" -dependencies = [ - "cc", - "cxxbridge-flags", - "cxxbridge-macro", - "link-cplusplus", -] - -[[package]] -name = "cxx-build" -version = "1.0.86" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5044281f61b27bc598f2f6647d480aed48d2bf52d6eb0b627d84c0361b17aa70" -dependencies = [ - "cc", - "codespan-reporting", - "once_cell", - "proc-macro2", - "quote", - "scratch", - "syn 1.0.107", -] - -[[package]] -name = "cxxbridge-flags" -version = "1.0.86" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61b50bc93ba22c27b0d31128d2d130a0a6b3d267ae27ef7e4fae2167dfe8781c" - -[[package]] -name = "cxxbridge-macro" -version = "1.0.86" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39e61fda7e62115119469c7b3591fd913ecca96fb766cfd3f2e2502ab7bc87a5" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.107", -] - -[[package]] -name = "difflib" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" - -[[package]] -name = "doc-comment" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" - -[[package]] -name = "dotenvy" -version = "0.15.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" - -[[package]] -name = "fantoccini" -version = "0.19.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65f0fbe245d714b596ba5802b46f937f5ce68dcae0f32f9a70b5c3b04d3c6f64" -dependencies = [ - "base64 0.13.1", - "cookie", - "futures-core", - "futures-util", - "http", - "hyper", - "hyper-rustls", - "mime", - "serde", - "serde_json", - "time 0.3.17", - "tokio", - "url", - "webdriver", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "form_urlencoded" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9c384f161156f5260c24a097c56119f9be8c798586aecc13afbcbe7b7e26bf8" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" - -[[package]] -name = "futures-executor" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" - -[[package]] -name = "futures-macro" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.48", -] - -[[package]] -name = "futures-sink" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" - -[[package]] -name = "futures-task" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" - -[[package]] -name = "futures-util" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", -] - -[[package]] -name = "gimli" -version = "0.27.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c80984affa11d98d1b88b66ac8853f143217b399d3c74116778ff8fdb4ed2e" - -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - -[[package]] -name = "heck" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" - -[[package]] -name = "hermit-abi" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee512640fe35acbfb4bb779db6f0d80704c2cacfa2e39b601ef3e3f47d1ae4c7" -dependencies = [ - "libc", -] - -[[package]] -name = "http" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75f43d41e26995c17e71ee126451dd3941010b0514a81a9d11f3b341debc2399" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http-body" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1" -dependencies = [ - "bytes", - "http", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" - -[[package]] -name = "httpdate" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" - -[[package]] -name = "hyper" -version = "0.14.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "034711faac9d2166cb1baf1a2fb0b60b1f277f8492fd72176c17f3515e1abd3c" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.4.9", - "tokio", - "tower-service", - "tracing", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1788965e61b367cd03a62950836d5cd41560c3577d90e40e0819373194d1661c" -dependencies = [ - "http", - "hyper", - "log", - "rustls", - "rustls-native-certs", - "tokio", - "tokio-rustls", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64c122667b287044802d6ce17ee2ddf13207ed924c712de9a66a5814d5b64765" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "wasm-bindgen", - "winapi", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0703ae284fc167426161c2e3f1da3ea71d94b21bedbcc9494e92b28e334e3dca" -dependencies = [ - "cxx", - "cxx-build", -] - -[[package]] -name = "idna" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" -dependencies = [ - "unicode-bidi", - "unicode-normalization", -] - -[[package]] -name = "indexmap" -version = "1.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885e79c1fc4b10f0e172c475f458b7f7b93061064d98c3293e98c5ba0c8b399" -dependencies = [ - "autocfg", - "hashbrown", -] - -[[package]] -name = "itoa" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fad582f4b9e86b6caa621cabeb0963332d92eea04729ab12892c2533951e6440" - -[[package]] -name = "js-sys" -version = "0.3.60" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49409df3e3bf0856b916e2ceaca09ee28e6871cf7d9ce97a692cacfdb2a25a47" -dependencies = [ - "wasm-bindgen", -] - -[[package]] -name = "lazy_static" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" - -[[package]] -name = "libc" -version = "0.2.150" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89d92a4743f9a61002fae18374ed11e7973f530cb3a3255fb354818118b2203c" - -[[package]] -name = "link-cplusplus" -version = "1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecd207c9c713c34f95a097a5b029ac2ce6010530c7b49d7fea24d977dede04f5" -dependencies = [ - "cc", -] - -[[package]] -name = "lock_api" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435011366fe56583b16cf956f9df0095b405b82d76425bc8981c0e22e60ec4df" -dependencies = [ - "autocfg", - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "matchers" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" -dependencies = [ - "regex-automata 0.1.10", -] - -[[package]] -name = "memchr" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" - -[[package]] -name = "mime" -version = "0.3.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" - -[[package]] -name = "miniz_oxide" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b275950c28b37e794e8c55d88aeb5e139d0ce23fdbbeda68f8d7174abdf9e8fa" -dependencies = [ - "adler", -] - -[[package]] -name = "mio" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dce281c5e46beae905d4de1870d8b1509a9142b62eedf18b443b011ca8343d0" -dependencies = [ - "libc", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.48.0", -] - -[[package]] -name = "nu-ansi-term" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" -dependencies = [ - "overload", - "winapi", -] - -[[package]] -name = "num-integer" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" -dependencies = [ - "autocfg", - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" -dependencies = [ - "autocfg", -] - -[[package]] -name = "num_cpus" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fac9e2da13b5eb447a6ce3d392f23a29d8694bff781bf03a16cd9ac8697593b" -dependencies = [ - "hermit-abi", - "libc", -] - -[[package]] -name = "object" -version = "0.30.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03b4680b86d9cfafba8fc491dc9b6df26b68cf40e9e6cd73909194759a63c385" -dependencies = [ - "memchr", -] - -[[package]] -name = "once_cell" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f61fba1741ea2b3d6a1e3178721804bb716a68a6aeba1149b5d52e3d464ea66" - -[[package]] -name = "openssl-probe" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" - -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" - -[[package]] -name = "parking_lot" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba1ef8814b5c993410bb3adfad7a5ed269563e4a2f90c41f5d85be7fb47133bf" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-sys 0.42.0", -] - -[[package]] -name = "percent-encoding" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e" - -[[package]] -name = "pin-project-lite" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12cc1b0bf1727a77a54b6654e7b5f1af8604923edc8b81885f8ec92f9e3f0a05" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "predicates" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68b87bfd4605926cdfefc1c3b5f8fe560e3feca9d5552cf68c466d3d8236c7e8" -dependencies = [ - "anstyle", - "difflib", - "predicates-core", -] - -[[package]] -name = "predicates-core" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b794032607612e7abeb4db69adb4e33590fa6cf1149e95fd7cb00e634b92f174" - -[[package]] -name = "predicates-tree" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368ba315fb8c5052ab692e68a0eefec6ec57b23a36959c14496f0b0df2c0cecf" -dependencies = [ - "predicates-core", - "termtree", -] - -[[package]] -name = "proc-macro-error" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" -dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn 1.0.107", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2", - "quote", - "version_check", -] - -[[package]] -name = "proc-macro2" -version = "1.0.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95fc56cda0b5c3325f5fbbd7ff9fda9e02bb00bb3dac51252d2f1bfa1cb8cc8c" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "redox_syscall" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" -dependencies = [ - "bitflags", -] - -[[package]] -name = "regex" -version = "1.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48aaa5748ba571fb95cd2c85c09f629215d3a6ece942baa100950af03a34f733" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" -dependencies = [ - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fed1ceff11a1dddaee50c9dc8e4938bd106e9d89ae372f192311e7da498e3b69" - -[[package]] -name = "regex-syntax" -version = "0.6.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848" - -[[package]] -name = "ring" -version = "0.16.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" -dependencies = [ - "cc", - "libc", - "once_cell", - "spin", - "untrusted", - "web-sys", - "winapi", -] - -[[package]] -name = "rustc-demangle" -version = "0.1.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" - -[[package]] -name = "rustls" -version = "0.20.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fff78fc74d175294f4e83b28343315ffcfb114b156f0185e9741cb5570f50e2f" -dependencies = [ - "log", - "ring", - "sct", - "webpki", -] - -[[package]] -name = "rustls-native-certs" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0167bac7a9f490495f3c33013e7722b53cb087ecbe082fb0c6387c96f634ea50" -dependencies = [ - "openssl-probe", - "rustls-pemfile", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pemfile" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d194b56d58803a43635bdc398cd17e383d6f71f9182b9a192c127ca42494a59b" -dependencies = [ - "base64 0.21.0", -] - -[[package]] -name = "ryu" -version = "1.0.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4b9743ed687d4b4bcedf9ff5eaa7398495ae14e61cba0a295704edbc7decde" - -[[package]] -name = "schannel" -version = "0.1.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "713cfb06c7059f3588fb8044c0fad1d09e3c01d225e25b9220dbfdcf16dbb1b3" -dependencies = [ - "windows-sys 0.42.0", -] - -[[package]] -name = "scopeguard" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" - -[[package]] -name = "scratch" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddccb15bcce173023b3fedd9436f882a0739b8dfb45e4f6b6002bee5929f61b2" - -[[package]] -name = "sct" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4" -dependencies = [ - "ring", - "untrusted", -] - -[[package]] -name = "security-framework" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bc1bb97804af6631813c55739f771071e0f2ed33ee20b68c86ec505d906356c" -dependencies = [ - "bitflags", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0160a13a177a45bfb43ce71c01580998474f556ad854dcbca936dd2841a5c556" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "serde" -version = "1.0.152" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb7d1f0d3021d347a83e556fc4683dea2ea09d87bccdf88ff5c12545d89d5efb" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.152" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af487d118eecd09402d70a5d72551860e788df87b464af30e5ea6a38c75c541e" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.107", -] - -[[package]] -name = "serde_json" -version = "1.0.91" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877c235533714907a8c2464236f5c4b2a17262ef1bd71f38f35ea592c8da6883" -dependencies = [ - "indexmap", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "serde_repr" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a5ec9fa74a20ebbe5d9ac23dac1fc96ba0ecfe9f50f2843b52e537b10fbcb4e" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.107", -] - -[[package]] -name = "sharded-slab" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "900fba806f70c630b0a382d0d825e17a0f19fcd059a2ade1ff237bcddf446b31" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "slab" -version = "0.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4614a76b2a8be0058caa9dbbaf66d988527d86d003c11a94fbd335d7661edcef" -dependencies = [ - "autocfg", -] - -[[package]] -name = "smallvec" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" - -[[package]] -name = "socket2" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64a4a911eed85daf18834cfaa86a79b7d266ff93ff5ba14005426219480ed662" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "socket2" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9" -dependencies = [ - "libc", - "windows-sys 0.48.0", -] - -[[package]] -name = "spin" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" - -[[package]] -name = "stringmatch" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aadc0801d92f0cdc26127c67c4b8766284f52a5ba22894f285e3101fa57d05d" -dependencies = [ - "regex", -] - -[[package]] -name = "strsim" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" - -[[package]] -name = "syn" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f4064b5b16e03ae50984a5a8ed5d4f8803e6bc1fd170a3cda91a1be4b18e3f5" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "tatutanatata" -version = "0.1.0" -dependencies = [ - "anyhow", - "assert_cmd", - "async-trait", - "clap", - "dotenvy", - "futures", - "predicates", - "thirtyfour", - "tokio", - "tracing", - "tracing-log", - "tracing-subscriber", -] - -[[package]] -name = "termcolor" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "termtree" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" - -[[package]] -name = "thirtyfour" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72fc70ad9624071cdd96d034676b84b504bfeb4bee1580df1324c99373ea0ca7" -dependencies = [ - "async-trait", - "base64 0.13.1", - "chrono", - "cookie", - "fantoccini", - "futures", - "http", - "log", - "parking_lot", - "serde", - "serde_json", - "serde_repr", - "stringmatch", - "thirtyfour-macros", - "thiserror", - "tokio", - "url", - "urlparse", -] - -[[package]] -name = "thirtyfour-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cae91d1c7c61ec65817f1064954640ee350a50ae6548ff9a1bdd2489d6ffbb0" -dependencies = [ - "proc-macro-error", - "proc-macro2", - "quote", - "syn 1.0.107", -] - -[[package]] -name = "thiserror" -version = "1.0.38" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a9cd18aa97d5c45c6603caea1da6628790b37f7a34b6ca89522331c5180fed0" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.38" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fb327af4685e4d03fa8cbcf1716380da910eeb2bb8be417e7f9fd3fb164f36f" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.107", -] - -[[package]] -name = "thread_local" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5516c27b78311c50bf42c071425c560ac799b11c30b31f87e3081965fe5e0180" -dependencies = [ - "once_cell", -] - -[[package]] -name = "time" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b797afad3f312d1c66a56d11d0316f916356d11bd158fbc6ca6389ff6bf805a" -dependencies = [ - "libc", - "wasi 0.10.0+wasi-snapshot-preview1", - "winapi", -] - -[[package]] -name = "time" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a561bf4617eebd33bca6434b988f39ed798e527f51a1e797d0ee4f61c0a38376" -dependencies = [ - "itoa", - "serde", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e153e1f1acaef8acc537e68b44906d2db6436e2b35ac2c6b42640fff91f00fd" - -[[package]] -name = "time-macros" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d967f99f534ca7e495c575c62638eebc2898a8c84c119b89e250477bc4ba16b2" -dependencies = [ - "time-core", -] - -[[package]] -name = "tinyvec" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" - -[[package]] -name = "tokio" -version = "1.35.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89b4efa943be685f629b149f53829423f8f5531ea21249408e8e2f8671ec104" -dependencies = [ - "backtrace", - "bytes", - "libc", - "mio", - "num_cpus", - "pin-project-lite", - "socket2 0.5.5", - "tokio-macros", - "windows-sys 0.48.0", -] - -[[package]] -name = "tokio-macros" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.48", -] - -[[package]] -name = "tokio-rustls" -version = "0.23.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59" -dependencies = [ - "rustls", - "tokio", - "webpki", -] - -[[package]] -name = "tower-service" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" - -[[package]] -name = "tracing" -version = "0.1.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" -dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.48", -] - -[[package]] -name = "tracing-core" -version = "0.1.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" -dependencies = [ - "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", -] - -[[package]] -name = "try-lock" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed" - -[[package]] -name = "unicode-bidi" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "099b7128301d285f79ddd55b9a83d5e6b9e97c92e0ea0daebee7263e932de992" - -[[package]] -name = "unicode-ident" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84a22b9f218b40614adcb3f4ff08b703773ad44fa9423e4e0d346d5db86e4ebc" - -[[package]] -name = "unicode-normalization" -version = "0.1.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "unicode-segmentation" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fdbf052a0783de01e944a6ce7a8cb939e295b1e7be835a1112c3b9a7f047a5a" - -[[package]] -name = "unicode-width" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" - -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - -[[package]] -name = "url" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d68c799ae75762b8c3fe375feb6600ef5602c883c5d21eb51c09f22b83c4643" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", -] - -[[package]] -name = "urlparse" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "110352d4e9076c67839003c7788d8604e24dcded13e0b375af3efaa8cf468517" - -[[package]] -name = "utf8parse" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" - -[[package]] -name = "valuable" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" - -[[package]] -name = "version_check" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" - -[[package]] -name = "wait-timeout" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f200f5b12eb75f8c1ed65abd4b2db8a6e1b138a20de009dacee265a2498f3f6" -dependencies = [ - "libc", -] - -[[package]] -name = "want" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ce8a968cb1cd110d136ff8b819a556d6fb6d919363c61534f6860c7eb172ba0" -dependencies = [ - "log", - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.10.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" - -[[package]] -name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" - -[[package]] -name = "wasm-bindgen" -version = "0.2.83" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaf9f5aceeec8be17c128b2e93e031fb8a4d469bb9c4ae2d7dc1888b26887268" -dependencies = [ - "cfg-if", - "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.83" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c8ffb332579b0557b52d268b91feab8df3615f265d5270fec2a8c95b17c1142" -dependencies = [ - "bumpalo", - "log", - "once_cell", - "proc-macro2", - "quote", - "syn 1.0.107", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.83" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "052be0f94026e6cbc75cdefc9bae13fd6052cdcaf532fa6c45e7ae33a1e6c810" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.83" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bc0c051dc5f23e307b13285f9d75df86bfdf816c5721e573dec1f9b8aa193c" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.107", - "wasm-bindgen-backend", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.83" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c38c045535d93ec4f0b4defec448e4291638ee608530863b1e2ba115d4fff7f" - -[[package]] -name = "web-sys" -version = "0.3.60" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcda906d8be16e728fd5adc5b729afad4e444e106ab28cd1c7256e54fa61510f" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webdriver" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9973cb72c8587d5ad5efdb91e663d36177dc37725e6c90ca86c626b0cc45c93f" -dependencies = [ - "base64 0.13.1", - "bytes", - "cookie", - "http", - "log", - "serde", - "serde_derive", - "serde_json", - "time 0.3.17", - "unicode-segmentation", - "url", -] - -[[package]] -name = "webpki" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f095d78192e208183081cc07bc5515ef55216397af48b873e5edcd72637fa1bd" -dependencies = [ - "ring", - "untrusted", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" -dependencies = [ - "winapi", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows-sys" -version = "0.42.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" -dependencies = [ - "windows_aarch64_gnullvm 0.42.1", - "windows_aarch64_msvc 0.42.1", - "windows_i686_gnu 0.42.1", - "windows_i686_msvc 0.42.1", - "windows_x86_64_gnu 0.42.1", - "windows_x86_64_gnullvm 0.42.1", - "windows_x86_64_msvc 0.42.1", -] - -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-targets" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5" -dependencies = [ - "windows_aarch64_gnullvm 0.48.0", - "windows_aarch64_msvc 0.48.0", - "windows_i686_gnu 0.48.0", - "windows_i686_msvc 0.48.0", - "windows_x86_64_gnu 0.48.0", - "windows_x86_64_gnullvm 0.48.0", - "windows_x86_64_msvc 0.48.0", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c9864e83243fdec7fc9c5444389dcbbfd258f745e7853198f365e3c4968a608" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c8b1b673ffc16c47a9ff48570a9d85e25d265735c503681332589af6253c6c7" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" - -[[package]] -name = "windows_i686_gnu" -version = "0.42.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de3887528ad530ba7bdbb1faa8275ec7a1155a45ffa57c37993960277145d640" - -[[package]] -name = "windows_i686_gnu" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" - -[[package]] -name = "windows_i686_msvc" -version = "0.42.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf4d1122317eddd6ff351aa852118a2418ad4214e6613a50e0191f7004372605" - -[[package]] -name = "windows_i686_msvc" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1040f221285e17ebccbc2591ffdc2d44ee1f9186324dd3e84e99ac68d699c45" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "628bfdf232daa22b0d64fdb62b09fcc36bb01f05a3939e20ab73aaf9470d0463" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "447660ad36a13288b1db4d4248e857b510e8c3a225c822ba4fb748c0aafecffd" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 index 78aa06b..0000000 --- a/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "tatutanatata" -version = "0.1.0" -edition = "2021" -license = "MIT/Apache-2.0" - -[dependencies] -anyhow = "1.0.79" -async-trait = "0.1.77" -clap = { version = "4.4.18", features = ["derive", "env"] } -dotenvy = "0.15.7" -futures = "0.3.30" -thirtyfour = "0.31.0" -tokio = { version = "1.35.1", features = ["macros", "rt-multi-thread"] } -tracing = "0.1.40" -tracing-log = "0.2.0" -tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } - -[dev-dependencies] -assert_cmd = "2.0.13" -predicates = { version = "3.1.0", default-features = false } diff --git a/README.md b/README.md index 4024040..1f29b1e 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ CLI (Command Line Interface) for [Tutanota], mostly meant for mass export. **Exporting your emails to your local systems strips any encryption. Please make sure that your device is sufficiently secured and encrypted and that you store your exported emails in a safe environment!** +**This only supports exporting emails that are already assigned to folders. This will NOT process new incoming emails. Use the official client to do that!** + ## Why [Tutanota] simple does NOT support single-click export of your emails (see [issue1292]). This is bad because their data @@ -23,15 +25,7 @@ theirs[^me_as_a_customer], you may want to export your email for the following r ## Usage -There are no pre-built binaries (yet). So you need [Rust] to be installed. Also you need [Firefox] and [geckodriver]. In -one terminal, start [geckodriver]: - -```console -$ geckodriver -1673785408803 geckodriver INFO Listening on 127.0.0.1:4444 -``` - -In a second terminal, clone Tatutanatata: +There are no pre-built binaries (yet). So you need [Rust] to be installed. Clone Tatutanatata: ```console $ git clone https://github.com/crepererum/tatutanatata.git @@ -70,17 +64,6 @@ You should now find all [EML] files in `./out`. You can use them in about any Em Have a look at our [issue tracker]. Pull requests are welcome. -## Technical Implementation -I have tried to understand the [Tutanota] proprietary protocol and their [TypeScript] codebase. My initial goal was to -hack this into their official app but I am not a frontend developer (and also do not want to deal with this mess). Their -protocol is even more closed, so I decided to go for a different route: let their app do the job. They have single-mail -(or "select some mails") export after all. - -So this CLI just uses a browser (via [WebDriver]) and drives to to export emails to [EML] (which should hopfully be -compliant with [RFC 2822]). The code implements all the shenanigans to navigate through the official frontend including -all the hidden state and a weird virtual, scrollable mail list. - - ## License Licensed under either of these: @@ -104,15 +87,11 @@ in the Apache-2.0 license, shall be dual-licensed as above, without any addition [EML]: https://docs.fileformat.com/email/eml/ [Firefox]: https://www.mozilla.org/en-US/firefox/ [GDPR]: https://en.wikipedia.org/wiki/General_Data_Protection_Regulation -[geckodriver]: https://github.com/mozilla/geckodriver [ImportExportTools NG]: https://addons.thunderbird.net/en-US/thunderbird/addon/importexporttools-ng/ [issue tracker]: https://github.com/crepererum/tatutanatata/issues [issue1292]: https://github.com/tutao/tutanota/issues/1292 [PGP]: https://en.wikipedia.org/wiki/Pretty_Good_Privacy -[RFC 2822]: https://www.rfc-editor.org/rfc/rfc2822 [Rust]: https://www.rust-lang.org/ [S/MIME]: https://en.wikipedia.org/wiki/S/MIME [Thunderbird]: https://www.thunderbird.net/ [Tutanota]: https://tutanota.com/ -[TypeScript]: https://www.typescriptlang.org/ -[WebDriver]: https://developer.mozilla.org/en-US/docs/Web/WebDriver \ No newline at end of file diff --git a/bors.toml b/bors.toml deleted file mode 100644 index 63e56e9..0000000 --- a/bors.toml +++ /dev/null @@ -1,5 +0,0 @@ -status = [ - "ci", -] - -delete_merged_branches = true diff --git a/src/commands/export.rs b/src/commands/export.rs deleted file mode 100644 index c3336b5..0000000 --- a/src/commands/export.rs +++ /dev/null @@ -1,305 +0,0 @@ -use std::{collections::HashSet, path::Path, time::Duration}; - -use anyhow::{anyhow, ensure, Context, Result}; -use clap::Parser; -use thirtyfour::{By, WebDriver, WebElement}; -use tracing::{debug, info}; - -use crate::thirtyfour_util::FindExt; - -use super::list_folders::list_folders; - -/// Export CLI config. -#[derive(Debug, Parser)] -pub struct ExportCLIConfig { - /// Folder - #[clap(long)] - folder: String, -} - -pub async fn export( - config: ExportCLIConfig, - storage_folder: &Path, - webdriver: &WebDriver, -) -> Result<()> { - navigate_to_folder(&config.folder, webdriver) - .await - .context("navigate to folder")?; - - let mut seen = HashSet::new(); - - loop { - let n_seen = seen.len(); - let (seen2, list_updated) = export_round(storage_folder, webdriver, seen).await?; - seen = seen2; - if seen.len() == n_seen && !list_updated { - break; - } - ensure_list_is_ready(webdriver).await?; - } - - info!(n = seen.len(), "exported folder"); - - Ok(()) -} - -async fn export_round( - storage_folder: &Path, - webdriver: &WebDriver, - mut seen: HashSet, -) -> Result<(HashSet, bool)> { - let mail_list = get_mail_list(webdriver).await.context("get mail-list")?; - - let list_height = style_height(&mail_list).await.context("list height")?; - - let list_elements = mail_list - .find_all(By::Tag("li")) - .await - .context("get list elements")?; - let list_size = list_elements.len(); - info!(list_size, list_height, "found mail list"); - - let mut list_elements_with_y = Vec::with_capacity(list_elements.len()); - for li in list_elements { - if let Some(translate_y) = style_translate_y(&li).await.context("get translateY")? { - list_elements_with_y.push((li, translate_y)); - } - } - list_elements_with_y.sort_by_key(|(_li, y)| *y); - - if list_elements_with_y.is_empty() { - info!("empty list"); - return Ok((seen, false)); - } - - for (li, y) in &list_elements_with_y { - if !seen.insert(*y) { - continue; - } - - info!(list_size, y, "handle entry"); - - li.click().await.context("click at list element")?; - - export_current_mail(storage_folder, webdriver) - .await - .context("export current main")?; - - let list_height2 = style_height(&mail_list).await.context("list height")?; - let first_element_y = style_translate_y(&list_elements_with_y[0].0) - .await - .context("first element y")? - .ok_or_else(|| anyhow!("first element lost y"))?; - if list_height != list_height2 || list_elements_with_y[0].1 != first_element_y { - info!("list updated"); - return Ok((seen, true)); - } - } - - Ok((seen, false)) -} - -async fn export_current_mail(storage_folder: &Path, webdriver: &WebDriver) -> Result<()> { - let action_bar = webdriver - .find_one(By::ClassName("action-bar")) - .await - .context("find action-bar")?; - - let more_button = action_bar - .find_one_with_attr(By::Tag("button"), "title", "More") - .await - .context("find more button")?; - - more_button.click().await.context("click more button")?; - - let dropdown_panel = webdriver - .find_one(By::ClassName("dropdown-panel")) - .await - .context("find dropdown-panel")?; - - let buttons = dropdown_panel - .find_all(By::Tag("button")) - .await - .context("find buttons")?; - let mut export_button = None; - for button in buttons { - let text_ellipsis = button - .find_one(By::ClassName("text-ellipsis")) - .await - .context("find text-ellipsis")?; - - if text_ellipsis.text().await.context("element text")? == "Export" { - ensure!(export_button.is_none(), "multiple export buttons"); - export_button = Some(button); - } - } - let export_button = export_button.ok_or_else(|| anyhow!("no export button"))?; - - let n_files_pre = count_files(storage_folder).await.context("count files")?; - - export_button.click().await.context("click export button")?; - - tokio::time::timeout(Duration::from_secs(20), async { - loop { - if count_files(storage_folder).await.context("count files")? != n_files_pre { - return Ok::<_, anyhow::Error>(()); - } - - tokio::time::sleep(Duration::from_millis(100)).await; - } - }) - .await - .context("timeout waiting for exported file")??; - - ensure_modal_is_closed(webdriver) - .await - .context("close modal")?; - - Ok(()) -} - -async fn count_files(path: &Path) -> Result { - let mut files = tokio::fs::read_dir(path).await?; - let mut n = 0; - while files.next_entry().await?.is_some() { - n += 1; - } - - Ok(n) -} - -async fn get_mail_list(webdriver: &WebDriver) -> Result { - let mail_list = webdriver - .find_one(By::ClassName("mail-list")) - .await - .context("find mail list")?; - - let tag_name = mail_list.tag_name().await.context("get tag name")?; - ensure!( - tag_name == "ul", - "mail-list should be a list but is {}", - tag_name - ); - - Ok(mail_list) -} - -async fn is_mail_list_loading(webdriver: &WebDriver) -> Result { - let mail_list = get_mail_list(webdriver).await.context("get mail-list")?; - - let progress_icon = mail_list - .find_one(By::ClassName("icon-progress")) - .await - .context("find progress icon")?; - progress_icon.is_displayed().await.context("is displayed") -} - -async fn navigate_to_folder(folder: &str, webdriver: &WebDriver) -> Result<()> { - for (anchor, title) in list_folders(webdriver).await.context("list folders")? { - if title == folder { - // modal might be left-over from some login dialog, make sure it is gone before we - // attempt to click any buttons - ensure_modal_is_closed(webdriver) - .await - .context("ensure modal is closed")?; - - anchor.click().await.context("clicking folder link")?; - - ensure_list_is_ready(webdriver) - .await - .context("folder ready?")?; - - return Ok(()); - } - } - - Err(anyhow!("folder not found")) -} - -async fn ensure_list_is_ready(webdriver: &WebDriver) -> Result<()> { - tokio::time::timeout(Duration::from_secs(20), async { - loop { - if !is_mail_list_loading(webdriver) - .await - .context("is mail-list loading?")? - { - return Ok::<_, anyhow::Error>(()); - } - - info!("folder still loading"); - tokio::time::sleep(Duration::from_secs(1)).await; - } - }) - .await - .context("wait for folder update")??; - - Ok(()) -} - -async fn ensure_modal_is_closed(webdriver: &WebDriver) -> Result<()> { - debug!("ensure modal is closed"); - - tokio::time::timeout(Duration::from_secs(20), async { - loop { - let modal = webdriver - .find_one(By::Id("modal")) - .await - .context("find modal")?; - - if !modal.is_displayed().await.context("modal displayed")? { - return Ok::<_, anyhow::Error>(()); - } - - tokio::time::sleep(Duration::from_millis(100)).await; - } - }) - .await - .context("modal not closing in time")??; - - debug!("modal is closed"); - - Ok(()) -} - -async fn style_height(element: &WebElement) -> Result { - let style = element - .attr("style") - .await - .context("list height")? - .ok_or_else(|| anyhow!("no style data found"))?; - - let needle = "height: "; - let pos = style - .find(needle) - .ok_or_else(|| anyhow!("height not found"))?; - let style = &style[pos + needle.len()..]; - - let needle = "px"; - let pos = style.find(needle).ok_or_else(|| anyhow!("px not found"))?; - - style[..pos].parse().context("cannot parse height") -} - -async fn style_translate_y(element: &WebElement) -> Result> { - let style = element - .attr("style") - .await - .context("list height")? - .ok_or_else(|| anyhow!("no style data found"))?; - - if style.contains("display: none") { - return Ok(None); - } - - let needle = "translateY("; - let pos = style - .find(needle) - .ok_or_else(|| anyhow!("translateY not found: {}", style))?; - let style = &style[pos + needle.len()..]; - - let needle = "px"; - let pos = style.find(needle).ok_or_else(|| anyhow!("px not found"))?; - - let y = style[..pos].parse().context("cannot parse translation")?; - Ok(Some(y)) -} diff --git a/src/commands/list_folders.rs b/src/commands/list_folders.rs deleted file mode 100644 index 4b191c5..0000000 --- a/src/commands/list_folders.rs +++ /dev/null @@ -1,65 +0,0 @@ -use std::{collections::HashSet, time::Duration}; - -use anyhow::{anyhow, ensure, Context, Result}; -use thirtyfour::{By, WebDriver, WebElement}; -use tracing::debug; - -use crate::thirtyfour_util::FindExt; - -pub async fn list_folders(webdriver: &WebDriver) -> Result> { - tokio::time::timeout(Duration::from_secs(20), async { - loop { - let folders = list_folders_inner(webdriver).await?; - - if folders.is_empty() { - debug!("folders empty, waiting"); - - tokio::time::sleep(Duration::from_millis(100)).await; - } else { - debug!("found folders"); - - return Ok(folders); - } - } - }) - .await - .context("no timeout")? -} - -async fn list_folders_inner(webdriver: &WebDriver) -> Result> { - let folder_column = webdriver - .find_one(By::ClassName("folder-column")) - .await - .context("find folder column")?; - debug!("found folder column"); - - let rows = folder_column - .find_all(By::ClassName("folder-row")) - .await - .context("find folder rows")?; - debug!("found folder rows"); - - let mut folders = Vec::with_capacity(rows.len()); - let mut seen = HashSet::new(); - for row in rows { - let Some(anchor) = row - .find_at_most_one(By::Tag("a")) - .await - .context("find folder anchor")? - else { - continue; - }; - - let title = anchor - .attr("title") - .await - .context("element attr")? - .ok_or_else(|| anyhow!("anchor has no title"))?; - - ensure!(seen.insert(title.clone()), "duplicate folder: {}", title); - - folders.push((anchor, title)); - } - - Ok(folders) -} diff --git a/src/commands/mod.rs b/src/commands/mod.rs deleted file mode 100644 index 0e8a1e9..0000000 --- a/src/commands/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod export; -pub mod list_folders; diff --git a/src/error.rs b/src/error.rs deleted file mode 100644 index 6b1c961..0000000 --- a/src/error.rs +++ /dev/null @@ -1,73 +0,0 @@ -use std::fmt::{self, Display}; - -#[derive(Debug)] -pub struct MultiError { - errors: Vec, -} - -impl MultiError { - pub fn into_anyhow(mut self) -> anyhow::Error { - if self.errors.len() == 1 { - self.errors.remove(0) - } else { - self.into() - } - } -} - -impl Display for MultiError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - assert!(!self.errors.is_empty()); - - for (i, e) in self.errors.iter().enumerate() { - if i > 0 { - writeln!(f)?; - } - writeln!(f, "{:?}", e)?; - } - - Ok(()) - } -} - -impl std::error::Error for MultiError {} - -pub type MultiResult = Result<(), MultiError>; - -pub trait MultiResultExt { - fn combine(self, other: anyhow::Result<()>) -> MultiResult; -} - -impl MultiResultExt for Result<(), anyhow::Error> { - fn combine(self, other: anyhow::Result<()>) -> MultiResult { - let mut errors = vec![]; - if let Err(e) = self { - errors.push(e); - } - if let Err(e) = other { - errors.push(e); - } - if errors.is_empty() { - Ok(()) - } else { - Err(MultiError { errors }) - } - } -} - -impl MultiResultExt for MultiResult { - fn combine(self, other: anyhow::Result<()>) -> MultiResult { - let mut errors = vec![]; - if let Err(mut e) = self { - errors.append(&mut e.errors); - } - if let Err(e) = other { - errors.push(e); - } - if errors.is_empty() { - Ok(()) - } else { - Err(MultiError { errors }) - } - } -} diff --git a/src/logging.rs b/src/logging.rs deleted file mode 100644 index 5cd6a42..0000000 --- a/src/logging.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! Logging setup. -use anyhow::Result; -use clap::Parser; -use tracing_log::LogTracer; -use tracing_subscriber::{EnvFilter, FmtSubscriber}; - -/// Logging CLI config. -#[derive(Debug, Parser)] -pub struct LoggingCLIConfig { - /// Log verbosity. - #[clap( - short = 'v', - long = "verbose", - action = clap::ArgAction::Count, - )] - log_verbose_count: u8, -} - -/// Setup process-wide logging. -pub fn setup_logging(config: LoggingCLIConfig) -> Result<()> { - LogTracer::init()?; - - let base_filter = match config.log_verbose_count { - 0 => "warn", - 1 => "info", - 2 => "debug", - _ => "trace", - }; - let filter = EnvFilter::try_new(format!("{base_filter},hyper=info"))?; - - let subscriber = FmtSubscriber::builder().with_env_filter(filter).finish(); - - tracing::subscriber::set_global_default(subscriber)?; - - Ok(()) -} diff --git a/src/login.rs b/src/login.rs deleted file mode 100644 index 08353c0..0000000 --- a/src/login.rs +++ /dev/null @@ -1,133 +0,0 @@ -use std::time::Duration; - -use anyhow::{Context, Result}; -use clap::Parser; -use thirtyfour::{By, WebDriver}; -use tracing::debug; - -use crate::{non_empty_string::NonEmptyString, thirtyfour_util::FindExt}; - -/// Login CLI config. -#[derive(Debug, Parser)] -pub struct LoginCLIConfig { - /// Username - #[clap(long, env = "TUTANOTA_CLI_USERNAME")] - username: NonEmptyString, - - /// Password - #[clap(long, env = "TUTANOTA_CLI_PASSWORD")] - password: NonEmptyString, -} - -/// Perform tutanota webinterface login. -pub async fn perform_login(config: LoginCLIConfig, webdriver: &WebDriver) -> Result<()> { - webdriver - .goto("https://mail.tutanota.com") - .await - .context("go to webinterface")?; - debug!("navigated to login page"); - - let input_username = webdriver - .find_one_with_attr(By::Tag("input"), "autocomplete", "email") - .await - .context("find username input")?; - let input_password = webdriver - .find_one_with_attr(By::Tag("input"), "autocomplete", "current-password") - .await - .context("find password input")?; - debug!("found username and password inputs"); - - input_username - .focus() - .await - .context("focus on username input")?; - input_username - .send_keys(config.username) - .await - .context("enter username")?; - input_password - .focus() - .await - .context("focus on password input")?; - input_password - .send_keys(config.password) - .await - .context("enter password")?; - debug!("entered username and password"); - - let login_button = webdriver - .find_one_with_attr(By::Tag("button"), "title", "Log in") - .await - .context("find login button")?; - debug!("found login button"); - - login_button.click().await.context("click login button")?; - debug!("clicked login button, waiting for login"); - - tokio::time::timeout(Duration::from_secs(20), async { - loop { - if has_new_email_button(webdriver) - .await - .context("search new-email button")? - { - return Ok::<_, anyhow::Error>(()); - } - - tokio::time::sleep(Duration::from_secs(1)).await; - } - }) - .await - .context("wait for login")??; - debug!("login done"); - - confirm_dialog(webdriver) - .await - .context("confirm potential dialog")?; - - Ok(()) -} - -async fn has_new_email_button(webdriver: &WebDriver) -> Result { - let buttons = webdriver - .find_all(By::Tag("button")) - .await - .context("find button elements")?; - - for button in buttons { - if let Some("New email") = button - .attr("title") - .await - .context("element attr")? - .as_deref() - { - return Ok(true); - } - } - - Ok(false) -} - -async fn confirm_dialog(webdriver: &WebDriver) -> Result<()> { - debug!("confirm potential dialogs"); - - let Some(dialog) = webdriver - .find_at_most_one(By::ClassName("dialog")) - .await - .context("find dialog box")? - else { - debug!("no dialog found"); - return Ok(()); - }; - debug!("found dialog, trying to click OK"); - - let ok_button = dialog - .find_one_with_attr(By::Tag("button"), "title", "Ok") - .await - .context("find OK button")?; - debug!("found OK button"); - - ok_button.click().await.context("click OK button")?; - debug!("clicked OK button"); - - Ok(()) -} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index 2f0bce3..0000000 --- a/src/main.rs +++ /dev/null @@ -1,96 +0,0 @@ -use anyhow::{Context, Result}; -use clap::{Parser, Subcommand}; -use commands::export::ExportCLIConfig; -use futures::FutureExt; -use logging::{setup_logging, LoggingCLIConfig}; -use login::{perform_login, LoginCLIConfig}; -use storage::{setup_storage, StorageCLIConfig}; -use webdriver::{run_webdriver, WebdriverCLIConfig}; - -mod commands; -mod error; -mod logging; -mod login; -mod non_empty_string; -mod storage; -mod thirtyfour_util; -mod webdriver; - -/// CLI args. -#[derive(Debug, Parser)] -struct Args { - /// Logging config. - #[clap(flatten)] - logging_cfg: LoggingCLIConfig, - - /// Webdriver config. - #[clap(flatten)] - webdriver_cfg: WebdriverCLIConfig, - - /// Login config. - #[clap(flatten)] - login_cfg: LoginCLIConfig, - - /// Storage config. - #[clap(flatten)] - storage_cfg: StorageCLIConfig, - - /// Command - #[clap(subcommand)] - command: Command, -} - -/// Command -#[derive(Debug, Subcommand)] -enum Command { - /// List folders. - ListFolders, - - /// Export emails - Export(ExportCLIConfig), -} - -#[tokio::main] -async fn main() -> Result<()> { - dotenvy::dotenv().ok(); - let args = Args::parse(); - setup_logging(args.logging_cfg).context("logging setup")?; - - let storage_folder = setup_storage(args.storage_cfg) - .await - .context("setup storage")?; - let storage_folder_captured = storage_folder.clone(); - - run_webdriver(args.webdriver_cfg, &storage_folder, move |webdriver| { - let storage_folder = storage_folder_captured.clone(); - - async move { - perform_login(args.login_cfg, webdriver) - .await - .context("perform login")?; - - match args.command { - Command::ListFolders => { - let folders = commands::list_folders::list_folders(webdriver) - .await - .context("list folders")?; - for (_anchor, title) in folders { - println!("{title}"); - } - } - Command::Export(config) => { - commands::export::export(config, &storage_folder, webdriver) - .await - .context("export")?; - } - } - - Ok(()) - } - .boxed() - }) - .await - .context("webdriver execution")?; - - Ok(()) -} diff --git a/src/non_empty_string.rs b/src/non_empty_string.rs deleted file mode 100644 index 517b8c2..0000000 --- a/src/non_empty_string.rs +++ /dev/null @@ -1,43 +0,0 @@ -use std::{ops::Deref, str::FromStr}; - -/// Non-empty [`String`]. -#[derive(Clone)] -pub struct NonEmptyString(String); - -impl std::fmt::Debug for NonEmptyString { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { - self.0.fmt(f) - } -} - -impl std::fmt::Display for NonEmptyString { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { - self.0.fmt(f) - } -} - -impl Deref for NonEmptyString { - type Target = str; - - fn deref(&self) -> &Self::Target { - self.0.deref() - } -} - -impl AsRef for NonEmptyString { - fn as_ref(&self) -> &str { - self.0.as_ref() - } -} - -impl FromStr for NonEmptyString { - type Err = String; - - fn from_str(s: &str) -> std::result::Result { - if s.is_empty() { - Err("cannot be empty".to_owned()) - } else { - Ok(Self(s.to_owned())) - } - } -} diff --git a/src/storage.rs b/src/storage.rs deleted file mode 100644 index cd286f0..0000000 --- a/src/storage.rs +++ /dev/null @@ -1,50 +0,0 @@ -use std::{ - io::ErrorKind, - path::{Path, PathBuf}, -}; - -use anyhow::{Context, Result}; -use clap::Parser; - -/// Storage CLI config. -#[derive(Debug, Parser)] -pub struct StorageCLIConfig { - /// Storage folder. - #[clap(long, default_value = "./out")] - storage_folder: PathBuf, -} - -pub async fn setup_storage(config: StorageCLIConfig) -> Result { - match use_existing_folder(&config.storage_folder).await { - Ok(path) => Ok(path), - Err(e) if e.kind() == ErrorKind::NotFound => use_new_folder(&config.storage_folder) - .await - .context("create new dir"), - Err(e) => Err(e).context("cannot use storage path"), - } -} - -async fn use_new_folder(path: &Path) -> Result { - tokio::fs::create_dir_all(path).await?; - tokio::fs::canonicalize(path).await -} - -async fn use_existing_folder(path: &Path) -> Result { - let path = tokio::fs::canonicalize(path).await?; - - let is_empty = is_empty(&path).await?; - - if !is_empty { - return Err(std::io::Error::new(ErrorKind::Other, "folder not empty")); - } - - Ok(path) -} - -async fn is_empty(path: &Path) -> Result { - Ok(tokio::fs::read_dir(path) - .await? - .next_entry() - .await? - .is_none()) -} diff --git a/src/thirtyfour_util.rs b/src/thirtyfour_util.rs deleted file mode 100644 index c6a63d4..0000000 --- a/src/thirtyfour_util.rs +++ /dev/null @@ -1,75 +0,0 @@ -use anyhow::{anyhow, ensure, Context, Result}; -use async_trait::async_trait; -use thirtyfour::{session::handle::SessionHandle, By, WebElement}; - -#[async_trait] -pub trait FindExt { - async fn find_all_ext(&self, by: impl Into + Send) -> Result>; - - async fn find_one(&self, by: impl Into + Send) -> Result { - let mut results = self.find_all_ext(by).await?; - ensure!( - results.len() == 1, - "expected exactly one element but got {}", - results.len() - ); - Ok(results.remove(0)) - } - - async fn find_at_most_one(&self, by: impl Into + Send) -> Result> { - let mut results = self.find_all_ext(by).await?; - ensure!( - results.len() <= 1, - "expected at most one element but got {}", - results.len() - ); - if results.is_empty() { - Ok(None) - } else { - Ok(Some(results.remove(0))) - } - } - - async fn find_one_with_attr( - &self, - by: impl Into + Send, - attr_name: &str, - attr_value: &str, - ) -> Result { - let elements = self.find_all_ext(by).await.context("find elements")?; - let mut found = None; - for element in elements { - if let Some(v) = element - .attr(attr_name) - .await - .context("element attr")? - .as_deref() - { - if v == attr_value { - ensure!(found.is_none(), "multiple matching elements"); - found = Some(element); - } - } - } - let found = found.ok_or_else(|| anyhow!("not found"))?; - Ok(found) - } -} - -#[async_trait] -impl FindExt for SessionHandle { - async fn find_all_ext(&self, by: impl Into + Send) -> Result> { - let results = self.find_all(by).await.context("find all")?; - - Ok(results) - } -} - -#[async_trait] -impl FindExt for WebElement { - async fn find_all_ext(&self, by: impl Into + Send) -> Result> { - let results = self.find_all(by).await.context("find all")?; - - Ok(results) - } -} diff --git a/src/webdriver.rs b/src/webdriver.rs deleted file mode 100644 index 976a679..0000000 --- a/src/webdriver.rs +++ /dev/null @@ -1,84 +0,0 @@ -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result}; -use clap::Parser; -use futures::future::BoxFuture; -use thirtyfour::{ - common::capabilities::firefox::FirefoxPreferences, DesiredCapabilities, WebDriver, -}; -use tracing::debug; - -use crate::error::MultiResultExt; - -/// Webdriver CLI config. -#[derive(Debug, Parser)] -pub struct WebdriverCLIConfig { - /// Create screenshot on failure. - #[clap(long, default_value_t = false)] - screenshot_on_failure: bool, - - /// Path for screenshot. - #[clap(long, default_value = "./screenshot.png")] - screenshot_path: PathBuf, - - /// Webdriver port. - #[clap(long, default_value_t = 4444)] - webdriver_port: u16, -} - -/// Run given async method with the given webdriver. -/// -/// This ensures that the webdriver is shut down after the given future finishes. -pub async fn run_webdriver(config: WebdriverCLIConfig, storage_path: &Path, f: F) -> Result<()> -where - for<'a> F: FnOnce(&'a WebDriver) -> BoxFuture<'a, Result<()>>, -{ - let mut prefs = FirefoxPreferences::new(); - prefs - .set("browser.download.folderList", 2) - .context("set pref")?; - prefs - .set("browser.download.manager.showWhenStarting", false) - .context("set pref")?; - prefs - .set("browser.download.dir", storage_path.display().to_string()) - .context("set pref")?; - prefs - .set( - "browser.helperApps.neverAsk.saveToDisk", - "application/octet-stream", - ) - .context("set pref")?; - - let mut caps = DesiredCapabilities::firefox(); - caps.set_preferences(prefs).context("set preferences")?; - caps.set_headless().context("enable headless")?; - - let addr = format!("http://localhost:{}", config.webdriver_port); - let driver = WebDriver::new(&addr, caps) - .await - .context("webdriver setup")?; - driver.maximize_window().await.context("maximize window")?; - debug!("webdriver setup done"); - - let res_f = f(&driver).await; - - let res_screenshot = if res_f.is_err() && config.screenshot_on_failure { - driver - .screenshot(&config.screenshot_path) - .await - .context("create screenshot") - } else { - Ok(()) - }; - - let res_shutdown = driver.quit().await.context("webdriver shutdown"); - - res_shutdown - .combine(res_screenshot) - .combine(res_f) - .map_err(|e| e.into_anyhow())?; - debug!("webdriver shutdown done"); - - Ok(()) -} diff --git a/tests/cli.rs b/tests/cli.rs deleted file mode 100644 index 674aa05..0000000 --- a/tests/cli.rs +++ /dev/null @@ -1,54 +0,0 @@ -use std::sync::{LockResult, Mutex, MutexGuard}; - -use assert_cmd::Command; -use predicates::prelude::*; - -/// We can only have a single webdriver session. -static WEBDRIVER_MUTEX: Mutex<()> = Mutex::new(()); - -#[test] -fn test_help() { - let mut cmd = cmd(); - cmd.arg("--help").assert().success(); -} - -#[test] -fn test_list_folders() { - let _guard = webdriver_mutex(); - let mut cmd = cmd(); - cmd.arg("--screenshot-on-failure") - .arg("--screenshot-path=test_list_folders.png") - .arg("-vv") - .arg("list-folders") - .assert() - .success() - .stdout(predicate::str::contains( - ["Inbox", "Drafts", "Sent", "Trash", "Archive", "Spam"].join("\n"), - )); -} - -#[test] -#[ignore] -fn test_export() { - let _guard = webdriver_mutex(); - let mut cmd = cmd(); - cmd.arg("--screenshot-on-failure") - .arg("--screenshot-path=test_export.png") - .arg("-vv") - .arg("export") - .arg("--folder=Archive") - .assert() - .success(); -} - -fn cmd() -> Command { - Command::cargo_bin(env!("CARGO_PKG_NAME")).unwrap() -} - -fn webdriver_mutex() -> MutexGuard<'static, ()> { - match WEBDRIVER_MUTEX.lock() { - LockResult::Ok(guard) => guard, - // poisoned locks are OK - LockResult::Err(e) => e.into_inner(), - } -}