diff --git a/.gitignore b/.gitignore index 929aee3..133e4dd 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,8 @@ cars_repo.json tracks_repo.json times/ *.csv +ca/ +captures/ +capture.toml AGENTS.md diff --git a/Cargo.lock b/Cargo.lock index 03e902a..cfdfd22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,6 +17,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "anstream" version = "1.0.0" @@ -67,6 +88,45 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -77,6 +137,29 @@ dependencies = [ "serde_json", ] +[[package]] +name = "async-compression" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -89,6 +172,29 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "base64" version = "0.22.1" @@ -101,7 +207,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] [[package]] @@ -110,6 +216,15 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -122,6 +237,56 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -141,6 +306,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -150,6 +317,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core 0.10.1", +] + [[package]] name = "clap" version = "4.6.1" @@ -190,6 +368,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -205,6 +392,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", + "zstd", + "zstd-safe", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "core-foundation" version = "0.9.4" @@ -231,12 +444,67 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "dashmap" version = "6.1.0" @@ -251,6 +519,12 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "defmt" version = "1.1.1" @@ -282,6 +556,47 @@ dependencies = [ "thiserror", ] +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -293,6 +608,12 @@ dependencies = [ "syn", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -341,6 +662,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -353,6 +694,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -389,6 +740,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.32" @@ -477,6 +834,31 @@ dependencies = [ "slab", ] +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -509,6 +891,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -604,6 +987,43 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hudsucker" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a1d7c1c828ff6db12ddafb16768ecbac920df6301778d0eb78e95da0e44d94b" +dependencies = [ + "async-compression", + "bstr", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tungstenite", + "hyper-util", + "moka", + "rand 0.10.2", + "rcgen", + "thiserror", + "time", + "tokio", + "tokio-graceful", + "tokio-rustls", + "tokio-tungstenite", + "tokio-util", + "tracing", +] + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.9.0" @@ -635,10 +1055,12 @@ dependencies = [ "http", "hyper", "hyper-util", + "log", "rustls", "tokio", "tokio-rustls", "tower-service", + "webpki-roots 1.0.9", ] [[package]] @@ -657,6 +1079,21 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aee7ba11c4933715ee2c2fea1d8932f19356d31648c0a1bb65223ef5d7962c4c" +dependencies = [ + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tokio-tungstenite", + "tungstenite", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -857,6 +1294,16 @@ dependencies = [ "syn", ] +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.2", + "libc", +] + [[package]] name = "js-sys" version = "0.3.97" @@ -869,6 +1316,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -908,6 +1361,41 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "pin-utils", + "scoped-tls", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + [[package]] name = "memchr" version = "2.8.0" @@ -920,6 +1408,22 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.0" @@ -948,7 +1452,7 @@ dependencies = [ "hyper-util", "log", "pin-project-lite", - "rand", + "rand 0.9.4", "regex", "serde_json", "serde_urlencoded", @@ -956,6 +1460,26 @@ dependencies = [ "tokio", ] +[[package]] +name = "moka" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9" +dependencies = [ + "async-lock", + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "event-listener", + "futures-util", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -973,6 +1497,50 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -982,6 +1550,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1037,6 +1614,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1060,6 +1643,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1072,6 +1665,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "pkg-config" version = "0.3.33" @@ -1102,6 +1701,12 @@ dependencies = [ "zerovec", ] +[[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.21" @@ -1137,10 +1742,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", - "bit-vec", + "bit-vec 0.8.0", "bitflags 2.11.1", "num-traits", - "rand", + "rand 0.9.4", "rand_chacha", "rand_xorshift", "regex-syntax", @@ -1183,7 +1788,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", ] [[package]] @@ -1193,7 +1809,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -1205,13 +1821,33 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_xorshift" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" dependencies = [ - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rcgen" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", ] [[package]] @@ -1306,6 +1942,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1325,6 +1970,8 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "aws-lc-rs", + "log", "once_cell", "rustls-pki-types", "rustls-webpki", @@ -1347,6 +1994,7 @@ version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -1385,6 +2033,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -1463,6 +2117,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1475,12 +2138,48 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "similar" version = "2.7.0" @@ -1579,6 +2278,12 @@ dependencies = [ "libc", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tempfile" version = "3.27.0" @@ -1612,6 +2317,45 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -1633,11 +2377,25 @@ dependencies = [ "mio", "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-graceful" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45740b38b48641855471cd402922e89156bdfbd97b69b45eeff170369cc18c7d" +dependencies = [ + "loom", + "pin-project-lite", + "slab", + "tokio", + "tracing", +] + [[package]] name = "tokio-macros" version = "2.7.0" @@ -1669,6 +2427,22 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -1682,15 +2456,61 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "top_drives" version = "0.3.0" dependencies = [ + "base64", "clap", "dashmap", "env_logger", "futures", + "http", + "http-body-util", + "hudsucker", "log", + "md-5", "mockito", "proptest", "reqwest", @@ -1698,7 +2518,9 @@ dependencies = [ "serde_json", "tempfile", "thiserror", + "time", "tokio", + "toml", "top_drives", ] @@ -1753,10 +2575,23 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -1764,6 +2599,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -1772,6 +2637,30 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.10.2", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unarray" version = "0.1.4" @@ -1820,12 +2709,35 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -1967,6 +2879,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -2084,6 +3014,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -2184,6 +3123,34 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec 0.9.1", + "time", +] + [[package]] name = "yoke" version = "0.8.2" @@ -2292,3 +3259,31 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index ed0866c..315a481 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,10 @@ name = "records" path = "src/records.rs" required-features = ["bin-logging"] +[[bin]] +name = "capture" +path = "src/capture/main.rs" + [dependencies] reqwest = "0.12.15" tokio = { version = "1.44.1", features = ["rt-multi-thread", "macros", "fs", "sync", "io-util"] } @@ -26,6 +30,15 @@ dashmap = "6" log = "0.4" env_logger = { version = "0.11", optional = true } +[target.'cfg(windows)'.dependencies] +hudsucker = "0.25" +http = "1" +http-body-util = "0.1" +md-5 = "0.10" +time = { version = "0.3", features = ["formatting"] } +base64 = "0.22" +toml = "0.8" + [features] default = ["bin-logging", "test-utils"] bin-logging = ["dep:env_logger"] diff --git a/capture.toml.example b/capture.toml.example new file mode 100644 index 0000000..e02e287 --- /dev/null +++ b/capture.toml.example @@ -0,0 +1,29 @@ +# Example configuration for `cargo run --bin capture`. +# Copy to capture.toml and adjust to your machine; capture.toml is gitignored. +# +# The capture binary is Windows-only (BlueStacks + adb interception); on Unix +# hosts it builds as a stub that exits with an error message. + +[device] +# BlueStacks ADB endpoint (BlueStacks Settings -> Advanced -> Android Debug Bridge) +serial = "127.0.0.1:5555" +# How long to wait for the emulator to come online before giving up +wait_secs = 180 + +[paths] +# Path to adb.exe (empty = auto-detect from LOCALAPPDATA, then PATH) +adb = 'C:\Users\\AppData\Local\Android\Sdk\platform-tools\adb.exe' +# Path to the BlueStacks player, started automatically if the device is offline +bluestacks = 'C:\Program Files\BlueStacks_nxt\HD-Player.exe' +# Where the captured garage document is written +output = 'my_cars.json' + +[proxy] +# Local port for the MITM proxy (must be free; close HTTP Toolkit first) +port = 8080 + +[capture] +# How long to wait for the game to boot and issue the sync request +timeout_secs = 300 +# Per-request timeout when re-issuing the stored sync request (`capture replay`) +replay_timeout_secs = 30 diff --git a/src/capture/main.rs b/src/capture/main.rs new file mode 100644 index 0000000..85bc8d5 --- /dev/null +++ b/src/capture/main.rs @@ -0,0 +1,13 @@ +#[cfg(windows)] +mod windows; + +#[cfg(windows)] +fn main() { + windows::run(); +} + +#[cfg(not(windows))] +fn main() { + eprintln!("error: the capture binary is Windows-only (BlueStacks + ADB MITM interception)"); + std::process::exit(2); +} diff --git a/src/capture/windows.rs b/src/capture/windows.rs new file mode 100644 index 0000000..7d3b0c5 --- /dev/null +++ b/src/capture/windows.rs @@ -0,0 +1,902 @@ +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use base64::Engine; +use hudsucker::certificate_authority::RcgenAuthority; +use hudsucker::rcgen::{ + BasicConstraints, CertificateParams, DnType, IsCa, Issuer, KeyPair, KeyUsagePurpose, + PKCS_ECDSA_P256_SHA256, +}; +use hudsucker::{Body, HttpContext, HttpHandler, Proxy, RequestOrResponse}; +use http::request::Request; +use http_body_util::BodyExt; +use md5::{Digest, Md5}; +use serde::{Deserialize, Serialize}; + +const GAME_PACKAGE: &str = "com.hutchgames.cccg"; +const CA_COMMON_NAME: &str = "Top Drives Capture CA"; +const GARAGE_MARKER: &[u8] = b"playerDeck"; +const CONFIG_FILE: &str = "capture.toml"; + +type Result = std::result::Result>; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(default)] +struct CaptureConfig { + device: DeviceConfig, + paths: PathsConfig, + proxy: ProxyConfig, + capture: CaptureSettings, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(default)] +struct DeviceConfig { + serial: String, + wait_secs: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(default)] +struct PathsConfig { + adb: String, + bluestacks: String, + output: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(default)] +struct ProxyConfig { + port: u16, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(default)] +struct CaptureSettings { + timeout_secs: u64, + replay_timeout_secs: u64, +} + +impl Default for CaptureConfig { + fn default() -> Self { + Self { + device: DeviceConfig::default(), + paths: PathsConfig::default(), + proxy: ProxyConfig::default(), + capture: CaptureSettings::default(), + } + } +} + +impl Default for DeviceConfig { + fn default() -> Self { + Self { + serial: "127.0.0.1:5555".to_string(), + wait_secs: 180, + } + } +} + +impl Default for PathsConfig { + fn default() -> Self { + Self { + adb: detect_adb(), + bluestacks: detect_bluestacks(), + output: "my_cars.json".to_string(), + } + } +} + +impl Default for ProxyConfig { + fn default() -> Self { + Self { port: 8080 } + } +} + +impl Default for CaptureSettings { + fn default() -> Self { + Self { + timeout_secs: 300, + replay_timeout_secs: 30, + } + } +} + +fn detect_adb() -> String { + if let Ok(local_app_data) = std::env::var("LOCALAPPDATA") { + let candidate = + format!("{local_app_data}\\Android\\Sdk\\platform-tools\\adb.exe"); + if Path::new(&candidate).exists() { + return candidate; + } + } + "adb".to_string() +} + +fn detect_bluestacks() -> String { + const CANDIDATES: [&str; 3] = [ + r"C:\Program Files\BlueStacks_nxt\HD-Player.exe", + r"C:\Program Files\BlueStacks\HD-Player.exe", + r"C:\Program Files (x86)\BlueStacks\HD-Player.exe", + ]; + CANDIDATES + .iter() + .find(|candidate| Path::new(candidate).exists()) + .map(|candidate| candidate.to_string()) + .unwrap_or_default() +} + +fn load_config(project_root: &Path) -> Result { + let config_path = project_root.join(CONFIG_FILE); + if config_path.exists() { + let text = std::fs::read_to_string(&config_path)?; + let config: CaptureConfig = toml::from_str(&text)?; + return Ok(config); + } + + let config = CaptureConfig::default(); + let pretty = toml::to_string_pretty(&config)?.replace("\\\\", "/"); + std::fs::write(&config_path, &pretty)?; + println!("created default configuration at {}", config_path.display()); + println!("review it and adjust paths if needed, then run this command again"); + std::process::exit(0); +} + +fn output_path(config: &CaptureConfig, project_root: &Path) -> PathBuf { + let raw = Path::new(&config.paths.output); + if raw.is_absolute() { + raw.to_path_buf() + } else { + project_root.join(raw) + } +} + +#[derive(Serialize, Deserialize)] +struct CapturedRequest { + method: String, + url: String, + headers: Vec<(String, String)>, + #[serde(skip_serializing_if = "Option::is_none")] + body: Option, +} + +#[derive(Clone)] +struct CaptureHandler { + state: Arc, +} + +struct CaptureState { + found: AtomicBool, + done: tokio::sync::Notify, + project_root: PathBuf, + output: PathBuf, + pending: std::sync::Mutex>, +} + +impl HttpHandler for CaptureHandler { + async fn handle_request( + &mut self, + _ctx: &HttpContext, + mut request: Request, + ) -> RequestOrResponse { + let host = request_host(&request).to_lowercase(); + + if request.method() == http::Method::CONNECT { + if !is_noise_host(&host) { + println!(" CONNECT {host}"); + } + return request.into(); + } + + let path_and_query = request + .uri() + .path_and_query() + .map(|value| value.as_str().to_string()) + .unwrap_or_else(|| "/".to_string()); + + if !is_noise_host(&host) { + println!(" {} {}{}", request.method(), host, path_and_query); + } + + request.headers_mut().remove(http::header::ACCEPT_ENCODING); + + if !self.state.found.load(Ordering::SeqCst) + && host.contains("topdrives") + && path_and_query.contains("/sync") + { + let body_bytes = BodyExt::collect(std::mem::replace( + request.body_mut(), + Body::from(Vec::new()), + )) + .await + .map(|collected| collected.to_bytes().to_vec()) + .unwrap_or_default(); + + let template = CapturedRequest { + method: request.method().to_string(), + url: format!("https://{host}{path_and_query}"), + headers: request + .headers() + .iter() + .map(|(name, value)| { + ( + name.as_str().to_string(), + String::from_utf8_lossy(value.as_bytes()).to_string(), + ) + }) + .collect(), + body: if body_bytes.is_empty() { + None + } else { + Some(base64::engine::general_purpose::STANDARD.encode(&body_bytes)) + }, + }; + *self.state.pending.lock().unwrap() = Some(template); + *request.body_mut() = Body::from(body_bytes); + } + + request.into() + } + + async fn handle_response( + &mut self, + _ctx: &HttpContext, + mut response: http::Response, + ) -> http::Response { + if self.state.found.load(Ordering::SeqCst) { + return response; + } + + let looks_textual = response + .headers() + .get(http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| value.contains("json") || value.contains("text")) + .unwrap_or(false); + if !looks_textual { + return response; + } + + let bytes = match BodyExt::collect(std::mem::replace( + response.body_mut(), + Body::from(Vec::new()), + )) + .await + { + Ok(collected) => collected.to_bytes().to_vec(), + Err(_) => return response, + }; + + if contains_marker(&bytes, GARAGE_MARKER) { + if let Ok(json) = serde_json::from_slice::(&bytes) { + if json.get("user").is_some() { + let template = self.state.pending.lock().unwrap().take(); + if let Err(error) = persist_capture( + &self.state.project_root, + &self.state.output, + &json, + template.as_ref(), + ) { + eprintln!(" failed to save capture: {error}"); + } + self.state.found.store(true, Ordering::SeqCst); + self.state.done.notify_waiters(); + } + } + } + + *response.body_mut() = Body::from(bytes); + response + } +} + +fn request_host(request: &Request) -> String { + if let Some(host) = request.uri().host() { + return host.to_string(); + } + request + .headers() + .get(http::header::HOST) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(':').next()) + .unwrap_or_default() + .to_string() +} + +async fn fetch_response( + client: &reqwest::Client, + method: &http::Method, + url: &str, + headers: &http::HeaderMap, + body: &[u8], + timeout: Duration, +) -> Result> { + let mut builder = client + .request(reqwest::Method::from_bytes(method.as_str().as_bytes())?, url) + .timeout(timeout); + + for (name, value) in headers.iter() { + let lower = name.as_str().to_lowercase(); + if matches!( + lower.as_str(), + "host" | "content-length" | "accept-encoding" | "connection" | "proxy-connection" | "keep-alive" + ) { + continue; + } + builder = builder.header(name.as_str(), value.as_bytes()); + } + + if !body.is_empty() { + builder = builder.body(body.to_vec()); + } + + let response = builder.send().await?; + let status = response.status(); + let bytes = response.bytes().await?; + if !status.is_success() { + return Err(format!("HTTP {status}").into()); + } + Ok(bytes.to_vec()) +} + +fn persist_capture( + project_root: &Path, + output: &Path, + json: &serde_json::Value, + template: Option<&CapturedRequest>, +) -> Result<()> { + let timestamp = time::OffsetDateTime::now_utc().format( + &time::format_description::parse_borrowed::<2>( + "[year]-[month]-[day]T[hour]-[minute]-[second]", + )?, + )?; + let run_dir = project_root.join("captures").join(×tamp); + std::fs::create_dir_all(&run_dir)?; + + let pretty = serde_json::to_string_pretty(json)?; + std::fs::write(run_dir.join("my_cars.json"), &pretty)?; + + if output.exists() { + let backup = run_dir.join("my_cars.previous.json"); + std::fs::copy(output, backup)?; + } + std::fs::write(output, &pretty)?; + + if let Some(template) = template { + std::fs::write( + project_root.join("captures").join("request.json"), + serde_json::to_string_pretty(template)?, + )?; + } + + println!(" garage data saved to {}", output.display()); + print_garage_summary(json); + Ok(()) +} + +fn print_garage_summary(json: &serde_json::Value) { + let social_name = json + .pointer("/user/socialName") + .and_then(|value| value.as_str()) + .unwrap_or("unknown player"); + let deck = json + .get("playerDeck") + .and_then(|value| value.as_array()) + .map(|cards| cards.len()) + .unwrap_or(0); + let locked = json + .get("playerDeck") + .and_then(|value| value.as_array()) + .map(|cards| { + cards + .iter() + .filter(|card| card.get("locked").and_then(|l| l.as_bool()).unwrap_or(false)) + .count() + }) + .unwrap_or(0); + println!(" player: {social_name}, garage: {deck} cars ({locked} locked)"); +} + +fn is_noise_host(host: &str) -> bool { + const NOISE: [&str; 15] = [ + "google", + "gstatic", + "crashlytics", + "firebase", + "facebook", + "fbcdn", + "unity", + "applovin", + "admob", + "doubleclick", + "vungle", + "ironsource", + "chartboost", + "sentry", + "bugsnag", + ]; + NOISE.iter().any(|fragment| host.contains(fragment)) +} + +fn contains_marker(haystack: &[u8], marker: &[u8]) -> bool { + haystack.windows(marker.len()).any(|window| window == marker) +} + +fn run_adb(config: &CaptureConfig, args: &[&str]) -> Result { + let output = Command::new(&config.paths.adb) + .args(args) + .stdin(Stdio::null()) + .output() + .map_err(|error| format!("failed to run adb ({}): {error}", config.paths.adb))?; + if !output.status.success() { + return Err(format!( + "adb {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + ) + .into()); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +fn adb(config: &CaptureConfig, args: &[&str]) -> Result { + let serial = config.device.serial.as_str(); + run_adb(config, &[&["-s", serial][..], args].concat()) +} + +fn adb_shell(config: &CaptureConfig, command: &str) -> Result { + adb(config, &["shell", command]) +} + +fn adb_shell_root(config: &CaptureConfig, command: &str) -> Result { + let quoted = format!("su -c '{command}'"); + adb(config, &["shell", quoted.as_str()]) +} + +fn device_listed(config: &CaptureConfig) -> bool { + match run_adb(config, &["devices"]) { + Ok(devices) => devices + .lines() + .any(|line| line.starts_with(&config.device.serial) && line.contains("device")), + Err(_) => false, + } +} + +fn ensure_device_connected(config: &CaptureConfig) -> Result<()> { + let deadline = Instant::now() + Duration::from_secs(config.device.wait_secs); + let mut bluestacks_started = false; + + loop { + let _ = run_adb(config, &["connect", &config.device.serial]); + if device_listed(config) { + return Ok(()); + } + + if !bluestacks_started { + if config.paths.bluestacks.is_empty() || !Path::new(&config.paths.bluestacks).exists() { + return Err(format!( + "device {} is offline and no usable BlueStacks path is configured ({} in {CONFIG_FILE})", + config.device.serial, config.paths.bluestacks + ) + .into()); + } + println!( + "device {} offline, starting BlueStacks ({}) ...", + config.device.serial, config.paths.bluestacks + ); + Command::new(&config.paths.bluestacks).spawn()?; + bluestacks_started = true; + } + + if Instant::now() >= deadline { + return Err(format!( + "device {} did not come online within {}s", + config.device.serial, config.device.wait_secs + ) + .into()); + } + std::thread::sleep(Duration::from_secs(3)); + } +} + +fn android_sdk_level(config: &CaptureConfig) -> Result { + adb_shell(config, "getprop ro.build.version.sdk")? + .trim() + .parse() + .map_err(|_| "could not read Android SDK level".into()) +} + +fn load_or_create_ca(project_root: &Path) -> Result<(String, String)> { + let ca_dir = project_root.join("ca"); + let cert_path = ca_dir.join("topdrives-ca.pem"); + let key_path = ca_dir.join("topdrives-ca.key.pem"); + + if cert_path.exists() && key_path.exists() { + let cert = std::fs::read_to_string(&cert_path)?; + let key = std::fs::read_to_string(&key_path)?; + return Ok((cert, key)); + } + + std::fs::create_dir_all(&ca_dir)?; + let key_pair = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256)?; + let mut params = CertificateParams::new(Vec::new())?; + params.distinguished_name.push(DnType::CommonName, CA_COMMON_NAME); + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + params.key_usages = vec![ + KeyUsagePurpose::KeyCertSign, + KeyUsagePurpose::CrlSign, + KeyUsagePurpose::DigitalSignature, + ]; + let now = time::OffsetDateTime::now_utc(); + params.not_before = now - time::Duration::days(1); + params.not_after = now + time::Duration::days(3650); + let certificate = params.self_signed(&key_pair)?; + + let cert_pem = certificate.pem(); + let key_pem = key_pair.serialize_pem(); + std::fs::write(&cert_path, &cert_pem)?; + std::fs::write(&key_path, &key_pem)?; + println!("created new CA certificate at {}", cert_path.display()); + Ok((cert_pem, key_pem)) +} + +fn android_ca_filename(common_name: &str) -> String { + let mut canonical = Vec::new(); + canonical.extend_from_slice(&[0x06, 0x03, 0x55, 0x04, 0x03]); + canonical.push(0x0C); + canonical.push(common_name.len() as u8); + canonical.extend_from_slice(common_name.as_bytes()); + + let digest = Md5::digest(&canonical); + let hash = u32::from_le_bytes([digest[0], digest[1], digest[2], digest[3]]); + format!("{hash:08x}.0") +} + +fn inject_ca(config: &CaptureConfig, cert_pem: &str, project_root: &Path) -> Result<()> { + let filename = android_ca_filename(CA_COMMON_NAME); + let store_path = format!("/system/etc/security/cacerts/{filename}"); + let staging_path = "/data/local/tmp/topdrives-ca.pem"; + let local_path = project_root.join("ca").join("topdrives-ca.pem"); + std::fs::write(&local_path, cert_pem)?; + + let presence = adb_shell( + config, + &format!("test -f {store_path} && echo present || echo absent"), + )?; + if presence.trim() == "present" { + println!("CA already present in the system trust store"); + return Ok(()); + } + + adb( + config, + &["push", local_path.to_string_lossy().as_ref(), staging_path], + )?; + adb_shell_root( + config, + "mkdir -p /data/local/tmp/cacerts-backup && cp /system/etc/security/cacerts/* /data/local/tmp/cacerts-backup/", + )?; + + let mounts = adb_shell(config, "cat /proc/mounts")?; + if !mounts.contains("/system/etc/security/cacerts") { + adb_shell_root(config, "mount -t tmpfs tmpfs /system/etc/security/cacerts")?; + } + + adb_shell_root( + config, + &format!( + "cp /data/local/tmp/cacerts-backup/* /system/etc/security/cacerts/ && cp {staging_path} {store_path} && chmod 644 {store_path}" + ), + )?; + + let count_text = adb_shell(config, "ls /system/etc/security/cacerts | wc -l")?; + let count: u32 = count_text + .trim() + .parse() + .map_err(|_| "could not count certificates in the store".to_string())?; + if count < 100 { + return Err(format!("system store looks emptied ({count} certificates), aborting").into()); + } + println!("injected CA into the system trust store as {filename} ({count} certificates total)"); + Ok(()) +} + +fn set_device_proxy(config: &CaptureConfig, port: u16) -> Result<()> { + adb_shell(config, &format!("settings put global http_proxy 127.0.0.1:{port}")).map(|_| ()) +} + +fn clear_device_proxy(config: &CaptureConfig) { + let _ = adb_shell(config, "settings put global http_proxy :0"); + let _ = adb_shell(config, "settings delete global http_proxy"); +} + +fn setup_adb_reverse(config: &CaptureConfig, port: u16) -> Result<()> { + let socket = format!("tcp:{port}"); + match adb(config, &["reverse", socket.as_str(), socket.as_str()]) { + Ok(_) => Ok(()), + Err(_) => { + let route = adb_shell(config, "ip route")?; + let gateway = route.lines().find_map(|line| { + line.strip_prefix("default via ") + .and_then(|rest| rest.split(' ').next()) + }); + match gateway { + Some(gateway) => { + println!("adb reverse failed, falling back to gateway address {gateway}:{port}"); + adb_shell(config, &format!("settings put global http_proxy {gateway}:{port}")).map(|_| ()) + } + None => Err("could not set up adb reverse and no default gateway found".into()), + } + } + } +} + +fn launch_game(config: &CaptureConfig) -> Result<()> { + adb_shell(config, &format!("am force-stop {GAME_PACKAGE}"))?; + std::thread::sleep(Duration::from_secs(2)); + adb( + config, + &[ + "shell", + "monkey", + "-p", + GAME_PACKAGE, + "-c", + "android.intent.category.LAUNCHER", + "1", + ], + )?; + Ok(()) +} + +fn build_proxy( + listener: tokio::net::TcpListener, + cert_pem: &str, + key_pem: &str, + handler: CaptureHandler, +) -> Result { + let key_pair = KeyPair::from_pem(key_pem)?; + let issuer = Issuer::from_ca_cert_pem(cert_pem, key_pair)?; + let provider = hudsucker::rustls::crypto::aws_lc_rs::default_provider(); + let authority = RcgenAuthority::new(issuer, 1_000, provider); + + let proxy = Proxy::builder() + .with_listener(listener) + .with_ca(authority) + .with_rustls_connector(hudsucker::rustls::crypto::aws_lc_rs::default_provider()) + .with_http_handler(handler) + .build()?; + Ok(Box::pin(proxy.start())) +} + +type ProxyFuture = + std::pin::Pin> + Send>>; + +async fn command_run(config: &CaptureConfig) -> Result<()> { + let project_root = std::env::current_dir()?; + let (cert_pem, key_pem) = load_or_create_ca(&project_root)?; + + ensure_device_connected(config)?; + let sdk_level = android_sdk_level(config)?; + if sdk_level > 28 { + println!("warning: Android {sdk_level} detected, the tmpfs CA injection is only tested up to Android 9"); + } + inject_ca(config, &cert_pem, &project_root)?; + + let listener = tokio::net::TcpListener::bind(std::net::SocketAddr::from(( + [127, 0, 0, 1], + config.proxy.port, + ))) + .await + .map_err(|error| { + format!( + "cannot listen on 127.0.0.1:{} (HTTP Toolkit still running?): {error}", + config.proxy.port + ) + })?; + + let state = Arc::new(CaptureState { + found: AtomicBool::new(false), + done: tokio::sync::Notify::new(), + project_root: project_root.clone(), + output: output_path(config, &project_root), + pending: std::sync::Mutex::new(None), + }); + let proxy = build_proxy(listener, &cert_pem, &key_pem, CaptureHandler { state: state.clone() })?; + tokio::spawn(proxy); + + set_device_proxy(config, config.proxy.port)?; + setup_adb_reverse(config, config.proxy.port)?; + println!( + "proxy active on 127.0.0.1:{}, launching {GAME_PACKAGE} ...", + config.proxy.port + ); + launch_game(config)?; + + println!( + "waiting for the garage request (timeout {}s, Ctrl+C to abort)", + config.capture.timeout_secs + ); + let outcome: Result<()> = tokio::select! { + _ = state.done.notified() => Ok(()), + _ = tokio::time::sleep(Duration::from_secs(config.capture.timeout_secs)) => Err("timed out waiting for the garage request".into()), + _ = tokio::signal::ctrl_c() => Err("aborted".into()), + }; + + clear_device_proxy(config); + let remove_socket = format!("--remove tcp:{}", config.proxy.port); + let _ = adb(config, &["reverse", remove_socket.as_str()]); + println!("device proxy settings restored"); + + match outcome { + Ok(()) => { + println!("capture finished, run `cargo run --bin records -- best` to analyze"); + Ok(()) + } + Err(message) => Err(message), + } +} + +async fn command_setup(config: &CaptureConfig) -> Result<()> { + let project_root = std::env::current_dir()?; + let (cert_pem, _) = load_or_create_ca(&project_root)?; + ensure_device_connected(config)?; + let sdk_level = android_sdk_level(config)?; + println!("device {} connected, Android SDK {}", config.device.serial, sdk_level); + inject_ca(config, &cert_pem, &project_root)?; + Ok(()) +} + +async fn command_replay(config: &CaptureConfig) -> Result<()> { + let project_root = std::env::current_dir()?; + let request_path = project_root.join("captures").join("request.json"); + if !request_path.exists() { + return Err("no captured request found, run `capture run` first".into()); + } + let template: CapturedRequest = serde_json::from_str(&std::fs::read_to_string(&request_path)?)?; + + let mut headers = http::HeaderMap::new(); + for (name, value) in &template.headers { + let lower = name.to_lowercase(); + if matches!( + lower.as_str(), + "host" | "content-length" | "accept-encoding" | "connection" | "proxy-connection" | "keep-alive" + ) { + continue; + } + headers.insert( + http::HeaderName::from_bytes(name.as_bytes())?, + http::HeaderValue::from_bytes(value.as_bytes())?, + ); + } + + let body = template + .body + .as_deref() + .map(|encoded| base64::engine::general_purpose::STANDARD.decode(encoded)) + .transpose()? + .unwrap_or_default(); + + let method: http::Method = template.method.parse()?; + let timeout = Duration::from_secs(config.capture.replay_timeout_secs); + let http = reqwest::Client::builder().timeout(timeout).build()?; + + println!("replaying {} {}", method, template.url); + let bytes = fetch_response(&http, &method, &template.url, &headers, &body, timeout).await?; + if !contains_marker(&bytes, GARAGE_MARKER) { + return Err( + "response no longer contains the garage document, the session token probably expired; run `capture run` again".into(), + ); + } + let json: serde_json::Value = serde_json::from_slice(&bytes)?; + persist_capture( + &project_root, + &output_path(config, &project_root), + &json, + Some(&template), + )?; + println!("replay finished successfully"); + Ok(()) +} + +async fn command_clean(config: &CaptureConfig) -> Result<()> { + ensure_device_connected(config)?; + clear_device_proxy(config); + let remove_socket = format!("--remove tcp:{}", config.proxy.port); + let _ = adb(config, &["reverse", remove_socket.as_str()]); + let filename = android_ca_filename(CA_COMMON_NAME); + let _ = adb_shell_root(config, &format!("rm -f /system/etc/security/cacerts/{filename}")); + let _ = adb_shell_root(config, "umount /system/etc/security/cacerts"); + println!("proxy settings cleared and CA removed from the device"); + Ok(()) +} + +fn print_usage() { + println!( + "usage: capture + +commands: + setup generate the CA and inject it into BlueStacks + run full flow: start BlueStacks if needed, proxy, game launch, garage capture + replay re-issue the stored garage request without the emulator + clean remove proxy settings and the injected CA + +configuration is read from {CONFIG_FILE} in the working directory" + ); +} + +#[tokio::main] +pub(crate) async fn run() { + let command = std::env::args().nth(1).unwrap_or_default(); + if command.is_empty() { + print_usage(); + return; + } + + let project_root = std::env::current_dir().expect("cannot determine working directory"); + let config = match load_config(&project_root) { + Ok(config) => config, + Err(error) => { + eprintln!("error reading {CONFIG_FILE}: {error}"); + std::process::exit(1); + } + }; + + let result = match command.as_str() { + "setup" => command_setup(&config).await, + "run" => command_run(&config).await, + "replay" => command_replay(&config).await, + "clean" => command_clean(&config).await, + _ => { + print_usage(); + return; + } + }; + if let Err(error) = result { + eprintln!("error: {error}"); + std::process::exit(1); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn marker_detection_finds_nested_json_key() { + let body = br#"{"serverId":"x","playerDeck":[{"cardId":"a"}]}"#; + assert!(contains_marker(body, GARAGE_MARKER)); + assert!(!contains_marker(b"{}", GARAGE_MARKER)); + } + + #[test] + fn android_ca_filename_is_openssl_style_hash() { + let filename = android_ca_filename(CA_COMMON_NAME); + assert_eq!(filename.len(), 10); + assert!(filename.ends_with(".0")); + assert!(filename[..8] + .chars() + .all(|character| character.is_ascii_hexdigit())); + } + + #[test] + fn noise_hosts_are_filtered() { + assert!(is_noise_host("ads.google.com")); + assert!(is_noise_host("config.firebase.io")); + assert!(!is_noise_host("api.hutchgames.com")); + assert!(!is_noise_host("topdrives-api.example.com")); + } + + #[test] + fn default_config_round_trips_through_toml() { + let config = CaptureConfig::default(); + let text = toml::to_string_pretty(&config).unwrap(); + let parsed: CaptureConfig = toml::from_str(&text).unwrap(); + assert_eq!(parsed.device.serial, config.device.serial); + assert_eq!(parsed.paths.output, config.paths.output); + assert_eq!(parsed.proxy.port, config.proxy.port); + } +}