commit 872d27a4e46c773b629069ac8bf15a1c83c6ceb3 Author: Yoshihiro OKUMURA Date: Fri Apr 17 16:52:04 2026 +0900 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..41cdc9a --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Build output +/target + +# Dependency lock file (include for binaries, exclude for libraries) +# Cargo.lock is intentionally kept for binary crates to ensure reproducible builds. +# Uncomment the next line if this were a library crate: +# Cargo.lock + +# Editor and IDE files +.idea/ +.vscode/ +*.iml +*.swp +*.swo +*~ + +# OS-generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +Thumbs.db +ehthumbs.db + +# Environment variable overrides (may contain secrets) +.env +.env.local +.env.*.local + +# Rust toolchain overrides +rust-toolchain +rust-toolchain.toml + +# Benchmark and profiling artifacts +perf.data +perf.data.old +flamegraph.svg diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..0ce146c --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2951 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[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 = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "borsh" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byte-unit" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6d47a4e2961fb8721bcfc54feae6455f2f64e7054f9bc67e875f0e77f4c58d" +dependencies = [ + "rust_decimal", + "schemars", + "serde", + "utf8-width", +] + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "configparser" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57e3272f0190c3f1584272d613719ba5fc7df7f4942fe542e63d949cf3a649b" + +[[package]] +name = "cow-utils" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[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 = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix 0.31.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "educe" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4bd92664bf78c4d3dba9b7cdafce6fa15b13ed3ed16175218196942e99168a8" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "enum-ordinalize" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-cache" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mdrs-client-rust" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "clap", + "configparser", + "ctrlc", + "dirs", + "dotenvy", + "fs2", + "futures", + "os_info", + "reqwest", + "rpassword", + "serde", + "serde_json", + "sha2", + "tokio", + "validators", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nix" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[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 = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "oncemutex" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d11de466f4a3006fe8a5e7ec84e93b79c70cb992ae0aa0eb631ad2df8abfe2" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "os_info" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224" +dependencies = [ + "android_system_properties", + "log", + "nix 0.30.1", + "objc2", + "objc2-foundation", + "objc2-ui-kit", + "serde", + "windows-sys 0.61.2", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phonenumber" +version = "0.3.9+9.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9114f9c1683dd09c5f4fa024c89fdad783eaae21d3d52dd23ddaaffa29ffb168" +dependencies = [ + "either", + "fnv", + "nom", + "once_cell", + "postcard", + "quick-xml", + "regex", + "regex-cache", + "serde", + "serde_derive", + "strum", + "thiserror 2.0.18", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "serde", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax 0.8.10", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.8.10", +] + +[[package]] +name = "regex-cache" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f7b62d69743b8b94f353b6b7c3deb4c5582828328bcb8d5fedf214373808793" +dependencies = [ + "lru-cache", + "oncemutex", + "regex", + "regex-syntax 0.6.29", +] + +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rpassword" +version = "7.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.59.0", +] + +[[package]] +name = "rtoolbox" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327b72899159dfae8060c51a1f6aebe955245bcd9cc4997eed0f623caea022e4" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "rust_decimal" +version = "1.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ce901f9a19d251159075a4c37af514c3b8ef99c22e02dd8c19161cf397ee94a" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.5", + "rkyv", + "serde", + "serde_json", + "wasm-bindgen", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[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 = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "str-utils" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d0847b4b404103d7f4335ee08a8bb9608d98bfaa89d1e115055e9bfbccee662" +dependencies = [ + "cow-utils", + "unicase", + "utf8-width", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a91135f59b1cbf38c91e73cf3386fca9bb77915c45ce2771460c9d92f0f3d776" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna 1.1.0", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "validators" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57e4dd623e1c294e7d7850097c41863cda2703166c5f58c225c5ca969299fb7a" +dependencies = [ + "byte-unit", + "data-encoding", + "idna 0.5.0", + "phonenumber", + "regex", + "semver", + "serde", + "serde_json", + "str-utils", + "url", + "validators-derive", +] + +[[package]] +name = "validators-derive" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72377736834d42b3e4029d46058f7ee2dbd44340bed0e82aaffd5c395bb0e6b3" +dependencies = [ + "educe", + "enum-ordinalize", + "phonenumber", + "proc-macro2", + "quote", + "regex", + "syn 2.0.117", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..45c269c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "mdrs-client-rust" +version = "0.1.0" +edition = "2024" +license = "MIT" +authors = ["Neuroinformatics Unit, RIKEN CBS"] +description = "A command-line client for uploading and downloading files to and from an MDRS-based repository." + +[[bin]] +name = "mdrs" +path = "src/main.rs" + +[dependencies] +clap = { version = "4.5", features = ["derive"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tokio = { version = "1.37", features = ["full"] } +futures = "0.3" +dirs = "5.0" +anyhow = "1.0.102" +configparser = "3.1.0" +validators = "0.25.3" +sha2 = "0.10" +rpassword = "7.0" +base64 = "0.22" +fs2 = "0.4" +ctrlc = "3" +os_info = "3" +dotenvy = "0.15" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4cdcdd0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026- Neuroinformatics Unit, RIKEN CBS + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9130543 --- /dev/null +++ b/README.md @@ -0,0 +1,213 @@ +# mdrs + +`mdrs` is a command-line client for uploading and downloading files to and from an MDRS-based repository. + +## Installing + +### Build from source + +```shell +cargo build --release +``` + +The compiled binary will be available at `target/release/mdrs`. + +To build a fully static binary for Linux: + +```shell +cargo build --release --target x86_64-unknown-linux-musl +``` + +## Configuration + +### Environment variables + +The following environment variables can be set, either in the shell or via a `.env` file placed in the working directory: + +| Variable | Default | Description | +|---|---|---| +| `MDRS_CLIENT_CONFIG_DIRNAME` | `~/.mdrs-client` | Directory where config and login cache files are stored | +| `MDRS_CLIENT_CONCURRENT` | `10` | Number of concurrent file transfers for upload/download | + +## Example Usage + +### config create + +Register a remote host. + +```shell +mdrs config create neurodata https://neurodata.riken.jp/api +``` + +### config update + +Update the URL of a registered remote host. + +```shell +mdrs config update neurodata https://neurodata.riken.jp/api +``` + +### config list + +List registered remote hosts. + +```shell +mdrs config list +mdrs config list -l +``` + +### config delete + +Remove a registered remote host. + +```shell +mdrs config delete neurodata +``` + +### login + +Log in to a remote host and cache credentials. + +```shell +mdrs login neurodata: +Username: (enter your login name) +Password: (enter your password) + +mdrs login -u USERNAME -p PASSWORD neurodata: +``` + +### logout + +Log out from a remote host and remove cached credentials. + +```shell +mdrs logout neurodata: +``` + +### whoami + +Print the currently logged-in user name. + +```shell +mdrs whoami neurodata: +``` + +### labs + +List all laboratories accessible to the current user. + +```shell +mdrs labs neurodata: +``` + +### ls + +List the contents of a remote folder. + +```shell +mdrs ls neurodata:/NIU/Repository/ +mdrs ls -p SHARING_PASSWORD neurodata:/NIU/Repository/PW_Open/ +mdrs ls -r neurodata:/NIU/Repository/Dataset1/ +mdrs ls -J -r neurodata:/NIU/Repository/Dataset1/ +mdrs ls -q neurodata:/NIU/Repository/ +``` + +### mkdir + +Create a new remote folder. + +```shell +mdrs mkdir neurodata:/NIU/Repository/TEST +``` + +### upload + +Upload a file or directory to a remote folder. + +```shell +mdrs upload ./sample.dat neurodata:/NIU/Repository/TEST/ +mdrs upload -r ./dataset neurodata:/NIU/Repository/TEST/ +mdrs upload -r --skip-if-exists ./dataset neurodata:/NIU/Repository/TEST/ +``` + +### download + +Download a file or folder from a remote path. + +```shell +mdrs download neurodata:/NIU/Repository/TEST/sample.dat ./ +mdrs download -r neurodata:/NIU/Repository/TEST/dataset/ ./ +mdrs download -p SHARING_PASSWORD neurodata:/NIU/Repository/PW_Open/Readme.dat ./ +mdrs download -r --exclude /NIU/Repository/TEST/dataset/skip neurodata:/NIU/Repository/TEST/dataset/ ./ +mdrs download -r --skip-if-exists neurodata:/NIU/Repository/TEST/dataset/ ./ +``` + +### mv + +Move or rename a file or folder on the remote. + +```shell +mdrs mv neurodata:/NIU/Repository/TEST/sample.dat neurodata:/NIU/Repository/TEST2/sample2.dat +mdrs mv neurodata:/NIU/Repository/TEST/dataset neurodata:/NIU/Repository/TEST2/ +``` + +### cp + +Copy a file or folder on the remote. + +```shell +mdrs cp neurodata:/NIU/Repository/TEST/sample.dat neurodata:/NIU/Repository/TEST2/sample2.dat +mdrs cp -r neurodata:/NIU/Repository/TEST/dataset neurodata:/NIU/Repository/TEST2/ +``` + +### rm + +Remove a file or folder from the remote. + +```shell +mdrs rm neurodata:/NIU/Repository/TEST/sample.dat +mdrs rm -r neurodata:/NIU/Repository/TEST/dataset +``` + +### chacl + +Change the access level of a remote folder. + +```shell +mdrs chacl private neurodata:/NIU/Repository/Private +mdrs chacl cbs_open -r neurodata:/NIU/Repository/CBS_Open +mdrs chacl pw_open -r -p SHARING_PASSWORD neurodata:/NIU/Repository/PW_Open +``` + +Available access levels: `private`, `public`, `pw_open`, `cbs_open`, `5kikan_open`, `cbs_pw_open`, `5kikan_pw_open` + +### metadata + +Show metadata for a remote folder. + +```shell +mdrs metadata neurodata:/NIU/Repository/TEST/ +mdrs metadata -p SHARING_PASSWORD neurodata:/NIU/Repository/PW_Open/ +``` + +### file-metadata + +Show metadata for a remote file. + +```shell +mdrs file-metadata neurodata:/NIU/Repository/TEST/dataset/sample.dat +mdrs file-metadata -p SHARING_PASSWORD neurodata:/NIU/Repository/PW_Open/Readme.txt +``` + +### help + +Show help for a command. + +```shell +mdrs --help +mdrs upload --help +``` + +## License + +[MIT](LICENSE) © 2026- Neuroinformatics Unit, RIKEN CBS diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..0635212 --- /dev/null +++ b/build.rs @@ -0,0 +1,17 @@ +fn main() { + let output = std::process::Command::new("rustc") + .arg("--version") + .output() + .expect("Failed to run rustc --version"); + let full = String::from_utf8(output.stdout).unwrap(); + // "rustc 1.79.0 (129f3b996 2024-06-10)" → "1.79.0" + let version = full + .trim() + .trim_start_matches("rustc ") + .split_whitespace() + .next() + .unwrap_or("unknown") + .to_string(); + println!("cargo:rustc-env=RUSTC_VERSION={}", version); + println!("cargo:rerun-if-changed=build.rs"); +} diff --git a/src/api/files.rs b/src/api/files.rs new file mode 100644 index 0000000..a15d3d2 --- /dev/null +++ b/src/api/files.rs @@ -0,0 +1,59 @@ +use crate::connection::MDRSConnection; +pub use crate::models::file::File; + +#[derive(serde::Deserialize)] +struct FileListResponse { + pub next: Option, + pub results: Vec, +} + +impl MDRSConnection { + /// List all files in a folder, following pagination automatically + pub async fn list_all_files( + &self, + folder_id: &str, + ) -> Result, Box> { + let mut all_files = Vec::new(); + let mut page: u32 = 1; + loop { + let resp = self + .client + .get(self.build_url("v3/files/")) + .headers(self.prepare_headers()) + .query(&[("folder_id", folder_id), ("page", &page.to_string())]) + .send() + .await?; + let list: FileListResponse = resp.json().await?; + let has_next = list.next.is_some(); + all_files.extend(list.results); + if !has_next { + break; + } + page += 1; + } + Ok(all_files) + } + + pub async fn upload_file( + &self, + folder_id: &str, + file_path: &str, + ) -> Result<(), Box> { + use reqwest::multipart; + let file_name = std::path::Path::new(file_path) + .file_name() + .unwrap() + .to_string_lossy() + .to_string(); + let file_bytes = tokio::fs::read(file_path).await?; + let part = multipart::Part::bytes(file_bytes).file_name(file_name.clone()); + let form = multipart::Form::new() + .text("folder_id", folder_id.to_string()) + .part("file", part); + let resp = self.post_multipart("v3/files/", form).await?; + if !resp.status().is_success() { + return Err(format!("Upload failed: {}", resp.status()).into()); + } + Ok(()) + } +} diff --git a/src/api/folders.rs b/src/api/folders.rs new file mode 100644 index 0000000..cdccc32 --- /dev/null +++ b/src/api/folders.rs @@ -0,0 +1,29 @@ +use crate::connection::MDRSConnection; +pub use crate::models::folder::{FolderDetail, FolderSimple}; + +impl MDRSConnection { + /// List folders matching the given path under a laboratory (GET v3/folders/?path=...&laboratory_id=...) + pub async fn list_folders_by_path( + &self, + lab_id: u32, + path: &str, + ) -> Result, reqwest::Error> { + let resp = self + .client + .get(self.build_url("v3/folders/")) + .headers(self.prepare_headers()) + .query(&[ + ("laboratory_id", lab_id.to_string()), + ("path", path.to_string()), + ]) + .send() + .await?; + resp.json::>().await + } + + /// Retrieve full folder details including sub_folders (GET v3/folders/{id}/) + pub async fn retrieve_folder(&self, id: &str) -> Result { + let resp = self.get(&format!("v3/folders/{}/", id)).await?; + resp.json::().await + } +} diff --git a/src/api/laboratories.rs b/src/api/laboratories.rs new file mode 100644 index 0000000..f2f8060 --- /dev/null +++ b/src/api/laboratories.rs @@ -0,0 +1,25 @@ +use crate::connection::MDRSConnection; +use crate::models::laboratory::{Laboratories, Laboratory}; +use serde::Deserialize; + +/// API response is a flat list; we wrap it in `Laboratories { items }` for cache compatibility. +#[derive(Debug, Deserialize)] +struct LabListResponse { + results: Option>, +} + +impl MDRSConnection { + pub async fn list_laboratories(&self) -> Result { + let resp = self.get("v3/laboratories/").await?; + // The API may return a paginated object or a direct array + let text = resp.text().await?; + let items: Vec = if let Ok(list) = serde_json::from_str::>(&text) { + list + } else if let Ok(paged) = serde_json::from_str::(&text) { + paged.results.unwrap_or_default() + } else { + vec![] + }; + Ok(Laboratories { items }) + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..a1fa623 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,6 @@ +// API module (add users, files, folders, laboratories, etc. here) + +pub mod files; +pub mod folders; +pub mod laboratories; +pub mod users; diff --git a/src/api/users.rs b/src/api/users.rs new file mode 100644 index 0000000..286afb1 --- /dev/null +++ b/src/api/users.rs @@ -0,0 +1,57 @@ +use crate::connection::MDRSConnection; +use crate::models::user::User as ModelUser; +use serde::Deserialize; + +/// Full API response shape from GET v3/users/current/ +#[derive(Debug, Deserialize)] +struct UsersApiCurrentResponse { + id: u32, + username: String, + laboratories: Vec, + is_reviewer: bool, +} + +#[derive(Debug, Deserialize)] +struct UsersCurrentResponseLaboratory { + id: u32, +} + +#[derive(Deserialize)] +struct TokenRefreshResponse { + access: String, +} + +impl MDRSConnection { + /// Fetch current user and return the slim 4-field model matching the Python cache format. + pub async fn get_current_user(&self) -> Result { + let resp = self.get("v3/users/current/").await?; + let obj = resp.json::().await?; + let laboratory_ids = obj.laboratories.into_iter().map(|l| l.id).collect(); + Ok(ModelUser { + id: obj.id, + username: obj.username, + laboratory_ids, + is_reviewer: obj.is_reviewer, + }) + } + + /// Refresh the access token using the refresh token. + /// POST v3/users/token/refresh/ {refresh: ...} -> {access: new_access} + pub async fn token_refresh( + &self, + refresh_token: &str, + ) -> Result> { + let body = serde_json::json!({ "refresh": refresh_token }); + let resp = self + .client + .post(self.build_url("v3/users/token/refresh/")) + .json(&body) + .send() + .await?; + if !resp.status().is_success() { + return Err(format!("Token refresh failed: {}", resp.status()).into()); + } + let r: TokenRefreshResponse = resp.json().await?; + Ok(r.access) + } +} diff --git a/src/commands/chacl.rs b/src/commands/chacl.rs new file mode 100644 index 0000000..fb2f8db --- /dev/null +++ b/src/commands/chacl.rs @@ -0,0 +1,50 @@ +use crate::commands::shared::{ + create_authenticated_conn, find_folder, find_lab_in_cache, load_cache_with_token_refresh, parse_remote_path, +}; + +pub async fn chacl( + remote_path: &str, + access_level_key: &str, + recursive: bool, + password: Option<&str>, +) -> Result<(), Box> { + let access_level_id: u32 = match access_level_key { + "private" => 0x0001, + "public" => 0x0002, + "pw_open" => 0x0004, + "cbs_open" => 0x0100, + "5kikan_open" => 0x0200, + "cbs_or_pw_open" => 0x0104, + "5kikan_or_pw_open" => 0x0204, + "storage" => 0x0000, + _ => return Err(format!("Unknown access level key: '{}'", access_level_key).into()), + }; + + let (remote, labname, folder_path) = parse_remote_path(remote_path)?; + let cache = load_cache_with_token_refresh(&remote).await?; + let conn = create_authenticated_conn(&remote, &cache)?; + let lab = find_lab_in_cache(&cache, &labname)?; + let folder = find_folder(&conn, lab.id, &folder_path, None).await?; + + let mut data = serde_json::Map::new(); + data.insert("access_level".to_string(), serde_json::json!(access_level_id)); + if recursive { + data.insert("lower".to_string(), serde_json::json!(1)); + } + if let Some(pw) = password { + data.insert("password".to_string(), serde_json::json!(pw)); + } + let resp = conn + .client + .post(conn.build_url(&format!("v3/folders/{}/acl/", folder.id))) + .headers(conn.prepare_headers()) + .json(&serde_json::Value::Object(data)) + .send() + .await?; + + if !resp.status().is_success() { + return Err(format!("ACL change failed: {}", resp.status()).into()); + } + println!("ACL changed successfully for: {}", remote_path); + Ok(()) +} diff --git a/src/commands/config.rs b/src/commands/config.rs new file mode 100644 index 0000000..6701e84 --- /dev/null +++ b/src/commands/config.rs @@ -0,0 +1,163 @@ +use configparser::ini::Ini; +use std::fs; +use std::path::PathBuf; + +fn config_path() -> PathBuf { + crate::settings::SETTINGS.config_dirname.join("config.ini") +} + +fn sanitize_config_file(path: &PathBuf) -> Result<(), Box> { + if !path.exists() { + return Ok(()); + } + let text = fs::read_to_string(path)?; + let lines: Vec<&str> = text.lines().collect(); + if let Some(first) = lines.first() { + if first.trim() == path.to_string_lossy() { + // remove the first line and write atomically + let new_text = if lines.len() > 1 { + lines[1..].join("\n") + } else { + String::new() + }; + let tmp = path.with_extension("tmp"); + fs::write(&tmp, new_text.as_bytes())?; + fs::rename(&tmp, path)?; + } + } + Ok(()) +} + +fn write_ini_atomic(path: &PathBuf, ini: &Ini) -> Result<(), Box> { + let tmp = path.with_extension("tmp"); + // write to tmp path then rename for atomicity + ini.write(&tmp)?; + fs::rename(&tmp, path)?; + Ok(()) +} + +pub fn get_remote_url(remote: &str) -> Result, Box> { + let path = config_path(); + sanitize_config_file(&path)?; + let path_str = path.to_string_lossy().to_string(); + let mut conf = Ini::new(); + if path.exists() { + let _ = conf.load(&path_str)?; + } + Ok(conf.get(remote, "url")) +} + +pub fn config_create(remote: &str, url: &str) -> Result<(), Box> { + if !validate_url(url) { + return Err("Malformed URL".into()); + } + let path = config_path(); + sanitize_config_file(&path)?; + let path_str = path.to_string_lossy().to_string(); + let mut conf = Ini::new(); + if path.exists() { + let _ = conf.load(&path_str)?; + } + // check if section exists + let section_exists = conf + .get_map() + .map(|m| m.contains_key(remote)) + .unwrap_or(false); + if section_exists { + return Err(format!("Remote host `{}` already exists.", remote).into()); + } + // set url + conf.set(remote, "url", Some(url.to_string())); + // ensure parent dir exists + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + write_ini_atomic(&path, &conf)?; + println!("Created remote host `{}`.", remote); + Ok(()) +} + +pub fn config_update(remote: &str, url: &str) -> Result<(), Box> { + if !validate_url(url) { + return Err("Malformed URL".into()); + } + let path = config_path(); + sanitize_config_file(&path)?; + let path_str = path.to_string_lossy().to_string(); + let mut conf = Ini::new(); + if path.exists() { + let _ = conf.load(&path_str)?; + } + // ensure section exists + let section_exists = conf + .get_map() + .map(|m| m.contains_key(remote)) + .unwrap_or(false); + if !section_exists { + return Err(format!("Remote host `{}` does not exist.", remote).into()); + } + conf.set(remote, "url", Some(url.to_string())); + write_ini_atomic(&path, &conf)?; + println!("Updated remote host `{}`.", remote); + Ok(()) +} + +pub fn config_list(long: bool) -> Result<(), Box> { + let path = config_path(); + if !path.exists() { + println!("No config file found at {}", path.to_string_lossy()); + return Ok(()); + } + sanitize_config_file(&path)?; + let path_str = path.to_string_lossy().to_string(); + let mut conf = Ini::new(); + let _ = conf.load(&path_str)?; + let map = conf.get_map().unwrap_or_default(); + let mut printed = false; + for (sec, props) in map.iter() { + if sec == "default" { + continue; + } + if !long { + println!("{}", sec); + printed = true; + } else { + let url = props.get("url").and_then(|v| v.clone()).unwrap_or_default(); + println!("{}:\t{}", sec, url); + printed = true; + } + } + if !printed { + println!("No remotes configured"); + } + Ok(()) +} + +pub fn config_delete(remote: &str) -> Result<(), Box> { + let path = config_path(); + sanitize_config_file(&path)?; + let path_str = path.to_string_lossy().to_string(); + let mut conf = Ini::new(); + if path.exists() { + let _ = conf.load(&path_str)?; + } + // fallback: reconstruct by removing the section in memory map and writing file + let mut map = conf.get_map().unwrap_or_default(); + if map.remove(remote).is_none() { + return Err(format!("Remote host `{}` does not exist.", remote).into()); + } + // build new Ini from map + let mut new_ini = Ini::new(); + for (sec, props) in map.iter() { + for (k, v) in props.iter() { + new_ini.set(sec, k, v.clone()); + } + } + write_ini_atomic(&path, &new_ini)?; + println!("Deleted remote host `{}`.", remote); + Ok(()) +} + +fn validate_url(url: &str) -> bool { + validators::url::Url::parse(url).is_ok() +} diff --git a/src/commands/config_subcommand.rs b/src/commands/config_subcommand.rs new file mode 100644 index 0000000..8252989 --- /dev/null +++ b/src/commands/config_subcommand.rs @@ -0,0 +1,36 @@ +use clap::{Args, Subcommand}; + +#[derive(Subcommand, Debug)] +pub enum ConfigSubcommand { + /// Create a new remote host + Create(ConfigCreateArgs), + /// Update an existing remote host + Update(ConfigUpdateArgs), + /// List all remote hosts + List(ConfigListArgs), + /// Delete a remote host + Delete(ConfigDeleteArgs), +} + +#[derive(Args, Debug)] +pub struct ConfigCreateArgs { + pub remote: String, + pub url: String, +} + +#[derive(Args, Debug)] +pub struct ConfigUpdateArgs { + pub remote: String, + pub url: String, +} + +#[derive(Args, Debug)] +pub struct ConfigListArgs { + #[arg(short, long)] + pub long: bool, +} + +#[derive(Args, Debug)] +pub struct ConfigDeleteArgs { + pub remote: String, +} diff --git a/src/commands/cp.rs b/src/commands/cp.rs new file mode 100644 index 0000000..3244df9 --- /dev/null +++ b/src/commands/cp.rs @@ -0,0 +1,121 @@ +use crate::commands::shared::{ + create_authenticated_conn, find_file_by_name, find_folder, find_lab_in_cache, load_cache_with_token_refresh, + parse_remote_path, +}; + +pub async fn cp( + src_path: &str, + dest_path: &str, + recursive: bool, +) -> Result<(), Box> { + let (s_remote, s_lab, s_path) = parse_remote_path(src_path)?; + let dest_ends_with_slash = dest_path.ends_with('/'); + let (d_remote, d_lab, d_path) = parse_remote_path(dest_path)?; + + if s_remote != d_remote { + return Err("Source and destination must use the same remote.".into()); + } + if s_lab != d_lab { + return Err("Source and destination must be in the same laboratory.".into()); + } + + let cache = load_cache_with_token_refresh(&s_remote).await?; + let conn = create_authenticated_conn(&s_remote, &cache)?; + let lab = find_lab_in_cache(&cache, &s_lab)?; + let lab_id = lab.id; + + // Split source path into parent directory and target name + let (s_dirname, s_basename) = split_path(&s_path); + + // If dest ends with '/', treat it as a directory and preserve src basename + let (d_dirname, d_basename) = if dest_ends_with_slash { + (d_path.clone(), s_basename.clone()) + } else { + split_path(&d_path) + }; + + let s_parent_folder = find_folder(&conn, lab_id, &s_dirname, None).await?; + let s_parent_files = conn.list_all_files(&s_parent_folder.id).await?; + + let d_parent_folder = find_folder(&conn, lab_id, &d_dirname, None).await?; + let d_parent_files = conn.list_all_files(&d_parent_folder.id).await?; + + // Try source as a file first + if let Some(src_file) = find_file_by_name(&s_parent_files, &s_basename) { + let src_file_id = src_file.id.clone(); + if find_file_by_name(&d_parent_files, &d_basename).is_some() { + return Err(format!("File `{}` already exists.", d_basename).into()); + } + if d_parent_folder + .sub_folders + .iter() + .any(|f| f.name.to_lowercase() == d_basename.to_lowercase()) + { + return Err("Cannot overwrite non-folder with folder.".into()); + } + let body = serde_json::json!({"folder": d_parent_folder.id, "name": d_basename}); + let resp = conn + .client + .post(conn.build_url(&format!("v3/files/{}/copy/", src_file_id))) + .headers(conn.prepare_headers()) + .json(&body) + .send() + .await?; + if !resp.status().is_success() { + return Err(format!("Copy failed: {}", resp.status()).into()); + } + println!("Copied: {} -> {}", src_path, dest_path); + return Ok(()); + } + + // Try source as a folder + if let Some(src_folder) = s_parent_folder + .sub_folders + .iter() + .find(|f| f.name.to_lowercase() == s_basename.to_lowercase()) + { + if !recursive { + return Err( + format!("{}: is a folder (use -r to copy folders)", src_path).into(), + ); + } + let src_folder_id = src_folder.id.clone(); + if find_file_by_name(&d_parent_files, &d_basename).is_some() { + return Err(format!("File `{}` already exists.", d_basename).into()); + } + if d_parent_folder + .sub_folders + .iter() + .any(|f| f.name.to_lowercase() == d_basename.to_lowercase()) + { + return Err("Folder not empty.".into()); + } + let body = serde_json::json!({"parent": d_parent_folder.id, "name": d_basename}); + let resp = conn + .client + .post(conn.build_url(&format!("v3/folders/{}/copy/", src_folder_id))) + .headers(conn.prepare_headers()) + .json(&body) + .send() + .await?; + if !resp.status().is_success() { + return Err(format!("Copy failed: {}", resp.status()).into()); + } + println!("Copied: {} -> {}", src_path, dest_path); + return Ok(()); + } + + Err(format!("Source `{}` not found.", src_path).into()) +} + +/// Split a path into (parent_dir, basename). +/// e.g. "/path/to/item" -> ("/path/to", "item") +fn split_path(path: &str) -> (String, String) { + let path = path.trim_end_matches('/'); + if let Some(pos) = path.rfind('/') { + let d = if pos == 0 { "/" } else { &path[..pos] }; + (d.to_string(), path[pos + 1..].to_string()) + } else { + ("/".to_string(), path.to_string()) + } +} diff --git a/src/commands/download.rs b/src/commands/download.rs new file mode 100644 index 0000000..fde254e --- /dev/null +++ b/src/commands/download.rs @@ -0,0 +1,187 @@ +use crate::commands::shared::{ + create_authenticated_conn, find_file_by_name, find_folder, find_lab_in_cache, load_cache_with_token_refresh, + parse_remote_path, +}; +use crate::connection::MDRSConnection; +use futures::stream::{FuturesUnordered, StreamExt}; +use std::path::Path; +use std::sync::Arc; + +pub async fn download( + remote_path: &str, + local_path: &str, + recursive: bool, + skip_if_exists: bool, + password: Option<&str>, + excludes: Vec, +) -> Result<(), Box> { + let (remote, labname, r_path) = parse_remote_path(remote_path)?; + let cache = load_cache_with_token_refresh(&remote).await?; + let conn = Arc::new(create_authenticated_conn(&remote, &cache)?); + let lab = find_lab_in_cache(&cache, &labname)?; + + // Validate that local_path is an existing directory (matching Python's behaviour). + let local_real = std::fs::canonicalize(local_path) + .map_err(|_| format!("Local directory `{}` not found.", local_path))?; + if !local_real.is_dir() { + return Err(format!("Local directory `{}` not found.", local_path).into()); + } + let local_dir_base = local_real.to_string_lossy().to_string(); + + // Split r_path into the parent directory path and the target basename. + // Trailing slashes are already stripped by parse_remote_path, so this is safe. + let r_path_clean = r_path.trim_end_matches('/'); + let (parent_path, basename) = match r_path_clean.rfind('/') { + Some(0) => ("/".to_string(), r_path_clean[1..].to_string()), + Some(pos) => ( + r_path_clean[..pos].to_string(), + r_path_clean[pos + 1..].to_string(), + ), + None => ("/".to_string(), r_path_clean.to_string()), + }; + + let parent_folder = find_folder(&conn, lab.id, &parent_path, password).await?; + let files = conn.list_all_files(&parent_folder.id).await?; + + // Case 1: basename matches a file in the parent folder. + if let Some(file) = find_file_by_name(&files, &basename) { + if is_excluded(&excludes, &lab.name, &parent_folder.path, Some(&file.name)) { + return Ok(()); + } + // Python always places the downloaded file inside the local directory. + let dest = format!("{}/{}", local_dir_base, file.name); + if skip_if_exists { + if Path::new(&dest).exists() { + if let Ok(meta) = std::fs::metadata(&dest) { + if meta.len() == file.size { + println!("{}", dest); + return Ok(()); + } + } + } + } + let url = make_absolute_url(&conn, &file.download_url); + conn.download_file(&url, &dest).await?; + println!("{}", dest); + return Ok(()); + } + + // Case 2: basename matches a sub-folder. + let subfolder = parent_folder + .sub_folders + .iter() + .find(|f| f.name.to_lowercase() == basename.to_lowercase()); + if let Some(sub) = subfolder { + if !recursive { + return Err(format!("Cannot download `{}`: Is a folder.", r_path_clean).into()); + } + // Python downloads into local_path// (not directly into local_path). + // We create that subdirectory first, then recurse into it. + let top_local = format!("{}/{}", local_dir_base, sub.name); + + // Iterative DFS: each entry is (remote_folder_id, local_dir) + let mut stack: Vec<(String, String)> = vec![(sub.id.clone(), top_local)]; + + while let Some((folder_id, local_dir)) = stack.pop() { + let folder = conn.retrieve_folder(&folder_id).await?; + + if is_excluded(&excludes, &lab.name, &folder.path, None) { + continue; + } + + tokio::fs::create_dir_all(&local_dir).await?; + println!("{}", local_dir); + + let dir_files = conn.list_all_files(&folder_id).await?; + + // Download files in this folder (up to 10 concurrent). + let mut futs: FuturesUnordered> = + FuturesUnordered::new(); + for f in &dir_files { + if is_excluded(&excludes, &lab.name, &folder.path, Some(&f.name)) { + continue; + } + let dest_path = format!("{}/{}", local_dir, f.name); + if skip_if_exists { + if Path::new(&dest_path).exists() { + if let Ok(meta) = std::fs::metadata(&dest_path) { + if meta.len() == f.size { + println!("{}", dest_path); + continue; + } + } + } + } + let url = make_absolute_url(&conn, &f.download_url); + let conn = conn.clone(); + let _fname = f.name.clone(); + futs.push(tokio::spawn(async move { + match conn.download_file(&url, &dest_path).await { + Ok(_) => println!("{}", dest_path), + Err(_) => { + eprintln!("Failed: {}", dest_path); + if Path::new(&dest_path).is_file() { + let _ = std::fs::remove_file(&dest_path); + } + } + } + })); + if futs.len() >= crate::settings::SETTINGS.concurrent { + let _ = futs.next().await; + } + } + while futs.next().await.is_some() {} + + // Push sub-folders onto the stack for recursive processing. + for sf in &folder.sub_folders { + if sf.lock { + match password { + Some(pw) => { + if conn.folder_auth(&sf.id, pw).await.is_err() { + continue; + } + } + None => continue, + } + } + let sub_local = format!("{}/{}", local_dir, sf.name); + stack.push((sf.id.clone(), sub_local)); + } + } + return Ok(()); + } + + Err(format!("File or folder `{}` not found.", r_path_clean).into()) +} + +/// Return true if the given lab/folder/file path matches any exclude pattern. +/// Constructs: `/{lab_name}{folder_path}{file_name}` lowercased, trailing slash stripped. +/// `folder_path` is expected to already start (and end) with "/". +fn is_excluded(excludes: &[String], lab_name: &str, folder_path: &str, file_name: Option<&str>) -> bool { + if excludes.is_empty() { + return false; + } + let path = format!( + "/{}{}{}", + lab_name, + folder_path, + file_name.unwrap_or("") + ) + .trim_end_matches('/') + .to_lowercase(); + excludes.iter().any(|e| e == &path) +} + +/// Return an absolute URL for a file download. +/// If the API returns a relative path, prepend the connection's base URL. +fn make_absolute_url(conn: &MDRSConnection, url: &str) -> String { + if url.starts_with("http") { + url.to_string() + } else { + format!( + "{}/{}", + conn.url.trim_end_matches('/'), + url.trim_start_matches('/') + ) + } +} diff --git a/src/commands/file_metadata.rs b/src/commands/file_metadata.rs new file mode 100644 index 0000000..7859b11 --- /dev/null +++ b/src/commands/file_metadata.rs @@ -0,0 +1,33 @@ +use crate::commands::shared::{ + create_authenticated_conn, find_file_by_name, find_folder, find_lab_in_cache, load_cache_with_token_refresh, + parse_remote_path, +}; + +pub async fn file_metadata(remote_path: &str, password: Option<&str>) -> Result<(), Box> { + let (remote, labname, r_path) = parse_remote_path(remote_path)?; + + let cache = load_cache_with_token_refresh(&remote).await?; + let conn = create_authenticated_conn(&remote, &cache)?; + let lab = find_lab_in_cache(&cache, &labname)?; + let lab_id = lab.id; + + // Split the file path into parent directory and filename + let path = r_path.trim_end_matches('/'); + let (dirname, basename) = if let Some(pos) = path.rfind('/') { + let d = if pos == 0 { "/" } else { &path[..pos] }; + (d.to_string(), path[pos + 1..].to_string()) + } else { + ("/".to_string(), path.to_string()) + }; + + let parent_folder = find_folder(&conn, lab_id, &dirname, password).await?; + let files = conn.list_all_files(&parent_folder.id).await?; + + let file = find_file_by_name(&files, &basename) + .ok_or_else(|| format!("File `{}` not found.", basename))?; + + let resp = conn.get(&format!("v3/files/{}/metadata/", file.id)).await?; + let json: serde_json::Value = resp.json().await?; + println!("{}", serde_json::to_string_pretty(&json)?); + Ok(()) +} diff --git a/src/commands/labs.rs b/src/commands/labs.rs new file mode 100644 index 0000000..0a6de86 --- /dev/null +++ b/src/commands/labs.rs @@ -0,0 +1,56 @@ +use crate::connection::MDRSConnection; + +use std::fs; +use std::sync::Arc; + +pub async fn labs( + conn: Arc, + remote_label: &str, +) -> Result<(), Box> { + // Try API first + match conn.list_laboratories().await { + Ok(labs) => { + println!("Laboratories:"); + for lab in labs.items { + println!( + " {} (PI: {}, Full: {})", + lab.name, lab.pi_name, lab.full_name + ); + } + return Ok(()); + } + Err(_) => { + // fallback to cache + } + } + + // fallback: read cache file using remote_label + let cache_path = crate::settings::SETTINGS + .config_dirname + .join("cache") + .join(format!("{}.json", remote_label)); + if !cache_path.exists() { + println!("No laboratories available (API failed and no cache)"); + return Ok(()); + } + let text = fs::read_to_string(&cache_path)?; + let v: serde_json::Value = serde_json::from_str(&text)?; + // Cache stores laboratories as `{"items": [...]}` (Python-compatible format) + let labs_arr = v + .get("laboratories") + .and_then(|l| l.get("items")) + .and_then(|a| a.as_array()); + if let Some(arr) = labs_arr { + println!("Laboratories (from cache):"); + for lab in arr { + let name = lab.get("name").and_then(|s| s.as_str()).unwrap_or(""); + let pi = lab.get("pi_name").and_then(|s| s.as_str()).unwrap_or(""); + let full = lab.get("full_name").and_then(|s| s.as_str()).unwrap_or(""); + println!(" {} (PI: {}, Full: {})", name, pi, full); + } + } else { + println!("No laboratories found in cache"); + } + Ok(()) +} + diff --git a/src/commands/login.rs b/src/commands/login.rs new file mode 100644 index 0000000..033badc --- /dev/null +++ b/src/commands/login.rs @@ -0,0 +1,151 @@ +use crate::commands::shared::{CacheLaboratory, CacheLabsWrapper, CacheUser, compute_digest}; +use crate::connection::MDRSConnection; +use crate::models::laboratory::Laboratories; +use crate::models::user::User; +use reqwest::Client; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::error::Error; +use std::fs; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +#[derive(Deserialize)] +struct TokenResp { + access: String, + refresh: String, +} + +/// Convert an API `User` into a `CacheUser` (same fields, different type). +fn to_cache_user(u: &User) -> CacheUser { + CacheUser { + id: u.id, + username: u.username.clone(), + laboratory_ids: u.laboratory_ids.clone(), + is_reviewer: u.is_reviewer, + } +} + +/// Convert API `Laboratories` into `CacheLabsWrapper` (all four fields already present). +fn to_cache_labs(labs: &Laboratories) -> CacheLabsWrapper { + CacheLabsWrapper { + items: labs + .items + .iter() + .map(|l| CacheLaboratory { + id: l.id, + name: l.name.clone(), + pi_name: l.pi_name.clone(), + full_name: l.full_name.clone(), + }) + .collect(), + } +} + +pub async fn login( + username: &str, + password: &str, + remote: &str, +) -> Result<(), Box> { + // resolve remote label to URL from config + let url_opt = crate::commands::config::get_remote_url(remote)?; + let base_url = url_opt.ok_or(format!("Remote host `{}` is not configured", remote))?; + let conn0 = MDRSConnection::new(&base_url); + let client = Client::new(); + let url = conn0.build_url("v3/users/token/"); + let params = [("username", username), ("password", password)]; + let resp_res = client.post(&url).form(¶ms).send().await; + let resp = match resp_res { + Ok(r) => r, + Err(e) => { + let src = e.source(); + return Err(format!( + "Login failed sending request to {}: {} (source: {:?})", + url, e, src + ) + .into()); + } + }; + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(format!("Login failed: {} - {}", status, body).into()); + } + let token: TokenResp = resp.json().await?; + + // fetch current user and laboratories with the new access token + let mut conn = MDRSConnection::new(&base_url); + conn.token = Some(token.access.clone()); + let user_opt: Option = conn.get_current_user().await.ok(); + let labs: Laboratories = conn.list_laboratories().await.unwrap_or_default(); + + // convert to cache types (all four Laboratory fields required for digest) + let cache_user_opt: Option = user_opt.as_ref().map(to_cache_user); + let cache_labs = to_cache_labs(&labs); + + // compute Python-compatible digest + let digest = compute_digest( + cache_user_opt.as_ref(), + &token.access, + &token.refresh, + &cache_labs, + ); + + // build the cache JSON — field order matches Python's dataclass layout: + // user (id, username, laboratory_ids, is_reviewer) + // token (access, refresh) + // laboratories (items) + // digest + let user_val: Value = match &cache_user_opt { + Some(u) => json!({ + "id": u.id, + "username": u.username, + "laboratory_ids": u.laboratory_ids, + "is_reviewer": u.is_reviewer + }), + None => Value::Null, + }; + let labs_items: Vec = cache_labs + .items + .iter() + .map(|l| { + json!({ + "id": l.id, + "name": l.name, + "pi_name": l.pi_name, + "full_name": l.full_name + }) + }) + .collect(); + let obj = json!({ + "user": user_val, + "token": {"access": token.access, "refresh": token.refresh}, + "laboratories": {"items": labs_items}, + "digest": digest + }); + + // write cache file: {config_dirname}/cache/.json + let cache_dir = crate::settings::SETTINGS.config_dirname.join("cache"); + fs::create_dir_all(&cache_dir)?; + #[cfg(unix)] + { + let mut perms = fs::metadata(&cache_dir)?.permissions(); + perms.set_mode(0o700); + fs::set_permissions(&cache_dir, perms)?; + } + let cache_file = cache_dir.join(format!("{}.json", remote)); + let tmp = cache_file.with_extension("tmp"); + + fs::write(&tmp, serde_json::to_vec_pretty(&obj)?)?; + #[cfg(unix)] + { + let mut perms = fs::metadata(&tmp)?.permissions(); + perms.set_mode(0o600); + fs::set_permissions(&tmp, perms)?; + } + fs::rename(&tmp, &cache_file)?; + + println!("Login successful and cached for {}.", remote); + Ok(()) +} + diff --git a/src/commands/logout.rs b/src/commands/logout.rs new file mode 100644 index 0000000..d844824 --- /dev/null +++ b/src/commands/logout.rs @@ -0,0 +1,15 @@ +use std::fs; + +pub fn logout(remote: &str) -> Result<(), Box> { + let cache_path = crate::settings::SETTINGS + .config_dirname + .join("cache") + .join(format!("{}.json", remote)); + if cache_path.exists() { + fs::remove_file(&cache_path)?; + println!("Logged out from {}", remote); + } else { + println!("No login cache found for {}", remote); + } + Ok(()) +} diff --git a/src/commands/ls.rs b/src/commands/ls.rs new file mode 100644 index 0000000..febeeb5 --- /dev/null +++ b/src/commands/ls.rs @@ -0,0 +1,315 @@ +use crate::models::file::File; +use crate::models::folder::{FolderDetail, FolderSimple}; +use crate::commands::shared::{ + create_authenticated_conn, find_folder, find_lab_in_cache, fmt_datetime, + load_cache_with_token_refresh, parse_remote_path, +}; +use crate::connection::MDRSConnection; +use serde_json::{json, Value}; +use std::future::Future; +use std::pin::Pin; + +pub async fn ls( + remote_path: &str, + password: Option<&str>, + is_json: bool, + is_recursive: bool, + is_quick: bool, +) -> Result<(), Box> { + let (remote, labname, path) = parse_remote_path(remote_path)?; + let cache = load_cache_with_token_refresh(&remote).await?; + let conn = create_authenticated_conn(&remote, &cache)?; + let lab = find_lab_in_cache(&cache, &labname)?; + + let folder = find_folder(&conn, lab.id, &path, password).await?; + + if is_json { + let output = if is_recursive { + build_folder_json_recursive(&conn, folder, &labname).await? + } else { + build_folder_json_flat(&conn, &folder, &labname).await? + }; + println!("{}", serde_json::to_string_pretty(&output)?); + } else if is_recursive { + let prefix = format!("{}:/{}", remote, labname); + ls_plain_recursive(&conn, folder, &labname, &prefix, password).await?; + } else { + let files = conn.list_all_files(&folder.id).await?; + let mut sub_folders = folder.sub_folders.clone(); + sub_folders.sort_by(|a, b| a.name.cmp(&b.name)); + let mut files_sorted = files; + files_sorted.sort_by(|a, b| a.name.cmp(&b.name)); + print_folder_plain( + &sub_folders, + &files_sorted, + folder.access_level_name(), + &labname, + !is_quick, + ); + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Plain-text output helpers +// --------------------------------------------------------------------------- + +fn print_folder_plain( + sub_folders: &[FolderSimple], + files: &[File], + folder_access: &str, + labname: &str, + show_header: bool, +) { + let header = ("Type", "Access", "Laboratory", "Size", "Date", "Name"); + let mut w_type = header.0.len(); + let mut w_access = header.1.len(); + let w_lab = header.2.len().max(labname.len()); + let mut w_size = header.3.len(); + let mut w_date = header.4.len(); + let mut w_name = header.5.len(); + + // [d], [l], [f] are each 3 chars; ensure w_type accommodates them + w_type = w_type.max(3); + + for sf in sub_folders { + w_access = w_access.max(sf.access_level_name().len()); + w_size = w_size.max(sf.size.to_string().len()); + w_date = w_date.max(fmt_datetime(&sf.updated_at).len()); + w_name = w_name.max(sf.name.len()); + } + for f in files { + w_access = w_access.max(folder_access.len()); + w_size = w_size.max(f.size.to_string().len()); + w_date = w_date.max(fmt_datetime(&f.updated_at).len()); + w_name = w_name.max(f.name.len()); + } + + if show_header { + println!( + "{:w_size$} {:w_size$} {:w_size$} {:( + conn: &'a MDRSConnection, + folder: FolderDetail, + labname: &'a str, + prefix: &'a str, + password: Option<&'a str>, +) -> Pin>> + 'a>> { + Box::pin(async move { + let files = conn.list_all_files(&folder.id).await?; + let total_size: u64 = files.iter().map(|f| f.size).sum(); + + println!("{}{}:", prefix, folder.path); + println!("total {}", total_size); + + let access = folder.access_level_name(); + let mut sub_folders = folder.sub_folders.clone(); + sub_folders.sort_by(|a, b| a.name.cmp(&b.name)); + let mut files_sorted = files; + files_sorted.sort_by(|a, b| a.name.cmp(&b.name)); + + print_folder_plain(&sub_folders, &files_sorted, access, labname, false); + + for sf in sub_folders { + if sf.lock { + match password { + None => continue, + Some(pw) => { + if conn.folder_auth(&sf.id, pw).await.is_err() { + continue; + } + } + } + } + match conn.retrieve_folder(&sf.id).await { + Ok(sub_detail) => { + ls_plain_recursive(conn, sub_detail, labname, prefix, password).await?; + } + Err(_) => continue, + } + } + + Ok(()) + }) +} + +// --------------------------------------------------------------------------- +// JSON output helpers +// --------------------------------------------------------------------------- + +async fn get_folder_metadata( + conn: &MDRSConnection, + folder_id: &str, +) -> Result> { + let resp = conn + .get(&format!("v3/folders/{}/metadata/", folder_id)) + .await?; + if resp.status().is_success() { + Ok(resp.json::().await?) + } else { + Ok(json!({})) + } +} + +fn file_to_json(f: &File, base_url: &str) -> Value { + let download_url = if f.download_url.starts_with("http") { + f.download_url.clone() + } else { + format!( + "{}{}", + base_url.trim_end_matches('/'), + f.download_url + ) + }; + json!({ + "id": f.id, + "name": f.name, + "type": f.r#type, + "size": f.size, + "description": f.description, + "metadata": f.metadata, + "download_url": download_url, + "created_at": f.created_at, + "updated_at": f.updated_at, + }) +} + +fn folder_simple_to_json(sf: &FolderSimple) -> Value { + json!({ + "id": sf.id, + "pid": sf.pid, + "name": sf.name, + "access_level": sf.access_level_name(), + "lock": sf.lock, + "size": sf.size, + "laboratory_id": sf.laboratory_id, + "description": sf.description, + "created_at": sf.created_at, + "updated_at": sf.updated_at, + }) +} + +/// Build JSON for the top-level folder without recursing into sub-folders. +async fn build_folder_json_flat( + conn: &MDRSConnection, + folder: &FolderDetail, + labname: &str, +) -> Result> { + let metadata = get_folder_metadata(conn, &folder.id).await?; + let files = conn.list_all_files(&folder.id).await?; + let files_json: Vec = files.iter().map(|f| file_to_json(f, &conn.url)).collect(); + let sub_folders_json: Vec = folder + .sub_folders + .iter() + .map(folder_simple_to_json) + .collect(); + + Ok(json!({ + "id": folder.id, + "pid": folder.pid, + "name": folder.name, + "size": folder.size, + "access_level": folder.access_level_name(), + "lock": folder.lock, + "laboratory": labname, + "description": folder.description, + "created_at": folder.created_at, + "updated_at": folder.updated_at, + "metadata": metadata, + "sub_folders": sub_folders_json, + "files": files_json, + })) +} + +/// Build JSON for a folder, recursively expanding every sub-folder. +fn build_folder_json_recursive<'a>( + conn: &'a MDRSConnection, + folder: FolderDetail, + labname: &'a str, +) -> Pin>> + 'a>> { + Box::pin(async move { + let metadata = get_folder_metadata(conn, &folder.id).await?; + let files = conn.list_all_files(&folder.id).await?; + let files_json: Vec = files.iter().map(|f| file_to_json(f, &conn.url)).collect(); + + let mut sub_folders_json = Vec::new(); + for sf in &folder.sub_folders { + let sf_detail = conn.retrieve_folder(&sf.id).await?; + let sf_json = build_folder_json_recursive(conn, sf_detail, labname).await?; + sub_folders_json.push(sf_json); + } + + Ok(json!({ + "id": folder.id, + "pid": folder.pid, + "name": folder.name, + "size": folder.size, + "access_level": folder.access_level_name(), + "lock": folder.lock, + "laboratory": labname, + "description": folder.description, + "created_at": folder.created_at, + "updated_at": folder.updated_at, + "metadata": metadata, + "sub_folders": sub_folders_json, + "files": files_json, + })) + }) +} diff --git a/src/commands/metadata.rs b/src/commands/metadata.rs new file mode 100644 index 0000000..980f29e --- /dev/null +++ b/src/commands/metadata.rs @@ -0,0 +1,18 @@ +use crate::commands::shared::{ + create_authenticated_conn, find_folder, find_lab_in_cache, load_cache_with_token_refresh, parse_remote_path, +}; + +pub async fn metadata(remote_path: &str, password: Option<&str>) -> Result<(), Box> { + let (remote, labname, folder_path) = parse_remote_path(remote_path)?; + let cache = load_cache_with_token_refresh(&remote).await?; + let conn = create_authenticated_conn(&remote, &cache)?; + let lab = find_lab_in_cache(&cache, &labname)?; + let folder = find_folder(&conn, lab.id, &folder_path, password).await?; + + let resp = conn + .get(&format!("v3/folders/{}/metadata/", folder.id)) + .await?; + let json: serde_json::Value = resp.json().await?; + println!("{}", serde_json::to_string_pretty(&json)?); + Ok(()) +} diff --git a/src/commands/mkdir.rs b/src/commands/mkdir.rs new file mode 100644 index 0000000..d718a1b --- /dev/null +++ b/src/commands/mkdir.rs @@ -0,0 +1,51 @@ +use crate::commands::shared::{ + create_authenticated_conn, find_file_by_name, find_folder, find_lab_in_cache, load_cache_with_token_refresh, + parse_remote_path, +}; + +pub async fn mkdir(remote_path: &str) -> Result<(), Box> { + let (remote, labname, path) = parse_remote_path(remote_path)?; + + // Split into parent path and new folder name + let path = path.trim_end_matches('/'); + let last_slash = path + .rfind('/') + .ok_or("Invalid path: cannot determine parent folder")?; + let parent_path = if last_slash == 0 { + "/" + } else { + &path[..last_slash] + }; + let new_folder_name = &path[last_slash + 1..]; + if new_folder_name.is_empty() { + return Err("Invalid path: folder name cannot be empty".into()); + } + + let cache = load_cache_with_token_refresh(&remote).await?; + let conn = create_authenticated_conn(&remote, &cache)?; + let lab = find_lab_in_cache(&cache, &labname)?; + let parent_folder = find_folder(&conn, lab.id, parent_path, None).await?; + + // Check for name conflict in sub-folders + if parent_folder + .sub_folders + .iter() + .any(|f| f.name == new_folder_name) + { + return Err(format!("'{}' already exists as a folder", new_folder_name).into()); + } + // Check for name conflict in files + let files = conn.list_all_files(&parent_folder.id).await?; + if find_file_by_name(&files, new_folder_name).is_some() { + return Err(format!("'{}' already exists as a file", new_folder_name).into()); + } + + let resp = conn + .create_folder(&parent_folder.id, new_folder_name) + .await?; + if !resp.status().is_success() { + return Err(format!("Failed to create folder: {}", resp.status()).into()); + } + println!("Created folder: {}", new_folder_name); + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..1cade36 --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,19 @@ +// CLI command implementations (login, upload, download, ls, whoami, etc.) + +pub mod chacl; +pub mod config; +pub mod config_subcommand; +pub mod cp; +pub mod download; +pub mod file_metadata; +pub mod labs; +pub mod login; +pub mod logout; +pub mod ls; +pub mod metadata; +pub mod mkdir; +pub mod mv; +pub mod rm; +pub mod shared; +pub mod upload; +pub mod whoami; diff --git a/src/commands/mv.rs b/src/commands/mv.rs new file mode 100644 index 0000000..83cabde --- /dev/null +++ b/src/commands/mv.rs @@ -0,0 +1,112 @@ +use crate::commands::shared::{ + create_authenticated_conn, find_file_by_name, find_folder, find_lab_in_cache, load_cache_with_token_refresh, + parse_remote_path, +}; + +pub async fn mv(src_path: &str, dest_path: &str) -> Result<(), Box> { + let (s_remote, s_lab, s_path) = parse_remote_path(src_path)?; + let dest_ends_with_slash = dest_path.ends_with('/'); + let (d_remote, d_lab, d_path) = parse_remote_path(dest_path)?; + + if s_remote != d_remote { + return Err("Source and destination must use the same remote.".into()); + } + if s_lab != d_lab { + return Err("Source and destination must be in the same laboratory.".into()); + } + + let cache = load_cache_with_token_refresh(&s_remote).await?; + let conn = create_authenticated_conn(&s_remote, &cache)?; + let lab = find_lab_in_cache(&cache, &s_lab)?; + let lab_id = lab.id; + + // Split source path into parent directory and target name + let (s_dirname, s_basename) = split_path(&s_path); + + // If dest ends with '/', treat it as a directory and preserve src basename + let (d_dirname, d_basename) = if dest_ends_with_slash { + (d_path.clone(), s_basename.clone()) + } else { + split_path(&d_path) + }; + + let s_parent_folder = find_folder(&conn, lab_id, &s_dirname, None).await?; + let s_parent_files = conn.list_all_files(&s_parent_folder.id).await?; + + let d_parent_folder = find_folder(&conn, lab_id, &d_dirname, None).await?; + let d_parent_files = conn.list_all_files(&d_parent_folder.id).await?; + + // Try source as a file first + if let Some(src_file) = find_file_by_name(&s_parent_files, &s_basename) { + let src_file_id = src_file.id.clone(); + if find_file_by_name(&d_parent_files, &d_basename).is_some() { + return Err(format!("File `{}` already exists.", d_basename).into()); + } + if d_parent_folder + .sub_folders + .iter() + .any(|f| f.name.to_lowercase() == d_basename.to_lowercase()) + { + return Err("Cannot overwrite non-folder with folder.".into()); + } + let body = serde_json::json!({"folder": d_parent_folder.id, "name": d_basename}); + let resp = conn + .client + .post(conn.build_url(&format!("v3/files/{}/move/", src_file_id))) + .headers(conn.prepare_headers()) + .json(&body) + .send() + .await?; + if !resp.status().is_success() { + return Err(format!("Move failed: {}", resp.status()).into()); + } + println!("Moved: {} -> {}", src_path, dest_path); + return Ok(()); + } + + // Try source as a folder + if let Some(src_folder) = s_parent_folder + .sub_folders + .iter() + .find(|f| f.name.to_lowercase() == s_basename.to_lowercase()) + { + let src_folder_id = src_folder.id.clone(); + if find_file_by_name(&d_parent_files, &d_basename).is_some() { + return Err(format!("File `{}` already exists.", d_basename).into()); + } + if d_parent_folder + .sub_folders + .iter() + .any(|f| f.name.to_lowercase() == d_basename.to_lowercase()) + { + return Err("Folder not empty.".into()); + } + let body = serde_json::json!({"parent": d_parent_folder.id, "name": d_basename}); + let resp = conn + .client + .post(conn.build_url(&format!("v3/folders/{}/move/", src_folder_id))) + .headers(conn.prepare_headers()) + .json(&body) + .send() + .await?; + if !resp.status().is_success() { + return Err(format!("Move failed: {}", resp.status()).into()); + } + println!("Moved: {} -> {}", src_path, dest_path); + return Ok(()); + } + + Err(format!("Source `{}` not found.", src_path).into()) +} + +/// Split a path into (parent_dir, basename). +/// e.g. "/path/to/item" -> ("/path/to", "item") +fn split_path(path: &str) -> (String, String) { + let path = path.trim_end_matches('/'); + if let Some(pos) = path.rfind('/') { + let d = if pos == 0 { "/" } else { &path[..pos] }; + (d.to_string(), path[pos + 1..].to_string()) + } else { + ("/".to_string(), path.to_string()) + } +} diff --git a/src/commands/rm.rs b/src/commands/rm.rs new file mode 100644 index 0000000..998b8c8 --- /dev/null +++ b/src/commands/rm.rs @@ -0,0 +1,67 @@ +use crate::commands::shared::{ + create_authenticated_conn, find_file_by_name, find_folder, find_lab_in_cache, load_cache_with_token_refresh, + parse_remote_path, +}; + +pub async fn rm(remote_path: &str, recursive: bool) -> Result<(), Box> { + let (remote, labname, path) = parse_remote_path(remote_path)?; + + // Split into parent path and target name + let path = path.trim_end_matches('/'); + let last_slash = path.rfind('/').ok_or("Invalid path")?; + let parent_path = if last_slash == 0 { + "/" + } else { + &path[..last_slash] + }; + let target_name = &path[last_slash + 1..]; + if target_name.is_empty() { + return Err("Cannot remove root folder".into()); + } + + let cache = load_cache_with_token_refresh(&remote).await?; + let conn = create_authenticated_conn(&remote, &cache)?; + let lab = find_lab_in_cache(&cache, &labname)?; + let parent_folder = find_folder(&conn, lab.id, parent_path, None).await?; + + // Check if target is a file + let files = conn.list_all_files(&parent_folder.id).await?; + if let Some(file) = find_file_by_name(&files, target_name) { + let resp = conn + .client + .delete(conn.build_url(&format!("v3/files/{}/", file.id))) + .headers(conn.prepare_headers()) + .send() + .await?; + if !resp.status().is_success() { + return Err(format!("Failed to delete file: {}", resp.status()).into()); + } + println!("Deleted file: {}", target_name); + return Ok(()); + } + + // Check if target is a sub-folder + if let Some(subfolder) = parent_folder + .sub_folders + .iter() + .find(|f| f.name == target_name) + { + if !recursive { + return Err(format!("'{}': Is a folder", target_name).into()); + } + let resp = conn + .client + .delete(conn.build_url(&format!("v3/folders/{}/", subfolder.id))) + .headers(conn.prepare_headers()) + .query(&[("recursive", "true")]) + .send() + .await?; + if !resp.status().is_success() { + return Err(format!("Failed to delete folder: {}", resp.status()).into()); + } + println!("Deleted folder: {}", target_name); + return Ok(()); + } + + Err(format!("'{}': No such file or directory", target_name).into()) +} diff --git a/src/commands/shared.rs b/src/commands/shared.rs new file mode 100644 index 0000000..71b564e --- /dev/null +++ b/src/commands/shared.rs @@ -0,0 +1,433 @@ +use crate::models::file::File; +use crate::models::folder::FolderDetail; +use crate::connection::MDRSConnection; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::fs; +use std::sync::{Arc, LazyLock, Mutex}; + +// --------------------------------------------------------------------------- +// Cache structs — matching Python's cache format exactly +// --------------------------------------------------------------------------- + +#[derive(Deserialize, Clone)] +pub struct CacheToken { + pub access: String, + pub refresh: String, +} + +/// Minimal user fields stored in cache — matches Python's `User` dataclass. +#[derive(Deserialize, Clone)] +pub struct CacheUser { + pub id: u32, + pub username: String, + pub laboratory_ids: Vec, + pub is_reviewer: bool, +} + +/// All four fields of a laboratory, needed for digest computation. +#[derive(Deserialize, Clone)] +pub struct CacheLaboratory { + pub id: u32, + pub name: String, + #[serde(default)] + pub pi_name: String, + #[serde(default)] + pub full_name: String, +} + +/// Wrapper matching Python's `Laboratories` serialization: `{"items": [...]}`. +#[derive(Deserialize, Clone, Default)] +pub struct CacheLabsWrapper { + pub items: Vec, +} + +#[derive(Deserialize, Clone)] +pub struct Cache { + pub user: Option, + pub token: CacheToken, + pub laboratories: CacheLabsWrapper, +} + +// --------------------------------------------------------------------------- +// Digest computation — must produce exactly the same hash as Python's +// `CacheData.__calc_digest()`: +// hashlib.sha256( +// json.dumps([user_asdict, token_asdict, labs_asdict]).encode("utf-8") +// ).hexdigest() +// +// Python's default json.dumps uses separators=(', ', ': ') and +// ensure_ascii=True. Field order follows dataclass definition order. +// --------------------------------------------------------------------------- + +/// Escape a string in Python json.dumps style: +/// - Special chars: ", \, and control chars -> standard JSON escapes +/// - Non-ASCII chars -> \uXXXX (matches Python ensure_ascii=True default) +fn python_json_string(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => { + out.push_str(&format!("\\u{:04x}", c as u32)); + } + c if c.is_ascii() => out.push(c), + c => { + // Non-ASCII: encode as \uXXXX (BMP) or surrogate pair (outside BMP) + let code = c as u32; + if code <= 0xFFFF { + out.push_str(&format!("\\u{:04x}", code)); + } else { + let code = code - 0x10000; + let high = 0xD800 + (code >> 10); + let low = 0xDC00 + (code & 0x3FF); + out.push_str(&format!("\\u{:04x}\\u{:04x}", high, low)); + } + } + } + } + out.push('"'); + out +} + +/// Serialize a list of u32 as a Python-style JSON array: `[1, 2, 3]` +fn python_json_u32_array(items: &[u32]) -> String { + if items.is_empty() { + return "[]".to_string(); + } + let inner: Vec = items.iter().map(|x| x.to_string()).collect(); + format!("[{}]", inner.join(", ")) +} + +/// Build the JSON array string that Python's `__calc_digest` hashes: +/// [user_asdict_or_null, token_asdict, labs_asdict] +/// +/// Field order matches each Python dataclass definition: +/// User: id, username, laboratory_ids, is_reviewer +/// Token: access, refresh +/// Laboratories: items +/// Laboratory: id, name, pi_name, full_name +pub fn python_digest_json( + user: Option<&CacheUser>, + access: &str, + refresh: &str, + labs: &CacheLabsWrapper, +) -> String { + let user_str = match user { + None => "null".to_string(), + Some(u) => format!( + "{{\"id\": {}, \"username\": {}, \"laboratory_ids\": {}, \"is_reviewer\": {}}}", + u.id, + python_json_string(&u.username), + python_json_u32_array(&u.laboratory_ids), + if u.is_reviewer { "true" } else { "false" } + ), + }; + + let token_str = format!( + "{{\"access\": {}, \"refresh\": {}}}", + python_json_string(access), + python_json_string(refresh) + ); + + let items: Vec = labs + .items + .iter() + .map(|lab| { + format!( + "{{\"id\": {}, \"name\": {}, \"pi_name\": {}, \"full_name\": {}}}", + lab.id, + python_json_string(&lab.name), + python_json_string(&lab.pi_name), + python_json_string(&lab.full_name) + ) + }) + .collect(); + + let items_str = if items.is_empty() { + "[]".to_string() + } else { + format!("[{}]", items.join(", ")) + }; + let labs_str = format!("{{\"items\": {}}}", items_str); + + format!("[{}, {}, {}]", user_str, token_str, labs_str) +} + +/// Compute the cache digest compatible with Python's `CacheData.__calc_digest`. +pub fn compute_digest( + user: Option<&CacheUser>, + access: &str, + refresh: &str, + labs: &CacheLabsWrapper, +) -> String { + let json_str = python_digest_json(user, access, refresh, labs); + let mut hasher = Sha256::new(); + hasher.update(json_str.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +// --------------------------------------------------------------------------- +// Per-remote async mutex map (in-process serialization) +// --------------------------------------------------------------------------- + +static REMOTE_LOCKS: LazyLock>>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +fn get_remote_lock(remote: &str) -> Arc> { + let mut map = REMOTE_LOCKS.lock().unwrap(); + map.entry(remote.to_string()) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) + .clone() +} + +// --------------------------------------------------------------------------- +// Cache file path helper +// --------------------------------------------------------------------------- + +fn cache_file_path(remote: &str) -> std::path::PathBuf { + crate::settings::SETTINGS + .config_dirname + .join("cache") + .join(format!("{}.json", remote)) +} + +// --------------------------------------------------------------------------- +// Load cache (low-level, no token refresh) +// --------------------------------------------------------------------------- + +/// Load token and laboratories from the login cache file (no token refresh check). +pub fn load_cache(remote: &str) -> Result> { + let cache_path = cache_file_path(remote); + if !cache_path.exists() { + return Err(format!( + "Not logged in to `{}`. Run `mdrs login {}` first.", + remote, remote + ) + .into()); + } + let data = fs::read_to_string(&cache_path)?; + serde_json::from_str::(&data).map_err(|e| { + format!( + "Cache for `{}` is invalid or outdated ({}). Run `mdrs login {}` to refresh it.", + remote, e, remote + ) + .into() + }) +} + +// --------------------------------------------------------------------------- +// Token-aware cache load with refresh and locking +// --------------------------------------------------------------------------- + +/// Load cache, check token expiry, and refresh the access token if needed. +/// +/// Locking strategy: +/// - Per-remote `tokio::sync::Mutex` serializes concurrent async tasks within +/// the same process. +/// - `flock(LOCK_EX)` on the cache file serializes across separate processes +/// on the same host. +pub async fn load_cache_with_token_refresh( + remote: &str, +) -> Result> { + // Acquire the in-process async mutex for this remote + let lock = get_remote_lock(remote); + let _guard = lock.lock().await; + + // Re-read the cache inside the lock (another task may have already refreshed) + let mut cache = load_cache(remote)?; + + if crate::token::is_expired(&cache.token.refresh) { + return Err(format!( + "Session for `{}` has expired. Please run `mdrs login {}` again.", + remote, remote + ) + .into()); + } + + if crate::token::is_refresh_required(&cache.token.access, &cache.token.refresh) { + let new_access = refresh_and_persist(remote, &cache).await?; + cache.token.access = new_access; + } + + Ok(cache) +} + +/// Call the token-refresh endpoint and write the new access token back to the +/// cache file. The caller must already hold the per-remote async mutex. +/// Also recomputes the digest so Python can verify the cache. +async fn refresh_and_persist( + remote: &str, + cache: &Cache, +) -> Result> { + // Build a connection without Bearer token just to reach the refresh endpoint + let url = crate::commands::config::get_remote_url(remote)? + .ok_or_else(|| format!("Remote `{}` is not configured.", remote))?; + let conn = MDRSConnection::new(&url); + + let new_access = conn.token_refresh(&cache.token.refresh).await?; + + // Recompute the digest with the new access token so the Python client + // can still verify the cache after a token refresh. + let new_digest = compute_digest( + cache.user.as_ref(), + &new_access, + &cache.token.refresh, + &cache.laboratories, + ); + + // Persist the updated access token to the cache file with an exclusive file + // lock so that other processes do not read a partially written file. + let cache_path = cache_file_path(remote); + + let raw = fs::read_to_string(&cache_path)?; + let mut obj: serde_json::Value = serde_json::from_str(&raw)?; + + obj["token"]["access"] = serde_json::Value::String(new_access.clone()); + obj["digest"] = serde_json::Value::String(new_digest); + + // Write atomically: write to .tmp then rename, while holding an exclusive + // flock on the .tmp file for cross-process safety. + let tmp_path = cache_path.with_extension("tmp"); + { + use fs2::FileExt; + use std::io::Write; + let mut tmp_file = fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&tmp_path)?; + tmp_file.lock_exclusive()?; + tmp_file.write_all(serde_json::to_string(&obj)?.as_bytes())?; + tmp_file.flush()?; + tmp_file.unlock()?; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&tmp_path)?.permissions(); + perms.set_mode(0o600); + fs::set_permissions(&tmp_path, perms)?; + } + fs::rename(&tmp_path, &cache_path)?; + + Ok(new_access) +} + +// --------------------------------------------------------------------------- +// Connection helpers +// --------------------------------------------------------------------------- + +/// Create an authenticated MDRSConnection for the given remote label +pub fn create_authenticated_conn( + remote: &str, + cache: &Cache, +) -> Result> { + let url = crate::commands::config::get_remote_url(remote)? + .ok_or_else(|| format!("Remote `{}` is not configured.", remote))?; + let mut conn = MDRSConnection::new(&url); + conn.token = Some(cache.token.access.clone()); + Ok(conn) +} + +// --------------------------------------------------------------------------- +// Path and lab helpers +// --------------------------------------------------------------------------- + +/// Parse "remote:/labname/path/" into (remote, labname, folder_path) +pub fn parse_remote_path( + remote_path: &str, +) -> Result<(String, String, String), Box> { + let parts: Vec<&str> = remote_path.splitn(2, ':').collect(); + if parts.len() != 2 { + return Err("remote_path must be in the form 'remote:/labname/path/'".into()); + } + let remote = parts[0].to_string(); + let rest = parts[1]; + if !rest.starts_with('/') { + return Err("Path must be absolute (start with '/')".into()); + } + let folder_parts: Vec<&str> = rest.trim_start_matches('/').splitn(2, '/').collect(); + let labname = folder_parts[0].to_string(); + let path = if folder_parts.len() > 1 && !folder_parts[1].is_empty() { + format!("/{}", folder_parts[1].trim_end_matches('/')) + } else { + "/".to_string() + }; + Ok((remote, labname, path)) +} + +/// Look up a laboratory by name in the cache +pub fn find_lab_in_cache<'a>( + cache: &'a Cache, + labname: &str, +) -> Result<&'a CacheLaboratory, Box> { + cache + .laboratories + .items + .iter() + .find(|l| l.name == labname) + .ok_or_else(|| format!("Laboratory `{}` not found.", labname).into()) +} + +/// Resolve a folder by path using the API (GET v3/folders/?path=...&laboratory_id=...) +pub async fn find_folder( + conn: &MDRSConnection, + lab_id: u32, + path: &str, + password: Option<&str>, +) -> Result> { + let folders = conn.list_folders_by_path(lab_id, path).await?; + if folders.is_empty() { + return Err(format!("Folder `{}` not found.", path).into()); + } + if folders.len() != 1 { + return Err( + format!("Ambiguous path `{}`: {} folders matched.", path, folders.len()).into(), + ); + } + let folder_simple = &folders[0]; + if folder_simple.lock { + match password { + None => { + return Err(format!( + "Folder `{}` is locked. Use -p/--password to provide a password.", + path + ) + .into()) + } + Some(pw) => conn.folder_auth(&folder_simple.id, pw).await?, + } + } + let folder = conn.retrieve_folder(&folder_simple.id).await?; + Ok(folder) +} + +/// Find a file by name (case-insensitive) in a file list +pub fn find_file_by_name<'a>(files: &'a [File], name: &str) -> Option<&'a File> { + let name_lower = name.to_lowercase(); + files.iter().find(|f| f.name.to_lowercase() == name_lower) +} + +/// Format an ISO 8601 timestamp as "YYYY/MM/DD HH:MM:SS" +pub fn fmt_datetime(iso: &str) -> String { + let s = iso.trim(); + let s = if let Some(pos) = s[10..].find(|c: char| c == '+' || c == '-') { + &s[..10 + pos] + } else { + s.trim_end_matches('Z') + }; + if s.len() >= 19 { + let date = s[..10].replace('-', "/"); + let time = &s[11..19]; + format!("{} {}", date, time) + } else { + iso.to_string() + } +} diff --git a/src/commands/upload.rs b/src/commands/upload.rs new file mode 100644 index 0000000..a4631ef --- /dev/null +++ b/src/commands/upload.rs @@ -0,0 +1,143 @@ +use crate::models::folder::FolderSimple; +use crate::commands::shared::{ + create_authenticated_conn, find_file_by_name, find_folder, find_lab_in_cache, load_cache_with_token_refresh, + parse_remote_path, +}; +use futures::stream::{FuturesUnordered, StreamExt}; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::fs; + +pub async fn upload( + local_path: &str, + remote_path: &str, + recursive: bool, + skip_if_exists: bool, +) -> Result<(), Box> { + let (remote, labname, r_path) = parse_remote_path(remote_path)?; + let cache = load_cache_with_token_refresh(&remote).await?; + let conn = Arc::new(create_authenticated_conn(&remote, &cache)?); + let lab = find_lab_in_cache(&cache, &labname)?; + let dest_folder = find_folder(&conn, lab.id, &r_path, None).await?; + + // Normalize local_path: resolve to an absolute canonical path so that + // trailing slashes and "./" prefixes are handled consistently (matching + // Python's os.path.abspath behaviour). + let local_abs = std::fs::canonicalize(local_path) + .map_err(|_| format!("File or directory `{}` not found.", local_path))?; + let local = local_abs.as_path(); + + if local.is_file() { + let filename = local.file_name().unwrap().to_string_lossy().to_string(); + let remote_files = conn.list_all_files(&dest_folder.id).await?; + if skip_if_exists { + if let Some(rf) = find_file_by_name(&remote_files, &filename) { + if rf.size == std::fs::metadata(local)?.len() { + println!("{}{}", dest_folder.path, filename); + return Ok(()); + } + } + } + conn.upload_file(&dest_folder.id, &local.to_string_lossy()).await?; + println!("{}{}", dest_folder.path, filename); + } else if local.is_dir() { + if !recursive { + return Err(format!("Cannot upload `{}`: Is a directory.", local_path).into()); + } + // Python always creates a sub-folder named after the local directory inside + // remote_path. E.g. `upload ./mydir remote:/lab/path/` creates + // `/lab/path/mydir/` on the remote and uploads into that folder. + let local_basename = local.file_name().unwrap().to_string_lossy().to_string(); + let top_remote_id = find_or_create_folder(&conn, &dest_folder.id, &dest_folder.sub_folders, &local_basename).await?; + let top_folder = conn.retrieve_folder(&top_remote_id).await?; + println!("{}", top_folder.path.trim_end_matches('/')); + + // Iterative depth-first walk: each entry is (local_dir, remote_folder_id) + let mut stack: Vec<(PathBuf, String)> = + vec![(local.to_path_buf(), top_remote_id)]; + + while let Some((local_dir, remote_id)) = stack.pop() { + let folder_detail = conn.retrieve_folder(&remote_id).await?; + let remote_files = conn.list_all_files(&remote_id).await?; + + let mut entries = fs::read_dir(&local_dir).await?; + let mut subdirs: Vec = Vec::new(); + let mut files: Vec = Vec::new(); + while let Some(entry) = entries.next_entry().await? { + let p = entry.path(); + if p.is_dir() { + subdirs.push(p); + } else { + files.push(p); + } + } + + // Ensure each local sub-directory exists on the remote side + for subdir in subdirs { + let dirname = subdir.file_name().unwrap().to_string_lossy().to_string(); + let sub_remote_id = find_or_create_folder(&conn, &remote_id, &folder_detail.sub_folders, &dirname).await?; + let sub_folder = conn.retrieve_folder(&sub_remote_id).await?; + println!("{}", sub_folder.path.trim_end_matches('/')); + stack.push((subdir, sub_remote_id)); + } + + // Upload files in this directory (up to 10 concurrent) + let mut futs: FuturesUnordered> = + FuturesUnordered::new(); + for file_path in files { + let filename = file_path.file_name().unwrap().to_string_lossy().to_string(); + let file_path_str = file_path.to_string_lossy().to_string(); + if skip_if_exists { + if let Some(rf) = find_file_by_name(&remote_files, &filename) { + if let Ok(meta) = std::fs::metadata(&file_path) { + if rf.size == meta.len() { + let remote_path_prefix = folder_detail.path.clone(); + println!("{}{}", remote_path_prefix, filename); + continue; + } + } + } + } + let conn = conn.clone(); + let folder_id = remote_id.clone(); + let remote_path_prefix = folder_detail.path.clone(); + let fname = filename.clone(); + futs.push(tokio::spawn(async move { + match conn.upload_file(&folder_id, &file_path_str).await { + Ok(_) => println!("{}{}", remote_path_prefix, fname), + Err(e) => eprintln!("Error: {}", e), + } + })); + if futs.len() >= crate::settings::SETTINGS.concurrent { + let _ = futs.next().await; + } + } + while futs.next().await.is_some() {} + } + } else { + return Err(format!("File or directory `{}` not found.", local_path).into()); + } + + Ok(()) +} + +/// Find an existing sub-folder by name or create it, returning its ID. +async fn find_or_create_folder( + conn: &crate::connection::MDRSConnection, + parent_id: &str, + existing: &[FolderSimple], + name: &str, +) -> Result> { + if let Some(sf) = existing.iter().find(|f| f.name == name) { + return Ok(sf.id.clone()); + } + let resp = conn.create_folder(parent_id, name).await?; + if !resp.status().is_success() { + return Err(format!("Failed to create remote folder: {}", name).into()); + } + let json: serde_json::Value = resp.json().await?; + json["id"] + .as_str() + .ok_or_else(|| format!("No id in create_folder response for {}", name).into()) + .map(|s| s.to_string()) +} diff --git a/src/commands/whoami.rs b/src/commands/whoami.rs new file mode 100644 index 0000000..3f98750 --- /dev/null +++ b/src/commands/whoami.rs @@ -0,0 +1,32 @@ +use serde::Deserialize; +use std::fs; + +#[derive(Deserialize)] +struct CacheUser { + username: String, +} + +#[derive(Deserialize)] +struct WhoamiCache { + user: Option, +} + +pub async fn whoami(remote: &str) -> Result<(), Box> { + let cache_path = crate::settings::SETTINGS + .config_dirname + .join("cache") + .join(format!("{}.json", remote)); + if !cache_path.exists() { + println!("(Anonymous)"); + return Ok(()); + } + let data = fs::read_to_string(&cache_path)?; + match serde_json::from_str::(&data) { + Ok(cache) => match cache.user { + Some(user) => println!("{}", user.username), + None => println!("(Anonymous)"), + }, + Err(_) => println!("(Anonymous)"), + } + Ok(()) +} diff --git a/src/connection.rs b/src/connection.rs new file mode 100644 index 0000000..9a63dc1 --- /dev/null +++ b/src/connection.rs @@ -0,0 +1,137 @@ +use reqwest::Client; +use reqwest::header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderValue, USER_AGENT}; + +fn build_user_agent() -> String { + let info = os_info::get(); + let mut parts = vec![info.os_type().to_string()]; + if *info.version() != os_info::Version::Unknown { + parts.push(info.version().to_string()); + } + if let Some(codename) = info.codename() { + parts.push(codename.to_string()); + } + if let Some(edition) = info.edition() { + parts.push(edition.to_string()); + } + let arch = info + .architecture() + .unwrap_or(std::env::consts::ARCH) + .to_string(); + parts.push(arch); + format!( + "MdrsClient/{} (Rust {} - {})", + env!("CARGO_PKG_VERSION"), + env!("RUSTC_VERSION"), + parts.join(" ") + ) +} + +pub struct MDRSConnection { + pub url: String, + pub client: Client, + pub token: Option, +} + +impl MDRSConnection { + pub fn new(url: &str) -> Self { + MDRSConnection { + url: url.to_string(), + client: Client::new(), + token: None, + } + } + + pub fn build_url(&self, path: &str) -> String { + format!("{}/{}", self.url.trim_end_matches('/'), path) + } + + pub fn prepare_headers(&self) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + USER_AGENT, + HeaderValue::from_str(&build_user_agent()).unwrap(), + ); + headers.insert(ACCEPT, HeaderValue::from_static("application/json")); + if let Some(token) = &self.token { + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {}", token)).unwrap(), + ); + } + headers + } + + pub async fn get(&self, path: &str) -> reqwest::Result { + self.client + .get(self.build_url(path)) + .headers(self.prepare_headers()) + .send() + .await + } + + pub async fn post_multipart( + &self, + path: &str, + form: reqwest::multipart::Form, + ) -> reqwest::Result { + self.client + .post(self.build_url(path)) + .headers(self.prepare_headers()) + .multipart(form) + .send() + .await + } + + pub async fn download_file( + &self, + url: &str, + dest: &str, + ) -> Result<(), Box> { + let resp = self + .client + .get(url) + .headers(self.prepare_headers()) + .send() + .await?; + let bytes = resp.bytes().await?; + tokio::fs::write(dest, &bytes).await?; + Ok(()) + } + + pub async fn create_folder( + &self, + parent_id: &str, + folder_name: &str, + ) -> reqwest::Result { + let body = serde_json::json!({"parent": parent_id, "name": folder_name}); + self.client + .post(self.build_url("v3/folders/")) + .headers(self.prepare_headers()) + .json(&body) + .send() + .await + } + + /// Authenticate against a password-locked folder (POST v3/folders/{id}/auth/). + /// Returns Err if the password is incorrect or the request fails. + pub async fn folder_auth( + &self, + folder_id: &str, + password: &str, + ) -> Result<(), Box> { + let resp = self + .client + .post(self.build_url(&format!("v3/folders/{}/auth/", folder_id))) + .headers(self.prepare_headers()) + .json(&serde_json::json!({"password": password})) + .send() + .await?; + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err("Password is incorrect.".into()); + } + if !resp.status().is_success() { + return Err(format!("Folder auth failed: {}", resp.status()).into()); + } + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..e97ba4a --- /dev/null +++ b/src/main.rs @@ -0,0 +1,360 @@ +pub mod api; +mod commands; +mod connection; +mod models; +mod settings; +mod token; + +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command(name = "mdrs")] +#[command(about = "MDRS Rust CLI client", long_about = None)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +use commands::config_subcommand::*; + +#[derive(Subcommand)] +enum Commands { + /// Config management (create, update, list, delete) + #[command(subcommand)] + Config(ConfigSubcommand), + Login { + #[arg(short, long)] + username: Option, + #[arg(short, long)] + password: Option, + remote: String, + }, + /// Logout and remove cached credentials for a remote + Logout { + remote: String, + }, + Upload { + #[arg(short, long)] + recursive: bool, + #[arg(short = 's', long)] + skip_if_exists: bool, + local_path: String, + remote_path: String, + }, + Download { + #[arg(short, long)] + recursive: bool, + #[arg(short = 's', long)] + skip_if_exists: bool, + #[arg(short = 'p', long)] + password: Option, + #[arg(long)] + exclude: Vec, + remote_path: String, + local_path: String, + }, + Ls { + remote_path: String, + #[arg(short = 'p', long)] + password: Option, + #[arg(short = 'J', long = "json")] + json: bool, + #[arg(short = 'r', long)] + recursive: bool, + #[arg(short = 'q', long)] + quick: bool, + }, + Whoami { + remote: String, + }, + Labs { + remote: String, + }, + Chacl { + /// Access level key: private, public, pw_open, cbs_open, 5kikan_open, + /// cbs_or_pw_open, 5kikan_or_pw_open, storage + access_level_key: String, + #[arg(short, long)] + recursive: bool, + #[arg(short = 'p', long)] + password: Option, + remote_path: String, + }, + Metadata { + #[arg(short = 'p', long)] + password: Option, + remote_path: String, + }, + Mkdir { + remote_path: String, + }, + Rm { + #[arg(short, long)] + recursive: bool, + remote_path: String, + }, + Mv { + src_path: String, + dest_path: String, + }, + Cp { + #[arg(short, long)] + recursive: bool, + src_path: String, + dest_path: String, + }, + /// Show metadata for a remote file + FileMetadata { + #[arg(short = 'p', long)] + password: Option, + remote_path: String, + }, +} + +/// Print the error message in Python-compatible format and exit with code 2. +/// JSON deserialization errors show a friendlier message matching Python's JSONDecodeError handling. +fn handle_error(e: Box) -> ! { + if is_json_error(&*e) { + eprintln!( + "Unexpected response returned. Please check the configuration or the server's operational status." + ); + } else { + eprintln!("Error: {}", e); + } + std::process::exit(2); +} + +/// Walk the error source chain to detect serde_json parse errors. +fn is_json_error(e: &(dyn std::error::Error + 'static)) -> bool { + let mut source: Option<&(dyn std::error::Error + 'static)> = Some(e); + while let Some(err) = source { + if err.downcast_ref::().is_some() { + return true; + } + source = err.source(); + } + false +} + +fn main() { + // Load .env file from current directory (silently ignore if not present). + dotenvy::dotenv().ok(); + + // Exit with code 130 on Ctrl+C, matching Python's KeyboardInterrupt handling. + ctrlc::set_handler(|| { + std::process::exit(130); + }) + .ok(); + + let cli = Cli::parse(); + match &cli.command { + Commands::Config(subcmd) => match subcmd { + ConfigSubcommand::Create(args) => { + if let Err(e) = crate::commands::config::config_create(&args.remote, &args.url) { + handle_error(e); + } + } + ConfigSubcommand::Update(args) => { + if let Err(e) = crate::commands::config::config_update(&args.remote, &args.url) { + handle_error(e); + } + } + ConfigSubcommand::List(args) => { + if let Err(e) = crate::commands::config::config_list(args.long) { + handle_error(e); + } + } + ConfigSubcommand::Delete(args) => { + if let Err(e) = crate::commands::config::config_delete(&args.remote) { + handle_error(e); + } + } + }, + Commands::Login { + username, + password, + remote, + } => { + let remote = remote.trim_end_matches(':'); + use std::io::{self, Write}; + let username_val: String = match username { + Some(u) => u.clone(), + None => { + print!("Username: "); + io::stdout().flush().unwrap(); + let mut s = String::new(); + io::stdin().read_line(&mut s).unwrap(); + s.trim().to_string() + } + }; + let password_val: String = match password { + Some(p) => p.clone(), + None => { + rpassword::prompt_password("Password: ").unwrap() + } + }; + let rt = tokio::runtime::Runtime::new().unwrap(); + if let Err(e) = + rt.block_on(commands::login::login(&username_val, &password_val, remote)) + { + handle_error(e); + } + } + Commands::Upload { + local_path, + remote_path, + recursive, + skip_if_exists, + } => { + if let Err(e) = tokio::runtime::Runtime::new() + .unwrap() + .block_on(commands::upload::upload( + local_path, + remote_path, + *recursive, + *skip_if_exists, + )) + { + handle_error(e); + } + } + Commands::Download { + remote_path, + local_path, + recursive, + skip_if_exists, + password, + exclude, + } => { + let excludes: Vec = exclude + .iter() + .map(|e| e.trim_end_matches('/').to_lowercase()) + .collect(); + if let Err(e) = tokio::runtime::Runtime::new() + .unwrap() + .block_on(commands::download::download( + remote_path, + local_path, + *recursive, + *skip_if_exists, + password.as_deref(), + excludes, + )) + { + handle_error(e); + } + } + Commands::Ls { + remote_path, + password, + json, + recursive, + quick, + } => { + if let Err(e) = tokio::runtime::Runtime::new() + .unwrap() + .block_on(commands::ls::ls( + remote_path, + password.as_deref(), + *json, + *recursive, + *quick, + )) + { + handle_error(e); + } + } + Commands::Whoami { remote } => { + let remote = remote.trim_end_matches(':'); + let rt = tokio::runtime::Runtime::new().unwrap(); + if let Err(e) = rt.block_on(commands::whoami::whoami(remote)) { + handle_error(e); + } + } + Commands::Labs { remote } => { + let remote = remote.trim_end_matches(':'); + match crate::commands::config::get_remote_url(remote) { + Ok(Some(url)) => { + let conn = std::sync::Arc::new(crate::connection::MDRSConnection::new(&url)); + let rt = tokio::runtime::Runtime::new().unwrap(); + if let Err(e) = rt.block_on(crate::commands::labs::labs(conn.clone(), remote)) { + handle_error(e); + } + } + Ok(None) => { + eprintln!("Error: Remote host `{}` is not configured", remote); + std::process::exit(2); + } + Err(e) => { + handle_error(e); + } + } + } + + Commands::Chacl { + access_level_key, + recursive, + password, + remote_path, + } => { + let rt = tokio::runtime::Runtime::new().unwrap(); + if let Err(e) = rt.block_on(crate::commands::chacl::chacl( + remote_path, + access_level_key, + *recursive, + password.as_deref(), + )) { + handle_error(e); + } + } + Commands::Metadata { remote_path, password } => { + let rt = tokio::runtime::Runtime::new().unwrap(); + if let Err(e) = rt.block_on(crate::commands::metadata::metadata(remote_path, password.as_deref())) { + handle_error(e); + } + } + Commands::Mkdir { remote_path } => { + let rt = tokio::runtime::Runtime::new().unwrap(); + if let Err(e) = rt.block_on(crate::commands::mkdir::mkdir(remote_path)) { + handle_error(e); + } + } + Commands::Rm { + recursive, + remote_path, + } => { + let rt = tokio::runtime::Runtime::new().unwrap(); + if let Err(e) = rt.block_on(crate::commands::rm::rm(remote_path, *recursive)) { + handle_error(e); + } + } + Commands::Logout { remote } => { + let remote = remote.trim_end_matches(':'); + if let Err(e) = crate::commands::logout::logout(remote) { + handle_error(e); + } + } + Commands::Mv { src_path, dest_path } => { + let rt = tokio::runtime::Runtime::new().unwrap(); + if let Err(e) = rt.block_on(crate::commands::mv::mv(src_path, dest_path)) { + handle_error(e); + } + } + Commands::Cp { + src_path, + dest_path, + recursive, + } => { + let rt = tokio::runtime::Runtime::new().unwrap(); + if let Err(e) = rt.block_on(crate::commands::cp::cp(src_path, dest_path, *recursive)) { + handle_error(e); + } + } + Commands::FileMetadata { remote_path, password } => { + let rt = tokio::runtime::Runtime::new().unwrap(); + if let Err(e) = rt.block_on(crate::commands::file_metadata::file_metadata(remote_path, password.as_deref())) { + handle_error(e); + } + } + } +} diff --git a/src/models/file.rs b/src/models/file.rs new file mode 100644 index 0000000..a7a2ead --- /dev/null +++ b/src/models/file.rs @@ -0,0 +1,15 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct File { + pub id: String, + pub name: String, + pub r#type: String, + pub size: u64, + pub thumbnail: Option, + pub description: String, + pub metadata: serde_json::Value, + pub download_url: String, + pub created_at: String, + pub updated_at: String, +} diff --git a/src/models/folder.rs b/src/models/folder.rs new file mode 100644 index 0000000..9ccfeb8 --- /dev/null +++ b/src/models/folder.rs @@ -0,0 +1,70 @@ +use serde::{Deserialize, Serialize}; + +/// Lightweight folder entry returned by the list endpoint (v3/folders/?path=...&laboratory_id=...) +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct FolderSimple { + pub id: String, + pub pid: Option, + pub name: String, + pub access_level: u32, + pub lock: bool, + pub size: u64, + pub laboratory_id: u32, + pub description: String, + pub created_at: String, + pub updated_at: String, +} + +impl FolderSimple { + pub fn access_level_name(&self) -> &'static str { + access_level_label(self.access_level) + } +} + +/// Detailed folder returned by the retrieve endpoint (v3/folders/{id}/) +#[derive(Debug, Serialize, Deserialize)] +pub struct FolderDetail { + pub id: String, + pub pid: Option, + pub name: String, + pub access_level: u32, + pub lock: bool, + pub size: u64, + pub laboratory_id: u32, + pub description: String, + pub created_at: String, + pub updated_at: String, + pub sub_folders: Vec, + pub path: String, +} + +impl FolderDetail { + pub fn access_level_name(&self) -> &'static str { + access_level_label(self.access_level) + } +} + +pub fn access_level_label(level: u32) -> &'static str { + if (level & 0x0204) == 0x0204 { + return "5Kikan or PW Open"; + } + if (level & 0x0104) == 0x0104 { + return "CBS or PW Open"; + } + if (level & 0x0200) == 0x0200 { + return "5Kikan Open"; + } + if (level & 0x0100) == 0x0100 { + return "CBS Open"; + } + if (level & 0x0004) == 0x0004 { + return "PW Open"; + } + if (level & 0x0002) == 0x0002 { + return "Public"; + } + if (level & 0x0001) == 0x0001 { + return "Private"; + } + "Storage" +} diff --git a/src/models/laboratory.rs b/src/models/laboratory.rs new file mode 100644 index 0000000..aae1720 --- /dev/null +++ b/src/models/laboratory.rs @@ -0,0 +1,15 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Laboratory { + pub id: u32, + pub name: String, + pub pi_name: String, + pub full_name: String, +} + +/// Wrapper matching Python's `Laboratories` dataclass serialization: `{"items": [...]}`. +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct Laboratories { + pub items: Vec, +} diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..4bb5258 --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1,6 @@ +// Model definitions (User, Laboratory, File, Folder, etc.) + +pub mod file; +pub mod folder; +pub mod laboratory; +pub mod user; diff --git a/src/models/user.rs b/src/models/user.rs new file mode 100644 index 0000000..8742998 --- /dev/null +++ b/src/models/user.rs @@ -0,0 +1,10 @@ +use serde::{Deserialize, Serialize}; + +/// Minimal user model matching the Python cache format. +#[derive(Debug, Serialize, Deserialize, Default, Clone)] +pub struct User { + pub id: u32, + pub username: String, + pub laboratory_ids: Vec, + pub is_reviewer: bool, +} diff --git a/src/settings.rs b/src/settings.rs new file mode 100644 index 0000000..074a01b --- /dev/null +++ b/src/settings.rs @@ -0,0 +1,40 @@ +use std::sync::LazyLock; + +pub struct Settings { + /// Base directory for config and cache files. + /// Controlled by `MDRS_CLIENT_CONFIG_DIRNAME` env var (default: `~/.mdrs-client`). + pub config_dirname: std::path::PathBuf, + /// Maximum number of concurrent upload/download workers. + /// Controlled by `MDRS_CLIENT_CONCURRENT` env var (default: 10). + pub concurrent: usize, +} + +impl Settings { + fn load() -> Self { + let config_dirname = std::env::var("MDRS_CLIENT_CONFIG_DIRNAME") + .ok() + .map(|s| { + let expanded = if s.starts_with("~/") { + dirs::home_dir() + .unwrap() + .join(&s[2..]) + } else { + std::path::PathBuf::from(&s) + }; + expanded + }) + .unwrap_or_else(|| dirs::home_dir().unwrap().join(".mdrs-client")); + + let concurrent = std::env::var("MDRS_CLIENT_CONCURRENT") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(10); + + Settings { + config_dirname, + concurrent, + } + } +} + +pub static SETTINGS: LazyLock = LazyLock::new(Settings::load); diff --git a/src/token.rs b/src/token.rs new file mode 100644 index 0000000..a020cdd --- /dev/null +++ b/src/token.rs @@ -0,0 +1,43 @@ +// JWT utilities for token expiry checking (no signature verification required) + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn now_secs() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 +} + +/// Decode the `exp` field from a JWT payload without signature verification. +pub fn jwt_exp(token: &str) -> Result> { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() < 2 { + return Err("Invalid JWT: expected at least 2 dot-separated parts".into()); + } + let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1])?; + let json: serde_json::Value = serde_json::from_slice(&payload_bytes)?; + let exp = json["exp"] + .as_i64() + .ok_or("JWT payload missing 'exp' field")?; + Ok(exp) +} + +/// Returns true when the access token expires within 10 seconds +/// and the refresh token has not yet expired (with 10-second buffer). +/// This mirrors Python's `Token.is_refresh_required`. +pub fn is_refresh_required(access: &str, refresh: &str) -> bool { + let t = now_secs(); + let access_exp = jwt_exp(access).unwrap_or(0); + let refresh_exp = jwt_exp(refresh).unwrap_or(0); + (t + 10) > access_exp && (t - 10) < refresh_exp +} + +/// Returns true when the refresh token itself has expired (with 10-second buffer). +/// This mirrors Python's `Token.is_expired`. +pub fn is_expired(refresh: &str) -> bool { + let t = now_secs(); + let refresh_exp = jwt_exp(refresh).unwrap_or(0); + (t - 10) > refresh_exp +}