diff --git a/.cargo/config.toml b/.cargo/config.toml deleted file mode 100644 index 9380186..0000000 --- a/.cargo/config.toml +++ /dev/null @@ -1,2 +0,0 @@ -[build] -target = "i686-pc-windows-msvc" diff --git a/.gitignore b/.gitignore index c987f25..3b8c39b 100644 --- a/.gitignore +++ b/.gitignore @@ -133,3 +133,5 @@ fabric.properties # End of https://www.toptal.com/developers/gitignore/api/intellij +*.pfx +*.json diff --git a/Cargo.lock b/Cargo.lock index 2b4e621..f0dc38c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,31 +44,71 @@ dependencies = [ ] [[package]] -name = "anyhow" -version = "1.0.81" +name = "anstream" +version = "0.6.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0952808a6c2afd1aa8947271f3a60f1a6763c7b912d210184c5149b5cf147247" +checksum = "418c75fa768af9c03be99d17643f93f79bbba589895012a80e3452a19ddda15b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b" + +[[package]] +name = "anstyle-parse" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03a11a9034d92058ceb6ee011ce58af4a9bf61491aa7e1e59ecd24bd40d22d4" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad186efb764318d35165f1758e7dcef3b10628e26d41a44bc5550652e6804391" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61a38449feb7068f52bb06c12759005cf459ee52bb4adc1d5a7c4322d716fb19" +dependencies = [ + "anstyle", + "windows-sys 0.52.0", +] + +[[package]] +name = "anyhow" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" [[package]] name = "autocfg" -version = "1.1.0" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" [[package]] name = "base64" -version = "0.21.7" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - -[[package]] -name = "binary-reader" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d173c51941d642588ed6a13d464617e3a9176b8fe00dc2de182434c36812a5e" -dependencies = [ - "byteorder", -] +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" @@ -78,9 +118,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" [[package]] name = "block-buffer" @@ -102,15 +142,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.15.4" +version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ff69b9dd49fd426c69a0db9fc04dd934cdb6645ff000864d98f7e2af8830eaa" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" [[package]] name = "cbc" @@ -123,9 +157,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.0.90" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cd6604a82acf3039f1144f54b8eb34e91ffba622051189e71b781822d5ee1f5" +checksum = "ac367972e516d45567c7eafc73d24e1c193dcf200a8d94e9db7b3d38b349572d" [[package]] name = "cfg-if" @@ -135,16 +169,16 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.35" +version = "0.4.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf5903dcbc0a39312feb77df2ff4c76387d591b9fc7b04a238dcf8bb62639a" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" dependencies = [ "android-tzdata", "iana-time-zone", "js-sys", "num-traits", "wasm-bindgen", - "windows-targets 0.52.4", + "windows-targets 0.52.5", ] [[package]] @@ -157,6 +191,12 @@ dependencies = [ "inout", ] +[[package]] +name = "colorchoice" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" + [[package]] name = "confy" version = "0.6.1" @@ -169,6 +209,26 @@ dependencies = [ "toml", ] +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom", + "once_cell", + "tiny-keccak", +] + [[package]] name = "convert_case" version = "0.6.0" @@ -195,9 +255,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3855a8a784b474f333699ef2bbca9db2c4a1f6d9088a90a2d25b1eb53111eaa" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" dependencies = [ "cfg-if", ] @@ -227,6 +287,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "crunchy" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" + [[package]] name = "crypto-common" version = "0.1.6" @@ -237,6 +303,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", +] + [[package]] name = "detour2" version = "0.9.0" @@ -306,20 +381,45 @@ checksum = "f2b99bf03862d7f545ebc28ddd33a665b50865f4dfd84031a393823879bd4c54" dependencies = [ "proc-macro2", "quote", - "syn 2.0.53", + "syn 2.0.68", +] + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "elain" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3522094fae7d65c8313a135fe45fa7e22ec2110c9d387063b66d235281f7f771" + +[[package]] +name = "env_filter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a009aa4810eb158359dda09d0c87378e4bbb89b5a801f016885a4707ba24f7ea" +dependencies = [ + "log", + "regex", ] [[package]] name = "env_logger" -version = "0.10.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" +checksum = "38b35839ba51819680ba087cd351788c9a3c476841207e0b8cee0b04722343b9" dependencies = [ + "anstream", + "anstyle", + "env_filter", "humantime", - "is-terminal", "log", - "regex", - "termcolor", ] [[package]] @@ -339,9 +439,9 @@ dependencies = [ [[package]] name = "flate2" -version = "1.0.28" +version = "1.0.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46303f565772937ffe1d394a4fac6f411c6013172fadde9dcdb1e147a086940e" +checksum = "5f54427cfd1c7829e2a139fcefea601bf088ebca651d2bf53ebc600eac295dae" dependencies = [ "crc32fast", "miniz_oxide", @@ -368,9 +468,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.12" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", "libc", @@ -379,21 +479,15 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.14.3" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" [[package]] -name = "hermit-abi" -version = "0.3.9" +name = "heck" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" - -[[package]] -name = "hex-literal" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hmac" @@ -464,21 +558,16 @@ dependencies = [ ] [[package]] -name = "is-terminal" -version = "0.4.12" +name = "is_terminal_polyfill" +version = "1.70.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f23ff5ef2b80d608d61efee834934d862cd92461afc0560dedf493e4c033738b" -dependencies = [ - "hermit-abi", - "libc", - "windows-sys 0.52.0", -] +checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800" [[package]] name = "itoa" -version = "1.0.10" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" [[package]] name = "js-sys" @@ -491,25 +580,24 @@ dependencies = [ [[package]] name = "lazy_static" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.153" +version = "0.2.155" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" +checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" [[package]] name = "libredox" -version = "0.0.1" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85c833ca1e66078851dba29046874e38f08b2c883700aa29a03ddd3b23814ee8" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.6.0", "libc", - "redox_syscall", ] [[package]] @@ -522,6 +610,15 @@ dependencies = [ "libc", ] +[[package]] +name = "lightningscanner" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdf74c5927984bb4009a1e299e971cc9bec70a129250255d232dcf16b0f038a" +dependencies = [ + "elain", +] + [[package]] name = "log" version = "0.4.21" @@ -529,25 +626,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" [[package]] -name = "mach" -version = "0.3.2" +name = "mach2" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b823e83b2affd8f40a9ee8c29dbc56404c1e34cd2710921f2801e2cf29527afa" +checksum = "19b955cdeb2a02b9117f121ce63aa52d08ade45de53e48fe6a38b39c10f6f709" dependencies = [ "libc", ] [[package]] name = "memchr" -version = "2.7.1" +version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] name = "miniz_oxide" -version = "0.7.2" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d811f3e15f28568be3407c8e7fdb6514c1cda3cb30683f15b6a1a1dc4ea14a7" +checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" dependencies = [ "adler", ] @@ -563,10 +660,16 @@ dependencies = [ ] [[package]] -name = "num-traits" -version = "0.2.18" +name = "num-conv" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", ] @@ -589,7 +692,16 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.53", + "syn 2.0.68", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", ] [[package]] @@ -604,6 +716,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown", +] + [[package]] name = "pbkdf2" version = "0.12.2" @@ -620,6 +742,18 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + [[package]] name = "proc-macro-crate" version = "3.1.0" @@ -631,36 +765,57 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.79" +version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835ff2298f5721608eb1a980ecaee1aef2c132bf95ecc026a11b7bf3c01c02e" +checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.35" +version = "1.0.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" dependencies = [ "proc-macro2", ] [[package]] -name = "redox_syscall" -version = "0.4.1" +name = "rand" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ - "bitflags 1.3.2", + "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_users" -version = "0.4.4" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a18479200779601e498ada4e8c1e1f50e3ee19deb0259c25825a98b5603b2cb4" +checksum = "bd283d9651eeda4b2a83a43c1c91b266c40fd76ecd39a50a8c630ae69dc72891" dependencies = [ "getrandom", "libredox", @@ -669,9 +824,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.10.4" +version = "1.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c" +checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f" dependencies = [ "aho-corasick", "memchr", @@ -681,9 +836,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.6" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea" +checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" dependencies = [ "aho-corasick", "memchr", @@ -692,20 +847,20 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" +checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" [[package]] name = "region" -version = "3.0.0" +version = "3.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76e189c2369884dce920945e2ddf79b3dff49e071a167dd1817fa9c4c00d512e" +checksum = "e6b6ebd13bc009aef9cd476c1310d49ac354d36e240cf1bd753290f3dc7199a7" dependencies = [ "bitflags 1.3.2", "libc", - "mach", - "winapi", + "mach2", + "windows-sys 0.52.0", ] [[package]] @@ -723,6 +878,17 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rust-ini" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d625ed57d8f49af6cfa514c42e1a71fadcff60eb0b1c517ff82fe41aa025b41" +dependencies = [ + "cfg-if", + "ordered-multimap", + "trim-in-place", +] + [[package]] name = "rustls" version = "0.22.4" @@ -739,15 +905,15 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.4.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "868e20fada228fefaf6b652e00cc73623d54f8171e7352c18bb281571f2d92da" +checksum = "976295e77ce332211c0d24d92c0e83e50f5c5f046d11082cea19f3df13a3562d" [[package]] name = "rustls-webpki" -version = "0.102.2" +version = "0.102.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faaa0a62740bedb9b2ef5afa303da42764c012f743917351dc9a237ea1663610" +checksum = "ff448f7e92e913c4b7d4c6d8e4540a1724b319b4152b8aef6d4cf8339712b33e" dependencies = [ "ring", "rustls-pki-types", @@ -755,46 +921,53 @@ dependencies = [ ] [[package]] -name = "ryu" +name = "rustversion" version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" +checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" [[package]] name = "saekawa" -version = "0.3.4" +version = "0.4.0" dependencies = [ "aes", - "anyhow", - "binary-reader", "cbc", "chrono", "confy", - "crc32fast", "crochet", "env_logger", "faster-hex", "flate2", - "hex-literal", - "lazy_static", + "lightningscanner", "log", "num_enum", "pbkdf2", + "rand", + "rust-ini", "serde", "serde-aux", "serde_json", "sha1", + "sha2", + "snafu", "ureq", "url", + "vergen", "widestring", "winapi", ] [[package]] name = "serde" -version = "1.0.197" +version = "1.0.203" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" +checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094" dependencies = [ "serde_derive", ] @@ -812,20 +985,20 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.197" +version = "1.0.203" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" +checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" dependencies = [ "proc-macro2", "quote", - "syn 2.0.53", + "syn 2.0.68", ] [[package]] name = "serde_json" -version = "1.0.114" +version = "1.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5f09b1bd632ef549eaa9f60a1f8de742bdbc698e6cee2095fc84dde5f549ae0" +checksum = "d947f6b3163d8857ea16c4fa0dd4840d52f3041039a85decd46867eb1abef2e4" dependencies = [ "itoa", "ryu", @@ -834,9 +1007,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "0.6.5" +version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3622f419d1296904700073ea6cc23ad690adbd66f13ea683df73298736f0c1" +checksum = "79e674e01f999af37c49f70a6ede167a8a60b2503e56c5599532a65baa5969a0" dependencies = [ "serde", ] @@ -852,12 +1025,44 @@ dependencies = [ "digest", ] +[[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 = "slice-pool" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "733fc6e5f1bd3a8136f842c9bdea4e5f17c910c2fcc98c90c3aa7604ef5e2e7a" +[[package]] +name = "snafu" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418b8136fec49956eba89be7da2847ec1909df92a9ae4178b5ff0ff092c8d95e" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a4812a669da00d17d8266a0439eddcacbc88b17f732f927e52eeb9d196f7fb5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.68", +] + [[package]] name = "spin" version = "0.9.8" @@ -866,9 +1071,9 @@ checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" [[package]] name = "subtle" -version = "2.5.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" @@ -883,49 +1088,82 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.53" +version = "2.0.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7383cd0e49fff4b6b90ca5670bfd3e9d6a733b3f90c686605aa7eec8c4996032" +checksum = "901fa70d88b9d6c98022e23b4136f9f3e54e4662c3bc1bd1d84a42a9a0f0c1e9" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - [[package]] name = "thiserror" -version = "1.0.58" +version = "1.0.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03468839009160513471e86a034bb2c5c0e4baae3b43f79ffc55c4a5427b3297" +checksum = "c546c80d6be4bc6a00c0f01730c08df82eaa7a7a61f11d656526506112cc1709" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.58" +version = "1.0.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61f3ba182994efc43764a46c018c347bc492c79f024e705f46567b418f6d4f7" +checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533" dependencies = [ "proc-macro2", "quote", - "syn 2.0.53", + "syn 2.0.68", +] + +[[package]] +name = "time" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +dependencies = [ + "deranged", + "itoa", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", ] [[package]] name = "tinyvec" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +checksum = "c55115c6fbe2d2bef26eb09ad74bde02d8255476fc0c7b515ef09fbb35742d82" dependencies = [ "tinyvec_macros", ] @@ -938,21 +1176,21 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "toml" -version = "0.8.12" +version = "0.8.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9dd1545e8208b4a5af1aa9bbd0b4cf7e9ea08fabc5d0a5c67fcaafa17433aa3" +checksum = "6f49eb2ab21d2f26bd6db7bf383edc527a7ebaee412d17af4d40fdccd442f335" dependencies = [ "serde", "serde_spanned", "toml_datetime", - "toml_edit 0.22.9", + "toml_edit 0.22.14", ] [[package]] name = "toml_datetime" -version = "0.6.5" +version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1" +checksum = "4badfd56924ae69bcc9039335b2e017639ce3f9b001c393c1b2d1ef846ce2cbf" dependencies = [ "serde", ] @@ -970,17 +1208,23 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.22.9" +version = "0.22.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e40bb779c5187258fd7aad0eb68cb8706a0a81fa712fbea808ab43c4b8374c4" +checksum = "f21c7aaf97f1bd9ca9d4f9e73b0a6c74bd5afef56f2bc931943a6e1c37e04e38" dependencies = [ "indexmap", "serde", "serde_spanned", "toml_datetime", - "winnow 0.6.5", + "winnow 0.6.13", ] +[[package]] +name = "trim-in-place" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343e926fc669bc8cde4fa3129ab681c63671bae288b1f1081ceee6d9d37904fc" + [[package]] name = "typenum" version = "1.17.0" @@ -1022,9 +1266,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "2.9.6" +version = "2.9.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f214ce18d8b2cbe84ed3aa6486ed3f5b285cf8d8fbdbce9f3f767a724adc35" +checksum = "d11a831e3c0b56e438a28308e7c810799e3c118417f342d30ecec080105395cd" dependencies = [ "base64", "flate2", @@ -1041,13 +1285,32 @@ dependencies = [ [[package]] name = "url" -version = "2.5.0" +version = "2.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" +checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", +] + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vergen" +version = "8.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e27d6bdd219887a9eadd19e1c34f32e47fa332301184935c6d9bca26f3cca525" +dependencies = [ + "anyhow", + "cfg-if", + "rustversion", + "time", ] [[package]] @@ -1083,7 +1346,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.53", + "syn 2.0.68", "wasm-bindgen-shared", ] @@ -1105,7 +1368,7 @@ checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.53", + "syn 2.0.68", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -1118,18 +1381,18 @@ checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" [[package]] name = "webpki-roots" -version = "0.26.1" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3de34ae270483955a94f4b21bdaaeb83d508bb84a01435f393818edb0012009" +checksum = "bd7c23921eeb1713a4e851530e9b9756e4fb0e89978582942612524cf09f01cd" dependencies = [ "rustls-pki-types", ] [[package]] name = "widestring" -version = "1.0.2" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "653f141f39ec16bba3c5abe400a0c60da7468261cc2cbf36805022876bc721a8" +checksum = "7219d36b6eac893fa81e84ebe06485e7dcbb616177469b142df14f1f4deb1311" [[package]] name = "winapi" @@ -1147,15 +1410,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" -[[package]] -name = "winapi-util" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f29e6f9198ba0d26b4c9f07dbe6f9ed633e1f3d5b8b414090084349e46a52596" -dependencies = [ - "winapi", -] - [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -1168,7 +1422,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" dependencies = [ - "windows-targets 0.52.4", + "windows-targets 0.52.5", ] [[package]] @@ -1186,7 +1440,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.4", + "windows-targets 0.52.5", ] [[package]] @@ -1206,17 +1460,18 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.52.4" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b" +checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" dependencies = [ - "windows_aarch64_gnullvm 0.52.4", - "windows_aarch64_msvc 0.52.4", - "windows_i686_gnu 0.52.4", - "windows_i686_msvc 0.52.4", - "windows_x86_64_gnu 0.52.4", - "windows_x86_64_gnullvm 0.52.4", - "windows_x86_64_msvc 0.52.4", + "windows_aarch64_gnullvm 0.52.5", + "windows_aarch64_msvc 0.52.5", + "windows_i686_gnu 0.52.5", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.5", + "windows_x86_64_gnu 0.52.5", + "windows_x86_64_gnullvm 0.52.5", + "windows_x86_64_msvc 0.52.5", ] [[package]] @@ -1227,9 +1482,9 @@ checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] name = "windows_aarch64_gnullvm" -version = "0.52.4" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9" +checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" [[package]] name = "windows_aarch64_msvc" @@ -1239,9 +1494,9 @@ checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] name = "windows_aarch64_msvc" -version = "0.52.4" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675" +checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" [[package]] name = "windows_i686_gnu" @@ -1251,9 +1506,15 @@ checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] name = "windows_i686_gnu" -version = "0.52.4" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3" +checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" [[package]] name = "windows_i686_msvc" @@ -1263,9 +1524,9 @@ checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] name = "windows_i686_msvc" -version = "0.52.4" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02" +checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" [[package]] name = "windows_x86_64_gnu" @@ -1275,9 +1536,9 @@ checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] name = "windows_x86_64_gnu" -version = "0.52.4" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03" +checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" [[package]] name = "windows_x86_64_gnullvm" @@ -1287,9 +1548,9 @@ checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] name = "windows_x86_64_gnullvm" -version = "0.52.4" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177" +checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" [[package]] name = "windows_x86_64_msvc" @@ -1299,9 +1560,9 @@ checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "windows_x86_64_msvc" -version = "0.52.4" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" +checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" [[package]] name = "winnow" @@ -1314,15 +1575,15 @@ dependencies = [ [[package]] name = "winnow" -version = "0.6.5" +version = "0.6.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dffa400e67ed5a4dd237983829e66475f0a4a26938c4b04c21baede6262215b8" +checksum = "59b5e5f6c299a3c7890b876a2a587f3115162487e704907d9b6cd29473052ba1" dependencies = [ "memchr", ] [[package]] name = "zeroize" -version = "1.7.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" diff --git a/Cargo.toml b/Cargo.toml index 8c8e8b9..41e1b46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,42 +1,38 @@ [package] name = "saekawa" -version = "0.3.4" -authors = ["beerpsi "] +version = "0.4.0" edition = "2021" license = "0BSD" [lib] crate-type = ["cdylib"] -[profile.release] -strip = true # Automatically strip symbols from the binary. -opt-level = "z" # Optimize for size. -lto = true -codegen-units = 1 -panic = "abort" - [dependencies] -aes = "0.8.3" -anyhow = "1.0.75" -binary-reader = "0.4.5" +aes = "0.8.4" cbc = "0.1.2" -chrono = "0.4.31" +chrono = "0.4.38" confy = "0.6.1" -crc32fast = "1.3.2" crochet = "0.2.3" -env_logger = "0.10.2" +env_logger = "0.11.3" faster-hex = "0.9.0" -flate2 = "1.0.28" -hex-literal = "0.4.1" -lazy_static = "1.4.0" -log = "0.4.20" -num_enum = "0.7.1" +flate2 = "1.0.30" +lightningscanner = "1.0.2" +log = "0.4.21" +num_enum = "0.7.2" pbkdf2 = "0.12.2" -serde = { version = "1.0.193", features = ["derive"] } -serde-aux = "4.3.1" -serde_json = "1.0.108" +rand = "0.8.5" +rust-ini = "0.21.0" +serde = { version = "1.0.203", features = ["derive"] } +serde-aux = "4.5.0" +serde_json = "1.0.117" sha1 = "0.10.6" -ureq = { version = "2.9.1", features = ["json"] } -url = "2.5.0" -widestring = "1.0.2" -winapi = { version = "0.3.9", features = ["winhttp", "minwindef", "debugapi", "synchapi", "libloaderapi", "processthreadsapi"] } +sha2 = "0.10.8" +snafu = "0.8.3" +ureq = { version = "2.9.7", features = ["json"] } +url = { version = "2.5.2", features = ["serde"] } +widestring = "1.1.0" +winapi = { version = "0.3.9", features = ["minwindef", "winnt", "psapi", "processthreadsapi", "libloaderapi", "errhandlingapi", "winhttp", "synchapi", "debugapi", "wincon", "heapapi", "winbase", "wincrypt", "softpub", "wintrust"] } + +[build-dependencies] +snafu = "0.8.3" +vergen = { version = "8.3.1", features = ["build", "git", "gitcl"] } diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..6ae417e --- /dev/null +++ b/build.rs @@ -0,0 +1,12 @@ +use snafu::{prelude::*, Whatever}; +use vergen::EmitBuilder; + +pub fn main() -> Result<(), Whatever> { + EmitBuilder::builder() + .build_timestamp() + .git_sha(false) + .git_branch() + .emit() + .with_whatever_context(|_| "Could not emit version information")?; + Ok(()) +} diff --git a/release.ps1 b/release.ps1 new file mode 100644 index 0000000..8d65a76 --- /dev/null +++ b/release.ps1 @@ -0,0 +1,16 @@ +cargo build --target i686-pc-windows-msvc --release + +if (!(Test-Path ./saekawa.pfx)) { + $cert = New-SelfSignedCertificate -Type Custom ` + -Subject "CN=saekawa self-signed certificate" ` + -CertStoreLocation cert:\CurrentUser\My ` + -KeyUsage DigitalSignature + + Export-PfxCertificate -Cert $cert ` + -FilePath saekawa.pfx ` + -Password (ConvertTo-SecureString -String "saekawa" -Force -AsPlainText) +} + +signtool sign -f saekawa.pfx -p "saekawa" -fd SHA256 -t http://timestamp.comodoca.com/authenticode -v target/i686-pc-windows-msvc/release/saekawa.dll + +Write-Output "Remember to make the .rtext section writable for auto-updates! I have fuck all idea how to do it in Rust itself, so it's manual from here. The DLL has already been signed." diff --git a/src/config/defaults.rs b/src/config/defaults.rs new file mode 100644 index 0000000..dd7ccfe --- /dev/null +++ b/src/config/defaults.rs @@ -0,0 +1,23 @@ +use std::{path::PathBuf, str::FromStr}; + +use url::Url; + +pub(super) fn default_true() -> bool { + true +} + +pub(super) fn default_false() -> bool { + false +} + +pub(super) fn default_timeout() -> u64 { + 5000 +} + +pub(super) fn default_tachi_url() -> Url { + Url::parse("https://kamai.tachi.ac").unwrap() +} + +pub(super) fn default_failed_import_dir() -> Option { + PathBuf::from_str("failed_saekawa_imports").ok() +} diff --git a/src/config/migrate.rs b/src/config/migrate.rs new file mode 100644 index 0000000..3c2254a --- /dev/null +++ b/src/config/migrate.rs @@ -0,0 +1,42 @@ +use serde::{Deserialize, Serialize}; + +use super::defaults::*; + +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +pub struct OldSaekawaConfig { + pub general: OldGeneralConfig, + pub cards: OldCardsConfig, + pub tachi: OldTachiConfig, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +pub struct OldGeneralConfig { + #[serde(default = "default_true")] + pub enable: bool, + + #[serde(default = "default_true")] + pub export_class: bool, + + #[serde(default = "default_false")] + pub export_pbs: bool, + + #[serde(default = "default_false")] + pub fail_over_lamp: bool, + + #[serde(default = "default_timeout")] + pub timeout: u64, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct OldCardsConfig { + #[serde(default)] + pub whitelist: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct OldTachiConfig { + pub base_url: String, + pub status: String, + pub import: String, + pub api_key: String, +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..8f12cd0 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,129 @@ +mod defaults; +mod migrate; + +use std::{collections::HashMap, path::PathBuf, str::FromStr}; + +use log::{info, warn}; +use migrate::OldSaekawaConfig; +use serde::{Deserialize, Serialize}; +use snafu::{ResultExt, Snafu}; +use url::Url; + +use self::defaults::*; + +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +pub struct SaekawaConfig { + pub general: GeneralConfig, + pub cards: HashMap, + pub tachi: TachiConfig, +} + +#[derive(Snafu, Debug)] +pub enum ConfigLoadError { + #[snafu(display( + "Could not load or save configuration. Is the configuration format correct?" + ))] + ConfyError { source: confy::ConfyError }, + + #[snafu(display("Could not migrate to new configuration format: {source:#?}"))] + MigrationError { source: MigrationError }, +} + +#[derive(Snafu, Debug)] +pub enum MigrationError { + #[snafu(display("Invalid Tachi base URL."))] + InvalidTachiUrl { source: url::ParseError }, +} + +impl SaekawaConfig { + pub fn load() -> Result { + let result = confy::load_path::("saekawa.toml"); + + match result { + Ok(_) => result.context(ConfySnafu), + Err(_) => { + warn!("Could not parse configuration, attempting to parse as old configuration..."); + let old_config = + confy::load_path::("saekawa.toml").context(ConfySnafu)?; + + info!("Successfully loaded as old configuration, migrating to new format..."); + let tachi_base_url = Url::parse(&old_config.tachi.base_url) + .context(InvalidTachiUrlSnafu) + .context(MigrationSnafu)?; + let new_tachi_config = TachiConfig { + base_url: tachi_base_url, + }; + + let mut new_cards_config: HashMap = HashMap::new(); + if old_config.cards.whitelist.is_empty() { + new_cards_config.insert("default".to_string(), old_config.tachi.api_key); + } else { + for card in old_config.cards.whitelist { + new_cards_config.insert(card, old_config.tachi.api_key.clone()); + } + } + + let new_general_config = GeneralConfig { + export_class: old_config.general.export_class, + fail_over_lamp: old_config.general.fail_over_lamp, + timeout: old_config.general.timeout, + ..Default::default() + }; + + let new_config = SaekawaConfig { + general: new_general_config, + cards: new_cards_config, + tachi: new_tachi_config, + }; + + confy::store_path("saekawa.toml", new_config.clone()).context(ConfySnafu)?; + + Ok(new_config) + } + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct GeneralConfig { + #[serde(default = "default_true")] + pub export_class: bool, + + #[serde(default = "default_false")] + pub fail_over_lamp: bool, + + #[serde(default = "default_timeout")] + pub timeout: u64, + + #[serde(default = "default_false")] + pub auto_update: bool, + + #[serde(default = "default_failed_import_dir")] + pub failed_import_dir: Option, +} + +impl Default for GeneralConfig { + fn default() -> Self { + Self { + export_class: true, + fail_over_lamp: false, + timeout: 5000, + auto_update: true, + failed_import_dir: PathBuf::from_str("failed_saekawa_imports").ok(), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct TachiConfig { + #[serde(default = "default_tachi_url")] + pub base_url: Url, +} + +impl Default for TachiConfig { + fn default() -> Self { + Self { + base_url: Url::parse("https://kamai.tachi.ac").unwrap(), + } + } +} diff --git a/src/configuration.rs b/src/configuration.rs deleted file mode 100644 index db8c95a..0000000 --- a/src/configuration.rs +++ /dev/null @@ -1,98 +0,0 @@ -use anyhow::Result; -use serde::{Deserialize, Serialize}; -use std::fs::File; -use std::io::Write; -use std::path::Path; - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct Configuration { - pub general: GeneralConfiguration, - - pub cards: CardsConfiguration, - - #[serde(default)] - pub crypto: CryptoConfiguration, - - pub tachi: TachiConfiguration, -} - -impl Configuration { - pub fn load() -> Result { - if !Path::new("saekawa.toml").exists() { - File::create("saekawa.toml") - .and_then(|mut file| file.write_all(include_bytes!("../res/saekawa.toml"))) - .map_err(|err| anyhow::anyhow!("Could not create default config file: {}", err))?; - } - - confy::load_path("saekawa.toml") - .map_err(|err| anyhow::anyhow!("Could not load config: {}", err)) - } - - pub fn update(cfg: Configuration) -> Result<()> { - confy::store_path("saekawa.toml", cfg) - .map_err(|err| anyhow::anyhow!("Could not update config: {}", err)) - } -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct GeneralConfiguration { - #[serde(default = "default_true")] - pub enable: bool, - - #[serde(default = "default_true")] - pub export_class: bool, - - #[serde(default = "default_false")] - pub export_pbs: bool, - - #[serde(default = "default_false")] - pub fail_over_lamp: bool, - - #[serde(default = "default_timeout")] - pub timeout: u64, -} - -fn default_true() -> bool { - true -} - -fn default_false() -> bool { - false -} - -fn default_timeout() -> u64 { - 3000 -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct CardsConfiguration { - #[serde(default)] - pub whitelist: Vec, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct CryptoConfiguration { - #[serde(with = "faster_hex::nopfx_lowercase")] - pub key: Vec, - - #[serde(with = "faster_hex::nopfx_lowercase")] - pub iv: Vec, - - #[serde(with = "faster_hex::nopfx_lowercase")] - pub salt: Vec, - - #[serde(default = "default_iterations")] - pub iterations: u32, -} - -fn default_iterations() -> u32 { - 70 -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct TachiConfiguration { - pub base_url: String, - pub status: String, - pub import: String, - pub api_key: String, -} diff --git a/src/handlers.rs b/src/handlers.rs deleted file mode 100644 index c639d9a..0000000 --- a/src/handlers.rs +++ /dev/null @@ -1,59 +0,0 @@ -use std::{fmt::Debug, sync::atomic::Ordering}; - -use log::{debug, error, info}; -use serde::de::DeserializeOwned; - -use crate::{ - helpers::execute_tachi_import, saekawa::GAME_MAJOR_VERSION, types::tachi::ToTachiImport, - CONFIGURATION, -}; - -pub fn score_handler(body: String, guard: impl Fn(&T) -> bool) -where - T: Debug + DeserializeOwned + ToTachiImport, -{ - let data = match serde_json::from_str::(body.as_ref()) { - Ok(req) => req, - Err(err) => { - error!("Could not parse request body: {:#}", err); - return; - } - }; - - debug!("parsed request body: {:#?}", data); - - if !guard(&data) { - return; - } - - let import = data.to_tachi_import( - GAME_MAJOR_VERSION.load(Ordering::SeqCst), - CONFIGURATION.general.export_class, - CONFIGURATION.general.fail_over_lamp, - ); - - if import.scores.is_empty() { - if import.classes.is_none() { - return; - } - - if import - .classes - .clone() - .is_some_and(|v| v.dan.is_none() && v.emblem.is_none()) - { - return; - } - } - - info!( - "Submitting {} scores from {} {}", - import.scores.len(), - data.displayed_id_type(), - data.displayed_id(), - ); - - if let Err(err) = execute_tachi_import(import) { - error!("{:#}", err); - } -} diff --git a/src/helpers/chuni_encoding.rs b/src/helpers/chuni_encoding.rs new file mode 100644 index 0000000..99f706f --- /dev/null +++ b/src/helpers/chuni_encoding.rs @@ -0,0 +1,66 @@ +use std::io::{self, Read}; + +use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit}; +use flate2::read::ZlibDecoder; +use pbkdf2::pbkdf2_hmac_array; +use sha1::Sha1; +use snafu::prelude::Snafu; + +type Aes256CbcDec = cbc::Decryptor; + +#[derive(Snafu, Debug)] +pub enum DecryptError { + InvalidLength, + UnpadError, +} + +#[derive(Debug)] +pub struct MaybeDecompressError { + pub zlib_error: io::Error, + pub raw_error: io::Error, +} + +pub fn hash_endpoint(endpoint: impl AsRef, salt: impl AsRef<[u8]>, rounds: u32) -> String { + let key_bytes = + pbkdf2_hmac_array::(endpoint.as_ref().as_bytes(), salt.as_ref(), rounds); + + faster_hex::hex_string(&key_bytes) +} + +pub fn decrypt_aes256_cbc( + body: &mut [u8], + key: impl AsRef<[u8]>, + iv: impl AsRef<[u8]>, +) -> Result, DecryptError> { + let cipher = Aes256CbcDec::new_from_slices(key.as_ref(), iv.as_ref()) + .map_err(|_| DecryptError::InvalidLength)?; + + Ok(cipher + .decrypt_padded_mut::(body) + .map_err(|_| DecryptError::UnpadError)? + .to_owned()) +} + +pub fn maybe_decompress_buffer(buf: impl AsRef<[u8]>) -> Result { + let mut ret = String::with_capacity(buf.as_ref().len() * 2); + + let mut decoder = ZlibDecoder::new(buf.as_ref()); + let zlib_result = decoder.read_to_string(&mut ret); + + if zlib_result.is_ok() { + return Ok(ret); + } + + ret.clear(); + + let result = buf.as_ref().read_to_string(&mut ret); + + if result.is_ok() { + return Ok(ret); + } + + Err(MaybeDecompressError { + zlib_error: zlib_result.expect_err("must be Err if reached here"), + raw_error: result.expect_err("must be Err if reached here"), + }) +} diff --git a/src/helpers/crypto.rs b/src/helpers/crypto.rs deleted file mode 100644 index 3d95d1d..0000000 --- a/src/helpers/crypto.rs +++ /dev/null @@ -1,16 +0,0 @@ -use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit}; -use anyhow::{anyhow, Result}; - -type Aes256CbcDec = cbc::Decryptor; - -pub fn decrypt_aes256_cbc( - body: &mut [u8], - key: impl AsRef<[u8]>, - iv: impl AsRef<[u8]>, -) -> Result> { - let cipher = Aes256CbcDec::new_from_slices(key.as_ref(), iv.as_ref())?; - Ok(cipher - .decrypt_padded_mut::(body) - .map_err(|err| anyhow!(err))? - .to_owned()) -} diff --git a/src/helpers/endpoint.rs b/src/helpers/endpoint.rs deleted file mode 100644 index 912b53e..0000000 --- a/src/helpers/endpoint.rs +++ /dev/null @@ -1,58 +0,0 @@ -use log::debug; -use pbkdf2::pbkdf2_hmac_array; -use sha1::Sha1; - -use crate::CONFIGURATION; - -pub fn is_endpoint( - endpoint: &str, - unencrypted_variant: &str, - encrypted_variant: &Option, -) -> bool { - if endpoint == unencrypted_variant { - return true; - } - - if encrypted_variant.as_ref().is_some_and(|v| v == endpoint) { - return true; - } - - return false; -} - -/// Determine if it is an encrypted endpoint by checking if the endpoint -/// is exactly 32 characters long, and consists of all hex characters. -/// -/// While this may trigger false positives, this should not happen as long -/// as CHUNITHM title APIs keep their `{method}{object}Api` endpoint -/// convention. -pub fn is_encrypted_endpoint(endpoint: &str) -> bool { - if endpoint.len() != 32 { - return false; - } - - // Lazy way to check if all digits are hexadecimal - if u128::from_str_radix(endpoint, 16).is_err() { - return false; - } - - true -} - -pub fn hash_endpoint(endpoint: &str) -> Option { - if CONFIGURATION.crypto.salt.is_empty() { - return None; - } - - let key_bytes = pbkdf2_hmac_array::( - endpoint.as_bytes(), - &CONFIGURATION.crypto.salt, - CONFIGURATION.crypto.iterations, - ); - - let key = faster_hex::hex_string(&key_bytes); - - debug!("Running with encryption support: {endpoint} maps to {key}"); - - Some(key) -} diff --git a/src/helpers/hinternet.rs b/src/helpers/hinternet.rs deleted file mode 100644 index fd25090..0000000 --- a/src/helpers/hinternet.rs +++ /dev/null @@ -1,114 +0,0 @@ -use std::ptr; - -use anyhow::{anyhow, Result}; -use widestring::U16CString; -use winapi::{ - ctypes::c_void, - shared::{minwindef::TRUE, winerror::ERROR_INSUFFICIENT_BUFFER}, - um::{ - errhandlingapi::GetLastError, - winhttp::{ - WinHttpQueryHeaders, WinHttpQueryOption, HINTERNET, WINHTTP_OPTION_URL, - WINHTTP_QUERY_FLAG_REQUEST_HEADERS, WINHTTP_QUERY_USER_AGENT, - }, - }, -}; - -/// Queries a HINTERNET handle for its URL, then return the result. -pub fn read_hinternet_url(handle: HINTERNET) -> Result { - let mut buf_length = 255; - let mut buffer = [0u16; 255]; - let result = unsafe { - WinHttpQueryOption( - handle, - WINHTTP_OPTION_URL, - buffer.as_mut_ptr() as *mut c_void, - &mut buf_length, - ) - }; - - if result == TRUE { - let url_str = U16CString::from_vec_truncate(&buffer[..buf_length as usize]); - return url_str - .to_string() - .map_err(|err| anyhow!("Could not decode wide string: {:#}", err)); - } - - let ec = unsafe { GetLastError() }; - if ec == ERROR_INSUFFICIENT_BUFFER { - let mut buffer = vec![0u16; buf_length as usize]; - let result = unsafe { - WinHttpQueryOption( - handle, - WINHTTP_OPTION_URL, - buffer.as_mut_ptr() as *mut c_void, - &mut buf_length, - ) - }; - - if result != TRUE { - let ec = unsafe { GetLastError() }; - return Err(anyhow!("Could not get URL from HINTERNET handle: {ec}")); - } - - let url_str = U16CString::from_vec_truncate(&buffer[..buf_length as usize]); - return url_str - .to_string() - .map_err(|err| anyhow!("Could not decode wide string: {:#}", err)); - } - - let ec = unsafe { GetLastError() }; - Err(anyhow!("Could not get URL from HINTERNET handle: {ec}")) -} - -pub fn read_hinternet_user_agent(handle: HINTERNET) -> Result { - let mut buf_length = 255; - let mut buffer = [0u16; 255]; - let result = unsafe { - WinHttpQueryHeaders( - handle, - WINHTTP_QUERY_USER_AGENT | WINHTTP_QUERY_FLAG_REQUEST_HEADERS, - ptr::null(), - buffer.as_mut_ptr() as *mut c_void, - &mut buf_length, - ptr::null_mut(), - ) - }; - - if result == TRUE { - let user_agent_str = U16CString::from_vec_truncate(&buffer[..buf_length as usize]); - return user_agent_str - .to_string() - .map_err(|err| anyhow!("Could not decode wide string: {:#}", err)); - } - - let ec = unsafe { GetLastError() }; - if ec == ERROR_INSUFFICIENT_BUFFER { - let mut buffer = vec![0u16; buf_length as usize]; - let result = unsafe { - WinHttpQueryHeaders( - handle, - WINHTTP_QUERY_USER_AGENT | WINHTTP_QUERY_FLAG_REQUEST_HEADERS, - ptr::null(), - buffer.as_mut_ptr() as *mut c_void, - &mut buf_length, - ptr::null_mut(), - ) - }; - - if result != TRUE { - let ec = unsafe { GetLastError() }; - return Err(anyhow!("Could not get URL from HINTERNET handle: {ec}")); - } - - let user_agent_str = U16CString::from_vec_truncate(&buffer[..buf_length as usize]); - return user_agent_str - .to_string() - .map_err(|err| anyhow!("Could not decode wide string: {:#}", err)); - } - - let ec = unsafe { GetLastError() }; - Err(anyhow!( - "Could not get User-Agent from HINTERNET handle: {ec}" - )) -} diff --git a/src/helpers/io.rs b/src/helpers/io.rs deleted file mode 100644 index 5629f16..0000000 --- a/src/helpers/io.rs +++ /dev/null @@ -1,35 +0,0 @@ -use std::io::Read; - -use anyhow::{anyhow, Result}; -use flate2::read::ZlibDecoder; - -pub fn read_slice(buf: *const u8, len: usize) -> Result> { - let mut slice = unsafe { std::slice::from_raw_parts(buf, len) }; - let mut ret = Vec::with_capacity(len); - - slice.read_to_end(&mut ret)?; - - Ok(ret) -} - -pub fn read_maybe_compressed_buffer(buf: impl AsRef<[u8]>) -> Result { - let mut ret = String::new(); - - let mut decoder = ZlibDecoder::new(buf.as_ref()); - let zlib_result = decoder.read_to_string(&mut ret); - if zlib_result.is_ok() { - return Ok(ret); - } - - ret.clear(); - let result = buf.as_ref().read_to_string(&mut ret); - if result.is_ok() { - return Ok(ret); - } - - Err(anyhow!( - "Could not decode contents of buffer as both DEFLATE-compressed ({:#}) and plaintext ({:#}) UTF-8 string.", - zlib_result.expect_err("This shouldn't happen, if Result was Ok the string should have been returned earlier."), - result.expect_err("This shouldn't happen, if Result was Ok the string should have been returned earlier."), - )) -} diff --git a/src/helpers/mod.rs b/src/helpers/mod.rs index 38a3dbb..aad253e 100644 --- a/src/helpers/mod.rs +++ b/src/helpers/mod.rs @@ -1,11 +1,2 @@ -mod crypto; -mod endpoint; -mod hinternet; -mod io; -mod net; - -pub use crypto::*; -pub use endpoint::*; -pub use hinternet::*; -pub use io::*; -pub use net::*; +pub mod chuni_encoding; +pub mod winapi_ext; diff --git a/src/helpers/net.rs b/src/helpers/net.rs deleted file mode 100644 index c2a1a45..0000000 --- a/src/helpers/net.rs +++ /dev/null @@ -1,140 +0,0 @@ -use std::fmt::Debug; - -use anyhow::{anyhow, Result}; -use log::{debug, error, info}; -use serde::{Deserialize, Serialize}; - -use crate::{ - types::tachi::{Import, ImportDocument, ImportPollStatus, ImportResponse, TachiResponse}, - CONFIGURATION, TACHI_IMPORT_URL, -}; - -pub fn request_agent() -> ureq::Agent { - let timeout = CONFIGURATION.general.timeout; - let timeout = if timeout > 10000 { 10000 } else { timeout }; - - ureq::builder() - .timeout(std::time::Duration::from_millis(timeout)) - .build() -} - -fn request( - method: impl AsRef, - url: impl AsRef, - body: Option, -) -> Result -where - T: Serialize + Debug, -{ - let agent = request_agent(); - - let method = method.as_ref(); - let url = url.as_ref(); - debug!("{} request to {} with body: {:#?}", method, url, body); - - let authorization = format!("Bearer {}", CONFIGURATION.tachi.api_key); - let request = agent - .request(method, url) - .set("Authorization", authorization.as_str()); - let response = match body { - Some(body) => request.send_json(body), - None => request.call(), - } - .map_err(|err| anyhow::anyhow!("Could not reach Tachi API: {:#}", err))?; - - Ok(response) -} - -pub fn request_tachi( - method: impl AsRef, - url: impl AsRef, - body: Option, -) -> Result -where - T: Serialize + Debug, - R: for<'de> Deserialize<'de> + Debug, -{ - let response = request(method, url, body)?; - let response = response.into_json()?; - debug!("Tachi API response: {:#?}", response); - - Ok(response) -} - -fn log_import(description: &str, import: ImportDocument) { - info!( - "{description} {} scores, {} sessions, {} errors", - import.score_ids.len(), - import.created_sessions.len(), - import.errors.len() - ); - - for err in import.errors { - error!("{}: {}", err.error_type, err.message); - } -} - -/// Executes a DIRECT-MANUAL import and logs some information on success. -/// -/// ## Important -/// This function blocks until import has fully finished! It is best to call this in a separate thread. -pub fn execute_tachi_import(import: Import) -> Result<()> { - let resp: TachiResponse = - match request_tachi("POST", TACHI_IMPORT_URL.as_str(), Some(import)) { - Err(err) => { - return Err(anyhow!("Could not send scores to Tachi: {:#}", err)); - } - Ok(resp) => resp, - }; - - let (body, description) = match resp { - TachiResponse::Err(err) => { - return Err(anyhow!( - "Tachi API returned an error: {:#}", - err.description - )); - } - TachiResponse::Ok(resp) => (resp.body, resp.description), - }; - - let poll_url = match body { - ImportResponse::Queued { url, import_id: _ } => { - info!("Queued import for processing. Status URL: {}", url); - url - } - ImportResponse::Finished(import) => { - log_import(&description, import); - return Ok(()); - } - }; - - loop { - let resp: TachiResponse = - match request_tachi("GET", &poll_url, None::<()>) { - Ok(resp) => resp, - Err(err) => { - error!("Could not poll import status: {:#}", err); - break; - } - }; - - let (body, description) = match resp { - TachiResponse::Ok(resp) => (resp.body, resp.description), - TachiResponse::Err(err) => { - return Err(anyhow!("Tachi API returned an error: {}", err.description)); - } - }; - - match body { - ImportPollStatus::Completed { import } => { - log_import(&description, import); - return Ok(()); - } - _ => {} - } - - std::thread::sleep(std::time::Duration::from_secs(1)); - } - - Ok(()) -} diff --git a/src/helpers/winapi_ext.rs b/src/helpers/winapi_ext.rs new file mode 100644 index 0000000..56f5d7e --- /dev/null +++ b/src/helpers/winapi_ext.rs @@ -0,0 +1,99 @@ +use snafu::prelude::Snafu; +use widestring::U16CString; +use winapi::{ + ctypes::c_void, + shared::{ + minwindef::{HINSTANCE, HMODULE, TRUE}, + winerror::ERROR_INSUFFICIENT_BUFFER, + }, + um::{ + errhandlingapi::GetLastError, + libloaderapi::{FreeLibraryAndExitThread, GetModuleFileNameW}, + winhttp::{WinHttpQueryOption, HINTERNET}, + }, +}; + +pub struct LibraryHandle(HINSTANCE); + +unsafe impl Send for LibraryHandle {} +unsafe impl Sync for LibraryHandle {} +impl LibraryHandle { + pub unsafe fn new(handle: HINSTANCE) -> Self { + Self(handle) + } + + pub fn handle(&self) -> HINSTANCE { + self.0 + } + + pub fn free_and_exit_thread(self, code: u32) -> ! { + unsafe { + FreeLibraryAndExitThread(self.0, code); + } + unreachable!() + } +} + +#[derive(Debug, Snafu)] +pub enum ReadStringFnError { + InvalidData, + Other { errno: u32 }, +} + +pub fn read_string_from_function_call( + reader: impl Fn(&mut [u16], &mut u32) -> i32, + is_success: impl Fn(i32) -> bool, +) -> Result { + let mut buffer = vec![0u16; 255]; + let mut buffer_length = 255; + let result = reader(&mut buffer, &mut buffer_length); + + if is_success(result) { + let out = U16CString::from_vec_truncate(&buffer[..buffer_length as usize]); + + return out.to_string().map_err(|_| ReadStringFnError::InvalidData); + } + + let errno = unsafe { GetLastError() }; + + if errno == ERROR_INSUFFICIENT_BUFFER { + buffer.resize(buffer_length as usize, 0); + let result = reader(&mut buffer, &mut buffer_length); + + if result != TRUE { + let errno = unsafe { GetLastError() }; + + return Err(ReadStringFnError::Other { errno }); + } + + let out = U16CString::from_vec_truncate(&buffer[..buffer_length as usize]); + + return out.to_string().map_err(|_| ReadStringFnError::InvalidData); + } + + Err(ReadStringFnError::Other { errno }) +} + +pub fn winhttp_query_option(handle: HINTERNET, option: u32) -> Result { + read_string_from_function_call( + |buf, buflen| unsafe { + WinHttpQueryOption(handle, option, buf.as_mut_ptr() as *mut c_void, buflen) + }, + |ret| ret == TRUE, + ) +} + +pub fn get_module_file_name(handle: HMODULE) -> Result { + read_string_from_function_call( + |buf, buflen| unsafe { + let ret = GetModuleFileNameW(handle, buf.as_mut_ptr(), *buflen) as i32; + + if GetLastError() == ERROR_INSUFFICIENT_BUFFER { + *buflen = 32767; + } + + ret + }, + |_| unsafe { GetLastError() != ERROR_INSUFFICIENT_BUFFER }, + ) +} diff --git a/src/icf.rs b/src/icf.rs deleted file mode 100644 index 048d4ae..0000000 --- a/src/icf.rs +++ /dev/null @@ -1,245 +0,0 @@ -use std::fmt::Display; - -use aes::cipher::{block_padding::NoPadding, BlockDecryptMut, KeyIvInit}; -use anyhow::{anyhow, Result}; -use binary_reader::{BinaryReader, Endian}; -use chrono::{NaiveDate, NaiveDateTime}; - -type Aes128CbcDec = cbc::Decryptor; - -const ICF_KEY: [u8; 16] = hex_literal::decode(&[env!("SAEKAWA_ICF_KEY").as_bytes()]); -const ICF_IV: [u8; 16] = hex_literal::decode(&[env!("SAEKAWA_ICF_IV").as_bytes()]); - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub struct Version { - pub major: u16, - pub minor: u8, - pub build: u8, -} - -impl Display for Version { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}.{:0>2}.{:0>2}", self.major, self.minor, self.build) - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct IcfInnerData { - pub id: String, - pub version: Version, - pub required_system_version: Version, - pub datetime: NaiveDateTime, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct IcfPatchData { - pub id: String, - pub source_version: Version, - pub target_version: Version, - pub required_system_version: Version, - pub datetime: NaiveDateTime, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum IcfData { - System(IcfInnerData), - App(IcfInnerData), - Patch(IcfPatchData), - Option(IcfInnerData), -} - -fn decrypt_icf(data: &mut [u8], key: impl AsRef<[u8]>, iv: impl AsRef<[u8]>) -> Result> { - let size = data.len(); - - let mut decrypted = Vec::with_capacity(size); - - for i in (0..size).step_by(4096) { - let from_start = i; - - let bufsz = std::cmp::min(4096, size - from_start); - let buf = &data[i..i + bufsz]; - let mut decbuf = vec![0; bufsz]; - - let cipher = Aes128CbcDec::new_from_slices(key.as_ref(), iv.as_ref())?; - cipher - .decrypt_padded_b2b_mut::(buf, &mut decbuf) - .map_err(|err| anyhow!(err))?; - - let xor1 = u64::from_le_bytes(decbuf[0..8].try_into()?) ^ (from_start as u64); - let xor2 = u64::from_le_bytes(decbuf[8..16].try_into()?) ^ (from_start as u64); - - decrypted.extend(xor1.to_le_bytes()); - decrypted.extend(xor2.to_le_bytes()); - decrypted.extend(&decbuf[16..]); - } - - Ok(decrypted) -} - -pub fn decode_icf_container_data( - rd: &mut BinaryReader, -) -> Result<(Version, NaiveDateTime, Version)> { - let version = Version { - build: rd.read_u8()?, - minor: rd.read_u8()?, - major: rd.read_u16()?, - }; - - let datetime = NaiveDate::from_ymd_opt( - rd.read_i16()? as i32, - rd.read_u8()? as u32, - rd.read_u8()? as u32, - ) - .ok_or(anyhow!("Invalid date"))? - .and_hms_milli_opt( - rd.read_u8()? as u32, - rd.read_u8()? as u32, - rd.read_u8()? as u32, - rd.read_u8()? as u32, - ) - .ok_or(anyhow!("Invalid time"))?; - - let required_system_version = Version { - build: rd.read_u8()?, - minor: rd.read_u8()?, - major: rd.read_u16()?, - }; - - Ok((version, datetime, required_system_version)) -} - -pub fn decode_icf(data: &mut [u8]) -> Result> { - let decrypted = decrypt_icf(data, ICF_KEY, ICF_IV)?; - - let mut rd = BinaryReader::from_vec(&decrypted); - rd.endian = Endian::Little; - - let checksum = crc32fast::hash(&decrypted[4..]); - let reported_crc = rd.read_u32()?; - if reported_crc != checksum { - return Err(anyhow!( - "Reported CRC32 ({reported_crc:02X}) does not match actual checksum ({checksum:02X})" - )); - } - - let reported_size = rd.read_u32()? as usize; - let actual_size = decrypted.len(); - if actual_size != reported_size { - return Err(anyhow!( - "Reported size {reported_size} does not match actual size {actual_size}" - )); - } - - let padding = rd.read_u64()?; - if padding != 0 { - return Err(anyhow!("Padding error. Expected 8 NULL bytes.")); - } - - let entry_count: usize = rd.read_u64()?.try_into()?; - let expected_size = 0x40 * (entry_count + 1); - if actual_size != expected_size { - return Err(anyhow!("Expected size {expected_size} ({entry_count} entries) does not match actual size {actual_size}")); - } - - let app_id = String::from_utf8(rd.read_bytes(4)?.to_vec())?; - let platform_id = String::from_utf8(rd.read_bytes(3)?.to_vec())?; - let _platform_generation = rd.read_u8()?; - - let reported_crc = rd.read_u32()?; - let mut checksum = 0; - for i in 1..=entry_count { - let container = &decrypted[0x40 * i..0x40 * (i + 1)]; - if container[0] == 2 && container[1] == 1 { - checksum ^= crc32fast::hash(container); - } - } - - if reported_crc != checksum { - return Err(anyhow!("Reported container CRC32 ({reported_crc:02X}) does not match actual checksum ({checksum:02X})")); - } - - for _ in 0..7 { - if rd.read_u32()? != 0 { - return Err(anyhow!("Padding error. Expected 28 NULL bytes.")); - } - } - - let mut entries: Vec = Vec::with_capacity(entry_count); - for _ in 0..entry_count { - let sig = rd.read_u32()?; - - if sig != 0x0102 && sig != 0x0201 { - return Err(anyhow!("Container does not start with signature (0x0102)")); - } - - let container_type = rd.read_u32()?; - for _ in 0..3 { - if rd.read_u64()? != 0 { - return Err(anyhow!("Padding error. Expected 24 NULL bytes.")); - } - } - - let data: IcfData = match container_type { - 0x0000 | 0x0001 | 0x0002 => { - let (version, datetime, required_system_version) = - decode_icf_container_data(&mut rd)?; - - for _ in 0..2 { - if rd.read_u64()? != 0 { - return Err(anyhow!("Padding error. Expected 16 NULL bytes.")); - } - } - - match container_type { - 0x0000 => IcfData::System(IcfInnerData { - id: platform_id.clone(), - version, - datetime, - required_system_version, - }), - 0x0001 => IcfData::App(IcfInnerData { - id: app_id.clone(), - version, - datetime, - required_system_version, - }), - 0x0002 => IcfData::Option(IcfInnerData { - id: app_id.clone(), - version, - datetime, - required_system_version, - }), - _ => unreachable!(), - } - } - _ => { - // PATCH container type also encode the patch's sequence number - // in the higher 16 bits. - // The lower 16 bits will always be 1. - let sequence_number = (container_type >> 8) as u8; - - if (container_type & 1) == 0 || sequence_number == 0 { - println!("Unknown ICF container type {container_type:#06x} at byte {:#06x}, skipping", rd.pos); - rd.read_bytes(32)?; - continue; - } - - let (target_version, target_datetime, _) = decode_icf_container_data(&mut rd)?; - let (source_version, _, source_required_system_version) = - decode_icf_container_data(&mut rd)?; - - IcfData::Patch(IcfPatchData { - id: app_id.clone(), - source_version, - target_version, - required_system_version: source_required_system_version, - datetime: target_datetime, - }) - } - }; - - entries.push(data); - } - - Ok(entries) -} diff --git a/src/lib.rs b/src/lib.rs index e2d0aa6..fc93faa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,111 +1,26 @@ -mod configuration; -mod handlers; +mod config; mod helpers; -mod icf; -mod log; +mod logging; mod saekawa; +mod score_import; +mod sigscan; mod types; +mod updater; -use std::ffi::c_void; -use std::{ptr, thread}; +use std::thread; -use ::log::{error, warn}; -use lazy_static::lazy_static; -use url::Url; -use winapi::shared::minwindef::{BOOL, DWORD, FALSE, HINSTANCE, LPVOID, TRUE}; -use winapi::um::errhandlingapi::GetLastError; -use winapi::um::handleapi::{CloseHandle, DuplicateHandle}; -use winapi::um::processthreadsapi::{GetCurrentProcess, GetCurrentThread}; -use winapi::um::synchapi::WaitForSingleObject; -use winapi::um::winnt::{DLL_PROCESS_ATTACH, DLL_PROCESS_DETACH, SYNCHRONIZE}; +use log::{error, info, warn}; +use winapi::{ + shared::minwindef::{BOOL, DWORD, HINSTANCE, LPVOID, TRUE}, + um::winnt::{DLL_PROCESS_ATTACH, DLL_PROCESS_DETACH}, +}; -use crate::configuration::Configuration; -use crate::helpers::hash_endpoint; -use crate::log::Logger; -use crate::saekawa::{hook_init, hook_release}; - -lazy_static! { - pub static ref CONFIGURATION: Configuration = { - let result = Configuration::load(); - if let Err(err) = result { - error!("{:#}", err); - std::process::exit(1); - } - - result.unwrap() - }; - pub static ref TACHI_STATUS_URL: String = { - let result = Url::parse(&CONFIGURATION.tachi.base_url) - .and_then(|url| url.join(&CONFIGURATION.tachi.status)); - if let Err(err) = result { - error!("Could not parse Tachi status URL: {:#}", err); - std::process::exit(1); - } - - result.unwrap().to_string() - }; - pub static ref TACHI_IMPORT_URL: String = { - let result = Url::parse(&CONFIGURATION.tachi.base_url) - .and_then(|url| url.join(&CONFIGURATION.tachi.import)); - if let Err(err) = result { - error!("Could not parse Tachi import URL: {:#}", err); - std::process::exit(1); - } - - result.unwrap().to_string() - }; - pub static ref UPSERT_USER_ALL_API_ENCRYPTED: Option = - hash_endpoint("UpsertUserAllApi"); - pub static ref GET_USER_MUSIC_API_ENCRYPTED: Option = hash_endpoint("GetUserMusicApi"); -} - -fn init_logger() { - env_logger::builder() - .filter_level(::log::LevelFilter::Error) - .filter_module( - "saekawa", - if cfg!(debug_assertions) { - ::log::LevelFilter::Debug - } else { - ::log::LevelFilter::Info - }, - ) - .parse_default_env() - .target(env_logger::Target::Pipe(Box::new(Logger::new()))) - .format(|f, record| { - use crate::log::{colored_level, max_target_width, Padded}; - use std::io::Write; - - let target = record.target(); - let max_width = max_target_width(target); - - let mut style = f.style(); - let level = colored_level(&mut style, record.level()); - - let mut style = f.style(); - let target = style.set_bold(true).value(Padded { - value: target, - width: max_width, - }); - - let time = chrono::Local::now().format("%d/%m/%Y %H:%M:%S"); - - writeln!(f, "[{}] {} {} -> {}", time, level, target, record.args()) - }) - .init(); -} - -struct ThreadHandle(*mut c_void); - -impl ThreadHandle { - pub unsafe fn wait_and_close(self, ms: u32) { - WaitForSingleObject(self.0, ms); - CloseHandle(self.0); - } -} - -unsafe impl Send for ThreadHandle {} -unsafe impl Sync for ThreadHandle {} +use crate::{ + helpers::winapi_ext::LibraryHandle, + logging::init_logger, + saekawa::{hook_init, hook_release}, + updater::self_update, +}; #[no_mangle] #[allow(non_snake_case, unused_variables)] @@ -114,42 +29,36 @@ extern "system" fn DllMain(dll_module: HINSTANCE, call_reason: DWORD, reserved: DLL_PROCESS_ATTACH => { init_logger(); - let (cur_thread, result) = unsafe { - let mut cur_thread = ptr::null_mut(); - let result = DuplicateHandle( - GetCurrentProcess(), - GetCurrentThread(), - GetCurrentProcess(), - &mut cur_thread, - SYNCHRONIZE, - FALSE, - 0, - ); - - if result == 0 { - warn!( - "Failed to get current thread handle, error code: {}", - GetLastError() - ); - } - - (ThreadHandle(cur_thread), result) - }; + let library_handle = unsafe { LibraryHandle::new(dll_module) }; thread::spawn(move || { - if result != 0 { - unsafe { cur_thread.wait_and_close(100) }; + info!( + "saekawa {} ({}@{}) starting up...", + env!("CARGO_PKG_VERSION"), + &env!("VERGEN_GIT_SHA")[0..7], + env!("VERGEN_GIT_BRANCH"), + ); + + match self_update(&library_handle) { + Ok(should_reboot) => { + if should_reboot { + info!("Self-update successful. Reloading into new hook..."); + library_handle.free_and_exit_thread(1); + } + } + Err(e) => { + error!("Self-update failed: {e:#}"); + } } - if let Err(err) = hook_init() { - error!("Failed to initialize hook: {:#}", err); + if let Err(e) = hook_init() { + error!("Failed to initialize hook: {e:#}"); } }); } DLL_PROCESS_DETACH => { - if let Err(err) = hook_release() { - error!("{:#}", err); - return FALSE; + if let Err(e) = hook_release() { + warn!("Failed to release hook: {e:#}") } } _ => {} diff --git a/src/log.rs b/src/log.rs deleted file mode 100644 index f7d729f..0000000 --- a/src/log.rs +++ /dev/null @@ -1,81 +0,0 @@ -use std::ffi::CString; -use std::fmt; -use std::fs::File; -use std::io::Write; -use std::sync::atomic::{AtomicUsize, Ordering}; - -use winapi::um::debugapi::OutputDebugStringA; - -#[derive(Debug)] -pub struct Logger { - file: File, -} - -impl Logger { - pub fn new() -> Self { - Self { - file: File::create("saekawa.log").unwrap(), - } - } -} - -impl Write for Logger { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - if let Ok(c_str) = CString::new(buf) { - unsafe { - OutputDebugStringA(c_str.as_ptr()); - } - } - - let _ = std::io::stdout().write(buf); - self.file.write(buf) - } - - fn flush(&mut self) -> std::io::Result<()> { - // Ignore the result of the write to stdout, since it's not really important - let _ = std::io::stdout().flush(); - self.file.flush() - } -} - -pub(crate) struct Padded { - pub(crate) value: T, - pub(crate) width: usize, -} - -impl fmt::Display for Padded { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "{: usize { - let max_width = MAX_MODULE_WIDTH.load(Ordering::Relaxed); - if max_width < target.len() { - MAX_MODULE_WIDTH.store(target.len(), Ordering::Relaxed); - target.len() - } else { - max_width - } -} - -pub(crate) fn colored_level( - style: &mut env_logger::fmt::Style, - level: log::Level, -) -> env_logger::fmt::StyledValue<&'static str> { - match level { - log::Level::Trace => style - .set_color(env_logger::fmt::Color::Magenta) - .value("TRACE"), - log::Level::Debug => style.set_color(env_logger::fmt::Color::Blue).value("DEBUG"), - log::Level::Info => style - .set_color(env_logger::fmt::Color::Green) - .value(" INFO"), - log::Level::Warn => style - .set_color(env_logger::fmt::Color::Yellow) - .value(" WARN"), - log::Level::Error => style.set_color(env_logger::fmt::Color::Red).value("ERROR"), - } -} diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 0000000..6c2f975 --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,61 @@ +use std::{ffi::CString, fs::File, io::Write}; + +use winapi::um::{debugapi::OutputDebugStringA, wincon::GetConsoleWindow}; + +#[derive(Debug)] +struct Logger { + file: File, + has_console_output: bool, +} + +impl Logger { + pub fn new() -> Self { + Self { + file: File::create("saekawa.log").unwrap(), + has_console_output: unsafe { !GetConsoleWindow().is_null() }, + } + } +} + +impl Write for Logger { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + if self.has_console_output { + let _ = std::io::stdout().write(buf); + } else if let Ok(c_str) = CString::new(buf) { + unsafe { + OutputDebugStringA(c_str.as_ptr()); + } + } + + self.file.write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + // Ignore the result of the write to stdout, since it's not really important + let _ = std::io::stdout().flush(); + self.file.flush() + } +} + +pub fn init_logger() { + env_logger::builder() + .filter_module( + "saekawa", + if cfg!(debug_assertions) { + log::LevelFilter::Debug + } else { + log::LevelFilter::Info + }, + ) + .parse_default_env() + .target(env_logger::Target::Pipe(Box::new(Logger::new()))) + .format(|f, record| { + let target = record.target(); + let level = record.level(); + let args = record.args(); + let time = chrono::Local::now().format("%Y-%m-%d %H:%M:%S"); + + writeln!(f, "{time} {level:<5} [{target}] {args}") + }) + .init(); +} diff --git a/src/saekawa.rs b/src/saekawa.rs index f8891bc..525be0f 100644 --- a/src/saekawa.rs +++ b/src/saekawa.rs @@ -1,331 +1,228 @@ use std::{ - fmt::Debug, - fs::File, - io::Read, - path::Path, - sync::atomic::{AtomicBool, AtomicU16, Ordering}, + io::{self, Read}, + mem::{self, MaybeUninit}, + num::ParseIntError, + ptr, + sync::OnceLock, + thread, }; -use ::log::{debug, error, info}; -use anyhow::{anyhow, Result}; -use log::warn; -use serde::de::DeserializeOwned; -use widestring::U16CString; +use ini::Ini; +use log::{debug, error, info}; +use snafu::{prelude::Snafu, ResultExt}; use winapi::{ - ctypes::c_void, - shared::minwindef::{BOOL, DWORD, FALSE, LPCVOID, LPDWORD, LPVOID, MAX_PATH}, - um::{errhandlingapi::GetLastError, winbase::GetPrivateProfileStringW, winhttp::HINTERNET}, + shared::minwindef::{BOOL, DWORD, LPCVOID, LPDWORD}, + um::{ + errhandlingapi::GetLastError, + libloaderapi::GetModuleHandleW, + processthreadsapi::GetCurrentProcess, + psapi::{GetModuleInformation, MODULEINFO}, + winhttp::{HINTERNET, WINHTTP_OPTION_URL}, + }, }; use crate::{ - configuration::{Configuration, GeneralConfiguration}, - handlers::score_handler, + config::{ConfigLoadError, SaekawaConfig}, helpers::{ - decrypt_aes256_cbc, is_encrypted_endpoint, is_endpoint, read_hinternet_url, - read_hinternet_user_agent, read_maybe_compressed_buffer, read_slice, request_tachi, + chuni_encoding::{decrypt_aes256_cbc, hash_endpoint, maybe_decompress_buffer}, + winapi_ext::{winhttp_query_option, ReadStringFnError}, }, - icf::{decode_icf, IcfData}, - types::{ - game::{UpsertUserAllRequest, UserMusicResponse}, - tachi::{StatusCheck, TachiResponse, ToTachiImport}, - }, - CONFIGURATION, GET_USER_MUSIC_API_ENCRYPTED, TACHI_STATUS_URL, UPSERT_USER_ALL_API_ENCRYPTED, + score_import::execute_score_import, + sigscan::{self, CryptoKeys}, + types::{chuni::UpsertUserAllRequest, ToBatchManual}, }; -pub static GAME_MAJOR_VERSION: AtomicU16 = AtomicU16::new(0); -pub static PB_IMPORTED: AtomicBool = AtomicBool::new(true); +#[derive(Debug, Snafu)] +pub enum HookError { + #[snafu(display("Could not load configuration"))] + ConfigError { source: ConfigLoadError }, -pub fn hook_init() -> Result<()> { - if !CONFIGURATION.general.enable { - return Ok(()); - } + #[snafu(display("No cards were configured in the [cards] section. There is nothing to export to. Add tokens under the cards section with the format `\"access_code\" = \"tachi_api_key\"`. If you wish to export scores from all cards, use `default` in place of an access code."))] + NoCardsError, - if CONFIGURATION.general.export_pbs { - warn!("==============================================================================="); - warn!("Exporting PBs is enabled. This should only be used once to sync up your scores!"); - warn!("Leaving it on can make your profile messy! This will be automatically be turned off after exporting is finished."); - warn!("You can check when it's done by searching for the message 'Submitting x scores from user ID xxxxx'."); - warn!("==============================================================================="); + #[snafu(display("An error occured hooking the underlying functions"))] + CrochetError { source: crochet::detour::Error }, - PB_IMPORTED.store(false, Ordering::SeqCst); - } + #[snafu(display("The game version specified in project.conf is not a number."))] + InvalidVersion { source: ParseIntError }, - debug!("Retrieving AMFS path from segatools.ini"); + #[snafu(display("An error occured parsing project.conf"))] + IniError { source: ini::Error }, - let mut buf = [0u16; MAX_PATH]; - let amfs_cfg = unsafe { - let sz = GetPrivateProfileStringW( - U16CString::from_str_unchecked("vfs").as_ptr(), - U16CString::from_str_unchecked("amfs").as_ptr(), - U16CString::new().as_ptr(), - buf.as_mut_ptr(), - MAX_PATH as u32, - U16CString::from_str(".\\segatools.ini").unwrap().as_ptr(), - ); + #[snafu(display("An error occured calling a Win32 function: {errno}"))] + Win32Error { errno: u32 }, - if sz == 0 { - let ec = GetLastError(); - return Err(anyhow!( - "AMFS path not specified in segatools.ini, error code {ec}" - )); - } + #[snafu(display("Could not find a pattern in the game executable"))] + CryptoScanError { source: sigscan::CryptoScanError }, - match U16CString::from_ptr(buf.as_ptr(), sz as usize) { - Ok(data) => data.to_string_lossy(), - Err(err) => { - return Err(anyhow!( - "could not read AMFS path from segatools.ini: {:#}", - err - )); - } - } - }; - let amfs_path = Path::new(&amfs_cfg); - let icf1_path = amfs_path.join("ICF1"); + #[snafu(display("The configured path for failed import exists and is not a directory."))] + FailedImportNotDir, - if !icf1_path.exists() { - return Err(anyhow!("Could not find ICF1 inside AMFS path. You will probably not be able to network without this file, so this hook will also be disabled.")); - } - - debug!("Reading ICF1 located at {:?}", icf1_path); - - let mut icf1_buf = { - let mut icf1_file = File::open(icf1_path)?; - let mut icf1_buf = Vec::new(); - icf1_file.read_to_end(&mut icf1_buf)?; - icf1_buf - }; - let icf = decode_icf(&mut icf1_buf).map_err(|err| anyhow!("Reading ICF failed: {:#}", err))?; - - for entry in icf { - if let IcfData::App(app) = entry { - info!("Running on {} {}", app.id, app.version); - GAME_MAJOR_VERSION.store(app.version.major, Ordering::Relaxed); - } - } - - debug!("Pinging Tachi API for status check and token verification"); - - let resp: TachiResponse = - request_tachi("GET", TACHI_STATUS_URL.as_str(), None::<()>)?; - let user_id = match resp { - TachiResponse::Err(err) => { - return Err(anyhow!("Tachi API returned an error: {}", err.description)); - } - TachiResponse::Ok(resp) => { - if !resp.body.permissions.iter().any(|v| v == "submit_score") { - return Err(anyhow!( - "API key has insufficient permissions. The permission submit_score must be set." - )); - } - - let Some(user_id) = resp.body.whoami else { - return Err(anyhow!( - "Status check was successful, yet API returned userID null?" - )); - }; - - user_id - } - }; - - info!("Logged in to Tachi with userID {user_id}"); - - debug!("Initializing detours"); - - crochet::enable!(winhttpwritedata_hook_wrapper)?; - - if CONFIGURATION.general.export_pbs || cfg!(debug_assertions) { - crochet::enable!(winhttpreaddata_hook_wrapper)?; - } - - info!("Hook successfully initialized"); - - Ok(()) + #[snafu(display("Could not create the configured directory for failed imports."))] + FailedCreatingFailedImportDir { source: io::Error }, } -pub fn hook_release() -> Result<()> { - if !CONFIGURATION.general.enable { - return Ok(()); - } +#[derive(Debug, Snafu)] +pub enum ProcessRequestError { + #[snafu(display("Could not read URL from HINTERNET handle"))] + UrlReadError { source: ReadStringFnError }, - if crochet::is_enabled!(winhttpreaddata_hook_wrapper) { - crochet::disable!(winhttpreaddata_hook_wrapper)?; - } + #[snafu(display("The URL does not have an endpoint"))] + UrlMissingEndpointError, - if crochet::is_enabled!(winhttpwritedata_hook_wrapper) { - crochet::disable!(winhttpwritedata_hook_wrapper)?; - } + #[snafu(display( + "Hooked function was called before all necessary state has been initialized" + ))] + UninitializedError, - Ok(()) + #[snafu(display("Could not read request body"))] + ReadBodyError { source: io::Error }, } -#[crochet::hook(compile_check, "winhttp.dll", "WinHttpReadData")] -fn winhttpreaddata_hook_wrapper( - h_request: HINTERNET, - lp_buffer: LPVOID, - dw_number_of_bytes_to_read: DWORD, - lpdw_number_of_bytes_read: LPDWORD, -) -> BOOL { - debug!("hit winhttpreaddata"); +#[derive(Debug, Clone)] +struct GameInformation { + pub game_id: String, + pub major: u16, + pub minor: u8, + pub build: u8, +} - let result = call_original!( - h_request, - lp_buffer, - dw_number_of_bytes_to_read, - lpdw_number_of_bytes_read +/// This is used by the Tachi <-> CHUNITHM conversion functions, +/// because some enum indexes changed between CHUNITHM and CHUNITHM NEW, +/// namely difficulty, and later on, clear lamps. +static GAME_MAJOR_VERSION: OnceLock = OnceLock::new(); +static CRYPTO_KEYS: OnceLock = OnceLock::new(); +static UPSERT_USER_ALL_API: OnceLock = OnceLock::new(); + +static CONFIG: OnceLock = OnceLock::new(); + +pub fn hook_init() -> Result<(), HookError> { + info!("Reading hook configuration"); + let config = SaekawaConfig::load().context(ConfigSnafu)?; + + if config.cards.is_empty() { + return Err(HookError::NoCardsError); + } + + info!("Loaded tokens for {} access codes", config.cards.len()); + + if let Some(d) = &config.general.failed_import_dir { + if d.exists() && !d.is_dir() { + return Err(HookError::FailedImportNotDir); + } + + if !d.exists() { + std::fs::create_dir_all(d).context(FailedCreatingFailedImportDirSnafu)?; + } + } + + CONFIG + .set(config) + .expect("OnceLock shouldn't be initialized."); + + debug!("Reading version information from project.conf"); + let info = get_project_conf()?; + + info!( + "Running on {} {}.{}.{}", + info.game_id, info.major, info.minor, info.build ); - if result == FALSE { - let ec = unsafe { GetLastError() }; - error!("Calling original WinHttpReadData function failed: {ec}"); - return result; + let ver = determine_major_version(&info); + + debug!("Game's major version is {ver}"); + + GAME_MAJOR_VERSION + .set(ver) + .expect("OnceLock shouldn't be initialized."); + + debug!("Checking if network requests are encrypted"); + setup_network_encryption(&info)?; + + info!("Enabling hooks"); + crochet::enable!(winhttpwritedata_hook).context(CrochetSnafu)?; + + Ok(()) +} + +pub fn hook_release() -> Result<(), HookError> { + if crochet::is_enabled!(winhttpwritedata_hook) { + info!("Disabling hooks"); + crochet::disable!(winhttpwritedata_hook).context(CrochetSnafu)?; } - let pb_imported = PB_IMPORTED.load(Ordering::SeqCst); - if cfg!(not(debug_assertions)) && pb_imported { - return result; - } - - if let Err(err) = winhttprwdata_hook::( - h_request, - lp_buffer, - dw_number_of_bytes_to_read, - "GetUserMusicApi", - &GET_USER_MUSIC_API_ENCRYPTED, - move |_| { - if pb_imported { - return false; - } - - PB_IMPORTED.store(true, Ordering::Relaxed); - if let Err(err) = Configuration::update(Configuration { - general: GeneralConfiguration { - export_pbs: false, - ..CONFIGURATION.general - }, - cards: CONFIGURATION.cards.clone(), - crypto: CONFIGURATION.crypto.clone(), - tachi: CONFIGURATION.tachi.clone(), - }) { - error!("Could not update configuration to disable exporting PBs: {err:?}"); - } - - true - }, - ) { - error!("{err:?}"); - } - - result + Ok(()) } #[crochet::hook(compile_check, "winhttp.dll", "WinHttpWriteData")] -fn winhttpwritedata_hook_wrapper( - h_request: HINTERNET, +fn winhttpwritedata_hook( + hrequest: HINTERNET, lp_buffer: LPCVOID, - dw_number_of_bytes_to_write: DWORD, - lpdw_number_of_bytes_written: LPDWORD, + dw_n_bytes_to_write: DWORD, + lpdw_n_bytes_written: LPDWORD, ) -> BOOL { - debug!("hit winhttpwritedata"); - - if let Err(err) = winhttprwdata_hook::( - h_request, - lp_buffer, - dw_number_of_bytes_to_write, - "UpsertUserAllApi", - &UPSERT_USER_ALL_API_ENCRYPTED, - |upsert_req| { - let user_data = &upsert_req.upsert_user_all.user_data[0]; - let access_code = &user_data.access_code; - if !CONFIGURATION.cards.whitelist.is_empty() - && !CONFIGURATION.cards.whitelist.contains(access_code) - { - info!("Card {access_code} is not whitelisted, skipping score submission"); - return false; - } - - true - }, - ) { - error!("{err:?}"); + if let Err(e) = process_request(hrequest, lp_buffer, dw_n_bytes_to_write) { + error!("{e:#?}"); } call_original!( - h_request, + hrequest, lp_buffer, - dw_number_of_bytes_to_write, - lpdw_number_of_bytes_written + dw_n_bytes_to_write, + lpdw_n_bytes_written ) } -/// Common hook for WinHttpWriteData/WinHttpReadData. The flow is similar for both -/// hooks: -/// - Read URL and User-Agent from the handle -/// - Extract the API method from the URL, and exit if it's not the method we're -/// looking for -/// - Determine if the API is encrypted, and exit if it is and we don't have keys -/// - Parse the body and convert it to Tachi's BATCH-MANUAL -/// - Submit it off to Tachi, if our guard function (which takes the parsed body) allows so. -fn winhttprwdata_hook<'a, T: Debug + DeserializeOwned + ToTachiImport + 'static>( - handle: HINTERNET, - buffer: *const c_void, - bufsz: DWORD, - unencrypted_endpoint: &str, - encrypted_endpoint: &Option, - guard_fn: impl Fn(&T) -> bool + Send + 'static, -) -> Result<()> { - let url = read_hinternet_url(handle)?; - let user_agent = read_hinternet_user_agent(handle)?; - debug!("user-agent {user_agent}, URL: {url}"); +fn process_request( + hrequest: HINTERNET, + buffer: LPCVOID, + bufsiz: DWORD, +) -> Result<(), ProcessRequestError> { + let url = winhttp_query_option(hrequest, WINHTTP_OPTION_URL).context(UrlReadSnafu)?; - let maybe_endpoint = url + debug!("Captured request to {url}"); + + let endpoint = url .split('/') .last() - .ok_or(anyhow!("Could not extract last part of a split URL"))?; + .ok_or(ProcessRequestError::UrlMissingEndpointError)?; + let upsert_user_all_endpoint = UPSERT_USER_ALL_API + .get() + .ok_or(ProcessRequestError::UninitializedError)?; - let is_encrypted = is_encrypted_endpoint(maybe_endpoint); - - let endpoint = if is_encrypted && user_agent.contains('#') { - user_agent - .split('#') - .next() - .ok_or(anyhow!("there should be at least one item in the split"))? - } else { - maybe_endpoint - }; - - let is_correct_endpoint = is_endpoint(endpoint, unencrypted_endpoint, encrypted_endpoint); - if cfg!(not(debug_assertions)) && !is_correct_endpoint { + if endpoint != upsert_user_all_endpoint { return Ok(()); } - if is_encrypted && (CONFIGURATION.crypto.key.is_empty() || CONFIGURATION.crypto.iv.is_empty()) { - return Err(anyhow!("Communications with the server is encrypted, but no keys were provided. Fill in the keys by editing 'saekawa.toml'.")); + let mut raw_body_slice = + unsafe { std::slice::from_raw_parts(buffer as *const u8, bufsiz as usize) }; + let mut raw_body = Vec::with_capacity(bufsiz as usize); + + raw_body_slice + .read_to_end(&mut raw_body) + .context(ReadBodySnafu)?; + + #[cfg(debug_assertions)] + { + debug!("raw request: {}", faster_hex::hex_string(&raw_body)); } - let mut raw_body = match read_slice(buffer as *const u8, bufsz as usize) { - Ok(data) => data, - Err(err) => { - return Err(anyhow!( - "There was an error reading the response body: {:#}", - err - )); - } - }; + thread::spawn(move || { + let Some(config) = CONFIG.get() else { + error!("Config has not been initialized?"); + return; + }; - debug!("raw body: {}", faster_hex::hex_string(&raw_body)); + let Some(major_version) = GAME_MAJOR_VERSION.get() else { + error!("The game's major version is not known?"); + return; + }; - std::thread::spawn(move || { - let compressed_body = if is_encrypted { - match decrypt_aes256_cbc( - &mut raw_body, - &CONFIGURATION.crypto.key, - &CONFIGURATION.crypto.iv, - ) { - Ok(res) => res, - Err(err) => { - error!("Could not decrypt response: {:#}", err); + let compressed_body = if let Some(keys) = CRYPTO_KEYS.get() { + match decrypt_aes256_cbc(&mut raw_body, &keys.key, &keys.iv) { + Ok(r) => r, + Err(e) => { + error!("Could not decrypt request: {e:#?}"); return; } } @@ -333,23 +230,171 @@ fn winhttprwdata_hook<'a, T: Debug + DeserializeOwned + ToTachiImport + 'static> raw_body }; - let body = match read_maybe_compressed_buffer(&compressed_body[..]) { - Ok(data) => data, - Err(err) => { - error!("There was an error decoding the request body: {:#}", err); + let body = match maybe_decompress_buffer(&compressed_body) { + Ok(s) => s, + Err(e) => { + error!("Could not read request as DEFLATE-compressed or plaintext: {e:#?}"); return; } }; - debug!("decoded response body: {body}"); - - // Hit in debug build - if !is_correct_endpoint { - return; + #[cfg(debug_assertions)] + { + debug!("decoded request: {}", body.trim()); } - score_handler::(body, guard_fn) + let data = match serde_json::from_str::(&body) { + Ok(d) => d, + Err(e) => { + error!("Could not parse request: {e:#?}"); + return; + } + }; + + let user_data = &data.upsert_user_all.user_data[0]; + let access_code = &user_data.access_code; + let Some(tachi_api_key) = config + .cards + .get(access_code) + .or_else(|| config.cards.get("default")) + else { + info!("No API keys was assigned to {access_code}, and no default API key was set, skipping score submission."); + return; + }; + + let import = data.to_batch_manual( + *major_version, + config.general.export_class, + config.general.fail_over_lamp, + ); + + if let Err(e) = execute_score_import(import, access_code, &tachi_api_key, &config) { + error!("{e}"); + } }); Ok(()) } + +fn get_project_conf() -> Result { + let project_conf = Ini::load_from_file("./project.conf").context(IniSnafu)?; + let major_version = &project_conf["Version"]["VerMajor"]; + let minor_version = &project_conf["Version"]["VerMinor"]; + let build_version = &project_conf["Version"]["VerRelease"]; + let game_id = &project_conf["Project"]["GameID"]; + + Ok(GameInformation { + game_id: game_id.to_string(), + major: major_version.parse::().context(InvalidVersionSnafu)?, + minor: minor_version.parse::().context(InvalidVersionSnafu)?, + build: build_version.parse::().context(InvalidVersionSnafu)?, + }) +} + +fn determine_major_version(info: &GameInformation) -> u16 { + if info.game_id == "SDGS" { + if info.minor < 10 { + 1 + } else { + 2 + } + } else { + info.major + } +} + +fn setup_network_encryption(info: &GameInformation) -> Result<(), HookError> { + debug!("Getting module information of the game process"); + let mut modinfo: MaybeUninit = MaybeUninit::uninit(); + let result = unsafe { + GetModuleInformation( + GetCurrentProcess(), + GetModuleHandleW(ptr::null_mut()), + modinfo.as_mut_ptr(), + mem::size_of::() as u32, + ) + }; + + if result == 0 { + let err = unsafe { GetLastError() }; + + error!("Could not get information about the game process, error code {err}"); + return Err(HookError::Win32Error { errno: err }); + } + + let modinfo = unsafe { modinfo.assume_init() }; + debug!( + "Base address: {:p}, image size: {:x}", + modinfo.lpBaseOfDll, modinfo.SizeOfImage + ); + + debug!("Scanning game for encryption status"); + let encryption_enabled = unsafe { + sigscan::is_network_encrypted(modinfo.lpBaseOfDll as *const _, modinfo.SizeOfImage as _) + .context(CryptoScanSnafu)? + }; + + let endpoint = if info.game_id == "SDGS" { + if info.minor < 10 { + "UpsertUserAllApiExp" + } else { + "UpsertUserAllApiC3Exp" + } + } else { + "UpsertUserAllApi" + }; + + if encryption_enabled { + info!("Network requests are encrypted."); + + debug!("Searching for encryption keys. This might take a bit..."); + + let keys = unsafe { + sigscan::get_crypto_keys(modinfo.lpBaseOfDll as *const _, modinfo.SizeOfImage as _) + .context(CryptoScanSnafu)? + }; + + info!("Search completed successfully."); + + #[cfg(debug_assertions)] + { + debug!( + "Key: {}, IV: {}, salt: {}, iterations: {}", + faster_hex::hex_string(&keys.key), + faster_hex::hex_string(&keys.iv), + faster_hex::hex_string(&keys.salt), + keys.iterations, + ) + } + + // For some reason, CHUNITHM SUPERSTAR/SUPERSTAR+ forgot to add "Exp" when + // hashing the endpoint. + let endpoint_password = if info.game_id == "SDGS" && info.minor < 10 { + "UpsertUserAllApi" + } else { + endpoint + }; + + let hashed_endpoint = hash_endpoint(endpoint_password, &keys.salt, keys.iterations); + + debug!( + "Hashed {endpoint_password} with {:#?} to {hashed_endpoint}", + keys.salt + ); + + UPSERT_USER_ALL_API + .set(hashed_endpoint) + .expect("OnceLock shouldn't be initialized."); + CRYPTO_KEYS + .set(keys) + .expect("OnceLock shouldn't be initialized."); + } else { + info!("Network requests are not encrypted."); + + UPSERT_USER_ALL_API + .set(endpoint.to_string()) + .expect("OnceLock shouldn't be initialized."); + } + + Ok(()) +} diff --git a/src/score_import.rs b/src/score_import.rs new file mode 100644 index 0000000..49f21d8 --- /dev/null +++ b/src/score_import.rs @@ -0,0 +1,257 @@ +use std::{fmt::Debug, fs::File, io, thread, time::Duration}; + +use log::{debug, error, info}; +use rand::{rngs::ThreadRng, Rng}; +use serde::{Deserialize, Serialize}; +use snafu::{prelude::Snafu, ResultExt}; + +use crate::{ + config::SaekawaConfig, + types::tachi::{ + api::{TachiFailureResponse, TachiResponse}, + api_returns::{ImportPollStatus, ImportResponse}, + batch_manual::BatchManualImport, + documents::ImportDocument, + }, +}; + +const MAX_RETRY_COUNT: u32 = 3; +static SAEKAWA_USER_AGENT: &str = concat!("saekawa/", env!("CARGO_PKG_VERSION")); + +#[derive(Snafu, Debug)] +pub enum ScoreImportError { + #[snafu(display("Could not create import URL"))] + InvalidImportUrl { source: url::ParseError }, + + #[snafu(display("Tachi API returned an error: {}", response.description))] + TachiError { response: TachiFailureResponse }, + + #[snafu(display("Could not communicate with Tachi {max_retries} times."))] + MaxRetriesExhausted { max_retries: u32 }, + + #[snafu(display("Tachi returned an invalid response."))] + InvalidTachiResponse { source: io::Error }, + + #[snafu(display("Could not create backup batch manual file."))] + FailedCreatingBackup { source: io::Error }, + + #[snafu(display("Could not write backup batch manual file."))] + FailedWritingBackup { source: serde_json::Error }, +} + +/// This function blocks until it has completed, which may take a long time +/// depending on the user's internet connection with Tachi. It's best to call +/// this in a separate thread. +pub fn execute_score_import( + import: BatchManualImport, + access_code: &str, + api_key: &str, + config: &SaekawaConfig, +) -> Result<(), ScoreImportError> { + // Checking if there's actually anything to import before continuing on + if import.scores.is_empty() + && (import.classes.is_none() + || import + .classes + .as_ref() + .is_some_and(|c| c.dan.is_none() && c.emblem.is_none())) + { + return Ok(()); + } + + let import_url = config + .tachi + .base_url + .join("/ir/direct-manual/import") + .context(InvalidImportUrlSnafu)? + .to_string(); + let client = saekawa_client(config); + let response = match request_tachi::<_, ImportResponse>( + &client, + "POST", + &import_url, + &api_key, + Some(&import), + ) { + Ok(r) => r, + Err(ScoreImportError::MaxRetriesExhausted { max_retries }) => { + error!("Could not reach Tachi after {max_retries} attempts."); + + let Some(d) = &config.general.failed_import_dir else { + return Err(ScoreImportError::MaxRetriesExhausted { max_retries }); + }; + + info!("Saving batch manual JSON to configured failed import directory for later import."); + + let current_time = chrono::Local::now().format("%Y-%m-%d_%H-%M-%S"); + let failed_import_filename = + d.join(format!("saekawa_{}_{}.json", access_code, current_time)); + + { + let file = File::create(&failed_import_filename).context(FailedCreatingBackupSnafu)?; + serde_json::to_writer(file, &import).context(FailedWritingBackupSnafu)?; + } + + info!("Saved batch manual JSON to {}", failed_import_filename.to_string_lossy()); + + return Ok(()); + } + Err(e) => return Err(e), + }; + + let response = match response { + TachiResponse::Ok(r) => r, + TachiResponse::Err(e) => return Err(ScoreImportError::TachiError { response: e }), + }; + + match response.body { + ImportResponse::Deferred(d) => { + info!("Import was queued for processing. Poll URL: {}", d.url); + poll_deferred_import(&client, &api_key, &d.url); + return Ok(()); + } + ImportResponse::Completed(d) => { + log_tachi_import(&response.description, &d); + return Ok(()); + } + } +} + +fn saekawa_client(config: &SaekawaConfig) -> ureq::Agent { + ureq::builder() + .timeout(Duration::from_millis(config.general.timeout)) + .user_agent(SAEKAWA_USER_AGENT) + .build() +} + +fn exponential_backoff(rand: &mut ThreadRng, attempt: u32) -> u64 { + // attempt | backoff + // 0 | 2-4 seconds + // 1 | 8-16 seconds + // 2 | 32-64 seconds + return rand.gen_range(500..=1000) * 4_u64.pow(attempt + 1); +} + +fn request_tachi( + client: &ureq::Agent, + method: &str, + url: &str, + api_key: &str, + body: Option, +) -> Result, ScoreImportError> +where + T: Serialize + Debug, + R: for<'de> Deserialize<'de> + Debug, +{ + let auth_header = format!("Bearer {}", api_key); + let mut rand = rand::thread_rng(); + + for attempt in 0..MAX_RETRY_COUNT { + debug!( + "Requesting Tachi, attempt {}/{MAX_RETRY_COUNT}", + attempt + 1 + ); + + let request = client + .request(method, url) + .set("Authorization", &auth_header); + let response = if let Some(ref body) = body { + request.send_json(body) + } else { + request.call() + }; + let response = match response { + Ok(r) => r, + Err(ureq::Error::Transport(e)) => { + error!("Could not reach Tachi API. Is your network up or are they having issues?."); + error!("Detailed error: {e:#?}"); + + if attempt != MAX_RETRY_COUNT - 1 { + let wait_time = exponential_backoff(&mut rand, attempt); + + info!("Waiting for {wait_time}ms before trying again..."); + thread::sleep(Duration::from_millis(wait_time)); + continue; + } + + break; + } + Err(ureq::Error::Status(code, response)) => { + if code >= 500 { + error!("Tachi is having a server error. Response code was {code}."); + + if let Ok(s) = response.into_string() { + error!("Response from Tachi: {s}"); + } else { + error!("No response could be read."); + } + + if attempt != MAX_RETRY_COUNT - 1 { + let wait_time = exponential_backoff(&mut rand, attempt); + + info!("Waiting for {wait_time}ms before trying again..."); + thread::sleep(Duration::from_millis(wait_time)); + continue; + } + + break; + } + + response + } + }; + + return response + .into_json::>() + .context(InvalidTachiResponseSnafu); + } + + return Err(ScoreImportError::MaxRetriesExhausted { + max_retries: MAX_RETRY_COUNT, + }); +} + +fn log_tachi_import(description: &str, import: &ImportDocument) { + info!( + "{description} {} scores, {} sessions, {} errors", + import.score_ids.len(), + import.created_sessions.len(), + import.errors.len() + ); + + for err in &import.errors { + error!("{}: {}", err.error_type, err.message); + } +} + +fn poll_deferred_import(client: &ureq::Agent, api_key: &str, poll_url: &str) { + loop { + let resp = match request_tachi::<_, ImportPollStatus>( + &client, "GET", &poll_url, &api_key, None::<()>, + ) { + Ok(r) => r, + Err(e) => { + error!("Could not poll import status. While Tachi has received the score, Saekawa cannot make any guarantees about its success. Detailed error: {e:#?}"); + return; + } + }; + + let resp = match resp { + TachiResponse::Ok(r) => r, + TachiResponse::Err(e) => { + error!("Tachi API returned an error: {}", e.description); + return; + } + }; + + match resp.body { + ImportPollStatus::Completed { import } => { + log_tachi_import(&resp.description, &import); + return; + } + _ => {} + } + + thread::sleep(Duration::from_secs(1)); + } +} diff --git a/src/sigscan.rs b/src/sigscan.rs new file mode 100644 index 0000000..d7e7197 --- /dev/null +++ b/src/sigscan.rs @@ -0,0 +1,236 @@ +use std::{ffi::CStr, slice}; + +use lightningscanner::{ScanMode, Scanner}; +use log::{debug, error}; +use pbkdf2::pbkdf2_hmac; +use sha1::Sha1; +use snafu::prelude::Snafu; + +#[derive(Debug)] +pub struct CryptoKeys { + pub key: Vec, + pub iv: Vec, + pub salt: Vec, + pub iterations: u32, +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Snafu)] +pub enum CryptoScanError { + MissingSignature, + NotEncrypted, +} + +/// SAFETY: The caller ensures that `module_base` and `module_size` are valid. +pub unsafe fn is_network_encrypted( + module_base: *const u8, + module_size: usize, +) -> Result { + let scan_mode = if is_x86_feature_detected!("avx2") { + ScanMode::Avx2 + } else if is_x86_feature_detected!("sse4.2") { + ScanMode::Sse42 + } else { + ScanMode::Scalar + }; + + debug!("Using {scan_mode:?} for signature scanning"); + + debug!("Scanning for the endpoint salt password"); + // b"?AVDeflate@projClient@@\x??\x??\x??\x??\x??\x??\x??\x??" + // This is what the patchers are patching out when disabling encryption. This is + // also where the endpoint salt password can be found. + let scanner = Scanner::new("3F 41 56 44 65 66 6C 61 74 65 40 70 72 6F 6A 43 6C 69 65 6E 74 40 40 ?? ?? ?? ?? ?? ?? ?? ??"); + let result = scanner.find(Some(scan_mode), module_base, module_size); + + if !result.is_valid() { + error!("Could not find the endpoint salt password"); + return Err(CryptoScanError::MissingSignature); + } + + let crypto_config = *result.get_addr().wrapping_add(0x1B); + + if crypto_config == 0 { + return Ok(false); + } + + Ok(true) +} + +/// SAFETY: The caller ensures that `module_base` and `module_size` are valid. +pub unsafe fn get_crypto_keys( + module_base: *const u8, + module_size: usize, +) -> Result { + let scan_mode = if is_x86_feature_detected!("avx2") { + ScanMode::Avx2 + } else if is_x86_feature_detected!("sse4.2") { + ScanMode::Sse42 + } else { + ScanMode::Scalar + }; + + debug!("Using {scan_mode:?} for signature scanning"); + + debug!("Scanning for the endpoint salt password"); + // b"?AVDeflate@projClient@@\x??\x??\x??\x??\x??\x??\x??\x??" + // This is what the patchers are patching out when disabling encryption. This is + // also where the endpoint salt password can be found. + let scanner = Scanner::new("3F 41 56 44 65 66 6C 61 74 65 40 70 72 6F 6A 43 6C 69 65 6E 74 40 40 ?? ?? ?? ?? ?? ?? ?? ??"); + let result = scanner.find(Some(scan_mode), module_base, module_size); + + if !result.is_valid() { + error!("Could not find the endpoint salt password"); + return Err(CryptoScanError::MissingSignature); + } + + let crypto_config = *result.get_addr().wrapping_add(0x1B); + + if crypto_config == 0 { + return Err(CryptoScanError::NotEncrypted); + } + + let endpoint_salt_password_address = if crypto_config == *result.get_addr().wrapping_add(0x1F) { + i32_from_ptr_le_bytes(result.get_addr().wrapping_add(0x23)) as *const i8 + } else { + i32_from_ptr_le_bytes(result.get_addr().wrapping_add(0x1F)) as *const i8 + }; + + let endpoint_salt_password = CStr::from_ptr(endpoint_salt_password_address); + + debug!( + "Endpoint salt password: {} ({endpoint_salt_password_address:p})", + endpoint_salt_password.to_string_lossy() + ); + + // Scanning for the call to [`PKCS5_PBKDF2_HMAC_SHA1`](https://www.openssl.org/docs/man3.2/man3/PKCS5_PBKDF2_HMAC_SHA1.html) + // with saltlen=16, iter=31, keylen=8 to find the endpoint salt's salt + let scanner = Scanner::new("52 6A 08 6A ?? 6A 10 68 ?? ?? ?? ?? 51 53 E8 ?? ?? ?? ??"); + let result = scanner.find(Some(scan_mode), module_base, module_size); + + if !result.is_valid() { + return Err(CryptoScanError::MissingSignature); + } + + let endpoint_salt_rounds = *result.get_addr().wrapping_add(0x04) as u32; + let endpoint_salt_salt_address = + i32_from_ptr_le_bytes(result.get_addr().wrapping_add(0x08)) as *const u8; + let endpoint_salt_salt = slice::from_raw_parts(endpoint_salt_salt_address, 16); + + debug!( + "Endpoint salt salt: {} ({endpoint_salt_salt_address:p})", + faster_hex::hex_string(endpoint_salt_salt) + ); + + let mut endpoint_salt = vec![0u8; 8]; + pbkdf2_hmac::( + endpoint_salt_password.to_bytes(), + endpoint_salt_salt, + endpoint_salt_rounds, + &mut endpoint_salt, + ); + + debug!("Endpoint salt: {endpoint_salt:X?}"); + + // Scanning for the call to [`PKCS5_PBKDF2_HMAC_SHA1`](https://www.openssl.org/docs/man3.2/man3/PKCS5_PBKDF2_HMAC_SHA1.html) + // with saltlen=16, iter=??, keylen=32 to find the encryption key's salt + let scanner = Scanner::new("50 6A 20 6A ?? 6A 10 2B CA 68 ?? ?? ?? ?? 51 55 E8 ?? ?? ?? ??"); + let result = scanner.find(Some(scan_mode), module_base, module_size); + + if !result.is_valid() { + return Err(CryptoScanError::MissingSignature); + } + + let encryption_key_rounds = *result.get_addr().wrapping_add(0x04) as u32; + let encryption_key_salt_address = + i32_from_ptr_le_bytes(result.get_addr().wrapping_add(0x0A)) as *const u8; + let encryption_key_salt = slice::from_raw_parts(encryption_key_salt_address, 16); + + debug!( + "Encryption key salt: {} ({encryption_key_salt_address:p})", + faster_hex::hex_string(encryption_key_salt) + ); + + // b"?AVSystemInterface@projClient@@\x??\x??\x??\x??\x??\x??\x??\x??\x??\x??\x??\x??" + let scanner = Scanner::new("3F 41 56 53 79 73 74 65 6D 49 6E 74 65 72 66 61 63 65 40 70 72 6F 6A 43 6C 69 65 6E 74 40 40 ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ?? ??"); + let result = scanner.find(Some(scan_mode), module_base, module_size); + + if !result.is_valid() { + return Err(CryptoScanError::MissingSignature); + } + + let encryption_key_password_address = + i32_from_ptr_le_bytes(result.get_addr().wrapping_add(0x23)) as *const i8; + let encryption_key_password = CStr::from_ptr(encryption_key_password_address); + + debug!( + "Encryption key password: {} ({encryption_key_password_address:p})", + encryption_key_password.to_string_lossy() + ); + + let mut encryption_key = vec![0u8; 32]; + pbkdf2_hmac::( + encryption_key_password.to_bytes(), + encryption_key_salt, + encryption_key_rounds, + &mut encryption_key, + ); + + for byte in encryption_key.iter_mut() { + *byte = (*byte % 0x5E) + 0x21; + } + + debug!("Encryption key: {encryption_key:X?}"); + + // Encryption IV + let scanner2 = Scanner::new("E8 ?? ?? ?? ?? F3 0F 7E 05 ?? ?? ?? ?? 6A 01"); + let result2 = scanner2.find(Some(scan_mode), module_base, module_size); + + let iv_addr = if result2.is_valid() { + i32_from_ptr_le_bytes(result2.get_addr().wrapping_add(0x09)) as *const i8 + } else { + let scanner1 = Scanner::new("F3 0F 7E 05 ?? ?? ?? ?? 8B 74 24 24 6A 01"); + let result1 = scanner1.find(Some(scan_mode), module_base, module_size); + + if !result1.is_valid() { + return Err(CryptoScanError::MissingSignature); + } + + i32_from_ptr_le_bytes(result1.get_addr().wrapping_add(0x04)) as *const i8 + }; + + let iv = CStr::from_ptr(iv_addr).to_bytes().to_vec(); + + debug!( + "Encryption IV: {} ({iv_addr:p})", + faster_hex::hex_string(&iv) + ); + + let scanner = Scanner::new("C7 86 ?? ?? ?? ?? ?? ?? ?? ?? 0F 8C ?? ?? ?? ?? 85 ED 0F 84 ?? ?? ?? ?? 85 DB 0F 84 ?? ?? ?? ??"); + let result = scanner.find(Some(scan_mode), module_base, module_size); + + if !result.is_valid() { + return Err(CryptoScanError::MissingSignature); + } + + let iterations = u32::from_le_bytes(from_raw_parts_const(result.get_addr().wrapping_add(0x06))); + + debug!("Iterations: {iterations}"); + + Ok(CryptoKeys { + key: encryption_key, + iv, + salt: endpoint_salt, + iterations, + }) +} + +unsafe fn from_raw_parts_const(ptr: *const u8) -> [u8; N] { + slice::from_raw_parts(ptr, N) + .try_into() + .expect("slice::from_raw_parts with len=N should convert to [u8; N]") +} + +#[inline] +unsafe fn i32_from_ptr_le_bytes(ptr: *const u8) -> i32 { + i32::from_le_bytes(from_raw_parts_const(ptr)) +} diff --git a/src/types/chuni/mod.rs b/src/types/chuni/mod.rs new file mode 100644 index 0000000..824dc08 --- /dev/null +++ b/src/types/chuni/mod.rs @@ -0,0 +1,31 @@ +mod music; +pub mod upsert; + +use serde::{de, Deserialize}; + +pub use self::upsert::UpsertUserAllRequest; + +fn deserialize_bool<'de, D>(deserializer: D) -> Result +where + D: de::Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum BooleanishTypes { + String(String), + Bool(bool), + Number(i32), + } + + let s: BooleanishTypes = de::Deserialize::deserialize(deserializer)?; + + match s { + BooleanishTypes::String(s) => match s.as_str() { + "true" => Ok(true), + "false" => Ok(false), + _ => Err(de::Error::unknown_variant(&s, &["true", "false"])), + }, + BooleanishTypes::Bool(b) => Ok(b), + BooleanishTypes::Number(n) => Ok(n > 0), + } +} diff --git a/src/types/chuni/music.rs b/src/types/chuni/music.rs new file mode 100644 index 0000000..b787f3a --- /dev/null +++ b/src/types/chuni/music.rs @@ -0,0 +1,41 @@ +use serde::{Deserialize, Serialize}; +use serde_aux::prelude::*; + +use super::deserialize_bool; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UserMusicResponse { + pub user_id: String, + pub length: u32, + pub user_music_list: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UserMusicItem { + pub length: u32, + pub user_music_detail_list: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UserMusicDetail { + #[serde(deserialize_with = "deserialize_number_from_string")] + pub music_id: u32, + + #[serde(deserialize_with = "deserialize_number_from_string")] + pub level: u32, + + #[serde(deserialize_with = "deserialize_number_from_string")] + pub score_max: u32, + + #[serde(deserialize_with = "deserialize_bool")] + pub is_all_justice: bool, + + #[serde(deserialize_with = "deserialize_bool")] + pub is_full_combo: bool, + + #[serde(deserialize_with = "deserialize_bool")] + pub is_success: bool, +} diff --git a/src/types/game.rs b/src/types/chuni/upsert.rs similarity index 56% rename from src/types/game.rs rename to src/types/chuni/upsert.rs index ee2c157..dec6aac 100644 --- a/src/types/game.rs +++ b/src/types/chuni/upsert.rs @@ -1,29 +1,21 @@ -use serde::{de, Deserialize, Serialize}; +use serde::{Deserialize, Serialize}; use serde_aux::prelude::*; -fn deserialize_bool<'de, D>(deserializer: D) -> Result -where - D: de::Deserializer<'de>, -{ - #[derive(Deserialize)] - #[serde(untagged)] - enum StringOrBoolean { - String(String), - Bool(bool), - Number(i32), - } +use super::deserialize_bool; - let s: StringOrBoolean = de::Deserialize::deserialize(deserializer)?; +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpsertUserAllRequest { + pub user_id: String, + pub upsert_user_all: UpsertUserAllBody, +} - match s { - StringOrBoolean::String(s) => match s.as_str() { - "true" => Ok(true), - "false" => Ok(false), - _ => Err(de::Error::unknown_variant(&s, &["true", "false"])), - }, - StringOrBoolean::Bool(b) => Ok(b), - StringOrBoolean::Number(n) => Ok(n > 0), - } +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpsertUserAllBody { + pub user_data: Vec, + pub user_data_ex: Option>, + pub user_playlog_list: Vec, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -31,11 +23,18 @@ where pub struct UserData { pub access_code: String, - #[serde(deserialize_with = "deserialize_number_from_string")] - pub class_emblem_base: u32, + #[serde(deserialize_with = "deserialize_option_number_from_string")] + pub class_emblem_base: Option, + #[serde(deserialize_with = "deserialize_option_number_from_string")] + pub class_emblem_medal: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UserDataEx { #[serde(deserialize_with = "deserialize_number_from_string")] - pub class_emblem_medal: u32, + pub medal: u32, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -71,6 +70,7 @@ pub struct UserPlaylog { #[serde(deserialize_with = "deserialize_number_from_string")] pub judge_critical: u32, + // Only introduced in CHUNITHM NEW, thus needing a default value. #[serde( default = "default_judge_heaven", @@ -91,54 +91,3 @@ pub struct UserPlaylog { fn default_judge_heaven() -> u32 { 0 } - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UserMusicDetail { - #[serde(deserialize_with = "deserialize_number_from_string")] - pub music_id: u32, - - #[serde(deserialize_with = "deserialize_number_from_string")] - pub level: u32, - - #[serde(deserialize_with = "deserialize_number_from_string")] - pub score_max: u32, - - #[serde(deserialize_with = "deserialize_bool")] - pub is_all_justice: bool, - - #[serde(deserialize_with = "deserialize_bool")] - pub is_full_combo: bool, - - #[serde(deserialize_with = "deserialize_bool")] - pub is_success: bool, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UserMusicItem { - pub length: u32, - pub user_music_detail_list: Vec, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UserMusicResponse { - pub user_id: String, - pub length: u32, - pub user_music_list: Vec, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpsertUserAllBody { - pub user_data: Vec, - pub user_playlog_list: Vec, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpsertUserAllRequest { - pub user_id: String, - pub upsert_user_all: UpsertUserAllBody, -} diff --git a/src/types/mod.rs b/src/types/mod.rs index 80ec264..4cf083b 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -1,2 +1,143 @@ -pub mod game; +pub mod chuni; pub mod tachi; + +use chrono::{FixedOffset, NaiveDateTime, TimeZone}; +use num_enum::TryFromPrimitiveError; +use snafu::{ResultExt, Snafu}; + +use self::{ + chuni::{upsert::UserPlaylog, UpsertUserAllRequest}, + tachi::batch_manual::{ + class::ClassEmblem, + score::{Difficulty, Judgements, Lamp, MatchType, OptionalMetrics}, + BatchManualClasses, BatchManualImport, BatchManualScore, + }, +}; + +#[derive(Debug, Snafu)] +pub enum ScoreConversionError { + #[snafu(display("Unknown difficulty index."))] + InvalidDifficulty { + source: TryFromPrimitiveError, + }, + + #[snafu(display("Invalid play date."))] + InvalidPlayDate { source: chrono::format::ParseError }, +} + +impl UserPlaylog { + pub fn to_batch_manual( + &self, + major_version: u16, + fail_over_lamp: bool, + ) -> Result { + let lamp = if !self.is_clear && fail_over_lamp { + Lamp::Failed + } else if self.is_all_justice { + if self.judge_justice + self.judge_attack + self.judge_guilty == 0 { + Lamp::AllJusticeCritical + } else { + Lamp::AllJustice + } + } else if self.is_full_combo { + Lamp::FullCombo + } else if self.is_clear { + Lamp::Clear + } else { + Lamp::Failed + }; + + let judgements = Judgements { + jcrit: self.judge_heaven + self.judge_critical, + justice: self.judge_justice, + attack: self.judge_attack, + miss: self.judge_guilty, + }; + + let difficulty = if major_version == 1 && self.level == 4 { + Difficulty::WorldsEnd + } else { + Difficulty::try_from(self.level).context(InvalidDifficultySnafu)? + }; + + let datetime = NaiveDateTime::parse_from_str(&self.user_play_date, "%Y-%m-%d %H:%M:%S") + .context(InvalidPlayDateSnafu)?; + let jst_offset = FixedOffset::east_opt(9 * 3600).expect("chrono should parse JST timezone"); + let jst_time = jst_offset.from_local_datetime(&datetime).unwrap(); + + Ok(BatchManualScore { + score: self.score, + lamp, + match_type: MatchType::InGameId, + identifier: self.music_id.clone(), + difficulty, + time_achieved: Some(jst_time.timestamp_millis() as u128), + judgements: Some(judgements), + optional: Some(OptionalMetrics { + max_combo: self.max_combo, + }), + }) + } +} + +pub trait ToBatchManual { + fn to_batch_manual( + &self, + major_version: u16, + export_class: bool, + fail_over_lamp: bool, + ) -> BatchManualImport; +} + +impl ToBatchManual for UpsertUserAllRequest { + fn to_batch_manual( + &self, + major_version: u16, + export_class: bool, + fail_over_lamp: bool, + ) -> BatchManualImport { + let user_data = &self.upsert_user_all.user_data[0]; + + let classes = if export_class { + let dan = if let Some(medal) = user_data.class_emblem_medal { + ClassEmblem::try_from(medal).ok() + } else if let Some(user_data_ex) = &self.upsert_user_all.user_data_ex { + ClassEmblem::try_from(user_data_ex[0].medal).ok() + } else { + None + }; + let emblem = user_data + .class_emblem_base + .map(|b| ClassEmblem::try_from(b).ok()) + .flatten(); + + Some(BatchManualClasses { dan, emblem }) + } else { + None + }; + + let scores = self + .upsert_user_all + .user_playlog_list + .iter() + .filter_map(|p| { + let conv = p.to_batch_manual(major_version, fail_over_lamp); + + if conv + .as_ref() + .is_ok_and(|s| s.difficulty != Difficulty::WorldsEnd) + { + conv.ok() + } else { + None + } + }) + .collect::>(); + + BatchManualImport { + classes, + scores, + ..Default::default() + } + } +} diff --git a/src/types/tachi.rs b/src/types/tachi.rs deleted file mode 100644 index 427ac0b..0000000 --- a/src/types/tachi.rs +++ /dev/null @@ -1,435 +0,0 @@ -use anyhow::Result; -use chrono::{FixedOffset, NaiveDateTime, TimeZone}; -use num_enum::TryFromPrimitive; -use serde::{de, Deserialize, Deserializer, Serialize}; -use serde_json::{Map, Value}; - -use super::game::{UpsertUserAllRequest, UserMusicDetail, UserMusicResponse, UserPlaylog}; - -#[derive(Debug, Clone)] -pub enum TachiResponse { - Ok(TachiSuccessResponse), - Err(TachiErrorResponse), -} - -impl<'de, T> Deserialize<'de> for TachiResponse -where - T: Deserialize<'de>, -{ - fn deserialize(deserializer: D) -> Result, D::Error> - where - D: Deserializer<'de>, - { - let mut map = Map::deserialize(deserializer)?; - - let success = map - .remove("success") - .ok_or_else(|| de::Error::missing_field("success")) - .map(Deserialize::deserialize)? - .map_err(de::Error::custom)?; - let rest = Value::Object(map); - - if success { - TachiSuccessResponse::deserialize(rest) - .map(TachiResponse::Ok) - .map_err(de::Error::custom) - } else { - TachiErrorResponse::deserialize(rest) - .map(TachiResponse::Err) - .map_err(de::Error::custom) - } - } -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct TachiSuccessResponse { - pub description: String, - pub body: T, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct TachiErrorResponse { - pub description: String, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct StatusCheck { - pub permissions: Vec, - pub whoami: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum ImportResponse { - Queued { - url: String, - - #[serde(rename = "importID")] - import_id: String, - }, - Finished(ImportDocument), -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct ImportDocument { - #[serde(rename = "scoreIDs")] - pub score_ids: Vec, - - pub errors: Vec, - - #[serde(rename = "createdSessions")] - pub created_sessions: Vec, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct ImportErrContent { - #[serde(rename = "type")] - pub error_type: String, - - pub message: String, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct SessionInfoReturn { - #[serde(rename = "type")] - pub session_type: String, - - #[serde(rename = "sessionID")] - pub session_id: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "importStatus")] -pub enum ImportPollStatus { - #[serde(rename = "ongoing")] - Ongoing { progress: ImportProgress }, - - #[serde(rename = "completed")] - Completed { import: ImportDocument }, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ImportProgress { - pub description: String, - pub value: i32, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct Import { - pub meta: ImportMeta, - #[serde(skip_serializing_if = "Option::is_none")] - pub classes: Option, - pub scores: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ImportMeta { - pub game: String, - pub playtype: String, - pub service: String, -} - -impl Default for ImportMeta { - fn default() -> Self { - Self { - game: "chunithm".to_string(), - playtype: "Single".to_string(), - service: "Saekawa".to_string(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct ImportClasses { - #[serde(skip_serializing_if = "Option::is_none")] - pub dan: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub emblem: Option, -} - -#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, TryFromPrimitive)] -#[repr(u32)] -pub enum ClassEmblem { - #[serde(rename = "DAN_I")] - First = 1, - - #[serde(rename = "DAN_II")] - Second = 2, - - #[serde(rename = "DAN_III")] - Third = 3, - - #[serde(rename = "DAN_IV")] - Fourth = 4, - - #[serde(rename = "DAN_V")] - Fifth = 5, - - #[serde(rename = "DAN_INFINITE")] - Infinite = 6, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ImportScore { - pub score: u32, - pub lamp: TachiLamp, - pub match_type: String, - pub identifier: String, - pub difficulty: Difficulty, - - #[serde(skip_serializing_if = "Option::is_none")] - pub time_achieved: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub judgements: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - pub optional: Option, -} - -#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, TryFromPrimitive)] -#[repr(u32)] -pub enum TachiLamp { - #[serde(rename = "FAILED")] - Failed = 0, - - #[serde(rename = "CLEAR")] - Clear = 1, - - #[serde(rename = "FULL COMBO")] - FullCombo = 2, - - #[serde(rename = "ALL JUSTICE")] - AllJustice = 3, - - #[serde(rename = "ALL JUSTICE CRITICAL")] - AllJusticeCritical = 4, -} - -#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, TryFromPrimitive)] -#[repr(u32)] -pub enum Difficulty { - #[serde(rename = "BASIC")] - Basic = 0, - - #[serde(rename = "ADVANCED")] - Advanced = 1, - - #[serde(rename = "EXPERT")] - Expert = 2, - - #[serde(rename = "MASTER")] - Master = 3, - - #[serde(rename = "ULTIMA")] - Ultima = 4, - - #[serde(rename = "WORLD'S END")] - WorldsEnd = 5, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct Judgements { - pub jcrit: u32, - pub justice: u32, - pub attack: u32, - pub miss: u32, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct OptionalMetrics { - pub max_combo: u32, -} - -impl ImportScore { - pub fn try_from_playlog( - p: &UserPlaylog, - major_version: u16, - fail_over_lamp: bool, - ) -> Result { - let lamp = if !p.is_clear && fail_over_lamp { - TachiLamp::Failed - } else if p.is_all_justice { - if p.judge_justice + p.judge_attack + p.judge_guilty == 0 { - TachiLamp::AllJusticeCritical - } else { - TachiLamp::AllJustice - } - } else if p.is_full_combo { - TachiLamp::FullCombo - } else if p.is_clear { - TachiLamp::Clear - } else { - TachiLamp::Failed - }; - - let judgements = Judgements { - jcrit: p.judge_heaven + p.judge_critical, - justice: p.judge_justice, - attack: p.judge_attack, - miss: p.judge_guilty, - }; - - let difficulty = if major_version == 1 && p.level == 4 { - Difficulty::WorldsEnd - } else { - Difficulty::try_from(p.level)? - }; - - let datetime = NaiveDateTime::parse_from_str(&p.user_play_date, "%Y-%m-%d %H:%M:%S")?; - let jst_offset = - FixedOffset::east_opt(9 * 3600).expect("chrono should be able to parse JST timezone"); - let jst_time = jst_offset.from_local_datetime(&datetime).unwrap(); - - Ok(ImportScore { - score: p.score, - lamp, - match_type: "inGameID".to_string(), - identifier: p.music_id.clone(), - difficulty, - time_achieved: Some(jst_time.timestamp_millis() as u128), - judgements: Some(judgements), - optional: Some(OptionalMetrics { - max_combo: p.max_combo, - }), - }) - } - - fn try_from_music_detail( - d: &UserMusicDetail, - major_version: u16, - fail_over_lamp: bool, - ) -> Result { - let lamp = if !d.is_success && fail_over_lamp { - TachiLamp::Failed - } else if d.is_all_justice { - TachiLamp::AllJustice - } else if d.is_full_combo { - TachiLamp::FullCombo - } else if d.is_success { - TachiLamp::Clear - } else { - TachiLamp::Failed - }; - - let difficulty = if major_version == 1 && d.level == 4 { - Difficulty::WorldsEnd - } else { - Difficulty::try_from(d.level)? - }; - - Ok(ImportScore { - score: d.score_max, - lamp, - match_type: "inGameID".to_string(), - identifier: d.music_id.to_string(), - difficulty, - time_achieved: None, - judgements: None, - optional: None, - }) - } -} - -pub trait ToTachiImport { - fn displayed_id(&self) -> &str; - fn displayed_id_type(&self) -> &str; - fn to_tachi_import( - &self, - major_version: u16, - export_class: bool, - fail_over_lamp: bool, - ) -> Import; -} - -impl ToTachiImport for UserMusicResponse { - fn displayed_id(&self) -> &str { - &self.user_id - } - - fn displayed_id_type(&self) -> &str { - "user ID" - } - - fn to_tachi_import(&self, major_version: u16, _: bool, fail_over_lamp: bool) -> Import { - let scores = self - .user_music_list - .iter() - .flat_map(|item| { - item.user_music_detail_list.iter().filter_map(|d| { - let result = - ImportScore::try_from_music_detail(d, major_version, fail_over_lamp); - if result - .as_ref() - .is_ok_and(|v| v.difficulty != Difficulty::WorldsEnd) - { - result.ok() - } else { - None - } - }) - }) - .collect::>(); - - Import { - scores, - ..Default::default() - } - } -} - -impl ToTachiImport for UpsertUserAllRequest { - fn displayed_id(&self) -> &str { - let user_data = &self.upsert_user_all.user_data[0]; - - &user_data.access_code - } - - fn displayed_id_type(&self) -> &str { - "access code" - } - - fn to_tachi_import( - &self, - major_version: u16, - export_class: bool, - fail_over_lamp: bool, - ) -> Import { - let user_data = &self.upsert_user_all.user_data[0]; - - let classes = if export_class { - Some(ImportClasses { - dan: ClassEmblem::try_from(user_data.class_emblem_medal).ok(), - emblem: ClassEmblem::try_from(user_data.class_emblem_base).ok(), - }) - } else { - None - }; - - let scores = self - .upsert_user_all - .user_playlog_list - .iter() - .filter_map(|playlog| { - let result = ImportScore::try_from_playlog(playlog, major_version, fail_over_lamp); - if result - .as_ref() - .is_ok_and(|v| v.difficulty != Difficulty::WorldsEnd) - { - result.ok() - } else { - None - } - }) - .collect::>(); - - Import { - classes, - scores, - ..Default::default() - } - } -} diff --git a/src/types/tachi/api.rs b/src/types/tachi/api.rs new file mode 100644 index 0000000..c6b1766 --- /dev/null +++ b/src/types/tachi/api.rs @@ -0,0 +1,74 @@ +use serde::{de, Deserialize, Serialize}; +use serde_json::{Map, Value}; + +#[derive(Debug, Clone)] +pub enum TachiResponse { + Ok(TachiSuccessResponse), + Err(TachiFailureResponse), +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TachiSuccessResponse { + pub description: String, + pub body: T, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TachiFailureResponse { + pub description: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub enum TachiApiPermission { + #[serde(rename = "customise_profile")] + CustomiseProfile, + + #[serde(rename = "customise_score")] + CustomiseScore, + + #[serde(rename = "customise_session")] + CustomiseSession, + + #[serde(rename = "delete_score")] + DeleteScore, + + #[serde(rename = "manage_challenges")] + ManageChallenges, + + #[serde(rename = "manage_rivals")] + ManageRivals, + + #[serde(rename = "manage_targets")] + ManageTargets, + + #[serde(rename = "submit_score")] + SubmitScore, +} + +impl<'de, T> Deserialize<'de> for TachiResponse +where + T: Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let mut map = Map::deserialize(deserializer)?; + let success = map + .remove("success") + .ok_or_else(|| de::Error::missing_field("success")) + .map(Deserialize::deserialize)? + .map_err(de::Error::custom)?; + let rest = Value::Object(map); + + if success { + TachiSuccessResponse::deserialize(rest) + .map(TachiResponse::Ok) + .map_err(de::Error::custom) + } else { + TachiFailureResponse::deserialize(rest) + .map(TachiResponse::Err) + .map_err(de::Error::custom) + } + } +} diff --git a/src/types/tachi/api_returns.rs b/src/types/tachi/api_returns.rs new file mode 100644 index 0000000..c0637f5 --- /dev/null +++ b/src/types/tachi/api_returns.rs @@ -0,0 +1,40 @@ +use serde::{Deserialize, Serialize}; + +use super::{api::TachiApiPermission, documents::ImportDocument}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ServerStatus { + pub whoami: Option, + pub permissions: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ImportDeferred { + pub url: String, + + #[serde(rename = "importID")] + pub import_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ImportResponse { + Deferred(ImportDeferred), + Completed(ImportDocument), +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ImportProgress { + description: String, + value: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "importStatus")] +pub enum ImportPollStatus { + #[serde(rename = "ongoing")] + Ongoing { progress: ImportProgress }, + + #[serde(rename = "completed")] + Completed { import: ImportDocument }, +} diff --git a/src/types/tachi/batch_manual/class.rs b/src/types/tachi/batch_manual/class.rs new file mode 100644 index 0000000..fa73cc2 --- /dev/null +++ b/src/types/tachi/batch_manual/class.rs @@ -0,0 +1,33 @@ +use num_enum::TryFromPrimitive; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct BatchManualClasses { + #[serde(skip_serializing_if = "Option::is_none")] + pub dan: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub emblem: Option, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, TryFromPrimitive)] +#[repr(u32)] +pub enum ClassEmblem { + #[serde(rename = "DAN_I")] + First = 1, + + #[serde(rename = "DAN_II")] + Second = 2, + + #[serde(rename = "DAN_III")] + Third = 3, + + #[serde(rename = "DAN_IV")] + Fourth = 4, + + #[serde(rename = "DAN_V")] + Fifth = 5, + + #[serde(rename = "DAN_INFINITE")] + Infinite = 6, +} diff --git a/src/types/tachi/batch_manual/meta.rs b/src/types/tachi/batch_manual/meta.rs new file mode 100644 index 0000000..1ce469b --- /dev/null +++ b/src/types/tachi/batch_manual/meta.rs @@ -0,0 +1,18 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchManualMeta { + pub game: String, + pub playtype: String, + pub service: String, +} + +impl Default for BatchManualMeta { + fn default() -> Self { + Self { + game: "chunithm".to_string(), + playtype: "Single".to_string(), + service: "Saekawa".to_string(), + } + } +} diff --git a/src/types/tachi/batch_manual/mod.rs b/src/types/tachi/batch_manual/mod.rs new file mode 100644 index 0000000..e805c5b --- /dev/null +++ b/src/types/tachi/batch_manual/mod.rs @@ -0,0 +1,16 @@ +pub mod class; +pub mod meta; +pub mod score; + +use serde::{Deserialize, Serialize}; + +pub use self::{class::BatchManualClasses, meta::BatchManualMeta, score::BatchManualScore}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct BatchManualImport { + pub meta: BatchManualMeta, + pub scores: Vec, + + #[serde(skip_serializing_if = "Option::is_none")] + pub classes: Option, +} diff --git a/src/types/tachi/batch_manual/score.rs b/src/types/tachi/batch_manual/score.rs new file mode 100644 index 0000000..8633b4a --- /dev/null +++ b/src/types/tachi/batch_manual/score.rs @@ -0,0 +1,105 @@ +use num_enum::TryFromPrimitive; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchManualScore { + pub match_type: MatchType, + pub identifier: String, + pub difficulty: Difficulty, + pub score: u32, + pub lamp: Lamp, + + #[serde(skip_serializing_if = "Option::is_none")] + pub judgements: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub time_achieved: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub optional: Option, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, TryFromPrimitive)] +#[repr(u32)] +pub enum Lamp { + #[serde(rename = "FAILED")] + Failed = 0, + + #[serde(rename = "CLEAR")] + Clear = 1, + + #[serde(rename = "FULL COMBO")] + FullCombo = 2, + + #[serde(rename = "ALL JUSTICE")] + AllJustice = 3, + + #[serde(rename = "ALL JUSTICE CRITICAL")] + AllJusticeCritical = 4, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, TryFromPrimitive)] +#[repr(u32)] +pub enum Difficulty { + #[serde(rename = "BASIC")] + Basic = 0, + + #[serde(rename = "ADVANCED")] + Advanced = 1, + + #[serde(rename = "EXPERT")] + Expert = 2, + + #[serde(rename = "MASTER")] + Master = 3, + + #[serde(rename = "ULTIMA")] + Ultima = 4, + + #[serde(rename = "WORLD'S END")] + WorldsEnd = 5, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub enum MatchType { + #[serde(rename = "bmsChartHash")] + BmsChartHash, + + #[serde(rename = "itgChartHash")] + ItgChartHash, + + #[serde(rename = "popnChartHash")] + PopnChartHash, + + #[serde(rename = "uscChartHash")] + UscChartHash, + + #[serde(rename = "inGameID")] + InGameId, + + #[serde(rename = "inGameStrID")] + InGameStrId, + + #[serde(rename = "sdvxInGameID")] + SdvxInGameId, + + #[serde(rename = "songTitle")] + SongTitle, + + #[serde(rename = "tachiSongID")] + TachiSongId, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Judgements { + pub jcrit: u32, + pub justice: u32, + pub attack: u32, + pub miss: u32, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OptionalMetrics { + pub max_combo: u32, +} diff --git a/src/types/tachi/documents.rs b/src/types/tachi/documents.rs new file mode 100644 index 0000000..f9d34fe --- /dev/null +++ b/src/types/tachi/documents.rs @@ -0,0 +1,35 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub enum SessionType { + Appended, + Created, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ImportErrContent { + #[serde(rename = "type")] + pub error_type: String, + + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionInfoReturn { + #[serde(rename = "type")] + pub session_type: SessionType, + + #[serde(rename = "sessionID")] + pub session_id: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ImportDocument { + #[serde(rename = "scoreIDs")] + pub score_ids: Vec, + + pub errors: Vec, + + #[serde(rename = "createdSessions")] + pub created_sessions: Vec, +} diff --git a/src/types/tachi/mod.rs b/src/types/tachi/mod.rs new file mode 100644 index 0000000..3407bfa --- /dev/null +++ b/src/types/tachi/mod.rs @@ -0,0 +1,4 @@ +pub mod api; +pub mod api_returns; +pub mod batch_manual; +pub mod documents; diff --git a/src/updater/external.rs b/src/updater/external.rs new file mode 100644 index 0000000..66f4a31 --- /dev/null +++ b/src/updater/external.rs @@ -0,0 +1,94 @@ +use winapi::{ + ctypes::c_void, + shared::{ + minwindef::{BOOL, DWORD, HMODULE, LPVOID, PROC}, + ntdef::{HANDLE, LPCWSTR, LPSTR}, + }, +}; + +#[link_section = ".rtext"] +#[used] +pub static mut GET_MODULE_FILE_NAME_A_PTR: PROC = 0 as PROC; + +#[link_section = ".rtext"] +#[used] +pub static mut GET_PROCESS_HEAP_PTR: PROC = 0 as PROC; + +#[link_section = ".rtext"] +#[used] +pub static mut REPLCE_FILE_W_PTR: PROC = 0 as PROC; + +#[link_section = ".rtext"] +#[used] +pub static mut LOAD_LIBRARY_W_POINTER: PROC = 0 as PROC; + +#[link_section = ".rtext"] +#[used] +pub static mut HEAP_FREE_PTR: PROC = 0 as PROC; + +#[link_section = ".rtext"] +#[used] +pub static mut SLEEP_PTR: PROC = 0 as PROC; + +#[link_section = ".rtext"] +#[used] +pub static mut GET_LAST_ERROR_PTR: PROC = 0 as PROC; + +type GetModuleFileNameAFn = unsafe extern "system" fn(HMODULE, LPSTR, DWORD) -> DWORD; +type GetProcessHeapFn = unsafe extern "system" fn() -> HANDLE; +type ReplaceFileWFn = + unsafe extern "system" fn(LPCWSTR, LPCWSTR, LPCWSTR, DWORD, LPVOID, LPVOID) -> BOOL; +type LoadLibraryWFn = unsafe extern "system" fn(LPCWSTR) -> HMODULE; +type HeapFreeFn = unsafe extern "system" fn(HANDLE, DWORD, LPVOID) -> BOOL; +type SleepFn = unsafe extern "system" fn(DWORD); + +#[repr(C)] +pub struct ReplaceArgs { + pub module: HMODULE, + pub old: [u16; 32767], + pub new: [u16; 32767], +} + +/// SAFETY: This function *must* only be called when the addresses for the function have been filled in. +#[allow(non_snake_case)] +#[link_section = ".rtext"] +pub unsafe extern "system" fn replace_with_new_library(parameter: *const c_void) -> u32 { + let args = parameter as *const ReplaceArgs; + let GetModuleFileNameA = + std::mem::transmute::<_, GetModuleFileNameAFn>(GET_MODULE_FILE_NAME_A_PTR); + let GetProcessHeap = std::mem::transmute::<_, GetProcessHeapFn>(GET_PROCESS_HEAP_PTR); + let ReplaceFileW = std::mem::transmute::<_, ReplaceFileWFn>(REPLCE_FILE_W_PTR); + let LoadLibraryW = std::mem::transmute::<_, LoadLibraryWFn>(LOAD_LIBRARY_W_POINTER); + let HeapFree = std::mem::transmute::<_, HeapFreeFn>(HEAP_FREE_PTR); + let Sleep = std::mem::transmute::<_, SleepFn>(SLEEP_PTR); + + // Wait for the old library to be freed + let mut filename = 0; + + loop { + let result = GetModuleFileNameA((*args).module, &mut filename, 1); + + if result == 0 { + break; + } + + Sleep(1000); + } + + let result = ReplaceFileW( + (*args).old.as_ptr(), + (*args).new.as_ptr(), + 0 as *const _, + 2, + 0 as *mut c_void, + 0 as *mut c_void, + ); + + if result > 0 { + LoadLibraryW((*args).old.as_ptr()); + } else { + LoadLibraryW((*args).new.as_ptr()); + } + + HeapFree(GetProcessHeap(), 0, args as *mut _) as u32 +} diff --git a/src/updater/mod.rs b/src/updater/mod.rs new file mode 100644 index 0000000..3f848d9 --- /dev/null +++ b/src/updater/mod.rs @@ -0,0 +1,578 @@ +mod external; + +use std::{ + ffi::CStr, + io::{self, Read}, + mem::{self}, + path::Path, + ptr, +}; + +use log::{debug, error, info}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use snafu::{prelude::Snafu, ResultExt}; +use widestring::U16CString; +use winapi::{ + shared::{ + minwindef::{BOOL, DWORD, HMODULE, LPVOID, PROC}, + ntdef::{LPCWSTR, LPSTR}, + winerror::{ + CERT_E_CHAINING, CERT_E_EXPIRED, CERT_E_UNTRUSTEDROOT, CRYPT_E_SECURITY_SETTINGS, + TRUST_E_EXPLICIT_DISTRUST, TRUST_E_NOSIGNATURE, + }, + }, + um::{ + heapapi::HeapAlloc, + memoryapi::{VirtualAlloc, VirtualProtect}, + minwinbase::LMEM_ZEROINIT, + processthreadsapi::CreateThread, + softpub::WINTRUST_ACTION_GENERIC_VERIFY_V2, + winbase::LocalAlloc, + wincrypt::{ + CertCloseStore, CertFindCertificateInStore, CryptMsgClose, CryptMsgGetParam, + CryptQueryObject, CERT_FIND_SUBJECT_CERT, CERT_INFO, + CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED_EMBED, CERT_QUERY_FORMAT_FLAG_BINARY, + CERT_QUERY_OBJECT_FILE, CMSG_SIGNER_INFO_PARAM, HCERTSTORE, HCRYPTMSG, + PCMSG_SIGNER_INFO, PKCS_7_ASN_ENCODING, X509_ASN_ENCODING, + }, + winnt::{ + HANDLE, HEAP_ZERO_MEMORY, IMAGE_DOS_HEADER, IMAGE_NT_HEADERS32, IMAGE_SECTION_HEADER, + MEM_COMMIT, MEM_RESERVE, PAGE_EXECUTE_READ, PAGE_READWRITE, + }, + wintrust::{ + WinVerifyTrust, WINTRUST_DATA, WINTRUST_FILE_INFO, WTD_CHOICE_FILE, WTD_REVOKE_NONE, + WTD_STATEACTION_VERIFY, WTD_UI_NONE, + }, + }, +}; + +use self::external::{replace_with_new_library, ReplaceArgs}; +use crate::helpers::winapi_ext::{get_module_file_name, LibraryHandle, ReadStringFnError}; + +const PUBLIC_KEY: [u8; 270] = [ + 0x30, 0x82, 0x01, 0x0a, 0x02, 0x82, 0x01, 0x01, 0x00, 0xf5, 0xbd, 0x02, 0xb0, 0x81, 0xc6, 0x4d, + 0x4c, 0xa0, 0x40, 0xa8, 0x76, 0x78, 0xe2, 0x61, 0x39, 0x13, 0x1d, 0x2f, 0x0c, 0x70, 0x71, 0x96, + 0x56, 0x67, 0xf2, 0xbe, 0xc2, 0x5c, 0xc7, 0xd4, 0xa6, 0xb5, 0x07, 0xc5, 0x7a, 0x19, 0x58, 0x10, + 0x70, 0xb5, 0x87, 0x5f, 0x3f, 0x9a, 0x78, 0x9e, 0x96, 0x5c, 0xc7, 0x88, 0x50, 0x8c, 0x34, 0xcc, + 0x51, 0xe5, 0xd5, 0xbd, 0xb8, 0xab, 0xed, 0x28, 0x7f, 0x68, 0x6e, 0x27, 0x2a, 0x1d, 0xdb, 0x9a, + 0xe9, 0x1d, 0xbc, 0xd8, 0xbf, 0xca, 0xdf, 0x65, 0xa3, 0x0a, 0x19, 0x3d, 0x00, 0x14, 0x16, 0xdd, + 0x87, 0x9f, 0xf5, 0x44, 0x9e, 0x56, 0x1e, 0xfd, 0xb5, 0xf0, 0x75, 0x3d, 0x11, 0x4c, 0x4d, 0xa5, + 0x1a, 0x24, 0xfe, 0x31, 0x77, 0xc1, 0x55, 0xf7, 0x5d, 0x9c, 0x34, 0xbe, 0x5f, 0x9d, 0x73, 0x2c, + 0x3e, 0xdb, 0x39, 0x18, 0x3c, 0xb3, 0x46, 0xe0, 0xf4, 0xa1, 0xcc, 0x2f, 0x7b, 0x07, 0xb7, 0x0e, + 0x7a, 0x92, 0x54, 0xa9, 0x9f, 0xfc, 0x4c, 0xe0, 0xbb, 0xcf, 0xba, 0x36, 0xc6, 0xcb, 0x9d, 0xb1, + 0x12, 0x4b, 0x50, 0x1c, 0x10, 0x23, 0x87, 0x28, 0x9b, 0x73, 0xe3, 0xd5, 0xc9, 0x38, 0xae, 0xd7, + 0x66, 0x73, 0x8f, 0xf8, 0x56, 0x2e, 0x48, 0x0a, 0xdb, 0x7f, 0x11, 0xbf, 0xd6, 0x4e, 0x77, 0x6c, + 0xb8, 0x12, 0xaf, 0x0b, 0x7b, 0x08, 0xe3, 0x0f, 0x7e, 0xf1, 0x6a, 0xc0, 0xac, 0x1c, 0xe2, 0x8c, + 0x47, 0xb0, 0xec, 0x10, 0xca, 0x02, 0x9c, 0x7d, 0x27, 0x78, 0x33, 0x3c, 0x25, 0x88, 0x5c, 0x4f, + 0x4b, 0xb8, 0x72, 0xeb, 0x85, 0x31, 0x39, 0xb1, 0x95, 0xae, 0xc3, 0x79, 0x38, 0x20, 0x25, 0x0e, + 0xab, 0xdc, 0x9c, 0xc8, 0x25, 0x53, 0xd2, 0xcf, 0x93, 0xf0, 0x1d, 0x95, 0x58, 0x0b, 0x0c, 0x9f, + 0xc5, 0x01, 0x7a, 0xad, 0x4f, 0x55, 0x2f, 0x24, 0xc5, 0x02, 0x03, 0x01, 0x00, 0x01, +]; + +// I don't know what the hell is going on with linking, but you have to link these manually, +// otherwise you end up with the addresses to the intermediary functions, which obviously +// doesn't exist once you unloads the original library. +#[link(name = "kernel32")] +extern "system" { + pub fn GetLastError() -> u32; + pub fn GetModuleFileNameA(hModule: HMODULE, lpFilename: LPSTR, nsize: DWORD) -> u32; + pub fn GetProcessHeap() -> HANDLE; + pub fn HeapFree(hHeap: HANDLE, dwFlags: DWORD, lpMem: LPVOID) -> BOOL; + pub fn LoadLibraryW(lpFileName: LPCWSTR) -> HMODULE; + pub fn ReplaceFileW( + lpReplacedFileName: LPCWSTR, + lpReplacementFileName: LPCWSTR, + lpBackupFileName: LPCWSTR, + dwReplaceFlags: DWORD, + lpExclude: LPVOID, + lpReserved: LPVOID, + ) -> BOOL; + pub fn Sleep(dwMilliseconds: DWORD); +} + +#[derive(Debug, Snafu)] +pub enum SelfUpdateError { + #[snafu(display("Could not get the file name of the currently running hook"))] + FailedToGetFilename { source: ReadStringFnError }, + + #[snafu(display("Invalid DOS signature"))] + InvalidDosSignature, + + #[snafu(display("Invalid NT signature"))] + InvalidNtSignature, + + #[snafu(display("Updater code section not found."))] + NoUpdaterCodeSection, + + #[snafu(display("Failed to allocate memory for update"))] + FailedToAllocateMemory, + + #[snafu(display("VirtualProtect failed with error code {errno}"))] + FailedVirtualProtect { errno: u32 }, + + #[snafu(display("Could not execute updater code: {errno}"))] + FailedCreateThread { errno: u32 }, + + #[snafu(display("Failed to request update information."))] + FailedRequestingUpdate { source: ureq::Error }, + + #[snafu(display("Invalid update information."))] + InvalidUpdateInformation { source: io::Error }, + + #[snafu(display("Could not download updated hook."))] + FailedDownloadingUpdate { source: io::Error }, + + #[snafu(display("Could not write updated hook to file."))] + FailedWritingUpdate { source: io::Error }, + + #[snafu(display("SHA-256 checksum mismatch."))] + InvalidChecksum, + + #[snafu(display("Could not verify signature: {source:#}"))] + InvalidSignature { source: VerifySignatureError }, + + #[snafu(display("Failed to get digital signature of the update: {source:#?}"))] + FailedGettingPubkey { source: GetSignaturePubkeyError }, + + #[snafu(display("Public key mismatched."))] + InvalidPubkey, +} + +#[derive(Snafu, Debug)] +pub enum VerifySignatureError { + #[snafu(display("Signature verification was disabled by a local policy."))] + VerificationDisabledByPolicy, + + #[snafu(display("No signatures found."))] + NoSignature, + + #[snafu(display("The signature was explicitly distrusted."))] + ExplicitlyDistrusted, + + #[snafu(display("An unknown validation error occured: {errno}"))] + Unknown { errno: i32 }, +} + +#[derive(Snafu, Debug)] +pub enum GetSignaturePubkeyError { + #[snafu(display("CertQueryObject failed: {errno}"))] + QueryObjectError { errno: u32 }, + + #[snafu(display("Could not obtain size of signer information: {errno}"))] + SignerInfoSizeError { errno: u32 }, + + #[snafu(display("Could not allocate memory for signer information: {errno}"))] + SignerInfoAllocError { errno: u32 }, + + #[snafu(display("Could not obtain signer information: {errno}"))] + SignerInfoObtainError { errno: u32 }, + + #[snafu(display("Could not look up certificate in certificate store: {errno}"))] + CertificateInStoreError { errno: u32 }, + + #[snafu(display("Could not read public key."))] + ReadPubkeyError { source: io::Error }, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +struct UpdateInformation { + pub version: String, + pub commit: String, + pub sha256: String, +} + +/// Checks if the hook has a newer version. Returns true if update was successful +/// and the hook should uninject itself so a newer version can load in. +pub fn self_update(module: &LibraryHandle) -> Result { + let agent = ureq::builder() + .user_agent(concat!("saekawa/", env!("CARGO_PKG_VERSION"))) + .build(); + + info!("Checking for updates..."); + let response = agent + .get("https://beerpiss.github.io/saekawa/update.json") + .call() + .context(FailedRequestingUpdateSnafu)? + .into_json::() + .context(InvalidUpdateInformationSnafu)?; + + if response.commit == env!("VERGEN_GIT_SHA") { + info!("Already up-to-date."); + + return Ok(false); + } + + let module_filename = + &get_module_file_name(module.handle()).context(FailedToGetFilenameSnafu)?; + let module_path = Path::new(&module_filename); + + debug!("Current hook is located at {module_filename:#?}."); + + info!("Downloading update v{}...", response.version); + let url = format!( + "https://github.com/beerpiss/saekawa/releases/download/v{}/saekawa.dll", + response.version + ); + + debug!("Requesting content from {url}..."); + let new_hook = { + let mut response = agent + .get(&url) + .call() + .context(FailedRequestingUpdateSnafu)? + .into_reader(); + let mut buf = vec![]; + response + .read_to_end(&mut buf) + .context(FailedDownloadingUpdateSnafu)?; + + buf + }; + + debug!("Validating update contents..."); + validate_sha256(&new_hook, &response.sha256)?; + + let new_module_path = module_path.with_file_name("saekawa.new.dll"); + + debug!("Writing update contents to {new_module_path:#?}..."); + std::fs::write(&new_module_path, new_hook).context(FailedWritingUpdateSnafu)?; + + debug!("Verifying digital signature..."); + let new_module_filename = new_module_path.to_string_lossy(); + verify_signature(&new_module_filename).context(InvalidSignatureSnafu)?; + + debug!("Verifying certificate public key..."); + let actual_pubkey = match get_signature_pubkey(&new_module_filename) { + Ok(k) => k, + Err(e) => { + let _ = std::fs::remove_file(&new_module_path); + return Err(SelfUpdateError::FailedGettingPubkey { source: e }); + } + }; + + if actual_pubkey != PUBLIC_KEY { + let _ = std::fs::remove_file(&new_module_path); + return Err(SelfUpdateError::InvalidPubkey); + } + + debug!("Starting update sequence"); + // You know stuff is going to be cursed when the unsafe block is ~120 lines long. + // + // TL;DR: There's a function that waits until the current hook has been unloaded, + // then replaces the old hook with the new hook, and loads in the new hook. + // + // This is achieved by linking that function alongside the required functions in a different + // code section (".rtext"), setting references for those functions, then copying out + // that entire section to a different memory region so it can keep executing when the + // old hook is unloaded. + // + // Thanks to DJTRACKERS and their fervidex hook for the approach. + unsafe { + external::GET_LAST_ERROR_PTR = GetLastError as PROC; + external::GET_MODULE_FILE_NAME_A_PTR = GetModuleFileNameA as PROC; + external::GET_PROCESS_HEAP_PTR = GetProcessHeap as PROC; + external::HEAP_FREE_PTR = HeapFree as PROC; + external::LOAD_LIBRARY_W_POINTER = LoadLibraryW as PROC; + external::REPLCE_FILE_W_PTR = ReplaceFileW as PROC; + external::SLEEP_PTR = Sleep as PROC; + + debug!("Locating updater code..."); + let dos_header = module.handle() as *const IMAGE_DOS_HEADER; + + if (*dos_header).e_magic != 0x5A4D { + return Err(SelfUpdateError::InvalidDosSignature); + } + + let nt_header_address = module.handle().byte_offset((*dos_header).e_lfanew as isize); + let nt_header = nt_header_address as *const IMAGE_NT_HEADERS32; + + if (*nt_header).Signature != 0x4550 { + return Err(SelfUpdateError::InvalidNtSignature); + } + + let number_of_sections = (*nt_header).FileHeader.NumberOfSections; + + if number_of_sections < 5 { + return Err(SelfUpdateError::NoUpdaterCodeSection); + } + + let section_header_offset = (&(*nt_header).OptionalHeader as *const _ as *const u8) + .byte_add((*nt_header).FileHeader.SizeOfOptionalHeader as usize) + as *const IMAGE_SECTION_HEADER; + + for i in 0..number_of_sections { + let section_header = *section_header_offset.byte_add(40 * i as usize); + let section_name = CStr::from_bytes_until_nul(§ion_header.Name) + .unwrap() + .to_str() + .unwrap(); + + if section_name != ".rtext" { + continue; + } + + let src_addr = module + .handle() + .byte_add(section_header.VirtualAddress as usize) + as *mut u8; + let section_size = *section_header.Misc.VirtualSize() as usize; + + let dst_addr = VirtualAlloc( + ptr::null_mut(), + section_size, + MEM_COMMIT | MEM_RESERVE, + PAGE_READWRITE, + ) as *mut u8; + + if dst_addr.is_null() { + return Err(SelfUpdateError::FailedToAllocateMemory); + } + + debug!( + "Copying updater code section from {:p} to {:p}", + src_addr, dst_addr + ); + std::ptr::copy_nonoverlapping(src_addr, dst_addr, section_size); + + let updater_start_address = (replace_with_new_library as PROC) + .byte_add(dst_addr as usize) + .byte_sub(src_addr as usize); + + debug!("Making updater code executable"); + let mut old_protect = 0u32; + let result = VirtualProtect( + dst_addr as *mut _, + section_size, + PAGE_EXECUTE_READ, + &mut old_protect, + ); + + if result == 0 { + return Err(SelfUpdateError::FailedVirtualProtect { + errno: GetLastError(), + }); + } + + let process_heap = GetProcessHeap(); + let heap = HeapAlloc( + process_heap, + HEAP_ZERO_MEMORY, + mem::size_of::(), + ) as *mut ReplaceArgs; + + debug!("Allocated heap for updater code at {heap:p}"); + + (*heap).module = module.handle(); + let old = U16CString::from_str_truncate(&module_filename); + let new = U16CString::from_str_truncate(&new_module_path.to_string_lossy()); + std::ptr::copy_nonoverlapping( + old.as_ptr(), + (*heap).old.as_mut_ptr(), + old.as_slice().len(), + ); + std::ptr::copy_nonoverlapping( + new.as_ptr(), + (*heap).new.as_mut_ptr(), + new.as_slice().len(), + ); + + debug!("Executing updater code at {updater_start_address:p}"); + let handle = CreateThread( + ptr::null_mut(), + 0, + Some(std::mem::transmute(updater_start_address)), + heap as *mut _, + 0, + ptr::null_mut(), + ); + + if handle.is_null() { + error!("Could not execute updater code: {}", GetLastError()); + return Err(SelfUpdateError::FailedCreateThread { + errno: GetLastError(), + }); + } + + return Ok(true); + } + + return Err(SelfUpdateError::NoUpdaterCodeSection); + } +} + +fn validate_sha256(data: &[u8], expected: &str) -> Result<(), SelfUpdateError> { + let mut hasher = Sha256::new(); + + hasher.update(data); + + let hash = hasher.finalize(); + let hash_string = faster_hex::hex_string(&hash[..]); + + debug!("Expected checksum: {}", expected); + debug!("Actual checksum: {}", hash_string); + + if hash_string != expected { + return Err(SelfUpdateError::InvalidChecksum); + } + + Ok(()) +} + +fn verify_signature(file: &str) -> Result<(), VerifySignatureError> { + let file_osstr = U16CString::from_str_truncate(file); + let mut verification_type = WINTRUST_ACTION_GENERIC_VERIFY_V2; + let mut wintrust_data_buf = vec![0u8; mem::size_of::()]; + let mut fileinfo_buf = vec![0u8; mem::size_of::()]; + + unsafe { + let fileinfo = fileinfo_buf.as_mut_ptr() as *mut WINTRUST_FILE_INFO; + let wintrust_data = wintrust_data_buf.as_mut_ptr() as *mut WINTRUST_DATA; + + (*fileinfo).cbStruct = mem::size_of::() as u32; + (*fileinfo).pcwszFilePath = file_osstr.as_ptr(); + (*fileinfo).hFile = ptr::null_mut(); + (*fileinfo).pgKnownSubject = ptr::null_mut(); + + (*wintrust_data).pPolicyCallbackData = ptr::null_mut(); + (*wintrust_data).pSIPClientData = ptr::null_mut(); + (*wintrust_data).cbStruct = mem::size_of::() as u32; + (*wintrust_data).dwStateAction = WTD_STATEACTION_VERIFY; + (*wintrust_data).dwUIChoice = WTD_UI_NONE; + (*wintrust_data).fdwRevocationChecks = WTD_REVOKE_NONE; + (*wintrust_data).dwUnionChoice = WTD_CHOICE_FILE; + (*wintrust_data).hWVTStateData = ptr::null_mut(); + (*wintrust_data).pwszURLReference = ptr::null_mut(); + (*wintrust_data).dwUIContext = 0; + *(*wintrust_data).u.pFile_mut() = fileinfo; + + let status = WinVerifyTrust( + ptr::null_mut(), + &mut verification_type, + wintrust_data as *mut _, + ); + + match status { + 0 | CERT_E_UNTRUSTEDROOT | CERT_E_EXPIRED | CERT_E_CHAINING => Ok(()), + CRYPT_E_SECURITY_SETTINGS => Err(VerifySignatureError::VerificationDisabledByPolicy), + TRUST_E_NOSIGNATURE => Err(VerifySignatureError::NoSignature), + TRUST_E_EXPLICIT_DISTRUST => Err(VerifySignatureError::ExplicitlyDistrusted), + _ => Err(VerifySignatureError::Unknown { errno: status }), + } + } +} + +fn get_signature_pubkey(file: &str) -> Result, GetSignaturePubkeyError> { + debug!("Getting public key of {file}."); + + let file_osstr = U16CString::from_str_truncate(file); + let mut cert_store: HCERTSTORE = ptr::null_mut(); + let mut crypt_msg: HCRYPTMSG = ptr::null_mut(); + let result = unsafe { + CryptQueryObject( + CERT_QUERY_OBJECT_FILE, + file_osstr.as_ptr() as *const _, + CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED_EMBED, + CERT_QUERY_FORMAT_FLAG_BINARY, + 0, + ptr::null_mut(), + ptr::null_mut(), + ptr::null_mut(), + &mut cert_store, + &mut crypt_msg, + ptr::null_mut(), + ) + }; + + if result == 0 { + return Err(GetSignaturePubkeyError::QueryObjectError { + errno: unsafe { GetLastError() }, + }); + } + + let mut signer_info_length = 0; + let result = unsafe { + CryptMsgGetParam( + crypt_msg, + CMSG_SIGNER_INFO_PARAM, + 0, + ptr::null_mut(), + &mut signer_info_length, + ) + }; + + if result == 0 { + return Err(GetSignaturePubkeyError::SignerInfoSizeError { + errno: unsafe { GetLastError() }, + }); + } + + let signer_info = + unsafe { LocalAlloc(LMEM_ZEROINIT, signer_info_length as usize) } as PCMSG_SIGNER_INFO; + + if signer_info.is_null() { + return Err(GetSignaturePubkeyError::SignerInfoAllocError { + errno: unsafe { GetLastError() }, + }); + } + + let result = unsafe { + CryptMsgGetParam( + crypt_msg, + CMSG_SIGNER_INFO_PARAM, + 0, + signer_info as *mut _, + &mut signer_info_length, + ) + }; + + if result == 0 { + return Err(GetSignaturePubkeyError::SignerInfoObtainError { + errno: unsafe { GetLastError() }, + }); + } + + let mut cert_search_params_buf = vec![0u8; mem::size_of::()]; + let cert_search_params = cert_search_params_buf.as_mut_ptr() as *mut CERT_INFO; + + unsafe { + (*cert_search_params).Issuer = (*signer_info).Issuer; + (*cert_search_params).SerialNumber = (*signer_info).SerialNumber; + } + + let cert = unsafe { + CertFindCertificateInStore( + cert_store, + X509_ASN_ENCODING | PKCS_7_ASN_ENCODING, + 0, + CERT_FIND_SUBJECT_CERT, + cert_search_params as *const _, + ptr::null(), + ) + }; + + if cert.is_null() { + return Err(GetSignaturePubkeyError::CertificateInStoreError { + errno: unsafe { GetLastError() }, + }); + } + + unsafe { + let cert_info = (*cert).pCertInfo; + let cbb = (*cert_info).SubjectPublicKeyInfo.PublicKey; + let public_key_length = cbb.cbData; + let public_key = cbb.pbData; + let mut public_key = + std::slice::from_raw_parts(public_key as *const _, public_key_length as _); + let mut pubkey_vec = Vec::with_capacity(public_key_length as _); + + public_key + .read_to_end(&mut pubkey_vec) + .context(ReadPubkeySnafu)?; + + CertCloseStore(cert_store, 0); + CryptMsgClose(crypt_msg); + + Ok(pubkey_vec) + } +}