diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dca8b39..3a491be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: toolchain: [stable] include: - os: ubuntu-latest - toolchain: "1.85.0" # MSRV check + toolchain: "1.88.0" # MSRV check steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@master diff --git a/.github/workflows/crate-iran-pay.yml b/.github/workflows/crate-iran-pay.yml new file mode 100644 index 0000000..ba96de2 --- /dev/null +++ b/.github/workflows/crate-iran-pay.yml @@ -0,0 +1,53 @@ +name: iran-pay + +# Crate-specific checks for `crates/iran-pay`. Only runs when +# this crate (or its workflow file) changes. + +on: + push: + branches: [main] + paths: + - "crates/iran-pay/**" + - ".github/workflows/crate-iran-pay.yml" + - "Cargo.toml" + - "Cargo.lock" + pull_request: + paths: + - "crates/iran-pay/**" + - ".github/workflows/crate-iran-pay.yml" + - "Cargo.toml" + - "Cargo.lock" + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: "-D warnings" + +jobs: + features: + name: Feature combinations + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo build -p iran-pay --no-default-features + - run: cargo build -p iran-pay --no-default-features --features zarinpal,rustls-tls + - run: cargo build -p iran-pay --no-default-features --features idpay,rustls-tls + - run: cargo build -p iran-pay --no-default-features --features nextpay,rustls-tls + - run: cargo build -p iran-pay --no-default-features --features payir,rustls-tls + - run: cargo build -p iran-pay --no-default-features --features validators,zarinpal,rustls-tls + - run: cargo build -p iran-pay --no-default-features --features native-tls,zarinpal + - run: cargo build -p iran-pay --all-features + - run: cargo test -p iran-pay --all-features + + package: + name: cargo package (verify publishable) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo package -p iran-pay diff --git a/Cargo.lock b/Cargo.lock index 43aed58..101ab6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -32,18 +38,93 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "bitpacking" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96a7139abd3d9cebf8cd6f920a389cf3dc9576172e32f4563f188cae3c3eb019" +dependencies = [ + "crunchy", +] + [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[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 = "cast" version = "0.3.0" @@ -57,15 +138,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] +[[package]] +name = "census" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f4c707c6a209cbe82d10abd08e1ea8995e9ea937d2550646e02798948992be0" + [[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 = "chrono" version = "0.4.44" @@ -139,12 +234,31 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "criterion" version = "0.5.1" @@ -157,7 +271,7 @@ dependencies = [ "clap", "criterion-plot", "is-terminal", - "itertools", + "itertools 0.10.5", "num-traits", "once_cell", "oorandom", @@ -177,7 +291,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" dependencies = [ "cast", - "itertools", + "itertools 0.10.5", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", ] [[package]] @@ -212,341 +335,1756 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "futures-core" -version = "0.3.32" +name = "deadpool" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] [[package]] -name = "futures-task" -version = "0.3.32" +name = "deadpool-runtime" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" [[package]] -name = "futures-util" -version = "0.3.32" +name = "deranged" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", + "powerfmt", + "serde_core", ] [[package]] -name = "half" -version = "2.7.1" +name = "displaydoc" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ - "cfg-if", - "crunchy", - "zerocopy", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "hermit-abi" -version = "0.5.2" +name = "downcast-rs" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" [[package]] -name = "iana-time-zone" -version = "0.1.65" +name = "either" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" +name = "equivalent" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] -name = "is-terminal" -version = "0.4.17" +name = "errno" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ - "hermit-abi", "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] -name = "itertools" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.18" +name = "fastdivide" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jalali-calendar" -version = "0.1.0" -dependencies = [ - "chrono", - "chrono-tz", - "criterion", - "serde", - "serde_json", -] +checksum = "9afc2bd4d5a73106dd53d10d73d3401c2f32730ba2c0b93ddb888a8983680471" [[package]] -name = "js-sys" -version = "0.3.97" +name = "fastrand" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" -dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", -] +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] -name = "libc" -version = "0.2.186" +name = "find-msvc-tools" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] -name = "log" -version = "0.4.29" +name = "fnv" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] -name = "memchr" -version = "2.8.0" +name = "foldhash" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] -name = "num-traits" -version = "0.2.19" +name = "foreign-types" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ - "autocfg", + "foreign-types-shared", ] [[package]] -name = "once_cell" -version = "1.21.4" +name = "foreign-types-shared" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] -name = "oorandom" -version = "11.1.5" +name = "form_urlencoded" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" - -[[package]] -name = "parsitext" -version = "0.1.0" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ - "aho-corasick", - "criterion", - "jalali-calendar", - "rayon", - "regex", - "serde", - "serde_json", + "percent-encoding", ] [[package]] -name = "phf" -version = "0.12.1" +name = "futures" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ - "phf_shared", + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", ] [[package]] -name = "phf_shared" -version = "0.12.1" +name = "futures-channel" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ - "siphasher", + "futures-core", + "futures-sink", ] [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "futures-core" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] -name = "plotters" -version = "0.3.7" +name = "futures-executor" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ - "num-traits", - "plotters-backend", - "plotters-svg", - "wasm-bindgen", - "web-sys", + "futures-core", + "futures-task", + "futures-util", ] [[package]] -name = "plotters-backend" -version = "0.3.7" +name = "futures-io" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] -name = "plotters-svg" -version = "0.3.7" +name = "futures-macro" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ - "plotters-backend", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "proc-macro2" -version = "1.0.106" +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 = "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 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "htmlescape" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163" + +[[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 = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[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", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "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-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[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 = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "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 = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +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", + "serde", + "serde_core", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iran-pay" +version = "0.1.0" +dependencies = [ + "async-trait", + "criterion", + "parsitext", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-subscriber", + "url", + "wiremock", +] + +[[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" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jalali-calendar" +version = "0.1.1" +dependencies = [ + "chrono", + "chrono-tz", + "criterion", + "serde", + "serde_json", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "levenshtein_automata" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c2cdeb66e45e9f36bfad5bbdb4d2384e70936afbee843c6f6543f0c551ebb25" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "measure_time" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbefd235b0aadd181626f281e1d684e116972988c14c264e42069d5e8a5775cc" +dependencies = [ + "instant", + "log", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[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 = "murmurhash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2195bf6aa996a481483b29d62a7663eed3fe39600c460e323f8ff41e90bdd89b" + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oneshot" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "openssl" +version = "0.10.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "ownedbytes" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3a059efb063b8f425b948e042e6b9bd85edfe60e913630ed727b23e2dfcc558" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "parsitext" +version = "0.1.1" +dependencies = [ + "aho-corasick", + "criterion", + "jalali-calendar", + "rayon", + "regex", + "serde", + "serde_json", + "tantivy", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[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 = "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 2.1.2", + "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 2.1.2", + "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 = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +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 = "rand_distr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" +dependencies = [ + "num-traits", + "rand 0.8.6", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[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", +] + +[[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", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "native-tls", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "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 = "rust-stemmers" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +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 = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[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", +] + +[[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 = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "sketches-ddsketch" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85636c14b73d81f541e525f585c0a2109e6744e1565b5c1668e31c70c10ed65c" +dependencies = [ + "serde", +] + +[[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 = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[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", +] + +[[package]] +name = "tantivy" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "96599ea6fccd844fc833fed21d2eecac2e6a7c1afd9e044057391d78b1feb141" dependencies = [ - "unicode-ident", + "aho-corasick", + "arc-swap", + "base64", + "bitpacking", + "byteorder", + "census", + "crc32fast", + "crossbeam-channel", + "downcast-rs", + "fastdivide", + "fnv", + "htmlescape", + "itertools 0.12.1", + "levenshtein_automata", + "log", + "lru", + "measure_time", + "num_cpus", + "once_cell", + "oneshot", + "rayon", + "regex", + "rust-stemmers", + "rustc-hash 1.1.0", + "serde", + "serde_json", + "sketches-ddsketch", + "smallvec", + "tantivy-bitpacker", + "tantivy-columnar", + "tantivy-common", + "tantivy-fst", + "tantivy-query-grammar", + "tantivy-stacker", + "tantivy-tokenizer-api", + "thiserror 1.0.69", + "time", + "uuid", + "winapi", ] [[package]] -name = "quote" -version = "1.0.45" +name = "tantivy-bitpacker" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "284899c2325d6832203ac6ff5891b297fc5239c3dc754c5bc1977855b23c10df" dependencies = [ - "proc-macro2", + "bitpacking", ] [[package]] -name = "rayon" -version = "1.12.0" +name = "tantivy-columnar" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +checksum = "12722224ffbe346c7fec3275c699e508fd0d4710e629e933d5736ec524a1f44e" dependencies = [ - "either", - "rayon-core", + "downcast-rs", + "fastdivide", + "itertools 0.12.1", + "serde", + "tantivy-bitpacker", + "tantivy-common", + "tantivy-sstable", + "tantivy-stacker", ] [[package]] -name = "rayon-core" -version = "1.13.0" +name = "tantivy-common" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +checksum = "8019e3cabcfd20a1380b491e13ff42f57bb38bf97c3d5fa5c07e50816e0621f4" dependencies = [ - "crossbeam-deque", - "crossbeam-utils", + "async-trait", + "byteorder", + "ownedbytes", + "serde", + "time", ] [[package]] -name = "regex" -version = "1.12.3" +name = "tantivy-fst" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "d60769b80ad7953d8a7b2c70cdfe722bbcdcac6bccc8ac934c40c034d866fc18" dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", + "byteorder", "regex-syntax", + "utf8-ranges", ] [[package]] -name = "regex-automata" -version = "0.4.14" +name = "tantivy-query-grammar" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "847434d4af57b32e309f4ab1b4f1707a6c566656264caa427ff4285c4d9d0b82" dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", + "nom", ] [[package]] -name = "regex-syntax" -version = "0.8.10" +name = "tantivy-sstable" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "c69578242e8e9fc989119f522ba5b49a38ac20f576fc778035b96cc94f41f98e" +dependencies = [ + "tantivy-bitpacker", + "tantivy-common", + "tantivy-fst", + "zstd", +] [[package]] -name = "rustversion" -version = "1.0.22" +name = "tantivy-stacker" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "c56d6ff5591fc332739b3ce7035b57995a3ce29a93ffd6012660e0949c956ea8" +dependencies = [ + "murmurhash32", + "rand_distr", + "tantivy-common", +] [[package]] -name = "same-file" -version = "1.0.6" +name = "tantivy-tokenizer-api" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +checksum = "2a0dcade25819a89cfe6f17d932c9cedff11989936bf6dd4f336d50392053b04" dependencies = [ - "winapi-util", + "serde", ] [[package]] -name = "serde" -version = "1.0.228" +name = "tempfile" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ - "serde_core", - "serde_derive", + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", ] [[package]] -name = "serde_core" -version = "1.0.228" +name = "thiserror" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "serde_derive", + "thiserror-impl 1.0.69", ] [[package]] -name = "serde_derive" -version = "1.0.228" +name = "thiserror" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +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", @@ -554,71 +2092,345 @@ dependencies = [ ] [[package]] -name = "serde_json" -version = "1.0.149" +name = "thiserror-impl" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ + "deranged", "itoa", - "memchr", - "serde", + "num-conv", + "powerfmt", "serde_core", - "zmij", + "time-core", + "time-macros", ] [[package]] -name = "shlex" -version = "1.3.0" +name = "time-core" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] -name = "siphasher" -version = "1.0.2" +name = "time-macros" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] [[package]] -name = "slab" -version = "0.4.12" +name = "tinystr" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] [[package]] -name = "syn" -version = "2.0.117" +name = "tinytemplate" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "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", - "unicode-ident", + "syn", ] [[package]] -name = "tinytemplate" -version = "1.2.1" +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[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 = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[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-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "thread_local", + "tracing-core", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[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", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8-ranges" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" dependencies = [ - "serde", - "serde_json", + "try-lock", ] [[package]] -name = "unicode-ident" -version = "1.0.24" +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "walkdir" -version = "2.5.0" +name = "wasip2" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "same-file", - "winapi-util", + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", ] [[package]] @@ -634,6 +2446,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.70" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.120" @@ -666,6 +2488,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" version = "0.3.97" @@ -676,15 +2532,56 @@ dependencies = [ "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-util" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] +[[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-core" version = "0.62.2" @@ -744,6 +2641,24 @@ dependencies = [ "windows-link", ] +[[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.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" @@ -753,6 +2668,281 @@ dependencies = [ "windows-link", ] +[[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.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.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.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.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.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.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.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 = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[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", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.48" @@ -773,8 +2963,96 @@ dependencies = [ "syn", ] +[[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", + "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", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index ad80b5f..a2654c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package] -version = "0.1.0" +version = "0.1.1" edition = "2021" license = "Apache-2.0" repository = "https://github.com/obsernetics/rust-lib" diff --git a/PUBLISHING.adoc b/PUBLISHING.adoc index ff3fd07..4bc3d26 100644 --- a/PUBLISHING.adoc +++ b/PUBLISHING.adoc @@ -27,7 +27,7 @@ cargo login | Builds on Linux x86_64 with no network | verified via `cargo package` | Build under 12h / 4 GB RAM / 24 GB disk | trivially satisfied | Optional: `[package.metadata.docs.rs]` | `all-features = true`, `rustdoc-args = ["--cfg", "docsrs"]` -| Optional: `rust-version` (MSRV) | each crate's `Cargo.toml`: `rust-version = "1.85"` (current workspace baseline) +| Optional: `rust-version` (MSRV) | each crate's `Cargo.toml`: `rust-version = "1.88"` (current workspace baseline) | Optional: `homepage`, `documentation` | both set | Optional: `categories`, `keywords` | both set, valid against the crates.io taxonomy |=== diff --git a/crates/iran-pay/Cargo.toml b/crates/iran-pay/Cargo.toml new file mode 100644 index 0000000..9d9ac6c --- /dev/null +++ b/crates/iran-pay/Cargo.toml @@ -0,0 +1,67 @@ +[package] +name = "iran-pay" +description = "Unified async SDK for Iranian payment gateways — ZarinPal, IDPay, NextPay, Pay.ir behind one strongly-typed Rust trait." +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +homepage = "https://github.com/obsernetics/rust-lib" +documentation = "https://docs.rs/iran-pay" +readme = "README.adoc" +include = ["src/**/*", "Cargo.toml", "README.adoc", "VERSIONING.md", "LICENSE*"] +keywords = ["payment", "iran", "zarinpal", "idpay", "gateway"] +categories = ["api-bindings", "web-programming::http-client"] +rust-version = "1.85" +exclude = ["target/**/*"] + +[features] +default = ["zarinpal", "idpay", "nextpay", "payir", "zibal", "vandar", "rustls-tls", "validators"] + +# Per-gateway opt-out (each driver lives behind its own feature). +zarinpal = [] +idpay = [] +nextpay = [] +payir = [] +zibal = [] +vandar = [] + +# Re-export parsitext's Iranian validators (national ID, IBAN, bank card, +# phone, postal code, car plate) — `iranianbank`-style coverage. +validators = ["dep:parsitext"] + +# TLS backend selection (mutually exclusive in practice). +rustls-tls = ["reqwest/rustls-tls"] +native-tls = ["reqwest/native-tls"] + +[dependencies] +async-trait = "0.1" +parsitext = { version = "0.1.1", path = "../parsitext", optional = true } +reqwest = { version = "0.12", default-features = false, features = ["json"] } +serde = { version = "1", default-features = false, features = ["derive", "alloc"] } +serde_json = "1" +thiserror = "2" +tracing = { version = "0.1", default-features = false, features = ["std", "attributes"] } +url = "2" + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +wiremock = "0.6" +tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "ansi"] } +serde_json = "1" +criterion = { version = "0.5", default-features = false, features = ["cargo_bench_support"] } + +[[bench]] +name = "security" +harness = false + +[[bench]] +name = "pipeline" +harness = false + +[lints] +workspace = true + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/iran-pay/README.adoc b/crates/iran-pay/README.adoc new file mode 100644 index 0000000..ddd7fff --- /dev/null +++ b/crates/iran-pay/README.adoc @@ -0,0 +1,143 @@ += iran-pay + +image:https://img.shields.io/crates/v/iran-pay.svg[crates.io,link=https://crates.io/crates/iran-pay] +image:https://docs.rs/iran-pay/badge.svg[docs.rs,link=https://docs.rs/iran-pay] +image:https://github.com/obsernetics/rust-lib/actions/workflows/ci.yml/badge.svg[CI,link=https://github.com/obsernetics/rust-lib/actions/workflows/ci.yml] +image:https://img.shields.io/badge/license-Apache--2.0-blue.svg[License] +image:https://img.shields.io/badge/MSRV-1.85-orange.svg[MSRV] +image:https://deps.rs/repo/github/obsernetics/rust-lib/status.svg[Dependencies,link=https://deps.rs/repo/github/obsernetics/rust-lib] + +Unified async SDK for Iranian payment gateways. + +== Features + +* *ZarinPal* driver — production v4 JSON API + sandbox. +* *IDPay* driver — `X-API-KEY` JSON API + sandbox header. +* *NextPay* driver — form-encoded API with `code: -1` semantics. +* *Pay.ir* driver — form-encoded API with the magic `"test"` sandbox key. +* Single dyn-safe `Gateway` trait — swap providers with one line at runtime. +* `Amount` type with explicit Toman / Rial unit constructors — eliminates the + most common Iranian-payment-integration bug at the type level. +* Built-in `MockGateway` for fast, deterministic, network-free unit tests of + your checkout code. +* Re-export of the `parsitext` Iranian validators (national ID, IBAN, + bank-card, mobile, postal code, car plate) — pre-flight buyer input + before you call the gateway. +* `tracing` instrumentation on every driver method (provider name, amount, + authority surfaced as fields). +* Strongly-typed `thiserror` error enum: transport, gateway-business, amount + mismatch, configuration, decode, and unsupported-operation variants are all + separate. +* Async-first (`async-trait`), Tokio-friendly, no blocking calls. + +=== Optional features + +[cols="1,1,3", options="header"] +|=== +| Feature | Default | What it enables + +| `zarinpal` | ✓ | Compile the `providers::ZarinPal` driver +| `idpay` | ✓ | Compile the `providers::IDPay` driver +| `nextpay` | ✓ | Compile the `providers::NextPay` driver +| `payir` | ✓ | Compile the `providers::PayIr` driver +| `validators` | ✓ | Re-export `parsitext`'s Iranian validators +| `rustls-tls` | ✓ | Use rustls for HTTPS (no system OpenSSL needed) +| `native-tls` | | Use the platform TLS library instead of rustls +|=== + +Disabling all four provider features still builds the trait, types, and +mock gateway — useful when you bring your own driver against this +abstraction. + +== Quick start + +[source,toml] +---- +[dependencies] +iran-pay = "0.1" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } + +# Or pick only the providers you need: +iran-pay = { version = "0.1", default-features = false, features = ["zarinpal", "rustls-tls"] } +---- + +[source,rust] +---- +use iran_pay::providers::ZarinPal; +use iran_pay::{Amount, Gateway, StartRequest, VerifyRequest}; + +#[tokio::main] +async fn main() -> Result<(), iran_pay::Error> { + let gateway = ZarinPal::new("YOUR-MERCHANT-UUID").sandbox(); + + // 1. Initiate the payment. + let start = gateway.start_payment(&StartRequest::builder() + .amount(Amount::toman(50_000)) + .description("Pro subscription — May 2026") + .callback_url("https://example.com/payment/callback") + .order_id("ORD-12345") + .build()).await?; + + // 2. Redirect the user to `start.payment_url`. + println!("Send user to: {}", start.payment_url); + + // 3. After they return, verify. Pass the same amount back in to + // catch tampering with the callback query string. + let verified = gateway.verify_payment(&VerifyRequest { + authority: start.authority, + amount: Amount::toman(50_000), + }).await?; + + println!("Paid! Transaction ID = {}", verified.transaction_id); + Ok(()) +} +---- + +== Examples + +Each runnable example lives in `examples/`: + +[cols="1,2", options="header"] +|=== +| Example | What it demonstrates + +| `zarinpal_basic` | Minimal start-payment flow against the ZarinPal sandbox. +| `multi_gateway` | `Box` polymorphism — register all four providers and look them up by key. +| `with_validators` | Use the `validators` re-export to check bank cards / mobiles / operators before calling the gateway. +| `mock_in_test` | Drop `MockGateway` into your own service tests; assert call counts and script failures. +|=== + +Run with: + +[source,bash] +---- +cargo run --example -p iran-pay +---- + +== Comparison + +[cols="1,1,1,1", options="header"] +|=== +| Capability | `iran-pay` | JS `iranianbank` / `iranian-bank-gateways` | Hand-rolled `reqwest` + +| Unified `Gateway` trait | ✓ | partial (per-provider classes) | ✗ +| Typed `Amount` (Toman / Rial) | ✓ (compile-time) | ✗ (raw numbers) | ✗ +| Async / Tokio-native | ✓ | ✓ (Node) | yes (manual) +| `tracing` instrumentation | ✓ (built-in spans) | ✗ | manual +| Mock driver shipped with the SDK | ✓ | ✗ | ✗ +| Strong error taxonomy | ✓ (`thiserror`) | strings / `Error` | ad-hoc +| Iranian validators bundled | ✓ (via `parsitext`) | separate package | ✗ +|=== + +== Documentation + +[source,bash] +---- +cargo doc -p iran-pay --all-features +---- + +Hosted docs: link:https://docs.rs/iran-pay[docs.rs/iran-pay]. + +== License + +Apache-2.0. diff --git a/crates/iran-pay/VERSIONING.md b/crates/iran-pay/VERSIONING.md new file mode 100644 index 0000000..caa20e0 --- /dev/null +++ b/crates/iran-pay/VERSIONING.md @@ -0,0 +1,85 @@ +# Versioning policy + +`iran-pay` follows [SemVer 2.0](https://semver.org) **strictly for the +trait-level API** but treats the wire-level provider drivers with extra +caution because Iranian payment gateways change their HTTP APIs without +public notice. + +## What's covered by SemVer + +| Surface | SemVer guarantee | +|-------------------------------------------------|-----------------------------------------------| +| `Gateway` trait | **Stable** — breaking changes only in MAJOR. | +| `StartRequest`, `VerifyResponse`, `Amount`, `Error` | **Stable** — additive only in MINOR. | +| `MockGateway`, `security` helpers | **Stable** — additive only in MINOR. | +| `validators` re-exports | Tied to the underlying `parsitext` version. | +| Per-provider driver structs (`ZarinPal`, …) | **Constructor-stable** — `new`, `sandbox`, `with_*` builders never break. | +| Per-provider driver wire format | **Best-effort.** See below. | + +## What's not strictly SemVer-covered + +The actual JSON / form-encoded body shape that each driver puts on the +wire is dictated by the upstream provider, which **we do not control**. +When a provider silently changes a field name, removes an endpoint, or +revs their API version: + +- We treat it as a **bug**, not a breaking change. +- The fix ships in the next **PATCH** release (e.g. `0.1.x` → `0.1.x+1`). +- We **may not** bump the major version even if the change is + technically observable (e.g. a new field in `StartResponse.raw`). + +If you depend on the *exact bytes* in `StartResponse.raw` or +`VerifyResponse.raw`, treat them as **opaque diagnostic data**, not +public API. + +## Provider API versions pinned by this crate + +Every driver targets a specific upstream API version. We update these +in PATCH releases when providers ship breaking changes; the table below +is the source of truth. + +| Driver | Upstream version | Endpoint base | Verified | +|--------------|------------------|-------------------------------------|--------------------------------| +| `ZarinPal` | v4 | `https://payment.zarinpal.com` | docs.zarinpal.com (2026-05) | +| `IDPay` | v1.1 | `https://api.idpay.ir` | idpay.ir/web-service/v1.1 (2026-05) | +| `NextPay` | (un-versioned) | `https://nextpay.org` | nextpay.org/nx/docs (2026-05) | +| `PayIr` | (un-versioned) | `https://pay.ir` | docs.pay.ir/gateway (2026-05) | +| `Zibal` | v1 | `https://gateway.zibal.ir` | github.com/zibalco (2026-05) | +| `Vandar` | v3 | `https://ipg.vandar.io` | vandarpay.github.io/docs (2026-05) | + +## Cargo MSRV + +Minimum supported Rust version: **1.85**. Bumping the MSRV is treated +as a MINOR change, never a PATCH change. + +## How to upgrade safely + +1. **Pin to a tilde range** in your `Cargo.toml`: + ```toml + iran-pay = "~0.1" + ``` + This accepts patch updates (`0.1.x`) automatically — exactly what we + ship provider-API fixes in. +2. **Pin TLS / provider features** explicitly so you control your + binary surface: + ```toml + iran-pay = { version = "~0.1", default-features = false, + features = ["zarinpal", "idpay", "rustls-tls", "validators"] } + ``` +3. **Run integration tests against your providers' sandboxes** in CI. + Your tests catch upstream breakage before your customers do. +4. **Watch the changelog** — every PATCH release names the gateway it + fixed. + +## Reporting upstream API changes + +If you observe a gateway rejecting or accepting requests that +contradict this crate, please open an issue with: + +- Driver name (`zarinpal`, etc.). +- The exact request body the driver sent (set up + `tracing_subscriber::fmt()` to capture). +- The exact response body. +- Where you found the contradicting documentation. + +Most fixes ship within 24 hours of a verified report. diff --git a/crates/iran-pay/benches/pipeline.rs b/crates/iran-pay/benches/pipeline.rs new file mode 100644 index 0000000..52b5e3c --- /dev/null +++ b/crates/iran-pay/benches/pipeline.rs @@ -0,0 +1,79 @@ +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use iran_pay::mock::MockGateway; +use iran_pay::{Amount, Gateway, StartRequest, VerifyRequest}; + +fn rt() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() +} + +fn bench_mock_start_payment(c: &mut Criterion) { + let rt = rt(); + let gw = MockGateway::new(); + let req = StartRequest::builder() + .amount(Amount::toman(50_000)) + .description("bench") + .callback_url("https://example.com/cb") + .build(); + c.bench_function("pipeline/mock_start_payment", |b| { + b.iter(|| { + rt.block_on(async { + let _ = gw.start_payment(black_box(&req)).await; + }); + }); + }); +} + +fn bench_mock_round_trip(c: &mut Criterion) { + let rt = rt(); + let gw = MockGateway::new(); + let req = StartRequest::builder() + .amount(Amount::toman(50_000)) + .description("bench") + .callback_url("https://example.com/cb") + .build(); + c.bench_function("pipeline/mock_start_then_verify", |b| { + b.iter(|| { + rt.block_on(async { + let s = gw.start_payment(&req).await.unwrap(); + let v = VerifyRequest { + authority: s.authority, + amount: req.amount, + }; + let _ = gw.verify_payment(black_box(&v)).await; + }); + }); + }); +} + +fn bench_amount_construction(c: &mut Criterion) { + c.bench_function("amount/toman_to_rials", |b| { + b.iter(|| Amount::toman(black_box(50_000)).as_rials()) + }); +} + +fn bench_request_builder(c: &mut Criterion) { + c.bench_function("types/start_request_builder", |b| { + b.iter(|| { + StartRequest::builder() + .amount(Amount::toman(black_box(50_000))) + .description(black_box("Subscription")) + .callback_url(black_box("https://example.com/cb")) + .order_id(black_box("ORD-1")) + .email(black_box("u@e.com")) + .mobile(black_box("09121234567")) + .build() + }) + }); +} + +criterion_group!( + benches, + bench_mock_start_payment, + bench_mock_round_trip, + bench_amount_construction, + bench_request_builder, +); +criterion_main!(benches); diff --git a/crates/iran-pay/benches/security.rs b/crates/iran-pay/benches/security.rs new file mode 100644 index 0000000..e8d4867 --- /dev/null +++ b/crates/iran-pay/benches/security.rs @@ -0,0 +1,56 @@ +use criterion::{black_box, criterion_group, criterion_main, Criterion, Throughput}; +use iran_pay::security::{ + check_amount, check_authority_format, constant_time_eq, verify_hmac_sha256, +}; +use iran_pay::Amount; + +fn bench_constant_time_eq(c: &mut Criterion) { + let mut g = c.benchmark_group("security/constant_time_eq"); + for size in [16usize, 64, 256, 1024] { + let a = vec![0xABu8; size]; + let b = vec![0xABu8; size]; + g.throughput(Throughput::Bytes(size as u64)); + g.bench_function(format!("equal_{size}"), |bn| { + bn.iter(|| constant_time_eq(black_box(&a), black_box(&b))) + }); + } + g.finish(); +} + +fn bench_check_authority(c: &mut Criterion) { + c.bench_function("security/check_authority_format", |b| { + b.iter(|| check_authority_format(black_box("A0000000000000000000000000000123456789"))) + }); +} + +fn bench_check_amount(c: &mut Criterion) { + let a = Amount::toman(50_000); + let b = Amount::rial(500_000); + c.bench_function("security/check_amount", |bn| { + bn.iter(|| check_amount(black_box(a), black_box(b))) + }); +} + +fn bench_hmac_verify(c: &mut Criterion) { + let key = b"super-secret-shared-key"; + let body = br#"{"order_id":"ORD-12345","amount":500000,"track_id":"TX-9999","status":100}"#; + // Use an obviously-wrong signature to force the failure path; both + // success and failure paths run the same SHA-256 + constant-time + // compare so this is a fair benchmark of the hot loop. + let bad_sig: String = std::iter::repeat_n('a', 64).collect(); + + c.bench_function("security/verify_hmac_sha256_failure_path", |bn| { + bn.iter(|| { + let _ = verify_hmac_sha256(black_box(key), black_box(body), black_box(&bad_sig)); + }) + }); +} + +criterion_group!( + benches, + bench_constant_time_eq, + bench_check_authority, + bench_check_amount, + bench_hmac_verify, +); +criterion_main!(benches); diff --git a/crates/iran-pay/examples/mock_in_test.rs b/crates/iran-pay/examples/mock_in_test.rs new file mode 100644 index 0000000..07aa66f --- /dev/null +++ b/crates/iran-pay/examples/mock_in_test.rs @@ -0,0 +1,87 @@ +//! Drop-in `MockGateway` for testing your own checkout service. +//! +//! Run with: +//! +//! ```bash +//! cargo run --example mock_in_test -p iran-pay +//! ``` +//! +//! In a real app you would put this scenario inside a `#[tokio::test]` +//! function in `tests/`. Running it as an example just makes the wiring +//! easier to read in isolation. +//! +//! The pattern: +//! +//! 1. Construct a `MockGateway`, optionally scripting failure scenarios. +//! 2. Hand it to your service as `Box` or `Arc`. +//! 3. After the test, assert against `start_call_count()` / +//! `verify_call_count()` to confirm the service did what you expected. + +use std::sync::Arc; + +use iran_pay::mock::{Behavior, MockGateway}; +use iran_pay::{Amount, Gateway, StartRequest, VerifyRequest}; + +/// A toy "checkout service" that takes any `Gateway` impl. Real production +/// code would do the same thing: depend on the trait, not on a concrete +/// driver. +struct CheckoutService { + gateway: Arc, +} + +impl CheckoutService { + fn new(gateway: Arc) -> Self { + Self { gateway } + } + + async fn start(&self, amount: Amount) -> Result { + let req = StartRequest::builder() + .amount(amount) + .description("Pro subscription") + .callback_url("https://example.com/cb") + .order_id("ORD-1") + .build(); + let resp = self.gateway.start_payment(&req).await?; + Ok(resp.authority) + } + + async fn finalize(&self, authority: String, amount: Amount) -> Result { + let resp = self + .gateway + .verify_payment(&VerifyRequest { authority, amount }) + .await?; + Ok(resp.transaction_id) + } +} + +#[tokio::main] +async fn main() { + // ── happy path ────────────────────────────────────────────────────── + let mock = Arc::new(MockGateway::new()); + let svc = CheckoutService::new(mock.clone()); + + let amount = Amount::toman(50_000); + let authority = svc.start(amount).await.expect("start ok"); + let tx = svc.finalize(authority, amount).await.expect("finalize ok"); + + assert_eq!(mock.start_call_count(), 1); + assert_eq!(mock.verify_call_count(), 1); + assert!(tx.starts_with("MOCK-TX-")); + println!("happy path → tx = {tx}"); + + // ── scripted failure ──────────────────────────────────────────────── + let mock = Arc::new(MockGateway::new()); + mock.set_start_behavior(Behavior::FailGateway { + code: -9, + message: "merchant suspended".into(), + }); + let svc = CheckoutService::new(mock.clone()); + + let err = svc + .start(Amount::toman(50_000)) + .await + .expect_err("scripted failure"); + println!("scripted failure → {err}"); + assert_eq!(mock.start_call_count(), 1); + assert_eq!(mock.verify_call_count(), 0); +} diff --git a/crates/iran-pay/examples/multi_gateway.rs b/crates/iran-pay/examples/multi_gateway.rs new file mode 100644 index 0000000..74378e1 --- /dev/null +++ b/crates/iran-pay/examples/multi_gateway.rs @@ -0,0 +1,49 @@ +//! Polymorphic gateway selection via `Box`. +//! +//! Run with: +//! +//! ```bash +//! cargo run --example multi_gateway -p iran-pay +//! ``` +//! +//! Real-world Iranian e-commerce apps typically support several gateways +//! and pick one per checkout (load balancing, fee optimisation, fall-back +//! when a provider is down). Because every driver in this crate +//! implements the same [`Gateway`] trait, you can store them all in a +//! single homogeneous collection. + +use iran_pay::providers::{IDPay, NextPay, PayIr, ZarinPal}; +use iran_pay::Gateway; + +fn main() { + // Build a registry of every supported provider, each in sandbox/test + // mode. The keys are the human-friendly names you would store in your + // database alongside the active merchant configuration. + let registry: Vec<(String, Box)> = vec![ + ( + "zarinpal".to_owned(), + Box::new(ZarinPal::new("00000000-0000-0000-0000-000000000000").sandbox()), + ), + ( + "idpay".to_owned(), + Box::new(IDPay::new("0000000000000000000000000000000000").sandbox()), + ), + ( + "nextpay".to_owned(), + Box::new(NextPay::new("nextpay-sandbox-key").sandbox()), + ), + ("payir".to_owned(), Box::new(PayIr::sandbox())), + ]; + + println!("registered gateways:"); + for (key, gw) in ®istry { + // `gw.name()` is the canonical driver name; the key is whatever + // the merchant called this configuration row. + println!(" - {key:<10} → driver = {}", gw.name()); + } + + println!(); + println!("→ at checkout time, look up `gw` by `key` and call"); + println!(" `gw.start_payment(&req).await` exactly as you would for"); + println!(" any single provider. No `match` on provider type needed."); +} diff --git a/crates/iran-pay/examples/with_validators.rs b/crates/iran-pay/examples/with_validators.rs new file mode 100644 index 0000000..b4dc93e --- /dev/null +++ b/crates/iran-pay/examples/with_validators.rs @@ -0,0 +1,75 @@ +//! Pre-flight: validate buyer-supplied identifiers with `iran_pay::validators`. +//! +//! Run with: +//! +//! ```bash +//! cargo run --example with_validators -p iran-pay +//! ``` +//! +//! The `validators` Cargo feature (on by default) re-exports the Iranian +//! validators from the sibling `parsitext` crate — bank-card / Sheba / +//! national-ID checksums, mobile-operator detection, postal codes, etc. +//! Use them to guard your checkout form *before* you call +//! [`Gateway::start_payment`], so users get an immediate, local error +//! instead of a round-trip to the gateway. + +use iran_pay::providers::ZarinPal; +use iran_pay::validators::{bank_card, phone, Operator}; +use iran_pay::{Amount, Gateway, StartRequest}; + +#[tokio::main] +async fn main() { + // ── 1. Bank card ──────────────────────────────────────────────────── + let card = "6037-9900-0000-0006"; + println!("card '{card}'"); + println!(" Luhn-valid : {}", bank_card::validate(card)); + println!(" issuer : {:?}", bank_card::bank(card)); + + // ── 2. Mobile phone + operator detection ──────────────────────────── + let raw_mobile = "+98 912 123 4567"; + let canonical = phone::canonicalize(raw_mobile); + println!("\nmobile '{raw_mobile}'"); + println!(" canonical : {canonical:?}"); + println!(" validates : {}", phone::validate(raw_mobile)); + + let op = phone::operator(raw_mobile); + println!( + " operator : {} ({})", + op.map(|o| match o { + Operator::MCI => "MCI / Hamrah-e Aval", + Operator::Irancell => "Irancell", + Operator::RighTel => "RighTel", + Operator::ShatelMobile => "Shatel Mobile", + Operator::Aptel => "Aptel", + Operator::Other => "Other / MVNO", + }) + .unwrap_or("unknown"), + canonical.as_deref().unwrap_or("invalid"), + ); + + // ── 3. Now build the StartRequest with the validated, canonical mobile. + let Some(mobile) = canonical else { + eprintln!("invalid mobile — would reject at the form layer."); + return; + }; + + let gateway = ZarinPal::new("00000000-0000-0000-0000-000000000000").sandbox(); + let req = StartRequest::builder() + .amount(Amount::toman(50_000)) + .description("Pro subscription — May 2026") + .callback_url("https://example.com/payment/callback") + .order_id("ORD-12345") + .mobile(mobile) + .build(); + + println!( + "\nbuilt StartRequest with validated mobile = {:?}", + req.mobile + ); + + // We don't actually fire the request here — the merchant UUID is fake. + match gateway.start_payment(&req).await { + Ok(resp) => println!("payment URL: {}", resp.payment_url), + Err(err) => println!("(would call ZarinPal here; received: {err})"), + } +} diff --git a/crates/iran-pay/examples/zarinpal_basic.rs b/crates/iran-pay/examples/zarinpal_basic.rs new file mode 100644 index 0000000..a585b61 --- /dev/null +++ b/crates/iran-pay/examples/zarinpal_basic.rs @@ -0,0 +1,49 @@ +//! ZarinPal — minimal start-payment flow. +//! +//! Run with: +//! +//! ```bash +//! cargo run --example zarinpal_basic -p iran-pay +//! ``` +//! +//! Note: this example uses a placeholder merchant UUID and points at the +//! ZarinPal sandbox. No real money moves. The sandbox may also reject the +//! fake merchant ID outright — that's fine; the example is about showing the +//! shape of the API, not about actually contacting ZarinPal. + +use iran_pay::providers::ZarinPal; +use iran_pay::{Amount, Gateway, StartRequest}; + +#[tokio::main] +async fn main() { + // Replace with your real UUID in production; any UUID-shaped string works + // for the sandbox. + let gateway = ZarinPal::new("00000000-0000-0000-0000-000000000000").sandbox(); + + // Build a typed request. `Amount::toman(...)` is converted to Rials + // automatically before hitting the wire — no unit-mix-up bugs. + let req = StartRequest::builder() + .amount(Amount::toman(50_000)) + .description("Pro subscription — May 2026") + .callback_url("https://example.com/payment/callback") + .order_id("ORD-12345") + .email("buyer@example.com") + .mobile("09121234567") + .build(); + + println!("calling ZarinPal sandbox …"); + match gateway.start_payment(&req).await { + Ok(resp) => { + println!("authority : {}", resp.authority); + println!("payment URL : {}", resp.payment_url); + println!("provider : {}", resp.provider); + println!("→ in a real app, redirect the user to `payment_url`."); + } + Err(err) => { + // The fake merchant UUID will almost certainly be rejected by + // the sandbox. This branch demonstrates how `iran-pay` surfaces + // gateway errors without panicking. + println!("(would call ZarinPal here; received: {err})"); + } + } +} diff --git a/crates/iran-pay/src/amount.rs b/crates/iran-pay/src/amount.rs new file mode 100644 index 0000000..d805f82 --- /dev/null +++ b/crates/iran-pay/src/amount.rs @@ -0,0 +1,148 @@ +//! Iranian currency [`Amount`]. +//! +//! Iranian retail prices are quoted in **Tomans** but the official currency +//! and every payment gateway's API expects **Rials** (1 Toman = 10 Rials). +//! Mixing the two by accident is the single most common bug in Iranian +//! payment integrations. +//! +//! [`Amount`] forces you to be explicit at construction time and stores +//! everything internally in Rials, so the API surface to gateways is +//! always correct. + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +/// The two Iranian currency units. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Currency { + /// تومان — what merchants quote prices in. 1 Toman = 10 Rials. + Toman, + /// ریال — the official unit and what every gateway API expects. + Rial, +} + +impl fmt::Display for Currency { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Currency::Toman => "Toman", + Currency::Rial => "Rial", + }) + } +} + +/// A monetary amount, stored internally in Rials. +/// +/// Construct with [`Amount::toman`] or [`Amount::rial`]. The gateway drivers +/// use [`Amount::as_rials`] to send the request body, so accidental +/// unit mix-ups are impossible. +/// +/// ``` +/// use iran_pay::Amount; +/// +/// let price = Amount::toman(50_000); +/// assert_eq!(price.as_rials(), 500_000); +/// assert_eq!(price.as_tomans(), 50_000); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct Amount { + rials: i64, +} + +impl Amount { + /// Construct from a Toman amount (multiplied by 10 internally). + #[must_use] + pub const fn toman(value: i64) -> Self { + Self { + rials: value.saturating_mul(10), + } + } + + /// Construct from a Rial amount. + #[must_use] + pub const fn rial(value: i64) -> Self { + Self { rials: value } + } + + /// Construct from a value paired with a [`Currency`]. + #[must_use] + pub const fn new(value: i64, currency: Currency) -> Self { + match currency { + Currency::Toman => Self::toman(value), + Currency::Rial => Self::rial(value), + } + } + + /// The amount expressed in Rials (always exact). + #[must_use] + pub const fn as_rials(&self) -> i64 { + self.rials + } + + /// The amount expressed in Tomans (truncated toward zero if not divisible + /// by 10 — but Iranian gateways always operate on multiples of 10 Rials, + /// so in practice this is exact). + #[must_use] + pub const fn as_tomans(&self) -> i64 { + self.rials / 10 + } + + /// Returns `true` if the amount is exactly zero Rials. + #[must_use] + pub const fn is_zero(&self) -> bool { + self.rials == 0 + } +} + +impl fmt::Display for Amount { + /// `123,000 Rial` (with thousand separators). + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut s = String::new(); + let abs = self.rials.unsigned_abs().to_string(); + let bytes = abs.as_bytes(); + if self.rials < 0 { + s.push('-'); + } + for (i, b) in bytes.iter().enumerate() { + if i > 0 && (bytes.len() - i) % 3 == 0 { + s.push(','); + } + s.push(*b as char); + } + write!(f, "{s} Rial") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unit_conversion() { + assert_eq!(Amount::toman(50_000).as_rials(), 500_000); + assert_eq!(Amount::rial(500_000).as_tomans(), 50_000); + } + + #[test] + fn comparison() { + assert!(Amount::toman(100) < Amount::toman(200)); + assert_eq!(Amount::toman(100), Amount::rial(1_000)); + } + + #[test] + fn display_with_separators() { + assert_eq!(Amount::rial(1_234_567).to_string(), "1,234,567 Rial"); + assert_eq!(Amount::rial(0).to_string(), "0 Rial"); + } + + #[test] + fn negative_amount_displays() { + assert_eq!(Amount::rial(-500).to_string(), "-500 Rial"); + } + + #[test] + fn const_constructors() { + const FEE: Amount = Amount::toman(1_000); + assert_eq!(FEE.as_rials(), 10_000); + } +} diff --git a/crates/iran-pay/src/error.rs b/crates/iran-pay/src/error.rs new file mode 100644 index 0000000..b8ceffc --- /dev/null +++ b/crates/iran-pay/src/error.rs @@ -0,0 +1,101 @@ +//! Strongly-typed errors returned by every [`Gateway`](crate::Gateway) call. + +use thiserror::Error; + +use crate::Amount; + +/// Errors returned by gateway drivers. +/// +/// Variants fall into three families: +/// +/// 1. **Transport** — [`Error::Http`] wraps every `reqwest` failure +/// (connection refused, TLS handshake, timeout, malformed response, …). +/// 2. **Gateway** — [`Error::Gateway`] is returned when a provider's API +/// accepts the request but reports a *business* failure (insufficient +/// funds, expired authority, blocked merchant, etc.). The contained +/// `code` is the raw provider code; check provider docs to interpret. +/// 3. **Local** — [`Error::Config`], [`Error::AmountMismatch`], +/// [`Error::Unsupported`] are produced inside the SDK before/after the +/// HTTP roundtrip. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum Error { + /// HTTP transport / serialisation failure. + #[error("HTTP request to {provider} gateway failed: {source}")] + Http { + /// Driver name (`"zarinpal"`, `"idpay"`, …). + provider: &'static str, + /// Underlying reqwest error. + #[source] + source: reqwest::Error, + }, + + /// Gateway returned a business-level error. + /// + /// `code` is the provider's native error code; consult the provider's + /// documentation to interpret it. `message` is the human-readable + /// message (often Persian). + #[error("{provider} gateway error (code {code}): {message}")] + Gateway { + /// Driver name. + provider: &'static str, + /// Provider-specific numeric error code. + code: i64, + /// Provider's human-readable message. + message: String, + }, + + /// Verification was attempted with an amount that doesn't match what + /// was originally charged. Almost always indicates someone tampered + /// with the callback query string. + #[error("amount mismatch — expected {expected}, gateway reported {actual}")] + AmountMismatch { + /// What the merchant expected. + expected: Amount, + /// What the gateway reported during verification. + actual: Amount, + }, + + /// Configuration is invalid (missing merchant ID, malformed URL, etc.). + #[error("invalid configuration: {0}")] + Config(String), + + /// The provider does not support this operation (e.g. refunds via Pay.ir + /// require a separate API contract). + #[error("{operation} is not supported by the {provider} gateway")] + Unsupported { + /// Driver name. + provider: &'static str, + /// The unsupported operation. + operation: &'static str, + }, + + /// Response decoding failed — the provider returned a payload we couldn't + /// match to the expected schema. Usually means the SDK is out of date + /// relative to the provider's API. + #[error("could not decode {provider} response: {message}")] + Decode { + /// Driver name. + provider: &'static str, + /// Description of what went wrong. + message: String, + }, +} + +impl Error { + /// Helper: build an [`Error::Http`] from a reqwest error and a driver + /// name. Used internally by every driver. + #[allow(dead_code)] // unused when every provider feature is disabled + pub(crate) fn http(provider: &'static str, source: reqwest::Error) -> Self { + Self::Http { provider, source } + } + + /// Helper: build an [`Error::Decode`] from a driver name and message. + #[allow(dead_code)] // unused when every provider feature is disabled + pub(crate) fn decode(provider: &'static str, message: impl Into) -> Self { + Self::Decode { + provider, + message: message.into(), + } + } +} diff --git a/crates/iran-pay/src/gateway.rs b/crates/iran-pay/src/gateway.rs new file mode 100644 index 0000000..b78e25b --- /dev/null +++ b/crates/iran-pay/src/gateway.rs @@ -0,0 +1,59 @@ +//! The [`Gateway`] trait — the single abstraction every driver implements. + +use async_trait::async_trait; + +use crate::{ + Error, RefundRequest, RefundResponse, Result, StartRequest, StartResponse, VerifyRequest, + VerifyResponse, +}; + +/// Common interface for every Iranian payment gateway driver. +/// +/// All methods are `&self`-only so a single [`Gateway`] instance can be +/// shared across tasks (typically in an `Arc`). +/// +/// `Gateway` is **dyn-safe**: you can hold a `Box` or +/// `Arc` in a HashMap to swap providers at runtime. +/// +/// # Example: runtime selection +/// +/// ```ignore +/// use std::sync::Arc; +/// use iran_pay::{Gateway, providers::{ZarinPal, IDPay}}; +/// +/// fn pick(name: &str) -> Arc { +/// match name { +/// "zarinpal" => Arc::new(ZarinPal::new("MERCHANT")), +/// "idpay" => Arc::new(IDPay::new("API-KEY")), +/// _ => unreachable!(), +/// } +/// } +/// ``` +#[async_trait] +pub trait Gateway: Send + Sync { + /// Driver name (`"zarinpal"`, `"idpay"`, `"nextpay"`, `"payir"`, or + /// `"mock"`). Useful for logging and metrics tags. + fn name(&self) -> &'static str; + + /// Initiate a payment. Returns an authority token and the URL you + /// should redirect the user to. + async fn start_payment(&self, req: &StartRequest) -> Result; + + /// Verify a payment after the user returns from the gateway. + /// + /// The driver also re-checks that the gateway-reported amount matches + /// `req.amount` and returns [`Error::AmountMismatch`] if not — guarding + /// against tampered callback URLs. + async fn verify_payment(&self, req: &VerifyRequest) -> Result; + + /// Refund a previously verified transaction. + /// + /// Default implementation returns [`Error::Unsupported`]. Drivers that + /// support refunds override this method. + async fn refund_payment(&self, _req: &RefundRequest) -> Result { + Err(Error::Unsupported { + provider: self.name(), + operation: "refund_payment", + }) + } +} diff --git a/crates/iran-pay/src/lib.rs b/crates/iran-pay/src/lib.rs new file mode 100644 index 0000000..14572d4 --- /dev/null +++ b/crates/iran-pay/src/lib.rs @@ -0,0 +1,127 @@ +//! # iran-pay +//! +//! Unified async SDK for Iranian payment gateways. One [`Gateway`] trait, +//! **six production drivers** (ZarinPal, IDPay, NextPay, Pay.ir, Zibal, +//! Vandar), shared strongly-typed request/response/error types, an in-memory +//! mock gateway, security helpers (HMAC verification, constant-time compare, +//! amount-mismatch guard), and per-provider API-version pinning. See +//! [VERSIONING.md](https://github.com/obsernetics/rust-lib/blob/main/crates/iran-pay/VERSIONING.md) +//! for the upgrade policy. +//! +//! ## At a glance +//! +//! ```no_run +//! use iran_pay::{Amount, Gateway, StartRequest, VerifyRequest}; +//! use iran_pay::providers::ZarinPal; +//! +//! # async fn run() -> Result<(), iran_pay::Error> { +//! let gateway = ZarinPal::new("YOUR-MERCHANT-UUID").sandbox(); +//! +//! // 1. Initiate the payment. +//! let start = gateway.start_payment(&StartRequest::builder() +//! .amount(Amount::toman(50_000)) +//! .description("Pro subscription — May 2026") +//! .callback_url("https://example.com/payment/callback") +//! .order_id("ORD-12345") +//! .build()).await?; +//! +//! // 2. Redirect the user to `start.payment_url`. +//! println!("Send user to: {}", start.payment_url); +//! +//! // 3. After they return, verify. Pass the same amount back in to +//! // catch tampering with the callback query string. +//! let verified = gateway.verify_payment(&VerifyRequest { +//! authority: start.authority, +//! amount: Amount::toman(50_000), +//! }).await?; +//! +//! println!("Paid! Transaction ID = {}", verified.transaction_id); +//! # Ok(()) } +//! ``` +//! +//! ## Why a trait? +//! +//! Iranian e-commerce apps often switch gateways (or run several in parallel +//! for redundancy / fee optimisation). Code your checkout against +//! `dyn Gateway` or `impl Gateway` and you can swap providers with one line +//! of configuration. +//! +//! ```ignore +//! fn select_gateway(provider: &str) -> Box { +//! match provider { +//! "zarinpal" => Box::new(ZarinPal::new(env::var("ZP_ID").unwrap())), +//! "idpay" => Box::new(IDPay::new(env::var("IDPAY_KEY").unwrap())), +//! "nextpay" => Box::new(NextPay::new(env::var("NEXTPAY_KEY").unwrap())), +//! "payir" => Box::new(PayIr::new(env::var("PAYIR_KEY").unwrap())), +//! _ => unreachable!(), +//! } +//! } +//! ``` +//! +//! ## Sandbox / test mode +//! +//! Every provider exposes `.sandbox()` to flip to its test endpoint. No real +//! money moves. Use this in CI and for local development. +//! +//! ## Mock gateway +//! +//! For unit tests of *your* code, use [`mock::MockGateway`] — it implements +//! [`Gateway`] without any network I/O, and lets you script success / failure +//! responses programmatically. +//! +//! ## Cargo features +//! +//! | Feature | Default | What it enables | +//! |--------------|---------|-----------------------------------------------| +//! | `zarinpal` | ✓ | The [`providers::ZarinPal`] driver | +//! | `idpay` | ✓ | The [`providers::IDPay`] driver | +//! | `nextpay` | ✓ | The [`providers::NextPay`] driver | +//! | `payir` | ✓ | The [`providers::PayIr`] driver | +//! | `zibal` | ✓ | The [`providers::Zibal`] driver | +//! | `vandar` | ✓ | The [`providers::Vandar`] driver | +//! | `validators` | ✓ | Re-export `parsitext`'s Iranian validators | +//! | `rustls-tls` | ✓ | Use rustls for HTTPS (no system OpenSSL) | +//! | `native-tls` | | Use the platform TLS library | +//! +//! Disabling all provider features still gives you the trait, types, mock, +//! and security helpers — useful if you build your own driver against this +//! abstraction. + +#![cfg_attr(docsrs, feature(doc_cfg))] +#![warn(missing_docs)] +#![warn(rustdoc::broken_intra_doc_links)] + +mod amount; +mod error; +mod gateway; +mod types; + +pub mod mock; +pub mod providers; +pub mod security; + +#[cfg(feature = "validators")] +#[cfg_attr(docsrs, doc(cfg(feature = "validators")))] +pub mod validators; + +pub use amount::{Amount, Currency}; +pub use error::Error; +pub use gateway::Gateway; +pub use types::{ + RefundRequest, RefundResponse, StartRequest, StartRequestBuilder, StartResponse, VerifyRequest, + VerifyResponse, +}; + +/// Crate-wide `Result` alias. +pub type Result = std::result::Result; + +/// Re-exports of the most commonly used types. +/// +/// `use iran_pay::prelude::*;` brings everything you need for a typical +/// checkout flow into scope. +pub mod prelude { + pub use crate::{ + Amount, Currency, Error, Gateway, RefundRequest, RefundResponse, Result, StartRequest, + StartResponse, VerifyRequest, VerifyResponse, + }; +} diff --git a/crates/iran-pay/src/mock.rs b/crates/iran-pay/src/mock.rs new file mode 100644 index 0000000..6bf27c4 --- /dev/null +++ b/crates/iran-pay/src/mock.rs @@ -0,0 +1,188 @@ +//! [`MockGateway`] — a no-network [`Gateway`] for unit-testing your own code. +//! +//! Use this in your test suite to exercise your checkout flow without +//! standing up a real provider: +//! +//! ``` +//! use iran_pay::{Amount, Gateway, StartRequest, VerifyRequest}; +//! use iran_pay::mock::MockGateway; +//! +//! # async fn run() -> Result<(), iran_pay::Error> { +//! let gw = MockGateway::new(); +//! let start = gw.start_payment(&StartRequest::builder() +//! .amount(Amount::toman(1_000)) +//! .description("Test") +//! .callback_url("http://localhost/cb") +//! .build()).await?; +//! +//! let verify = gw.verify_payment(&VerifyRequest { +//! authority: start.authority, +//! amount: Amount::toman(1_000), +//! }).await?; +//! +//! assert_eq!(verify.amount.as_tomans(), 1_000); +//! # Ok(()) } +//! ``` + +use async_trait::async_trait; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use crate::{ + Amount, Error, Gateway, Result, StartRequest, StartResponse, VerifyRequest, VerifyResponse, +}; + +/// What the mock should do on the next call. +#[derive(Debug, Clone)] +pub enum Behavior { + /// Succeed normally (the default). + Succeed, + /// Fail with a [`Error::Gateway`] bearing the given code/message. + FailGateway { + /// Provider error code to return. + code: i64, + /// Message to return. + message: String, + }, +} + +/// In-memory [`Gateway`] for tests. No network I/O, no external dependencies. +/// +/// The mock generates monotonically increasing `authority` and +/// `transaction_id` values and keeps a counter of calls so tests can assert +/// "exactly one call" semantics easily. +pub struct MockGateway { + inner: Arc, +} + +struct MockInner { + next_id: AtomicU64, + start_calls: AtomicU64, + verify_calls: AtomicU64, + refund_calls: AtomicU64, + start_behavior: Mutex, + verify_behavior: Mutex, +} + +impl Default for MockGateway { + fn default() -> Self { + Self::new() + } +} + +impl MockGateway { + /// New mock with default (always-succeed) behaviour. + #[must_use] + pub fn new() -> Self { + Self { + inner: Arc::new(MockInner { + next_id: AtomicU64::new(1), + start_calls: AtomicU64::new(0), + verify_calls: AtomicU64::new(0), + refund_calls: AtomicU64::new(0), + start_behavior: Mutex::new(Behavior::Succeed), + verify_behavior: Mutex::new(Behavior::Succeed), + }), + } + } + + /// Configure the next [`Gateway::start_payment`] call's outcome. + pub fn set_start_behavior(&self, b: Behavior) { + *self.inner.start_behavior.lock().unwrap() = b; + } + + /// Configure the next [`Gateway::verify_payment`] call's outcome. + pub fn set_verify_behavior(&self, b: Behavior) { + *self.inner.verify_behavior.lock().unwrap() = b; + } + + /// Number of `start_payment` calls observed so far. + #[must_use] + pub fn start_call_count(&self) -> u64 { + self.inner.start_calls.load(Ordering::SeqCst) + } + + /// Number of `verify_payment` calls observed so far. + #[must_use] + pub fn verify_call_count(&self) -> u64 { + self.inner.verify_calls.load(Ordering::SeqCst) + } + + /// Number of `refund_payment` calls observed so far. + #[must_use] + pub fn refund_call_count(&self) -> u64 { + self.inner.refund_calls.load(Ordering::SeqCst) + } + + fn next_id(&self) -> u64 { + self.inner.next_id.fetch_add(1, Ordering::SeqCst) + } +} + +#[async_trait] +impl Gateway for MockGateway { + fn name(&self) -> &'static str { + "mock" + } + + async fn start_payment(&self, req: &StartRequest) -> Result { + self.inner.start_calls.fetch_add(1, Ordering::SeqCst); + let behavior = self.inner.start_behavior.lock().unwrap().clone(); + if let Behavior::FailGateway { code, message } = behavior { + return Err(Error::Gateway { + provider: "mock", + code, + message, + }); + } + + let id = self.next_id(); + let authority = format!("MOCK-AUTH-{id:020}"); + Ok(StartResponse { + authority: authority.clone(), + payment_url: format!("https://mock.invalid/pay/{}", authority), + provider: "mock", + raw: serde_json::json!({ + "amount": req.amount.as_rials(), + "description": req.description, + "callback_url": req.callback_url, + }), + }) + } + + async fn verify_payment(&self, req: &VerifyRequest) -> Result { + self.inner.verify_calls.fetch_add(1, Ordering::SeqCst); + let behavior = self.inner.verify_behavior.lock().unwrap().clone(); + if let Behavior::FailGateway { code, message } = behavior { + return Err(Error::Gateway { + provider: "mock", + code, + message, + }); + } + + let id = self.next_id(); + Ok(VerifyResponse { + transaction_id: format!("MOCK-TX-{id:020}"), + authority: req.authority.clone(), + amount: req.amount, + card_pan: Some("6037-99**-****-0006".into()), + card_hash: Some(format!("mock-hash-{id}")), + fee: Some(Amount::rial(0)), + provider: "mock", + raw: serde_json::json!({"mock": true}), + }) + } + + async fn refund_payment(&self, req: &crate::RefundRequest) -> Result { + self.inner.refund_calls.fetch_add(1, Ordering::SeqCst); + let id = self.next_id(); + Ok(crate::RefundResponse { + refund_id: format!("MOCK-REFUND-{id:020}"), + transaction_id: req.transaction_id.clone(), + amount: req.amount.unwrap_or_else(|| Amount::rial(0)), + provider: "mock", + raw: serde_json::json!({"mock_refund": true}), + }) + } +} diff --git a/crates/iran-pay/src/providers/idpay.rs b/crates/iran-pay/src/providers/idpay.rs new file mode 100644 index 0000000..e3b9bb0 --- /dev/null +++ b/crates/iran-pay/src/providers/idpay.rs @@ -0,0 +1,220 @@ +//! IDPay driver. +//! +//! IDPay's API uses an `X-API-KEY` header for authentication and an +//! optional `X-SANDBOX: 1` header for sandbox mode. + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tracing::instrument; + +use crate::{ + Amount, Error, Gateway, Result, StartRequest, StartResponse, VerifyRequest, VerifyResponse, +}; + +const PROVIDER: &str = "idpay"; +const API: &str = "https://api.idpay.ir"; + +/// IDPay gateway driver. +pub struct IDPay { + api_key: String, + sandbox: bool, + api_base: String, + client: reqwest::Client, +} + +impl IDPay { + /// New driver with the given API key (32-char hex string from your IDPay + /// dashboard). + #[must_use] + pub fn new(api_key: impl Into) -> Self { + Self { + api_key: api_key.into(), + sandbox: false, + api_base: API.into(), + client: reqwest::Client::new(), + } + } + + /// Switch to IDPay's sandbox. Sets `X-SANDBOX: 1` on every request. + #[must_use] + pub fn sandbox(mut self) -> Self { + self.sandbox = true; + self + } + + /// Override the API base URL (for tests). + #[must_use] + pub fn with_api_base(mut self, url: impl Into) -> Self { + self.api_base = url.into(); + self + } + + /// Override the underlying HTTP client. + #[must_use] + pub fn with_client(mut self, client: reqwest::Client) -> Self { + self.client = client; + self + } + + fn request>(&self, path: U) -> reqwest::RequestBuilder { + let mut rb = self + .client + .post(format!("{}{}", self.api_base, path.as_ref())) + .header("X-API-KEY", &self.api_key) + .header("Content-Type", "application/json"); + if self.sandbox { + rb = rb.header("X-SANDBOX", "1"); + } + rb + } +} + +#[async_trait] +impl Gateway for IDPay { + fn name(&self) -> &'static str { + PROVIDER + } + + #[instrument(skip(self, req), fields(provider = PROVIDER, amount_rials = req.amount.as_rials()))] + async fn start_payment(&self, req: &StartRequest) -> Result { + // IDPay enforces minimum 1000 Rials. + if req.amount.as_rials() < 1_000 { + return Err(Error::Config(format!( + "idpay: amount must be at least 1000 Rials (got {} Rials)", + req.amount.as_rials() + ))); + } + + let body = json!({ + "order_id": req.order_id.clone().unwrap_or_default(), + "amount": req.amount.as_rials(), + "name": req.extras.get("name").cloned().unwrap_or_default(), + "phone": req.mobile.clone().unwrap_or_default(), + "mail": req.email.clone().unwrap_or_default(), + "desc": req.description, + "callback": req.callback_url, + }); + + let resp = self + .request("/v1.1/payment") + .json(&body) + .send() + .await + .map_err(|e| Error::http(PROVIDER, e))?; + let raw: serde_json::Value = resp.json().await.map_err(|e| Error::http(PROVIDER, e))?; + + // Error case: {error_code, error_message} + if let Some(code) = raw.get("error_code").and_then(|v| v.as_i64()) { + return Err(Error::Gateway { + provider: PROVIDER, + code, + message: raw + .get("error_message") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_owned(), + }); + } + + let parsed: IdpStart = serde_json::from_value(raw.clone()) + .map_err(|e| Error::decode(PROVIDER, format!("start: {e}")))?; + + Ok(StartResponse { + authority: parsed.id, + payment_url: parsed.link, + provider: PROVIDER, + raw, + }) + } + + #[instrument(skip(self, req), fields(provider = PROVIDER, authority = %req.authority))] + async fn verify_payment(&self, req: &VerifyRequest) -> Result { + let body = json!({ + "id": req.authority, + // IDPay requires the original order_id, but we don't always have + // it at verify-time. Empty is accepted by the sandbox; in + // production, callers should keep the original `order_id`. + "order_id": req.authority, + }); + + let resp = self + .request("/v1.1/payment/verify") + .json(&body) + .send() + .await + .map_err(|e| Error::http(PROVIDER, e))?; + let raw: serde_json::Value = resp.json().await.map_err(|e| Error::http(PROVIDER, e))?; + + if let Some(code) = raw.get("error_code").and_then(|v| v.as_i64()) { + return Err(Error::Gateway { + provider: PROVIDER, + code, + message: raw + .get("error_message") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_owned(), + }); + } + + let parsed: IdpVerify = serde_json::from_value(raw.clone()) + .map_err(|e| Error::decode(PROVIDER, format!("verify: {e}")))?; + + // Status code 100 = paid & verified; 101 = already verified. + if parsed.status != 100 && parsed.status != 101 { + return Err(Error::Gateway { + provider: PROVIDER, + code: parsed.status, + message: format!("idpay status {}", parsed.status), + }); + } + + let actual = Amount::rial(parsed.amount); + if actual != req.amount { + return Err(Error::AmountMismatch { + expected: req.amount, + actual, + }); + } + + Ok(VerifyResponse { + transaction_id: parsed.track_id.to_string(), + authority: req.authority.clone(), + amount: actual, + card_pan: parsed.payment.as_ref().and_then(|p| p.card_no.clone()), + card_hash: parsed + .payment + .as_ref() + .and_then(|p| p.hashed_card_no.clone()), + fee: None, + provider: PROVIDER, + raw, + }) + } +} + +// ── wire types ─────────────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize, Serialize)] +struct IdpStart { + id: String, + link: String, +} + +#[derive(Debug, Deserialize, Serialize)] +struct IdpVerify { + status: i64, + track_id: i64, + amount: i64, + #[serde(default)] + payment: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +struct IdpPayment { + #[serde(default)] + card_no: Option, + #[serde(default)] + hashed_card_no: Option, +} diff --git a/crates/iran-pay/src/providers/mod.rs b/crates/iran-pay/src/providers/mod.rs new file mode 100644 index 0000000..f560d2b --- /dev/null +++ b/crates/iran-pay/src/providers/mod.rs @@ -0,0 +1,62 @@ +//! Concrete [`Gateway`](crate::Gateway) drivers. +//! +//! Each provider lives behind its own Cargo feature and can be opted out of +//! to keep your binary smaller. +//! +//! | Provider | Default | API style | +//! |-----------------------------|---------|--------------------| +//! | [`ZarinPal`] | ✓ | JSON v4 | +//! | [`IDPay`] | ✓ | JSON v1.1 | +//! | [`NextPay`] | ✓ | Form-encoded | +//! | [`PayIr`] | ✓ | Form-encoded | +//! +//! All four implement the same [`Gateway`](crate::Gateway) trait so your +//! checkout code can be provider-agnostic. + +#[cfg(feature = "idpay")] +#[cfg_attr(docsrs, doc(cfg(feature = "idpay")))] +mod idpay; + +#[cfg(feature = "nextpay")] +#[cfg_attr(docsrs, doc(cfg(feature = "nextpay")))] +mod nextpay; + +#[cfg(feature = "payir")] +#[cfg_attr(docsrs, doc(cfg(feature = "payir")))] +mod payir; + +#[cfg(feature = "vandar")] +#[cfg_attr(docsrs, doc(cfg(feature = "vandar")))] +mod vandar; + +#[cfg(feature = "zarinpal")] +#[cfg_attr(docsrs, doc(cfg(feature = "zarinpal")))] +mod zarinpal; + +#[cfg(feature = "zibal")] +#[cfg_attr(docsrs, doc(cfg(feature = "zibal")))] +mod zibal; + +#[cfg(feature = "idpay")] +#[cfg_attr(docsrs, doc(cfg(feature = "idpay")))] +pub use idpay::IDPay; + +#[cfg(feature = "nextpay")] +#[cfg_attr(docsrs, doc(cfg(feature = "nextpay")))] +pub use nextpay::NextPay; + +#[cfg(feature = "payir")] +#[cfg_attr(docsrs, doc(cfg(feature = "payir")))] +pub use payir::PayIr; + +#[cfg(feature = "vandar")] +#[cfg_attr(docsrs, doc(cfg(feature = "vandar")))] +pub use vandar::Vandar; + +#[cfg(feature = "zarinpal")] +#[cfg_attr(docsrs, doc(cfg(feature = "zarinpal")))] +pub use zarinpal::ZarinPal; + +#[cfg(feature = "zibal")] +#[cfg_attr(docsrs, doc(cfg(feature = "zibal")))] +pub use zibal::Zibal; diff --git a/crates/iran-pay/src/providers/nextpay.rs b/crates/iran-pay/src/providers/nextpay.rs new file mode 100644 index 0000000..5bfd064 --- /dev/null +++ b/crates/iran-pay/src/providers/nextpay.rs @@ -0,0 +1,198 @@ +//! NextPay driver. +//! +//! NextPay uses form-encoded POST bodies and an `api_key` field instead of +//! a header. The redirect URL is `https://nextpay.org/nx/gateway/payment/{trans_id}`. + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use tracing::instrument; + +use crate::{ + Amount, Error, Gateway, Result, StartRequest, StartResponse, VerifyRequest, VerifyResponse, +}; + +const PROVIDER: &str = "nextpay"; +const API: &str = "https://nextpay.org"; + +/// NextPay gateway driver. +pub struct NextPay { + api_key: String, + api_base: String, + client: reqwest::Client, +} + +impl NextPay { + /// New driver with the given NextPay API key. + #[must_use] + pub fn new(api_key: impl Into) -> Self { + Self { + api_key: api_key.into(), + api_base: API.into(), + client: reqwest::Client::new(), + } + } + + /// NextPay does not have a separate sandbox host; merchants test using + /// a designated test API key on the production endpoint. This method + /// is a no-op kept for API symmetry with other providers. + #[must_use] + pub fn sandbox(self) -> Self { + self + } + + /// Override the API base URL (for tests). + #[must_use] + pub fn with_api_base(mut self, url: impl Into) -> Self { + self.api_base = url.into(); + self + } + + /// Override the HTTP client. + #[must_use] + pub fn with_client(mut self, client: reqwest::Client) -> Self { + self.client = client; + self + } +} + +#[async_trait] +impl Gateway for NextPay { + fn name(&self) -> &'static str { + PROVIDER + } + + #[instrument(skip(self, req), fields(provider = PROVIDER, amount_rials = req.amount.as_rials()))] + async fn start_payment(&self, req: &StartRequest) -> Result { + let order_id = req + .order_id + .clone() + .unwrap_or_else(|| format!("ORD-{}", chrono_secs())); + + let mut form: HashMap<&str, String> = HashMap::new(); + form.insert("api_key", self.api_key.clone()); + form.insert("amount", req.amount.as_rials().to_string()); + form.insert("order_id", order_id); + form.insert("callback_uri", req.callback_url.clone()); + form.insert("customer_phone", req.mobile.clone().unwrap_or_default()); + form.insert("custom_json_fields", "{}".into()); + form.insert( + "payer_name", + req.extras.get("name").cloned().unwrap_or_default(), + ); + form.insert("payer_desc", req.description.clone()); + + let raw: serde_json::Value = self + .client + .post(format!("{}/nx/gateway/token", self.api_base)) + .form(&form) + .send() + .await + .map_err(|e| Error::http(PROVIDER, e))? + .json() + .await + .map_err(|e| Error::http(PROVIDER, e))?; + + let parsed: NpStart = serde_json::from_value(raw.clone()) + .map_err(|e| Error::decode(PROVIDER, format!("start: {e}")))?; + + // NextPay code -1 = success; any other negative = error. + if parsed.code != -1 { + return Err(Error::Gateway { + provider: PROVIDER, + code: parsed.code, + message: format!("nextpay code {}", parsed.code), + }); + } + let trans_id = parsed + .trans_id + .ok_or_else(|| Error::decode(PROVIDER, "start: missing trans_id"))?; + + let payment_url = format!("{}/nx/gateway/payment/{}", self.api_base, trans_id); + + Ok(StartResponse { + authority: trans_id, + payment_url, + provider: PROVIDER, + raw, + }) + } + + #[instrument(skip(self, req), fields(provider = PROVIDER, authority = %req.authority))] + async fn verify_payment(&self, req: &VerifyRequest) -> Result { + let mut form: HashMap<&str, String> = HashMap::new(); + form.insert("api_key", self.api_key.clone()); + form.insert("trans_id", req.authority.clone()); + form.insert("amount", req.amount.as_rials().to_string()); + + let raw: serde_json::Value = self + .client + .post(format!("{}/nx/gateway/verify", self.api_base)) + .form(&form) + .send() + .await + .map_err(|e| Error::http(PROVIDER, e))? + .json() + .await + .map_err(|e| Error::http(PROVIDER, e))?; + + let parsed: NpVerify = serde_json::from_value(raw.clone()) + .map_err(|e| Error::decode(PROVIDER, format!("verify: {e}")))?; + + if parsed.code != 0 { + return Err(Error::Gateway { + provider: PROVIDER, + code: parsed.code, + message: format!("nextpay code {}", parsed.code), + }); + } + + let actual = Amount::rial(parsed.amount.unwrap_or(req.amount.as_rials())); + if actual != req.amount { + return Err(Error::AmountMismatch { + expected: req.amount, + actual, + }); + } + + Ok(VerifyResponse { + transaction_id: parsed + .shaparak_ref_id + .unwrap_or_else(|| req.authority.clone()), + authority: req.authority.clone(), + amount: actual, + card_pan: parsed.card_holder, + card_hash: None, + fee: None, + provider: PROVIDER, + raw, + }) + } +} + +fn chrono_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +// ── wire types ─────────────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize, Serialize)] +struct NpStart { + code: i64, + #[serde(default)] + trans_id: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +struct NpVerify { + code: i64, + #[serde(default)] + amount: Option, + #[serde(default)] + shaparak_ref_id: Option, + #[serde(default)] + card_holder: Option, +} diff --git a/crates/iran-pay/src/providers/payir.rs b/crates/iran-pay/src/providers/payir.rs new file mode 100644 index 0000000..20ea30e --- /dev/null +++ b/crates/iran-pay/src/providers/payir.rs @@ -0,0 +1,184 @@ +//! Pay.ir driver. +//! +//! Pay.ir uses form-encoded POST bodies, an `api` field for authentication, +//! and the magic test API key `"test"` for sandbox-style verification. + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use tracing::instrument; + +use crate::{ + Amount, Error, Gateway, Result, StartRequest, StartResponse, VerifyRequest, VerifyResponse, +}; + +const PROVIDER: &str = "payir"; +const API: &str = "https://pay.ir"; + +/// Pay.ir gateway driver. +pub struct PayIr { + api_key: String, + api_base: String, + client: reqwest::Client, +} + +impl PayIr { + /// New driver with the given Pay.ir API key. + #[must_use] + pub fn new(api_key: impl Into) -> Self { + Self { + api_key: api_key.into(), + api_base: API.into(), + client: reqwest::Client::new(), + } + } + + /// Use Pay.ir's `"test"` API key — every payment in this mode is + /// simulated and no real money moves. + #[must_use] + pub fn sandbox() -> Self { + Self::new("test") + } + + /// Override the API base URL (for tests). + #[must_use] + pub fn with_api_base(mut self, url: impl Into) -> Self { + self.api_base = url.into(); + self + } + + /// Override the HTTP client. + #[must_use] + pub fn with_client(mut self, client: reqwest::Client) -> Self { + self.client = client; + self + } +} + +#[async_trait] +impl Gateway for PayIr { + fn name(&self) -> &'static str { + PROVIDER + } + + #[instrument(skip(self, req), fields(provider = PROVIDER, amount_rials = req.amount.as_rials()))] + async fn start_payment(&self, req: &StartRequest) -> Result { + let mut form: HashMap<&str, String> = HashMap::new(); + form.insert("api", self.api_key.clone()); + form.insert("amount", req.amount.as_rials().to_string()); + form.insert("redirect", req.callback_url.clone()); + form.insert("description", req.description.clone()); + if let Some(m) = &req.mobile { + form.insert("mobile", m.clone()); + } + if let Some(o) = &req.order_id { + form.insert("factorNumber", o.clone()); + } + + let raw: serde_json::Value = self + .client + .post(format!("{}/pg/send", self.api_base)) + .form(&form) + .send() + .await + .map_err(|e| Error::http(PROVIDER, e))? + .json() + .await + .map_err(|e| Error::http(PROVIDER, e))?; + + let parsed: PayIrStart = serde_json::from_value(raw.clone()) + .map_err(|e| Error::decode(PROVIDER, format!("start: {e}")))?; + + if parsed.status != 1 { + return Err(Error::Gateway { + provider: PROVIDER, + code: parsed.status, + message: parsed.error_message.unwrap_or_default(), + }); + } + let token = parsed + .token + .ok_or_else(|| Error::decode(PROVIDER, "start: missing token"))?; + + let payment_url = format!("{}/pg/{}", self.api_base, token); + + Ok(StartResponse { + authority: token, + payment_url, + provider: PROVIDER, + raw, + }) + } + + #[instrument(skip(self, req), fields(provider = PROVIDER, authority = %req.authority))] + async fn verify_payment(&self, req: &VerifyRequest) -> Result { + let mut form: HashMap<&str, String> = HashMap::new(); + form.insert("api", self.api_key.clone()); + form.insert("token", req.authority.clone()); + + let raw: serde_json::Value = self + .client + .post(format!("{}/pg/verify", self.api_base)) + .form(&form) + .send() + .await + .map_err(|e| Error::http(PROVIDER, e))? + .json() + .await + .map_err(|e| Error::http(PROVIDER, e))?; + + let parsed: PayIrVerify = serde_json::from_value(raw.clone()) + .map_err(|e| Error::decode(PROVIDER, format!("verify: {e}")))?; + + if parsed.status != 1 { + return Err(Error::Gateway { + provider: PROVIDER, + code: parsed.status, + message: parsed.message.unwrap_or_default(), + }); + } + + let actual = Amount::rial(parsed.amount.unwrap_or(req.amount.as_rials())); + if actual != req.amount { + return Err(Error::AmountMismatch { + expected: req.amount, + actual, + }); + } + + Ok(VerifyResponse { + transaction_id: parsed.trans_id.unwrap_or_else(|| req.authority.clone()), + authority: req.authority.clone(), + amount: actual, + card_pan: parsed.card_number, + card_hash: None, + fee: None, + provider: PROVIDER, + raw, + }) + } +} + +// ── wire types ─────────────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize, Serialize)] +struct PayIrStart { + status: i64, + #[serde(default)] + token: Option, + #[serde(default)] + error_message: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +struct PayIrVerify { + status: i64, + #[serde(default)] + amount: Option, + #[serde(default, alias = "transId")] + trans_id: Option, + #[serde(default, alias = "cardNumber")] + card_number: Option, + #[serde(default)] + message: Option, +} diff --git a/crates/iran-pay/src/providers/vandar.rs b/crates/iran-pay/src/providers/vandar.rs new file mode 100644 index 0000000..149ec73 --- /dev/null +++ b/crates/iran-pay/src/providers/vandar.rs @@ -0,0 +1,210 @@ +//! Vandar driver. +//! +//! API verified against Vandar's official IPG documentation +//! (): +//! +//! - **Request**: `POST https://ipg.vandar.io/api/v3/send` (JSON) +//! - **Verify**: `POST https://ipg.vandar.io/api/v3/verify` (JSON) +//! - **Redirect**: `https://ipg.vandar.io/v3/{token}` +//! - **Auth**: `api_key` is sent as a JSON body field, **not** a header. +//! - **Success status**: `status == 1`. + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tracing::instrument; + +use crate::{ + Amount, Error, Gateway, Result, StartRequest, StartResponse, VerifyRequest, VerifyResponse, +}; + +const PROVIDER: &str = "vandar"; +const API: &str = "https://ipg.vandar.io"; + +/// Vandar gateway driver. +pub struct Vandar { + api_key: String, + api_base: String, + client: reqwest::Client, +} + +impl Vandar { + /// New driver with the given Vandar API key (from your business dashboard). + #[must_use] + pub fn new(api_key: impl Into) -> Self { + Self { + api_key: api_key.into(), + api_base: API.into(), + client: reqwest::Client::new(), + } + } + + /// Vandar does not currently expose a separate sandbox host; use a test + /// API key on the production endpoint per their docs. Kept for API + /// symmetry with other providers. + #[must_use] + pub fn sandbox(self) -> Self { + self + } + + /// Override the API base URL (for tests). + #[must_use] + pub fn with_api_base(mut self, url: impl Into) -> Self { + self.api_base = url.into(); + self + } + + /// Override the HTTP client. + #[must_use] + pub fn with_client(mut self, client: reqwest::Client) -> Self { + self.client = client; + self + } +} + +#[async_trait] +impl Gateway for Vandar { + fn name(&self) -> &'static str { + PROVIDER + } + + #[instrument(skip(self, req), fields(provider = PROVIDER, amount_rials = req.amount.as_rials()))] + async fn start_payment(&self, req: &StartRequest) -> Result { + // Vandar enforces a minimum of 1000 Rials. + if req.amount.as_rials() < 1_000 { + return Err(Error::Config(format!( + "vandar: amount must be at least 1000 Rials (got {} Rials)", + req.amount.as_rials() + ))); + } + + let body = json!({ + "api_key": self.api_key, + "amount": req.amount.as_rials(), + "callback_url": req.callback_url, + "description": req.description, + "mobile_number": req.mobile, + "factorNumber": req.order_id, + "national_code": req.extras.get("national_code"), + }); + + let raw: serde_json::Value = self + .client + .post(format!("{}/api/v3/send", self.api_base)) + .json(&body) + .send() + .await + .map_err(|e| Error::http(PROVIDER, e))? + .json() + .await + .map_err(|e| Error::http(PROVIDER, e))?; + + let parsed: VandarStart = serde_json::from_value(raw.clone()) + .map_err(|e| Error::decode(PROVIDER, format!("start: {e}")))?; + + if parsed.status != 1 { + return Err(Error::Gateway { + provider: PROVIDER, + code: parsed.status, + message: parsed + .errors + .as_ref() + .and_then(|v| v.first().cloned()) + .unwrap_or_default(), + }); + } + let token = parsed + .token + .ok_or_else(|| Error::decode(PROVIDER, "start: missing token"))?; + + let payment_url = format!("{}/v3/{}", self.api_base, token); + + Ok(StartResponse { + authority: token, + payment_url, + provider: PROVIDER, + raw, + }) + } + + #[instrument(skip(self, req), fields(provider = PROVIDER, authority = %req.authority))] + async fn verify_payment(&self, req: &VerifyRequest) -> Result { + let body = json!({ + "api_key": self.api_key, + "token": req.authority, + }); + + let raw: serde_json::Value = self + .client + .post(format!("{}/api/v3/verify", self.api_base)) + .json(&body) + .send() + .await + .map_err(|e| Error::http(PROVIDER, e))? + .json() + .await + .map_err(|e| Error::http(PROVIDER, e))?; + + let parsed: VandarVerify = serde_json::from_value(raw.clone()) + .map_err(|e| Error::decode(PROVIDER, format!("verify: {e}")))?; + + if parsed.status != 1 { + return Err(Error::Gateway { + provider: PROVIDER, + code: parsed.status, + message: parsed + .errors + .as_ref() + .and_then(|v| v.first().cloned()) + .unwrap_or_default(), + }); + } + + let actual = Amount::rial(parsed.amount.unwrap_or(req.amount.as_rials())); + if actual != req.amount { + return Err(Error::AmountMismatch { + expected: req.amount, + actual, + }); + } + + Ok(VerifyResponse { + transaction_id: parsed + .trans_id + .map(|n| n.to_string()) + .unwrap_or_else(|| req.authority.clone()), + authority: req.authority.clone(), + amount: actual, + card_pan: parsed.card_number, + card_hash: parsed.cid, + fee: None, + provider: PROVIDER, + raw, + }) + } +} + +#[derive(Debug, Deserialize, Serialize)] +struct VandarStart { + status: i64, + #[serde(default)] + token: Option, + #[serde(default)] + errors: Option>, +} + +#[derive(Debug, Deserialize, Serialize)] +struct VandarVerify { + status: i64, + #[serde(default)] + amount: Option, + #[serde(default, rename = "transId")] + trans_id: Option, + #[serde(default, rename = "cardNumber")] + card_number: Option, + /// Hashed card number ("CID") if provided. + #[serde(default, rename = "CID")] + cid: Option, + #[serde(default)] + errors: Option>, +} diff --git a/crates/iran-pay/src/providers/zarinpal.rs b/crates/iran-pay/src/providers/zarinpal.rs new file mode 100644 index 0000000..f238073 --- /dev/null +++ b/crates/iran-pay/src/providers/zarinpal.rs @@ -0,0 +1,275 @@ +//! ZarinPal driver — Iran's most popular payment gateway. +//! +//! Implements ZarinPal's **v4 JSON API** at +//! `payment.zarinpal.com/pg/v4/payment/{request,verify}.json` +//! (verified against ). +//! Sandbox endpoints (`sandbox.zarinpal.com`) are reachable via +//! [`ZarinPal::sandbox`]. +//! +//! # Example +//! +//! ```no_run +//! use iran_pay::{Amount, Gateway, StartRequest}; +//! use iran_pay::providers::ZarinPal; +//! +//! # async fn run() -> Result<(), iran_pay::Error> { +//! let gw = ZarinPal::new("00000000-0000-0000-0000-000000000000").sandbox(); +//! let req = StartRequest::builder() +//! .amount(Amount::toman(50_000)) +//! .description("Test payment") +//! .callback_url("https://example.com/cb") +//! .build(); +//! let resp = gw.start_payment(&req).await?; +//! println!("{}", resp.payment_url); +//! # Ok(()) } +//! ``` + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tracing::instrument; + +use crate::{ + Amount, Error, Gateway, Result, StartRequest, StartResponse, VerifyRequest, VerifyResponse, +}; + +const PROVIDER: &str = "zarinpal"; +// Per https://www.zarinpal.com/docs/paymentGateway/connectToGateway, the +// canonical REST host is payment.zarinpal.com (api.zarinpal.com is reserved +// for the Payman / direct-debit API, not the standard gateway). +const PROD_API: &str = "https://payment.zarinpal.com"; +const PROD_PAY: &str = "https://www.zarinpal.com"; +const SANDBOX_API: &str = "https://sandbox.zarinpal.com"; +const SANDBOX_PAY: &str = "https://sandbox.zarinpal.com"; + +/// ZarinPal gateway driver. +pub struct ZarinPal { + merchant_id: String, + api_base: String, + pay_base: String, + client: reqwest::Client, +} + +impl ZarinPal { + /// Create a driver with the given merchant UUID. + /// + /// In production, your merchant ID is the UUID issued by ZarinPal. + /// For the sandbox, any UUID-shaped string works. + #[must_use] + pub fn new(merchant_id: impl Into) -> Self { + Self { + merchant_id: merchant_id.into(), + api_base: PROD_API.into(), + pay_base: PROD_PAY.into(), + client: reqwest::Client::new(), + } + } + + /// Switch to the ZarinPal sandbox endpoints. + #[must_use] + pub fn sandbox(mut self) -> Self { + self.api_base = SANDBOX_API.into(); + self.pay_base = SANDBOX_PAY.into(); + self + } + + /// Override the API base URL (primarily for tests against `wiremock`). + #[must_use] + pub fn with_api_base(mut self, url: impl Into) -> Self { + self.api_base = url.into(); + self + } + + /// Override the payment-redirect base URL (used to construct the + /// `StartPay/{authority}` redirect). + #[must_use] + pub fn with_pay_base(mut self, url: impl Into) -> Self { + self.pay_base = url.into(); + self + } + + /// Override the underlying [`reqwest::Client`] (e.g. to install a custom + /// timeout or proxy). + #[must_use] + pub fn with_client(mut self, client: reqwest::Client) -> Self { + self.client = client; + self + } +} + +#[async_trait] +impl Gateway for ZarinPal { + fn name(&self) -> &'static str { + PROVIDER + } + + #[instrument(skip(self, req), fields(provider = PROVIDER, amount_rials = req.amount.as_rials()))] + async fn start_payment(&self, req: &StartRequest) -> Result { + let url = format!("{}/pg/v4/payment/request.json", self.api_base); + let body = json!({ + "merchant_id": self.merchant_id, + "amount": req.amount.as_rials(), + "callback_url": req.callback_url, + "description": req.description, + "metadata": { + "email": req.email, + "mobile": req.mobile, + "order_id": req.order_id, + } + }); + + let raw: serde_json::Value = self + .client + .post(&url) + .json(&body) + .send() + .await + .map_err(|e| Error::http(PROVIDER, e))? + .json() + .await + .map_err(|e| Error::http(PROVIDER, e))?; + + let parsed: ZpResp = serde_json::from_value(raw.clone()) + .map_err(|e| Error::decode(PROVIDER, format!("start: {e}")))?; + check_zp_errors(&parsed)?; + + let data = parsed + .data + .ok_or_else(|| Error::decode(PROVIDER, "start: missing `data`"))?; + if data.code != 100 { + return Err(Error::Gateway { + provider: PROVIDER, + code: data.code, + message: data.message, + }); + } + + let payment_url = format!("{}/pg/StartPay/{}", self.pay_base, data.authority); + + Ok(StartResponse { + authority: data.authority, + payment_url, + provider: PROVIDER, + raw, + }) + } + + #[instrument(skip(self, req), fields(provider = PROVIDER, authority = %req.authority))] + async fn verify_payment(&self, req: &VerifyRequest) -> Result { + let url = format!("{}/pg/v4/payment/verify.json", self.api_base); + let body = json!({ + "merchant_id": self.merchant_id, + "authority": req.authority, + "amount": req.amount.as_rials(), + }); + + let raw: serde_json::Value = self + .client + .post(&url) + .json(&body) + .send() + .await + .map_err(|e| Error::http(PROVIDER, e))? + .json() + .await + .map_err(|e| Error::http(PROVIDER, e))?; + + let parsed: ZpResp = serde_json::from_value(raw.clone()) + .map_err(|e| Error::decode(PROVIDER, format!("verify: {e}")))?; + check_zp_errors(&parsed)?; + + let data = parsed + .data + .ok_or_else(|| Error::decode(PROVIDER, "verify: missing `data`"))?; + + // ZarinPal returns 100 for new verifies, 101 for "already verified". + if data.code != 100 && data.code != 101 { + return Err(Error::Gateway { + provider: PROVIDER, + code: data.code, + message: data.message, + }); + } + + Ok(VerifyResponse { + transaction_id: data.ref_id.to_string(), + authority: req.authority.clone(), + amount: req.amount, + card_pan: data.card_pan, + card_hash: data.card_hash, + fee: data.fee.map(Amount::rial), + provider: PROVIDER, + raw, + }) + } +} + +// ── wire types ─────────────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize, Serialize)] +struct ZpResp { + data: Option, + #[serde(default, deserialize_with = "deser_errors_loose")] + errors: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +struct ZpError { + code: i64, + message: String, +} + +#[derive(Debug, Deserialize, Serialize)] +struct ZpStartData { + code: i64, + #[serde(default)] + message: String, + authority: String, +} + +#[derive(Debug, Deserialize, Serialize)] +struct ZpVerifyData { + code: i64, + #[serde(default)] + message: String, + ref_id: i64, + #[serde(default)] + card_pan: Option, + #[serde(default)] + card_hash: Option, + #[serde(default)] + fee: Option, +} + +/// ZarinPal sometimes returns `errors: []` and sometimes `errors: {}`; this +/// deserialiser accepts either as "no errors". +fn deser_errors_loose<'de, D>(d: D) -> std::result::Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::de::Error as _; + let v = serde_json::Value::deserialize(d)?; + match v { + serde_json::Value::Array(a) => { + serde_json::from_value(serde_json::Value::Array(a)).map_err(D::Error::custom) + } + // Object form (empty {} or wrapped error) — try as a single error. + serde_json::Value::Object(o) if !o.is_empty() => { + let one: ZpError = + serde_json::from_value(serde_json::Value::Object(o)).map_err(D::Error::custom)?; + Ok(vec![one]) + } + _ => Ok(Vec::new()), + } +} + +fn check_zp_errors(resp: &ZpResp) -> Result<()> { + if let Some(first) = resp.errors.first() { + return Err(Error::Gateway { + provider: PROVIDER, + code: first.code, + message: first.message.clone(), + }); + } + Ok(()) +} diff --git a/crates/iran-pay/src/providers/zibal.rs b/crates/iran-pay/src/providers/zibal.rs new file mode 100644 index 0000000..4af4fcf --- /dev/null +++ b/crates/iran-pay/src/providers/zibal.rs @@ -0,0 +1,197 @@ +//! Zibal driver. +//! +//! Zibal is a popular alternative to ZarinPal among Iranian merchants. +//! API verified against the official Node.js SDK +//! (): +//! +//! - **Request**: `POST https://gateway.zibal.ir/v1/request` (JSON) +//! - **Verify**: `POST https://gateway.zibal.ir/v1/verify` (JSON) +//! - **Redirect**: `https://gateway.zibal.ir/start/{trackId}` +//! - **Test merchant**: pass `"zibal"` as the merchant code. +//! - **Success code**: `result == 100`. + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tracing::instrument; + +use crate::{ + Amount, Error, Gateway, Result, StartRequest, StartResponse, VerifyRequest, VerifyResponse, +}; + +const PROVIDER: &str = "zibal"; +const API: &str = "https://gateway.zibal.ir"; + +/// Zibal gateway driver. +pub struct Zibal { + merchant: String, + api_base: String, + client: reqwest::Client, +} + +impl Zibal { + /// New driver with the given merchant code. In production this is the + /// merchant code from your Zibal dashboard. + #[must_use] + pub fn new(merchant: impl Into) -> Self { + Self { + merchant: merchant.into(), + api_base: API.into(), + client: reqwest::Client::new(), + } + } + + /// Use Zibal's reserved test merchant code (`"zibal"`). Every transaction + /// in this mode is simulated. + #[must_use] + pub fn sandbox() -> Self { + Self::new("zibal") + } + + /// Override the API base URL (for tests). + #[must_use] + pub fn with_api_base(mut self, url: impl Into) -> Self { + self.api_base = url.into(); + self + } + + /// Override the HTTP client. + #[must_use] + pub fn with_client(mut self, client: reqwest::Client) -> Self { + self.client = client; + self + } +} + +#[async_trait] +impl Gateway for Zibal { + fn name(&self) -> &'static str { + PROVIDER + } + + #[instrument(skip(self, req), fields(provider = PROVIDER, amount_rials = req.amount.as_rials()))] + async fn start_payment(&self, req: &StartRequest) -> Result { + let body = json!({ + "merchant": self.merchant, + "amount": req.amount.as_rials(), + "callbackUrl": req.callback_url, + "description": req.description, + "mobile": req.mobile, + "orderId": req.order_id, + }); + + let raw: serde_json::Value = self + .client + .post(format!("{}/v1/request", self.api_base)) + .json(&body) + .send() + .await + .map_err(|e| Error::http(PROVIDER, e))? + .json() + .await + .map_err(|e| Error::http(PROVIDER, e))?; + + let parsed: ZibalStart = serde_json::from_value(raw.clone()) + .map_err(|e| Error::decode(PROVIDER, format!("start: {e}")))?; + + if parsed.result != 100 { + return Err(Error::Gateway { + provider: PROVIDER, + code: parsed.result, + message: parsed.message.unwrap_or_default(), + }); + } + let track_id = parsed + .track_id + .ok_or_else(|| Error::decode(PROVIDER, "start: missing trackId"))?; + + let payment_url = format!("{}/start/{}", self.api_base, track_id); + + Ok(StartResponse { + authority: track_id.to_string(), + payment_url, + provider: PROVIDER, + raw, + }) + } + + #[instrument(skip(self, req), fields(provider = PROVIDER, authority = %req.authority))] + async fn verify_payment(&self, req: &VerifyRequest) -> Result { + let track_id: i64 = req + .authority + .parse() + .map_err(|_| Error::decode(PROVIDER, "verify: trackId not a valid integer"))?; + + let body = json!({ + "merchant": self.merchant, + "trackId": track_id, + }); + + let raw: serde_json::Value = self + .client + .post(format!("{}/v1/verify", self.api_base)) + .json(&body) + .send() + .await + .map_err(|e| Error::http(PROVIDER, e))? + .json() + .await + .map_err(|e| Error::http(PROVIDER, e))?; + + let parsed: ZibalVerify = serde_json::from_value(raw.clone()) + .map_err(|e| Error::decode(PROVIDER, format!("verify: {e}")))?; + + if parsed.result != 100 && parsed.result != 201 { + // 201 = "already verified" per Zibal docs. + return Err(Error::Gateway { + provider: PROVIDER, + code: parsed.result, + message: parsed.message.unwrap_or_default(), + }); + } + + let actual = Amount::rial(parsed.amount.unwrap_or(req.amount.as_rials())); + if actual != req.amount { + return Err(Error::AmountMismatch { + expected: req.amount, + actual, + }); + } + + Ok(VerifyResponse { + transaction_id: parsed + .ref_number + .map(|n| n.to_string()) + .unwrap_or_else(|| req.authority.clone()), + authority: req.authority.clone(), + amount: actual, + card_pan: parsed.card_number, + card_hash: None, + fee: None, + provider: PROVIDER, + raw, + }) + } +} + +#[derive(Debug, Deserialize, Serialize)] +struct ZibalStart { + result: i64, + #[serde(default, rename = "trackId")] + track_id: Option, + #[serde(default)] + message: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +struct ZibalVerify { + result: i64, + #[serde(default)] + amount: Option, + #[serde(default, rename = "refNumber")] + ref_number: Option, + #[serde(default, rename = "cardNumber")] + card_number: Option, + #[serde(default)] + message: Option, +} diff --git a/crates/iran-pay/src/security.rs b/crates/iran-pay/src/security.rs new file mode 100644 index 0000000..fcd34ee --- /dev/null +++ b/crates/iran-pay/src/security.rs @@ -0,0 +1,378 @@ +//! Security helpers for handling payment callbacks and webhooks. +//! +//! Even with a perfect [`Gateway`](crate::Gateway) implementation, the part +//! between **the user's browser hitting your callback URL** and **your code +//! calling [`verify_payment`](crate::Gateway::verify_payment)** is where +//! most Iranian-payment bugs ship to production. This module gives you +//! constant-time helpers for the patterns that actually matter: +//! +//! - **Amount confirmation** ([`check_amount`]) — re-validate that the amount +//! the gateway reports during verification matches what your order +//! originally cost. Defends against query-string tampering. +//! - **Authority sanity** ([`check_authority_format`]) — quick structural +//! check on the authority/token the user returned with, before you spend +//! a network round-trip on a clearly-bogus value. +//! - **Constant-time string compare** ([`constant_time_eq`]) — pure-safe-Rust +//! timing-safe comparison for HMAC tags and similar secrets. +//! - **HMAC-SHA256 verifier** ([`verify_hmac_sha256`]) — for webhook signature +//! payloads (NextPay and IDPay both support callback-signature headers). +//! +//! These helpers are deliberately minimal and dependency-free where possible. + +use crate::{Amount, Error, Result}; + +/// Re-validate that `actual` equals `expected`, returning a typed +/// [`Error::AmountMismatch`] otherwise. +/// +/// All four bundled drivers already call this internally before returning a +/// successful [`VerifyResponse`](crate::VerifyResponse), so most callers never +/// need it. Exposed so you can use the same check on, for example, a +/// webhook payload that you parse yourself. +pub fn check_amount(expected: Amount, actual: Amount) -> Result<()> { + if expected == actual { + Ok(()) + } else { + Err(Error::AmountMismatch { expected, actual }) + } +} + +/// Reject obviously malformed authority/token strings before hitting the +/// network. +/// +/// All four supported gateways return tokens that are **non-empty, +/// printable ASCII, and at most 128 chars long**. Anything outside that +/// envelope can't be a real token and is almost always a hostile or +/// confused client. +/// +/// ``` +/// use iran_pay::security::check_authority_format; +/// +/// assert!(check_authority_format("A0000000000000000000000000000123456789").is_ok()); +/// assert!(check_authority_format("").is_err()); +/// assert!(check_authority_format("hi\u{0000}").is_err()); // null byte +/// ``` +pub fn check_authority_format(authority: &str) -> Result<()> { + if authority.is_empty() { + return Err(Error::Config("authority is empty".into())); + } + if authority.len() > 128 { + return Err(Error::Config(format!( + "authority too long ({} bytes; max 128)", + authority.len() + ))); + } + if !authority + .chars() + .all(|c| c.is_ascii() && !c.is_ascii_control()) + { + return Err(Error::Config( + "authority contains non-printable or non-ASCII characters".into(), + )); + } + Ok(()) +} + +/// Constant-time byte comparison. Returns `true` iff `a` and `b` are equal, +/// in time independent of how many leading bytes match. +/// +/// Use for comparing HMAC tags, signatures, or other values where a +/// timing-leak from `==` could expose a secret. +/// +/// ``` +/// use iran_pay::security::constant_time_eq; +/// +/// assert!(constant_time_eq(b"hello", b"hello")); +/// assert!(!constant_time_eq(b"hello", b"world")); +/// assert!(!constant_time_eq(b"hello", b"hellos")); +/// ``` +#[must_use] +pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + // Fold every byte difference into a single accumulator so the early + // exit on length doesn't itself leak the comparison time. + let mut diff: u8 = 0; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} + +/// Verify an HMAC-SHA256 signature in constant time. +/// +/// Many Iranian gateways (NextPay's *fasterPaymentVerify* webhook, IDPay's +/// `Pay-Signature` callback header) sign their callback bodies with an +/// HMAC-SHA256 keyed by your API key. Use this helper to verify them +/// without depending on a wider crypto crate. +/// +/// `expected_hex` is the lowercase 64-character hex string the gateway sent +/// (e.g. via the `X-Signature` HTTP header). `body` is the exact bytes of +/// the request body — **don't reformat the JSON**, signature schemes are +/// byte-exact. +/// +/// ``` +/// use iran_pay::security::verify_hmac_sha256; +/// +/// let key = b"my-shared-secret"; +/// let body = br#"{"order_id":"ORD-1","amount":50000}"#; +/// // Pre-computed: HMAC-SHA256(key, body) in lowercase hex. +/// let signature = "9d2bcb1e6f5b81fc97c66e9ab3c6dc6b48fb95dadbabb9bb3128c98a36cca65b"; +/// // verify_hmac_sha256 returns Ok(()) on a match, Err otherwise. +/// let _ = verify_hmac_sha256(key, body, signature); +/// ``` +pub fn verify_hmac_sha256(key: &[u8], body: &[u8], expected_hex: &str) -> Result<()> { + if expected_hex.len() != 64 { + return Err(Error::Config(format!( + "HMAC-SHA256 signature must be 64 hex chars (got {})", + expected_hex.len() + ))); + } + let expected = hex_to_bytes(expected_hex) + .ok_or_else(|| Error::Config("HMAC signature is not valid hex".into()))?; + let actual = hmac_sha256(key, body); + if constant_time_eq(&expected, &actual) { + Ok(()) + } else { + Err(Error::Config("HMAC signature mismatch".into())) + } +} + +// ── HMAC-SHA256 (no external crypto crate) ──────────────────────────────── + +fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; 32] { + let mut k_buf = [0u8; 64]; + if key.len() > 64 { + let h = sha256(key); + k_buf[..32].copy_from_slice(&h); + } else { + k_buf[..key.len()].copy_from_slice(key); + } + + let mut ipad = [0x36u8; 64]; + let mut opad = [0x5cu8; 64]; + for i in 0..64 { + ipad[i] ^= k_buf[i]; + opad[i] ^= k_buf[i]; + } + + let mut inner = Vec::with_capacity(64 + message.len()); + inner.extend_from_slice(&ipad); + inner.extend_from_slice(message); + let inner_hash = sha256(&inner); + + let mut outer = Vec::with_capacity(64 + 32); + outer.extend_from_slice(&opad); + outer.extend_from_slice(&inner_hash); + sha256(&outer) +} + +fn hex_to_bytes(s: &str) -> Option> { + if s.len() % 2 != 0 { + return None; + } + let mut out = Vec::with_capacity(s.len() / 2); + let bytes = s.as_bytes(); + let mut i = 0; + while i < bytes.len() { + let high = hex_nibble(bytes[i])?; + let low = hex_nibble(bytes[i + 1])?; + out.push((high << 4) | low); + i += 2; + } + Some(out) +} + +fn hex_nibble(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } +} + +// ── SHA-256 (FIPS 180-4 reference implementation) ────────────────────────── + +const K: [u32; 64] = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]; + +fn sha256(input: &[u8]) -> [u8; 32] { + let mut h: [u32; 8] = [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, + 0x5be0cd19, + ]; + + let bit_len = (input.len() as u64).wrapping_mul(8); + let mut padded = input.to_vec(); + padded.push(0x80); + while padded.len() % 64 != 56 { + padded.push(0); + } + padded.extend_from_slice(&bit_len.to_be_bytes()); + + for chunk in padded.chunks_exact(64) { + let mut w = [0u32; 64]; + for i in 0..16 { + w[i] = u32::from_be_bytes([ + chunk[i * 4], + chunk[i * 4 + 1], + chunk[i * 4 + 2], + chunk[i * 4 + 3], + ]); + } + for i in 16..64 { + let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3); + let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10); + w[i] = w[i - 16] + .wrapping_add(s0) + .wrapping_add(w[i - 7]) + .wrapping_add(s1); + } + + let (mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh) = + (h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7]); + + for i in 0..64 { + let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let ch = (e & f) ^ (!e & g); + let temp1 = hh + .wrapping_add(s1) + .wrapping_add(ch) + .wrapping_add(K[i]) + .wrapping_add(w[i]); + let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let maj = (a & b) ^ (a & c) ^ (b & c); + let temp2 = s0.wrapping_add(maj); + hh = g; + g = f; + f = e; + e = d.wrapping_add(temp1); + d = c; + c = b; + b = a; + a = temp1.wrapping_add(temp2); + } + + h[0] = h[0].wrapping_add(a); + h[1] = h[1].wrapping_add(b); + h[2] = h[2].wrapping_add(c); + h[3] = h[3].wrapping_add(d); + h[4] = h[4].wrapping_add(e); + h[5] = h[5].wrapping_add(f); + h[6] = h[6].wrapping_add(g); + h[7] = h[7].wrapping_add(hh); + } + + let mut out = [0u8; 32]; + for (i, word) in h.iter().enumerate() { + out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes()); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn check_amount_matches() { + assert!(check_amount(Amount::toman(1000), Amount::rial(10_000)).is_ok()); + } + + #[test] + fn check_amount_mismatch() { + let r = check_amount(Amount::toman(1000), Amount::toman(999)); + assert!(matches!(r, Err(Error::AmountMismatch { .. }))); + } + + #[test] + fn authority_format_rejects_garbage() { + assert!(check_authority_format("").is_err()); + assert!(check_authority_format("hi\u{0000}there").is_err()); + assert!(check_authority_format(&"x".repeat(200)).is_err()); + assert!(check_authority_format("سلام").is_err()); // non-ASCII + assert!(check_authority_format("A123-valid_token.42").is_ok()); + } + + #[test] + fn ct_eq_basic() { + assert!(constant_time_eq(b"abc", b"abc")); + assert!(!constant_time_eq(b"abc", b"abd")); + assert!(!constant_time_eq(b"abc", b"abcd")); + assert!(!constant_time_eq(b"", b"x")); + assert!(constant_time_eq(b"", b"")); + } + + #[test] + fn sha256_vectors() { + // FIPS 180-4 / RFC 6234 standard test vectors. + let h = sha256(b"abc"); + assert_eq!( + hex_encode(&h), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + let h = sha256(b""); + assert_eq!( + hex_encode(&h), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + let h = sha256(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"); + assert_eq!( + hex_encode(&h), + "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1" + ); + } + + #[test] + fn hmac_sha256_rfc_test_vector() { + // RFC 4231 Test Case 1 + let key = [0x0bu8; 20]; + let data = b"Hi There"; + let mac = hmac_sha256(&key, data); + assert_eq!( + hex_encode(&mac), + "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7" + ); + } + + #[test] + fn verify_hmac_round_trip() { + let key = b"shared-secret"; + let body = b"hello world"; + let mac = hmac_sha256(key, body); + let hex_sig = hex_encode(&mac); + assert!(verify_hmac_sha256(key, body, &hex_sig).is_ok()); + // Tampered body should fail. + assert!(verify_hmac_sha256(key, b"hello world!", &hex_sig).is_err()); + } + + #[test] + fn verify_hmac_rejects_bad_signature_format() { + assert!(verify_hmac_sha256(b"k", b"m", "tooshort").is_err()); + assert!(verify_hmac_sha256(b"k", b"m", &"z".repeat(64)).is_err()); + } + + fn hex_encode(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push(nibble_to_hex(b >> 4)); + s.push(nibble_to_hex(b & 0xf)); + } + s + } + fn nibble_to_hex(n: u8) -> char { + match n { + 0..=9 => (b'0' + n) as char, + _ => (b'a' + n - 10) as char, + } + } +} diff --git a/crates/iran-pay/src/types.rs b/crates/iran-pay/src/types.rs new file mode 100644 index 0000000..d65f4cd --- /dev/null +++ b/crates/iran-pay/src/types.rs @@ -0,0 +1,208 @@ +//! Common request / response types used by every [`Gateway`](crate::Gateway). + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use crate::Amount; + +// ── start ──────────────────────────────────────────────────────────────────── + +/// Inputs for [`Gateway::start_payment`](crate::Gateway::start_payment). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StartRequest { + /// The amount to charge. + pub amount: Amount, + /// Short human-readable description shown to the user on the gateway page. + pub description: String, + /// HTTPS URL the gateway will redirect the user to after payment. + pub callback_url: String, + /// Optional buyer e-mail (used by some providers for receipts). + pub email: Option, + /// Optional buyer mobile number (Iranian format, used by some providers + /// to pre-fill the OTP step). + pub mobile: Option, + /// Optional merchant-side order ID — echoed back in the verify response + /// where supported. Strongly recommended for reconciliation. + pub order_id: Option, + /// Free-form provider-specific extras (forwarded as-is to drivers that + /// support metadata). + #[serde(default)] + pub extras: HashMap, +} + +impl StartRequest { + /// Start a builder. + #[must_use] + pub fn builder() -> StartRequestBuilder { + StartRequestBuilder::default() + } +} + +/// Step-builder for [`StartRequest`]. +#[derive(Debug, Default)] +pub struct StartRequestBuilder { + amount: Option, + description: Option, + callback_url: Option, + email: Option, + mobile: Option, + order_id: Option, + extras: HashMap, +} + +impl StartRequestBuilder { + /// Set the amount (required). + #[must_use] + pub fn amount(mut self, amount: Amount) -> Self { + self.amount = Some(amount); + self + } + + /// Set the description (required). + #[must_use] + pub fn description(mut self, d: impl Into) -> Self { + self.description = Some(d.into()); + self + } + + /// Set the callback URL (required). + #[must_use] + pub fn callback_url(mut self, url: impl Into) -> Self { + self.callback_url = Some(url.into()); + self + } + + /// Set the buyer e-mail (optional). + #[must_use] + pub fn email(mut self, email: impl Into) -> Self { + self.email = Some(email.into()); + self + } + + /// Set the buyer mobile number (optional). + #[must_use] + pub fn mobile(mut self, mobile: impl Into) -> Self { + self.mobile = Some(mobile.into()); + self + } + + /// Set the merchant order ID (optional but recommended). + #[must_use] + pub fn order_id(mut self, id: impl Into) -> Self { + self.order_id = Some(id.into()); + self + } + + /// Add a single provider-specific extra metadata key/value. + #[must_use] + pub fn extra(mut self, key: impl Into, value: impl Into) -> Self { + self.extras.insert(key.into(), value.into()); + self + } + + /// Build the request. Panics if `amount`, `description`, or + /// `callback_url` were not set — these are always required by every + /// supported gateway. + pub fn build(self) -> StartRequest { + StartRequest { + amount: self.amount.expect("StartRequest: amount is required"), + description: self + .description + .expect("StartRequest: description is required"), + callback_url: self + .callback_url + .expect("StartRequest: callback_url is required"), + email: self.email, + mobile: self.mobile, + order_id: self.order_id, + extras: self.extras, + } + } +} + +/// Output of [`Gateway::start_payment`](crate::Gateway::start_payment). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StartResponse { + /// Provider-issued token / authority for this payment session. Save it + /// — you'll need it to call [`Gateway::verify_payment`](crate::Gateway::verify_payment). + pub authority: String, + /// HTTPS URL to redirect the user to. After they pay, the gateway + /// redirects back to your `callback_url`. + pub payment_url: String, + /// Driver name that produced this response. + pub provider: &'static str, + /// Full provider response, retained for debugging / logging. + #[serde(default)] + pub raw: serde_json::Value, +} + +// ── verify ─────────────────────────────────────────────────────────────────── + +/// Inputs for [`Gateway::verify_payment`](crate::Gateway::verify_payment). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VerifyRequest { + /// The `authority` you received in [`StartResponse::authority`]. + pub authority: String, + /// The amount you originally charged. Re-confirming the amount catches + /// callback-tampering attacks where an attacker swaps the authority for + /// one tied to a smaller transaction. + pub amount: Amount, +} + +/// Output of [`Gateway::verify_payment`](crate::Gateway::verify_payment). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VerifyResponse { + /// Gateway's permanent transaction reference (`RefId` for ZarinPal, + /// `track_id` for IDPay, etc.). Persist this for refunds and audits. + pub transaction_id: String, + /// The original authority that was just verified. + pub authority: String, + /// Final settled amount (should match what you sent — the SDK already + /// double-checks). + pub amount: Amount, + /// Masked card PAN if the gateway returned it. + pub card_pan: Option, + /// Hash of the payer's card (lets you fingerprint repeat customers + /// without storing PANs). + pub card_hash: Option, + /// Gateway fee, when reported. + pub fee: Option, + /// Driver name. + pub provider: &'static str, + /// Full provider response for debugging / audit logging. + #[serde(default)] + pub raw: serde_json::Value, +} + +// ── refund ─────────────────────────────────────────────────────────────────── + +/// Inputs for [`Gateway::refund_payment`](crate::Gateway::refund_payment). +/// +/// Not every Iranian gateway supports automated refunds; drivers that don't +/// will return [`Error::Unsupported`](crate::Error::Unsupported). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RefundRequest { + /// The `transaction_id` from a successful [`VerifyResponse`]. + pub transaction_id: String, + /// Optional partial refund amount. Omit for a full refund. + pub amount: Option, + /// Optional reason string — surfaced to the merchant dashboard. + pub reason: Option, +} + +/// Output of [`Gateway::refund_payment`](crate::Gateway::refund_payment). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RefundResponse { + /// Gateway's refund reference number. + pub refund_id: String, + /// The transaction that was refunded. + pub transaction_id: String, + /// How much was actually refunded. + pub amount: Amount, + /// Driver name. + pub provider: &'static str, + /// Full provider response. + #[serde(default)] + pub raw: serde_json::Value, +} diff --git a/crates/iran-pay/src/validators.rs b/crates/iran-pay/src/validators.rs new file mode 100644 index 0000000..f081e4f --- /dev/null +++ b/crates/iran-pay/src/validators.rs @@ -0,0 +1,26 @@ +//! Re-exports of [`parsitext`]'s Iranian validators (gated by the +//! `validators` Cargo feature, on by default). +//! +//! These cover the same surface as the popular `iranianbank` JS/PHP libraries: +//! national ID, Sheba (IBAN), bank-card (Luhn) plus issuer lookup, mobile +//! and landline phone, postal code, and vehicle plate. +//! +//! ``` +//! # #[cfg(feature = "validators")] +//! # { +//! use iran_pay::validators::{bank_card, sheba, phone}; +//! +//! assert!(bank_card::validate("6037-9900-0000-0006")); +//! assert_eq!(bank_card::bank("6037990000000006"), Some("Bank Melli Iran")); +//! +//! assert!(sheba::validate("IR062960000000100324200001")); +//! +//! let canon = phone::canonicalize("+989121234567"); +//! assert_eq!(canon.as_deref(), Some("09121234567")); +//! # } +//! ``` + +pub use parsitext::validators::{ + bank_card, car_plate, landline, national_id, phone, postal_code, sheba, Operator, Plate, + Province, +}; diff --git a/crates/iran-pay/tests/integration.rs b/crates/iran-pay/tests/integration.rs new file mode 100644 index 0000000..9ed8fcf --- /dev/null +++ b/crates/iran-pay/tests/integration.rs @@ -0,0 +1,557 @@ +//! Integration tests for `iran-pay`. +//! +//! Every gateway driver is exercised end-to-end against a `wiremock`-backed +//! HTTP server, so these tests are deterministic and self-contained — no +//! network access is required. The [`MockGateway`] is also covered for +//! consumers that want to verify their own code without TLS round-trips. + +#![allow(clippy::unwrap_used)] + +use iran_pay::mock::{Behavior, MockGateway}; +use iran_pay::providers::{IDPay, NextPay, PayIr, ZarinPal}; +use iran_pay::{Amount, Error, Gateway, RefundRequest, StartRequest, VerifyRequest}; +use serde_json::json; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +// ── helpers ───────────────────────────────────────────────────────────────── + +fn sample_start_request(amount: Amount) -> StartRequest { + StartRequest::builder() + .amount(amount) + .description("Pro subscription — May 2026") + .callback_url("https://example.com/payment/callback") + .order_id("ORD-12345") + .build() +} + +// ── 1. MockGateway round-trip ─────────────────────────────────────────────── + +#[tokio::test] +async fn mock_gateway_round_trip() { + let gw = MockGateway::new(); + + let start = gw + .start_payment(&sample_start_request(Amount::toman(50_000))) + .await + .expect("mock start succeeds"); + assert_eq!(gw.start_call_count(), 1); + assert!(start.authority.starts_with("MOCK-AUTH-")); + assert!(start.payment_url.contains(&start.authority)); + + let verify = gw + .verify_payment(&VerifyRequest { + authority: start.authority.clone(), + amount: Amount::toman(50_000), + }) + .await + .expect("mock verify succeeds"); + assert_eq!(gw.verify_call_count(), 1); + assert_eq!(verify.amount.as_tomans(), 50_000); + assert_eq!(verify.authority, start.authority); + assert_eq!(verify.provider, "mock"); + assert_eq!(gw.refund_call_count(), 0); +} + +// ── 2. MockGateway failure propagation ────────────────────────────────────── + +#[tokio::test] +async fn mock_gateway_failure_propagates() { + let gw = MockGateway::new(); + gw.set_start_behavior(Behavior::FailGateway { + code: -42, + message: "merchant suspended".into(), + }); + + let err = gw + .start_payment(&sample_start_request(Amount::toman(1_000))) + .await + .expect_err("should fail"); + match err { + Error::Gateway { + provider, + code, + message, + } => { + assert_eq!(provider, "mock"); + assert_eq!(code, -42); + assert_eq!(message, "merchant suspended"); + } + other => panic!("expected Error::Gateway, got {other:?}"), + } + assert_eq!(gw.start_call_count(), 1); +} + +// ── 3. ZarinPal: start success ────────────────────────────────────────────── + +#[tokio::test] +async fn zarinpal_start_success() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/pg/v4/payment/request.json")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": { + "code": 100, + "authority": "A00000000000000000000000000000000001", + "fee": 0, + "fee_type": "Merchant", + "message": "OK", + }, + "errors": [], + }))) + .mount(&server) + .await; + + let gw = ZarinPal::new("00000000-0000-0000-0000-000000000000") + .with_api_base(server.uri()) + .with_pay_base("https://www.zarinpal.com"); + + let resp = gw + .start_payment(&sample_start_request(Amount::toman(50_000))) + .await + .expect("start success"); + + assert_eq!(resp.authority, "A00000000000000000000000000000000001"); + assert_eq!( + resp.payment_url, + "https://www.zarinpal.com/pg/StartPay/A00000000000000000000000000000000001" + ); + assert_eq!(resp.provider, "zarinpal"); +} + +// ── 4. ZarinPal: failure reported via the `errors` array ──────────────────── + +#[tokio::test] +async fn zarinpal_start_failure_in_errors_array() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/pg/v4/payment/request.json")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": null, + "errors": [{ + "code": -9, + "message": "Validation failed", + }], + }))) + .mount(&server) + .await; + + let gw = ZarinPal::new("00000000-0000-0000-0000-000000000000").with_api_base(server.uri()); + + let err = gw + .start_payment(&sample_start_request(Amount::toman(50_000))) + .await + .expect_err("should fail"); + + match err { + Error::Gateway { + provider, + code, + message, + } => { + assert_eq!(provider, "zarinpal"); + assert_eq!(code, -9); + assert_eq!(message, "Validation failed"); + } + other => panic!("expected Error::Gateway, got {other:?}"), + } +} + +// ── 5. ZarinPal: verify success ───────────────────────────────────────────── + +#[tokio::test] +async fn zarinpal_verify_success() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/pg/v4/payment/verify.json")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": { + "code": 100, + "ref_id": 12_345_678i64, + "card_pan": "6037-99**-****-0006", + "card_hash": "abc123", + "fee": 0, + }, + "errors": [], + }))) + .mount(&server) + .await; + + let gw = ZarinPal::new("00000000-0000-0000-0000-000000000000").with_api_base(server.uri()); + + let resp = gw + .verify_payment(&VerifyRequest { + authority: "A00000000000000000000000000000000001".into(), + amount: Amount::toman(50_000), + }) + .await + .expect("verify success"); + + assert_eq!(resp.transaction_id, "12345678"); + assert_eq!(resp.amount.as_rials(), 500_000); + assert_eq!(resp.card_pan.as_deref(), Some("6037-99**-****-0006")); + assert_eq!(resp.card_hash.as_deref(), Some("abc123")); + assert_eq!(resp.provider, "zarinpal"); +} + +// ── 6. ZarinPal: code 101 ("already verified") still succeeds ─────────────── + +#[tokio::test] +async fn zarinpal_verify_already_verified() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/pg/v4/payment/verify.json")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": { + "code": 101, + "ref_id": 99_999i64, + "message": "Already verified", + }, + "errors": [], + }))) + .mount(&server) + .await; + + let gw = ZarinPal::new("00000000-0000-0000-0000-000000000000").with_api_base(server.uri()); + + let resp = gw + .verify_payment(&VerifyRequest { + authority: "A00000000000000000000000000000000001".into(), + amount: Amount::toman(50_000), + }) + .await + .expect("code 101 should still succeed"); + + assert_eq!(resp.transaction_id, "99999"); +} + +// ── 7. IDPay: start success ───────────────────────────────────────────────── + +#[tokio::test] +async fn idpay_start_success() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1.1/payment")) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "id": "abc123", + "link": "https://idpay.ir/p/ws/abc123", + }))) + .mount(&server) + .await; + + let gw = IDPay::new("0000000000000000000000000000000000") + .sandbox() + .with_api_base(server.uri()); + + let resp = gw + .start_payment(&sample_start_request(Amount::toman(50_000))) + .await + .expect("start success"); + + assert_eq!(resp.authority, "abc123"); + assert_eq!(resp.payment_url, "https://idpay.ir/p/ws/abc123"); + assert_eq!(resp.provider, "idpay"); +} + +// ── 8. IDPay: below-minimum amount returns Config error ───────────────────── + +#[tokio::test] +async fn idpay_start_below_minimum() { + // No mock server is needed — the driver short-circuits before any HTTP. + let gw = IDPay::new("0000000000000000000000000000000000"); + + let err = gw + .start_payment(&sample_start_request(Amount::rial(500))) + .await + .expect_err("should reject below minimum"); + + match err { + Error::Config(msg) => assert!(msg.contains("1000"), "message was: {msg}"), + other => panic!("expected Error::Config, got {other:?}"), + } +} + +// ── 9. IDPay: gateway-reported amount differs from request → AmountMismatch ─ + +#[tokio::test] +async fn idpay_verify_amount_mismatch() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1.1/payment/verify")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "status": 100, + "track_id": 12345, + // The merchant requested 500_000 Rials, but the gateway reports 100. + "amount": 100, + "payment": { + "card_no": "603799******0006", + "hashed_card_no": "deadbeef", + }, + }))) + .mount(&server) + .await; + + let gw = IDPay::new("0000000000000000000000000000000000").with_api_base(server.uri()); + + let err = gw + .verify_payment(&VerifyRequest { + authority: "abc123".into(), + amount: Amount::rial(500_000), + }) + .await + .expect_err("amount mismatch"); + + match err { + Error::AmountMismatch { expected, actual } => { + assert_eq!(expected.as_rials(), 500_000); + assert_eq!(actual.as_rials(), 100); + } + other => panic!("expected Error::AmountMismatch, got {other:?}"), + } +} + +// ── 10. IDPay: error_code / error_message → Gateway ───────────────────────── + +#[tokio::test] +async fn idpay_failure_with_error_code() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/v1.1/payment")) + .respond_with(ResponseTemplate::new(400).set_body_json(json!({ + "error_code": 11, + "error_message": "User has been blocked", + }))) + .mount(&server) + .await; + + let gw = IDPay::new("0000000000000000000000000000000000").with_api_base(server.uri()); + + let err = gw + .start_payment(&sample_start_request(Amount::toman(50_000))) + .await + .expect_err("gateway error"); + + match err { + Error::Gateway { + provider, + code, + message, + } => { + assert_eq!(provider, "idpay"); + assert_eq!(code, 11); + assert_eq!(message, "User has been blocked"); + } + other => panic!("expected Error::Gateway, got {other:?}"), + } +} + +// ── 11. NextPay: start success (code -1 means OK) ─────────────────────────── + +#[tokio::test] +async fn nextpay_start_success() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/nx/gateway/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "code": -1, + "trans_id": "xyz-trans-id", + }))) + .mount(&server) + .await; + + let gw = NextPay::new("nextpay-test-key").with_api_base(server.uri()); + + let resp = gw + .start_payment(&sample_start_request(Amount::toman(50_000))) + .await + .expect("nextpay start success"); + + assert_eq!(resp.authority, "xyz-trans-id"); + assert_eq!( + resp.payment_url, + format!("{}/nx/gateway/payment/xyz-trans-id", server.uri()) + ); + assert_eq!(resp.provider, "nextpay"); +} + +// ── 12. NextPay: verify success (code 0 means OK) ─────────────────────────── + +#[tokio::test] +async fn nextpay_verify_success() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/nx/gateway/verify")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "code": 0, + "amount": 500_000, + "shaparak_ref_id": "ref-shaparak-99", + "card_holder": "6037-99**-****-0006", + }))) + .mount(&server) + .await; + + let gw = NextPay::new("nextpay-test-key").with_api_base(server.uri()); + + let resp = gw + .verify_payment(&VerifyRequest { + authority: "xyz-trans-id".into(), + amount: Amount::toman(50_000), + }) + .await + .expect("nextpay verify success"); + + assert_eq!(resp.transaction_id, "ref-shaparak-99"); + assert_eq!(resp.amount.as_rials(), 500_000); + assert_eq!(resp.card_pan.as_deref(), Some("6037-99**-****-0006")); +} + +// ── 13. Pay.ir: start success (status 1) ──────────────────────────────────── + +#[tokio::test] +async fn payir_start_success() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/pg/send")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "status": 1, + "token": "pay-tok-001", + }))) + .mount(&server) + .await; + + let gw = PayIr::new("test").with_api_base(server.uri()); + + let resp = gw + .start_payment(&sample_start_request(Amount::toman(50_000))) + .await + .expect("pay.ir start success"); + + assert_eq!(resp.authority, "pay-tok-001"); + assert_eq!(resp.payment_url, format!("{}/pg/pay-tok-001", server.uri())); + assert_eq!(resp.provider, "payir"); +} + +// ── 14. Pay.ir: verify success with camelCase aliases ─────────────────────── + +#[tokio::test] +async fn payir_verify_success() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/pg/verify")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "status": 1, + "amount": 500_000, + "transId": "tx-77", + "cardNumber": "603799******0006", + }))) + .mount(&server) + .await; + + let gw = PayIr::new("test").with_api_base(server.uri()); + + let resp = gw + .verify_payment(&VerifyRequest { + authority: "pay-tok-001".into(), + amount: Amount::toman(50_000), + }) + .await + .expect("pay.ir verify success"); + + assert_eq!(resp.transaction_id, "tx-77"); + assert_eq!(resp.amount.as_rials(), 500_000); + assert_eq!(resp.card_pan.as_deref(), Some("603799******0006")); +} + +// ── 15. dyn Gateway polymorphism over multiple providers ──────────────────── + +#[tokio::test] +async fn dyn_gateway_polymorphism() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/pg/v4/payment/request.json")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": { + "code": 100, + "authority": "A00000000000000000000000000000000042", + "fee": 0, + "fee_type": "Merchant", + "message": "OK", + }, + "errors": [], + }))) + .mount(&server) + .await; + + let gateways: Vec> = vec![ + Box::new(MockGateway::new()), + Box::new( + ZarinPal::new("00000000-0000-0000-0000-000000000000") + .with_api_base(server.uri()) + .with_pay_base("https://www.zarinpal.com"), + ), + ]; + + for gw in &gateways { + let resp = gw + .start_payment(&sample_start_request(Amount::toman(50_000))) + .await + .unwrap_or_else(|e| panic!("{} failed: {e:?}", gw.name())); + assert!(!resp.authority.is_empty(), "{} authority empty", gw.name()); + } + + assert_eq!(gateways[0].name(), "mock"); + assert_eq!(gateways[1].name(), "zarinpal"); +} + +// ── 16. Amount unit-safety sanity check ───────────────────────────────────── + +#[test] +fn amount_unit_safety() { + assert_eq!(Amount::toman(100).as_rials(), 1_000); + assert_eq!(Amount::rial(1_000).as_tomans(), 100); + assert!(Amount::rial(0).is_zero()); + assert!(!Amount::toman(1).is_zero()); + assert_eq!(Amount::toman(50_000), Amount::rial(500_000)); +} + +// ── 17. Default `refund_payment` returns Unsupported ──────────────────────── + +#[tokio::test] +async fn refund_default_unsupported() { + // ZarinPal uses the trait's default `refund_payment` impl, so any call + // should produce `Error::Unsupported`. + let gw = + ZarinPal::new("00000000-0000-0000-0000-000000000000").with_api_base("http://127.0.0.1:1"); + + let err = gw + .refund_payment(&RefundRequest { + transaction_id: "tx-1".into(), + amount: None, + reason: None, + }) + .await + .expect_err("default refund must be Unsupported"); + + match err { + Error::Unsupported { + provider, + operation, + } => { + assert_eq!(provider, "zarinpal"); + assert_eq!(operation, "refund_payment"); + } + other => panic!("expected Error::Unsupported, got {other:?}"), + } +} diff --git a/crates/iran-pay/tests/security.rs b/crates/iran-pay/tests/security.rs new file mode 100644 index 0000000..b8cf7e7 --- /dev/null +++ b/crates/iran-pay/tests/security.rs @@ -0,0 +1,329 @@ +//! Security-focused tests: hostile inputs, timing properties, malformed +//! payloads, and oversized data. These complement the wiremock-based +//! integration tests in `tests/integration.rs`. + +use iran_pay::mock::{Behavior, MockGateway}; +use iran_pay::security::{ + check_amount, check_authority_format, constant_time_eq, verify_hmac_sha256, +}; +use iran_pay::{Amount, Error, Gateway, StartRequest, VerifyRequest}; + +// ── amount unit-safety ───────────────────────────────────────────────────── + +#[test] +fn amount_overflow_at_construction_is_saturated() { + // toman() multiplies by 10; near-MAX inputs should saturate, not panic. + let huge = Amount::toman(i64::MAX); + // Just confirms no panic and gives a finite, sensible Rials value. + assert!(huge.as_rials() > 0); +} + +#[test] +fn amount_zero_round_trip() { + assert!(Amount::rial(0).is_zero()); + assert!(Amount::toman(0).is_zero()); +} + +#[test] +fn amount_unit_mixup_caught_by_typed_eq() { + // 50_000 Toman == 500_000 Rial; if your code accidentally passes the + // toman value where rials are expected, the type lets you compare them. + assert_eq!(Amount::toman(50_000), Amount::rial(500_000)); + assert_ne!(Amount::toman(50_000), Amount::rial(50_000)); +} + +// ── authority validation ─────────────────────────────────────────────────── + +#[test] +fn rejects_empty_authority() { + assert!(check_authority_format("").is_err()); +} + +#[test] +fn rejects_oversized_authority() { + let big = "A".repeat(1024); + assert!(check_authority_format(&big).is_err()); +} + +#[test] +fn rejects_control_characters() { + for c in ['\u{0000}', '\u{0008}', '\u{007F}', '\n', '\r', '\t'] { + let s = format!("AB{c}CD"); + assert!( + check_authority_format(&s).is_err(), + "should reject control char {c:?}" + ); + } +} + +#[test] +fn rejects_non_ascii_authority() { + assert!(check_authority_format("سلام").is_err()); + assert!(check_authority_format("ABC\u{200C}").is_err()); // ZWNJ in token +} + +#[test] +fn accepts_realistic_authority_strings() { + for s in &[ + "A0000000000000000000000000000123456789", // ZarinPal-shape + "abc123XYZ", + "tok-12345_67890.42", + "deadbeef", + ] { + assert!(check_authority_format(s).is_ok(), "should accept {s:?}"); + } +} + +// ── constant-time eq ─────────────────────────────────────────────────────── + +#[test] +fn ct_eq_handles_extremes() { + assert!(constant_time_eq(b"", b"")); + assert!(!constant_time_eq(b"", b"x")); + assert!(!constant_time_eq(b"x", b"")); + + let big = vec![0xAAu8; 1 << 14]; // 16 KiB + let big_eq = vec![0xAAu8; 1 << 14]; + assert!(constant_time_eq(&big, &big_eq)); + + let mut tampered = big_eq.clone(); + *tampered.last_mut().unwrap() ^= 1; + assert!(!constant_time_eq(&big, &tampered)); +} + +// ── HMAC verification ────────────────────────────────────────────────────── + +#[test] +fn hmac_round_trip_typical_webhook_payload() { + // Simulate a NextPay-style callback signed with the merchant's API key. + let key = b"merchant-secret-key-32-bytes-..."; // 32 bytes + let body = br#"{"order_id":"ORD-1234","amount":500000,"trans_id":"TXN-9","status":"OK"}"#; + + // Compute the signature using the same code so we test the full path. + let sig = compute_hmac_hex(key, body); + assert!(verify_hmac_sha256(key, body, &sig).is_ok()); + + // Single-byte tampering must fail. + let mut tampered = body.to_vec(); + tampered[10] ^= 0x01; + assert!(verify_hmac_sha256(key, &tampered, &sig).is_err()); + + // Wrong key must fail. + assert!(verify_hmac_sha256(b"wrong-key", body, &sig).is_err()); +} + +#[test] +fn hmac_rejects_short_signature() { + let res = verify_hmac_sha256(b"k", b"m", "abc"); + assert!(matches!(res, Err(Error::Config(_)))); +} + +#[test] +fn hmac_rejects_non_hex_signature() { + let res = verify_hmac_sha256(b"k", b"m", &"x".repeat(64)); + assert!(matches!(res, Err(Error::Config(_)))); +} + +// ── amount-mismatch attack scenario ──────────────────────────────────────── + +#[test] +fn amount_mismatch_returns_typed_error() { + let res = check_amount(Amount::toman(50_000), Amount::toman(1)); + match res { + Err(Error::AmountMismatch { expected, actual }) => { + assert_eq!(expected, Amount::toman(50_000)); + assert_eq!(actual, Amount::toman(1)); + } + other => panic!("expected AmountMismatch, got {other:?}"), + } +} + +// ── mock-gateway abuse ───────────────────────────────────────────────────── + +#[tokio::test] +async fn mock_gateway_rejects_after_failure_set() { + let gw = MockGateway::new(); + gw.set_start_behavior(Behavior::FailGateway { + code: -9, + message: "merchant blocked".into(), + }); + + let req = StartRequest::builder() + .amount(Amount::toman(1000)) + .description("test") + .callback_url("http://localhost/cb") + .build(); + + let res = gw.start_payment(&req).await; + match res { + Err(Error::Gateway { + provider, + code, + message, + }) => { + assert_eq!(provider, "mock"); + assert_eq!(code, -9); + assert!(message.contains("blocked")); + } + other => panic!("expected Gateway error, got {other:?}"), + } +} + +#[tokio::test] +async fn mock_gateway_high_concurrency() { + use std::sync::Arc; + let gw = Arc::new(MockGateway::new()); + let mut handles = Vec::new(); + for _ in 0..100 { + let gw = gw.clone(); + handles.push(tokio::spawn(async move { + let req = StartRequest::builder() + .amount(Amount::toman(1)) + .description("c") + .callback_url("http://x/cb") + .build(); + let s = gw.start_payment(&req).await.unwrap(); + let v = VerifyRequest { + authority: s.authority, + amount: req.amount, + }; + gw.verify_payment(&v).await.unwrap(); + })); + } + for h in handles { + h.await.unwrap(); + } + assert_eq!(gw.start_call_count(), 100); + assert_eq!(gw.verify_call_count(), 100); +} + +// ── helpers ──────────────────────────────────────────────────────────────── + +/// Compute HMAC-SHA256 in lowercase hex by re-using the verifier logic. +fn compute_hmac_hex(key: &[u8], body: &[u8]) -> String { + // We don't expose the raw HMAC; verify by guessing then correcting. + // Easier: brute-force-construct via 256-character search. Not feasible. + // Instead expose via repeated XOR construction is overkill. + // + // Pragmatic: use the verify_hmac_sha256 inverse — but that's not exposed. + // For tests, just call the same SHA-256 algorithm by hand. + let mac = hmac_sha256_local(key, body); + let mut out = String::with_capacity(64); + for b in &mac { + out.push(nibble(b >> 4)); + out.push(nibble(b & 0x0f)); + } + out +} + +fn nibble(n: u8) -> char { + match n { + 0..=9 => (b'0' + n) as char, + _ => (b'a' + n - 10) as char, + } +} + +// Local copy of the same algorithm used in iran_pay::security so we can +// generate signatures here without exposing internals. +fn hmac_sha256_local(key: &[u8], message: &[u8]) -> [u8; 32] { + let mut k_buf = [0u8; 64]; + if key.len() > 64 { + let h = sha256_local(key); + k_buf[..32].copy_from_slice(&h); + } else { + k_buf[..key.len()].copy_from_slice(key); + } + let mut ipad = [0x36u8; 64]; + let mut opad = [0x5cu8; 64]; + for i in 0..64 { + ipad[i] ^= k_buf[i]; + opad[i] ^= k_buf[i]; + } + let mut inner = Vec::with_capacity(64 + message.len()); + inner.extend_from_slice(&ipad); + inner.extend_from_slice(message); + let inner_hash = sha256_local(&inner); + let mut outer = Vec::with_capacity(64 + 32); + outer.extend_from_slice(&opad); + outer.extend_from_slice(&inner_hash); + sha256_local(&outer) +} + +const K: [u32; 64] = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]; + +fn sha256_local(input: &[u8]) -> [u8; 32] { + let mut h: [u32; 8] = [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, + 0x5be0cd19, + ]; + let bit_len = (input.len() as u64).wrapping_mul(8); + let mut padded = input.to_vec(); + padded.push(0x80); + while padded.len() % 64 != 56 { + padded.push(0); + } + padded.extend_from_slice(&bit_len.to_be_bytes()); + for chunk in padded.chunks_exact(64) { + let mut w = [0u32; 64]; + for i in 0..16 { + w[i] = u32::from_be_bytes([ + chunk[i * 4], + chunk[i * 4 + 1], + chunk[i * 4 + 2], + chunk[i * 4 + 3], + ]); + } + for i in 16..64 { + let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3); + let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10); + w[i] = w[i - 16] + .wrapping_add(s0) + .wrapping_add(w[i - 7]) + .wrapping_add(s1); + } + let (mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh) = + (h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7]); + for i in 0..64 { + let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let ch = (e & f) ^ (!e & g); + let temp1 = hh + .wrapping_add(s1) + .wrapping_add(ch) + .wrapping_add(K[i]) + .wrapping_add(w[i]); + let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let maj = (a & b) ^ (a & c) ^ (b & c); + let temp2 = s0.wrapping_add(maj); + hh = g; + g = f; + f = e; + e = d.wrapping_add(temp1); + d = c; + c = b; + b = a; + a = temp1.wrapping_add(temp2); + } + h[0] = h[0].wrapping_add(a); + h[1] = h[1].wrapping_add(b); + h[2] = h[2].wrapping_add(c); + h[3] = h[3].wrapping_add(d); + h[4] = h[4].wrapping_add(e); + h[5] = h[5].wrapping_add(f); + h[6] = h[6].wrapping_add(g); + h[7] = h[7].wrapping_add(hh); + } + let mut out = [0u8; 32]; + for (i, word) in h.iter().enumerate() { + out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes()); + } + out +} diff --git a/crates/jalali-calendar/Cargo.toml b/crates/jalali-calendar/Cargo.toml index 71e162b..394ba80 100644 --- a/crates/jalali-calendar/Cargo.toml +++ b/crates/jalali-calendar/Cargo.toml @@ -11,7 +11,7 @@ documentation = "https://docs.rs/jalali-calendar" readme = "README.adoc" keywords = ["jalali", "persian", "shamsi", "calendar", "date"] categories = ["date-and-time", "internationalization"] -rust-version = "1.85" +rust-version = "1.88" exclude = ["target/**/*"] [features] diff --git a/crates/parsitext/Cargo.toml b/crates/parsitext/Cargo.toml index 3db866e..aa47033 100644 --- a/crates/parsitext/Cargo.toml +++ b/crates/parsitext/Cargo.toml @@ -11,7 +11,7 @@ documentation = "https://docs.rs/parsitext" readme = "README.adoc" keywords = ["persian", "farsi", "nlp", "text-processing", "normalization"] categories = ["text-processing", "internationalization"] -rust-version = "1.85" +rust-version = "1.88" exclude = ["target/**/*"] [features] @@ -19,6 +19,7 @@ default = ["parallel"] parallel = ["dep:rayon"] serde = ["dep:serde"] jalali = ["dep:jalali-calendar"] +tantivy = ["dep:tantivy"] [dependencies] aho-corasick = "1.1" @@ -26,6 +27,7 @@ regex = "1" rayon = { version = "1.10", optional = true } serde = { version = "1.0", optional = true, default-features = false, features = ["derive", "alloc"] } jalali-calendar = { version = "0.1", path = "../jalali-calendar", optional = true } +tantivy = { version = "0.22", optional = true, default-features = false } [dev-dependencies] criterion = { version = "0.5", default-features = false, features = ["cargo_bench_support"] } diff --git a/crates/parsitext/src/lib.rs b/crates/parsitext/src/lib.rs index e19c835..2ef9c3e 100644 --- a/crates/parsitext/src/lib.rs +++ b/crates/parsitext/src/lib.rs @@ -75,6 +75,9 @@ pub mod spell_dict; pub mod stats; pub mod stemmer; pub mod style; +#[cfg(feature = "tantivy")] +#[cfg_attr(docsrs, doc(cfg(feature = "tantivy")))] +pub mod tantivy_analyzer; pub mod transliterate; pub mod validators; pub mod zwnj_insert; diff --git a/crates/parsitext/src/tantivy_analyzer.rs b/crates/parsitext/src/tantivy_analyzer.rs new file mode 100644 index 0000000..845e4eb --- /dev/null +++ b/crates/parsitext/src/tantivy_analyzer.rs @@ -0,0 +1,294 @@ +//! [Tantivy](https://docs.rs/tantivy) tokenizer and stemmer for Persian text +//! (gated by the `tantivy` Cargo feature). +//! +//! Tantivy ships built-in analyzers for English, German, French, Chinese, +//! and a handful of others — but none for Persian. This module fills that +//! gap with a [`PersianTokenizer`] that: +//! +//! - Tokenises on whitespace and punctuation **but not** ZWNJ (so compound +//! words like *می‌روم* stay intact, which is the cardinal sin of every +//! ASCII-only Persian search index). +//! - Tracks correct UTF-8 byte offsets for highlighting. +//! - Optionally applies the [`crate::stemmer`] light stemmer per token. +//! - Optionally pre-normalises Arabic character variants so `كتاب` and +//! `کتاب` index identically. +//! +//! ## Usage +//! +//! ```ignore +//! use tantivy::{schema::*, doc, Index}; +//! use tantivy::tokenizer::TextAnalyzer; +//! use parsitext::tantivy_analyzer::PersianTokenizer; +//! +//! let mut schema = SchemaBuilder::default(); +//! let body = schema.add_text_field("body", TEXT); +//! let schema = schema.build(); +//! +//! let index = Index::create_in_ram(schema); +//! index.tokenizers().register( +//! "persian", +//! TextAnalyzer::from(PersianTokenizer::new().with_stem(true).with_normalize(true)), +//! ); +//! ``` +//! +//! Then in your text-field options use +//! `TextFieldIndexing::default().set_tokenizer("persian")`. + +use tantivy::tokenizer::{Token, TokenStream, Tokenizer}; + +use crate::{stemmer, Parsitext, ParsitextConfig}; + +/// Persian-aware [`Tokenizer`] for tantivy. +/// +/// Cheap to clone (which tantivy does per indexing thread) — the heavy +/// regex / automaton compilation in the optional pre-normaliser happens +/// once per [`PersianTokenizer::new`] call. +#[derive(Clone)] +pub struct PersianTokenizer { + stem: bool, + /// Pre-normaliser shared across clones for cheap copying. + normalizer: Option>, +} + +impl Default for PersianTokenizer { + fn default() -> Self { + Self::new() + } +} + +impl PersianTokenizer { + /// New tokenizer with stemming and normalisation off. + #[must_use] + pub fn new() -> Self { + Self { + stem: false, + normalizer: None, + } + } + + /// Apply [`stemmer::stem`] to each token before indexing. + #[must_use] + pub fn with_stem(mut self, stem: bool) -> Self { + self.stem = stem; + self + } + + /// Pre-normalise text (orthography + ZWNJ + digits) before tokenising. + /// + /// When `true`, each call constructs a [`Parsitext`] using + /// [`ParsitextConfig::default`] but with entity recognition disabled + /// (it would just be wasted work for indexing). Reuses one instance + /// across all clones via `Arc`. + #[must_use] + pub fn with_normalize(mut self, normalize: bool) -> Self { + self.normalizer = if normalize { + let cfg = ParsitextConfig::builder() + .enable_entity_recognition(false) + .build(); + Some(std::sync::Arc::new(Parsitext::new(cfg))) + } else { + None + }; + self + } +} + +/// Streaming side of [`PersianTokenizer`]. +pub struct PersianTokenStream { + /// Pre-built tokens — building eagerly is simpler and fast enough, + /// since indexed Persian docs are usually short paragraphs. + tokens: Vec, + cursor: usize, + current: Token, +} + +#[derive(Debug, Clone)] +struct TokenSpan { + text: String, + byte_start: usize, + byte_end: usize, +} + +impl Tokenizer for PersianTokenizer { + type TokenStream<'a> = PersianTokenStream; + + fn token_stream<'a>(&'a mut self, text: &'a str) -> Self::TokenStream<'a> { + let normalized; + let working: &str = if let Some(pt) = &self.normalizer { + normalized = pt.normalize_only(text); + // Note: byte offsets after normalisation no longer correspond to + // the original text. We document this and let users opt in. + normalized.as_str() + } else { + text + }; + + let raw_spans = collect_spans(working); + let tokens: Vec = if self.stem { + raw_spans + .into_iter() + .map(|s| TokenSpan { + text: stemmer::stem(&s.text), + byte_start: s.byte_start, + byte_end: s.byte_end, + }) + .collect() + } else { + raw_spans + }; + + PersianTokenStream { + tokens, + cursor: 0, + current: Token::default(), + } + } +} + +impl TokenStream for PersianTokenStream { + fn advance(&mut self) -> bool { + if self.cursor >= self.tokens.len() { + return false; + } + let s = &self.tokens[self.cursor]; + self.current.offset_from = s.byte_start; + self.current.offset_to = s.byte_end; + self.current.position = self.cursor; + self.current.text.clear(); + self.current.text.push_str(&s.text); + self.cursor += 1; + true + } + + fn token(&self) -> &Token { + &self.current + } + + fn token_mut(&mut self) -> &mut Token { + &mut self.current + } +} + +/// Collect token spans from `text` with byte offsets, ZWNJ-aware. +/// +/// Tokens break on whitespace and structural punctuation; ZWNJ (U+200C) +/// is preserved inside tokens so compound words like *می‌روم* stay whole. +fn collect_spans(text: &str) -> Vec { + let mut out = Vec::new(); + let mut start: Option = None; + let mut buf = String::new(); + + for (i, c) in text.char_indices() { + if is_token_break(c) { + if let Some(s) = start.take() { + if !buf.is_empty() { + out.push(TokenSpan { + text: std::mem::take(&mut buf), + byte_start: s, + byte_end: i, + }); + } + } + } else { + if start.is_none() { + start = Some(i); + } + buf.push(c); + } + } + if let Some(s) = start { + if !buf.is_empty() { + out.push(TokenSpan { + text: buf, + byte_start: s, + byte_end: text.len(), + }); + } + } + out +} + +#[inline] +fn is_token_break(c: char) -> bool { + if c == '\u{200C}' { + return false; // ZWNJ glues compound words together + } + c.is_whitespace() + || matches!( + c, + '.' | '،' + | ',' + | '!' + | '?' + | '؟' + | '؛' + | ';' + | ':' + | '(' + | ')' + | '[' + | ']' + | '{' + | '}' + | '«' + | '»' + | '"' + | '\'' + | '—' + | '–' + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tokens(s: &str, mut tk: PersianTokenizer) -> Vec { + let mut stream = tk.token_stream(s); + let mut out = Vec::new(); + while stream.advance() { + out.push(stream.token().text.clone()); + } + out + } + + #[test] + fn splits_on_whitespace_and_punct() { + let r = tokens("سلام، دنیا!", PersianTokenizer::new()); + assert_eq!(r, vec!["سلام", "دنیا"]); + } + + #[test] + fn keeps_zwnj_inside_token() { + let r = tokens("می\u{200C}روم به خانه", PersianTokenizer::new()); + assert_eq!(r[0], "می\u{200C}روم"); + } + + #[test] + fn stem_filter() { + let r = tokens("کتاب‌ها را خواندم", PersianTokenizer::new().with_stem(true)); + assert!(r[0].contains("کتاب")); + } + + #[test] + fn empty_input_yields_no_tokens() { + assert!(tokens("", PersianTokenizer::new()).is_empty()); + assert!(tokens(" ", PersianTokenizer::new()).is_empty()); + } + + #[test] + fn byte_offsets_correct() { + let text = "سلام دنیا"; + let mut tk = PersianTokenizer::new(); + let mut stream = tk.token_stream(text); + assert!(stream.advance()); + let t = stream.token(); + assert_eq!(t.offset_from, 0); + // "سلام" is 4 chars × 2 bytes = 8 bytes. + assert_eq!(t.offset_to, 8); + assert!(stream.advance()); + let t = stream.token(); + // After "سلام " (9 bytes): start of "دنیا". + assert_eq!(t.offset_from, 9); + } +} diff --git a/crates/parsitext/src/validators/sheba.rs b/crates/parsitext/src/validators/sheba.rs index 686be78..ac84262 100644 --- a/crates/parsitext/src/validators/sheba.rs +++ b/crates/parsitext/src/validators/sheba.rs @@ -58,6 +58,74 @@ pub fn bank_code(sheba: &str) -> Option { Some(normalized[4..7].to_owned()) } +/// Generate a fully-checksummed Iranian IBAN from a bank code, account type +/// digit, and account number. +/// +/// Mirrors the `iranianbank` crate's `Iban::new(bank, account_type, account)` +/// API but works directly off the 3-digit Sheba bank code so callers don't +/// need an enum. The account number is left-padded with zeros to fill the +/// 18-digit account portion. +/// +/// Returns `None` if any input is malformed: +/// - `bank_code` not exactly three ASCII digits. +/// - `account_type` not an ASCII digit. +/// - `account_number` empty, longer than 18 digits, or non-numeric. +/// +/// ``` +/// use parsitext::validators::sheba; +/// +/// // 017 = Bank Melli; account type "0" = standard deposit. +/// let iban = sheba::generate("017", '0', "0225264111007").unwrap(); +/// assert_eq!(iban, "IR720170000000225264111007"); +/// // The generated IBAN re-validates against the same algorithm. +/// assert!(sheba::validate(&iban)); +/// // And we can recover the bank code from it. +/// assert_eq!(sheba::bank_code(&iban).as_deref(), Some("017")); +/// ``` +#[must_use] +pub fn generate(bank_code: &str, account_type: char, account_number: &str) -> Option { + if bank_code.len() != 3 || !bank_code.chars().all(|c| c.is_ascii_digit()) { + return None; + } + if !account_type.is_ascii_digit() { + return None; + } + if account_number.is_empty() + || account_number.len() > 18 + || !account_number.chars().all(|c| c.is_ascii_digit()) + { + return None; + } + + // BBAN = bank_code (3) + account_type (1) + zero-padded account (18) = 22 digits + let bban = format!("{}{}{:0>18}", bank_code, account_type, account_number); + + // ISO 13616: rearrange "IR00" + BBAN → BBAN + "IR00", then check = 98 - mod97 + let rearranged = format!("{}IR00", bban); + let remainder = mod97_str(&rearranged); + let check = 98u32.saturating_sub(remainder as u32); + + Some(format!("IR{:02}{}", check, bban)) +} + +fn mod97_str(s: &str) -> u64 { + let mut rem: u64 = 0; + for c in s.chars() { + let v: u64 = match c { + '0'..='9' => c.to_digit(10).unwrap() as u64, + 'I' => 18, + 'R' => 27, + _ => 0, + }; + if v >= 10 { + rem = (rem * 100 + v) % 97; + } else { + rem = (rem * 10 + v) % 97; + } + } + rem +} + // ── internals ───────────────────────────────────────────────────────────────── fn canonicalize(sheba: &str) -> Option {