From 7dc153b82f4cdcf7dc7e1b3a98501cc3cbdd559d Mon Sep 17 00:00:00 2001 From: Andrew Battat Date: Thu, 12 Mar 2026 01:02:06 +0000 Subject: [PATCH 1/5] rename repo to ic-http-gateway-protocol and add compatibility cleanup markers --- .github/CONTRIBUTING.md | 8 +- .github/workflows/publish.yml | 5 + Cargo.lock | 548 ++++++++++++------ Cargo.toml | 10 +- README.md | 5 +- examples/http-gateway/rust/Cargo.toml | 2 +- examples/http-gateway/rust/src/main.rs | 2 +- package.json | 2 +- packages/http-canister-client-js/package.json | 6 +- packages/ic-http-gateway-protocol/Cargo.toml | 45 ++ packages/ic-http-gateway-protocol/README.md | 1 + .../src/client/http_gateway_client.rs | 32 + .../src/client/http_gateway_client_builder.rs | 37 ++ .../src/client/mod.rs | 5 + .../ic-http-gateway-protocol/src/consts.rs | 4 + .../ic-http-gateway-protocol/src/error.rs | 42 ++ packages/ic-http-gateway-protocol/src/lib.rs | 20 + .../src/protocol/handler.rs | 512 ++++++++++++++++ .../src/protocol/mod.rs | 5 + .../src/protocol/validate.rs | 46 ++ .../request/http_gateway_request_builder.rs | 50 ++ .../src/request/mod.rs | 2 + .../src/response/http_gateway_response.rs | 43 ++ .../src/response/mod.rs | 5 + .../src/response/response_handler.rs | 548 ++++++++++++++++++ .../test-container/Dockerfile | 23 + .../test-container/app.py | 18 + .../test-container/requirements.txt | 7 + .../tests/custom_assets.rs | 130 +++++ .../tests/protocol_error_tests.rs | 94 +++ .../tests/range_request_stream.rs | 521 +++++++++++++++++ .../tests/utils/mod.rs | 26 + packages/ic-http-gateway/Cargo.toml | 16 +- packages/ic-http-gateway/README.md | 6 +- packages/ic-http-gateway/src/lib.rs | 19 +- 35 files changed, 2611 insertions(+), 234 deletions(-) create mode 100644 packages/ic-http-gateway-protocol/Cargo.toml create mode 100644 packages/ic-http-gateway-protocol/README.md create mode 100644 packages/ic-http-gateway-protocol/src/client/http_gateway_client.rs create mode 100644 packages/ic-http-gateway-protocol/src/client/http_gateway_client_builder.rs create mode 100644 packages/ic-http-gateway-protocol/src/client/mod.rs create mode 100644 packages/ic-http-gateway-protocol/src/consts.rs create mode 100644 packages/ic-http-gateway-protocol/src/error.rs create mode 100644 packages/ic-http-gateway-protocol/src/lib.rs create mode 100644 packages/ic-http-gateway-protocol/src/protocol/handler.rs create mode 100644 packages/ic-http-gateway-protocol/src/protocol/mod.rs create mode 100644 packages/ic-http-gateway-protocol/src/protocol/validate.rs create mode 100644 packages/ic-http-gateway-protocol/src/request/http_gateway_request_builder.rs create mode 100644 packages/ic-http-gateway-protocol/src/request/mod.rs create mode 100644 packages/ic-http-gateway-protocol/src/response/http_gateway_response.rs create mode 100644 packages/ic-http-gateway-protocol/src/response/mod.rs create mode 100644 packages/ic-http-gateway-protocol/src/response/response_handler.rs create mode 100644 packages/ic-http-gateway-protocol/test-container/Dockerfile create mode 100644 packages/ic-http-gateway-protocol/test-container/app.py create mode 100644 packages/ic-http-gateway-protocol/test-container/requirements.txt create mode 100644 packages/ic-http-gateway-protocol/tests/custom_assets.rs create mode 100644 packages/ic-http-gateway-protocol/tests/protocol_error_tests.rs create mode 100644 packages/ic-http-gateway-protocol/tests/range_request_stream.rs create mode 100644 packages/ic-http-gateway-protocol/tests/utils/mod.rs diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 3bfa034..9193661 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing -Thank you for your interest in contributing to the HTTP Gateway Protocol monorepo for the Internet Computer. +Thank you for your interest in contributing to the Internet Computer HTTP Gateway Protocol monorepo. By participating in this project, you agree to abide by our [Code of Conduct](./CODE_OF_CONDUCT.md). As a member of the community, you are invited and encouraged to contribute by submitting issues, offering suggestions for improvements, adding review comments to existing pull requests, or creating new pull requests to fix issues. @@ -15,7 +15,7 @@ The contributor guidelines are intended to make the contribution process easy an Before contributing, consider the following: -- If you want to report an issue, click [issues](https://github.com/dfinity/http-gateway/issues). +- If you want to report an issue, click [issues](https://github.com/dfinity/ic-http-gateway-protocol/issues). - If you have more general questions related to this package and its use, post a message to the [community forum](https://forum.dfinity.org/). - If you are reporting a bug, provide as much information about the problem as possible. - If you want to contribute directly to this repository, typical fixes might include any of the following: @@ -40,7 +40,7 @@ This section describes the most common workflow scenarios: To open a new issue: -1. Click [create a new issue](https://github.com/dfinity/http-gateway/issues/new). +1. Click [create a new issue](https://github.com/dfinity/ic-http-gateway-protocol/issues/new). 2. Type a title and description, then click **Submit new issue**. - Be as clear and descriptive as possible. - For any problem, describe it in detail, including details about the crate, the version of the code you are using, the results you expected, and how the actual results differed from your expectations. @@ -50,7 +50,7 @@ To open a new issue: If you want to submit a pull request to fix an issue or add a feature, here's a summary of what you need to do: 1. Make sure you have a GitHub account, an internet connection, and access to a terminal shell or GitHub Desktop application for running commands. -2. Navigate to the [repository's homepage](https://github.com/dfinity/http-gateway) in a web browser. +2. Navigate to the [repository's homepage](https://github.com/dfinity/ic-http-gateway-protocol) in a web browser. 3. Click **Fork** to create a copy of the repository under your GitHub account or organization name. 4. Clone the forked repository to your local machine. 5. Create a new branch for your fix by running a command similar to the following: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c73fdf2..910c724 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -27,6 +27,11 @@ jobs: sed "s/0\\.0\\.0-git/${RELEASE_TAG##*\/v}/" Cargo.toml.orig > Cargo.toml rm -f Cargo.toml.orig + - run: cargo publish --package ic-http-gateway-protocol --allow-dirty + env: + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} + + # TODO(NODE-1907): Remove once ic-http-gateway is fully deprecated. - run: cargo publish --package ic-http-gateway --allow-dirty env: CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} diff --git a/Cargo.lock b/Cargo.lock index 4cd4a05..d20bedf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,9 +46,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "ar_archive_writer" @@ -61,9 +61,9 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.8.1" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ded5f9a03ac8f24d1b8a25101ee812cd32cdc8c50a4c50237de2c4915850e73" +checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" dependencies = [ "rustversion", ] @@ -122,7 +122,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -218,9 +218,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "block-buffer" @@ -302,9 +302,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "byteorder" @@ -355,7 +355,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -366,9 +366,9 @@ checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" [[package]] name = "candid" -version = "0.10.21" +version = "0.10.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e49a6e53730e2d41f6fc3ad9ef4d9bc7ad738ddc6aed4ceb30a35a2cd63e9bcb" +checksum = "601b519700ec333a2a2c7eb3e8e1eca89177055e3e1fb24ca42cbbb025986696" dependencies = [ "anyhow", "binread", @@ -389,21 +389,21 @@ dependencies = [ [[package]] name = "candid_derive" -version = "0.10.21" +version = "0.10.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab75e3283c7912bb2986dd7033a87e4e5f3f472158816308a04d40f5d697099c" +checksum = "f195a40cd3d199191fc8b534165fadd78c08a1f9666222addaf9f58593002a73" dependencies = [ "lazy_static", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "cc" -version = "1.2.55" +version = "1.2.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", "shlex", @@ -423,9 +423,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.43" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "num-traits", @@ -555,7 +555,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -602,7 +602,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -616,7 +616,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -627,7 +627,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -638,7 +638,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -660,9 +660,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", "serde_core", @@ -697,7 +697,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -958,9 +958,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -973,9 +973,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -983,15 +983,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -1000,32 +1000,32 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-timer" @@ -1035,9 +1035,9 @@ checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -1047,7 +1047,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -1084,11 +1083,24 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "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 = "glob" version = "0.3.3" @@ -1286,7 +1298,7 @@ dependencies = [ "hyper", "hyper-util", "ic-agent", - "ic-http-gateway", + "ic-http-gateway-protocol", "pocket-ic", "tokio", ] @@ -1546,7 +1558,7 @@ dependencies = [ "quote", "serde", "serde_tokenstream", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1617,6 +1629,21 @@ dependencies = [ [[package]] name = "ic-http-gateway" version = "0.0.0-git" +dependencies = [ + "assert_matches", + "ic-http-gateway-protocol", + "pocket-ic", + "rand_chacha 0.3.1", + "reqwest", + "rstest", + "sha2 0.10.9", + "testcontainers", + "tokio", +] + +[[package]] +name = "ic-http-gateway-protocol" +version = "0.0.0-git" dependencies = [ "assert_matches", "bytes", @@ -1870,6 +1897,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -1951,9 +1984,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.11.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" @@ -1973,9 +2006,9 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "js-sys" -version = "0.3.85" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" dependencies = [ "once_cell", "wasm-bindgen", @@ -2016,28 +2049,35 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" -version = "0.2.180" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libredox" -version = "0.1.12" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "libc", - "redox_syscall 0.7.0", + "plain", + "redox_syscall 0.7.3", ] [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" @@ -2077,9 +2117,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "merlin" @@ -2138,17 +2178,17 @@ dependencies = [ [[package]] name = "native-tls" -version = "0.2.14" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" dependencies = [ "libc", "log", "openssl", - "openssl-probe 0.1.6", + "openssl-probe", "openssl-sys", "schannel", - "security-framework 2.11.1", + "security-framework", "security-framework-sys", "tempfile", ] @@ -2234,7 +2274,7 @@ version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cfg-if", "foreign-types", "libc", @@ -2251,15 +2291,9 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - [[package]] name = "openssl-probe" version = "0.2.1" @@ -2350,7 +2384,7 @@ dependencies = [ "regex", "regex-syntax", "structmeta", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2386,9 +2420,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" @@ -2412,6 +2446,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "pocket-ic" version = "12.0.0" @@ -2481,6 +2521,16 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -2501,9 +2551,9 @@ dependencies = [ [[package]] name = "psm" -version = "0.1.29" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa96cb91275ed31d6da3e983447320c4eb219ac180fa1679a0889ff32861e2d" +checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8" dependencies = [ "ar_archive_writer", "cc", @@ -2531,9 +2581,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "bytes", "getrandom 0.3.4", @@ -2566,9 +2616,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.44" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -2579,6 +2629,12 @@ 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.5" @@ -2659,16 +2715,16 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] name = "redox_syscall" -version = "0.7.0" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] @@ -2688,7 +2744,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2716,9 +2772,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.9" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "relative-path" @@ -2825,7 +2881,7 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn 2.0.114", + "syn 2.0.117", "unicode-ident", ] @@ -2846,11 +2902,11 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys", @@ -2859,9 +2915,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.36" +version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "once_cell", "ring", @@ -2877,10 +2933,10 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe 0.2.1", + "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.5.1", + "security-framework", ] [[package]] @@ -2921,15 +2977,15 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "schannel" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] @@ -2979,7 +3035,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3004,24 +3060,11 @@ dependencies = [ [[package]] name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags 2.10.0", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework" -version = "3.5.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -3030,9 +3073,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -3095,7 +3138,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3106,7 +3149,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3130,19 +3173,19 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "serde_tokenstream" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64060d864397305347a78851c51588fd283767e7e7589829e8121d65512340f1" +checksum = "d7c49585c52c01f13c5c2ebb333f14f6885d76daa768d8a037d28017ec538c69" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3159,9 +3202,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.16.1" +version = "3.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" +checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" dependencies = [ "base64 0.22.1", "chrono", @@ -3178,14 +3221,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.16.1" +version = "3.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" +checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3279,12 +3322,12 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3305,9 +3348,9 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stacker" -version = "0.1.22" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1f8b29fb42aafcea4edeeb6b2f2d7ecd0d969c48b4cf0d2e64aafc471dd6e59" +checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013" dependencies = [ "cc", "cfg-if", @@ -3343,7 +3386,7 @@ dependencies = [ "proc-macro2", "quote", "structmeta-derive", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3354,7 +3397,7 @@ checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3382,7 +3425,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3394,7 +3437,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3422,9 +3465,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.114" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -3448,7 +3491,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3457,7 +3500,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -3474,12 +3517,12 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.24.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -3540,7 +3583,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3551,7 +3594,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3621,9 +3664,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.49.0" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ "bytes", "libc", @@ -3638,13 +3681,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3727,7 +3770,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "bytes", "futures-util", "http", @@ -3782,7 +3825,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3864,9 +3907,9 @@ checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-width" @@ -3874,6 +3917,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[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" @@ -3947,11 +3996,20 @@ dependencies = [ "wit-bindgen", ] +[[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", +] + [[package]] name = "wasm-bindgen" -version = "0.2.108" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" dependencies = [ "cfg-if", "once_cell", @@ -3962,9 +4020,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.58" +version = "0.4.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" dependencies = [ "cfg-if", "futures-util", @@ -3976,9 +4034,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3986,26 +4044,48 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" 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 2.13.0", + "wasm-encoder", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.4.2" @@ -4019,11 +4099,23 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", +] + [[package]] name = "web-sys" -version = "0.3.85" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" dependencies = [ "js-sys", "wasm-bindgen", @@ -4091,7 +4183,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4102,7 +4194,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4376,6 +4468,88 @@ 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-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 2.13.0", + "prettyplease", + "syn 2.0.117", + "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 2.0.117", + "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 2.11.0", + "indexmap 2.13.0", + "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 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" @@ -4418,28 +4592,28 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.38" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57cf3aa6855b23711ee9852dfc97dfaa51c45feaba5b645d0c777414d494a961" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.38" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a616990af1a287837c4fe6596ad77ef57948f787e46ce28e166facc0cc1cb75" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4459,7 +4633,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "synstructure", ] @@ -4480,7 +4654,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4513,11 +4687,11 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "zmij" -version = "1.0.19" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index b13294b..5fbb06a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,19 +4,21 @@ resolver = "2" members = [ "examples/http-gateway/canister/src/custom_assets", "examples/http-gateway/rust", + "packages/ic-http-gateway-protocol", + # TODO(NODE-1907): Remove once ic-http-gateway is fully deprecated. "packages/ic-http-gateway", ] # https://github.com/rust-lang/cargo/issues/9406 # includes all members except those that must be compiled to WASM -default-members = ["packages/ic-http-gateway"] +default-members = ["packages/ic-http-gateway-protocol"] [workspace.package] version = "0.0.0-git" authors = ["DFINITY Stiftung"] edition = "2021" -repository = "https://github.com/dfinity/http-gateway" -homepage = "https://github.com/dfinity/http-gateway#readme" +repository = "https://github.com/dfinity/ic-http-gateway-protocol" +homepage = "https://github.com/dfinity/ic-http-gateway-protocol#readme" license = "Apache-2.0" [profile.release] @@ -57,4 +59,6 @@ ic-certification = ">=3.0.3, <4.0.0" ic-http-certification = ">=3.0.3, <4.0.0" ic-asset-certification = ">=3.0.3, <4.0.0" +ic-http-gateway-protocol = { path = "./packages/ic-http-gateway-protocol", version = "0.0.0-git" } +# TODO(NODE-1907): Remove once ic-http-gateway is fully deprecated. ic-http-gateway = { path = "./packages/ic-http-gateway", version = "0.0.0-git" } diff --git a/README.md b/README.md index eb0571f..fcf5080 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ -# HTTP Gateway Protocol +# Internet Computer HTTP Gateway Protocol ## Overview -The HTTP Gateway Protocol is an extension of the Internet Computer Protocol that allows conventional HTTP clients to interact with the Internet Computer network. This is important for software such as web browsers to be able to fetch and render client-side canister code, including HTML, CSS, and JavaScript as well as other static assets such as images or videos. The HTTP Gateway does this by translating between standard HTTP requests and API canister calls that the Internet Computer Protocol will understand. +The Internet Computer HTTP Gateway Protocol (HTTP Gateway Protocol) is an extension of the Internet Computer Protocol that allows conventional HTTP clients to interact with the Internet Computer network. This is important for software such as web browsers to be able to fetch and render client-side canister code, including HTML, CSS, and JavaScript as well as other static assets such as images or videos. The HTTP Gateway does this by translating between standard HTTP requests and API canister calls that the Internet Computer Protocol will understand. You can read more about this protocol in [the spec](https://github.com/dfinity/interface-spec/blob/master/spec/http-gateway-protocol-spec.md). + ## Projects ### HTTP Canister Client diff --git a/examples/http-gateway/rust/Cargo.toml b/examples/http-gateway/rust/Cargo.toml index 645b07a..851955d 100644 --- a/examples/http-gateway/rust/Cargo.toml +++ b/examples/http-gateway/rust/Cargo.toml @@ -13,7 +13,7 @@ hyper.workspace = true hyper-util.workspace = true http-body-util.workspace = true -ic-http-gateway.workspace = true +ic-http-gateway-protocol.workspace = true ic-agent.workspace = true pocket-ic.workspace = true diff --git a/examples/http-gateway/rust/src/main.rs b/examples/http-gateway/rust/src/main.rs index 7b3d327..1cf6708 100644 --- a/examples/http-gateway/rust/src/main.rs +++ b/examples/http-gateway/rust/src/main.rs @@ -2,7 +2,7 @@ use http_body_util::BodyExt; use hyper::{body::Incoming, server::conn::http2, service::service_fn, Request, Response}; use hyper_util::rt::TokioIo; use ic_agent::Agent; -use ic_http_gateway::{HttpGatewayClient, HttpGatewayRequestArgs, HttpGatewayResponseBody}; +use ic_http_gateway_protocol::{HttpGatewayClient, HttpGatewayRequestArgs, HttpGatewayResponseBody}; use pocket_ic::PocketIcBuilder; use std::{convert::Infallible, net::SocketAddr, path::PathBuf, sync::Arc}; use tokio::{fs::File, io::AsyncReadExt, net::TcpListener, task}; diff --git a/package.json b/package.json index 8869f69..68e53ff 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "http-gateway", + "name": "ic-http-gateway-protocol", "private": true, "engines": { "node": "^20", diff --git a/packages/http-canister-client-js/package.json b/packages/http-canister-client-js/package.json index 44bdc18..bb6651f 100644 --- a/packages/http-canister-client-js/package.json +++ b/packages/http-canister-client-js/package.json @@ -4,14 +4,14 @@ "description": "A JavaScript client for Internet Computer canisters that implement the HTTP interface", "author": "DFINITY Stiftung", "license": "Apache-2.0", - "homepage": "https://github.com/dfinity/http-gateway#readme", + "homepage": "https://github.com/dfinity/ic-http-gateway-protocol#readme", "repository": { "type": "git", - "url": "https://github.com/dfinity/http-gateway.git", + "url": "https://github.com/dfinity/ic-http-gateway-protocol.git", "directory": "packages/http-canister-client" }, "bugs": { - "url": "https://github.com/dfinity/http-gateway/issues" + "url": "https://github.com/dfinity/ic-http-gateway-protocol/issues" }, "keywords": [ "internet", diff --git a/packages/ic-http-gateway-protocol/Cargo.toml b/packages/ic-http-gateway-protocol/Cargo.toml new file mode 100644 index 0000000..d36058f --- /dev/null +++ b/packages/ic-http-gateway-protocol/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "ic-http-gateway-protocol" +description = "An HTTP Gateway implementation for interfacing with the Internet Computer over HTTP" +readme = "README.md" +documentation = "https://docs.rs/ic-http-gateway-protocol" +categories = [ + "api-bindings", + "cryptography::cryptocurrencies", + "network-programming", + "wasm", +] +keywords = ["internet-computer", "http", "gateway", "icp", "dfinity"] +include = ["src", "Cargo.toml", "README.md"] + +version.workspace = true +authors.workspace = true +edition.workspace = true +repository.workspace = true +license.workspace = true +homepage.workspace = true + +[dependencies] +thiserror.workspace = true +futures.workspace = true +http.workspace = true +http-body.workspace = true +http-body-util.workspace = true +bytes.workspace = true + +ic-agent.workspace = true +ic-utils.workspace = true +candid.workspace = true + +ic-http-certification.workspace = true +ic-response-verification.workspace = true + +[dev-dependencies] +assert_matches.workspace = true +pocket-ic.workspace = true +reqwest.workspace = true +testcontainers.workspace = true +tokio.workspace = true +rand_chacha.workspace = true +rstest.workspace = true +sha2.workspace = true diff --git a/packages/ic-http-gateway-protocol/README.md b/packages/ic-http-gateway-protocol/README.md new file mode 100644 index 0000000..fffa248 --- /dev/null +++ b/packages/ic-http-gateway-protocol/README.md @@ -0,0 +1 @@ +# HTTP Gateway Protocol diff --git a/packages/ic-http-gateway-protocol/src/client/http_gateway_client.rs b/packages/ic-http-gateway-protocol/src/client/http_gateway_client.rs new file mode 100644 index 0000000..3ab5e29 --- /dev/null +++ b/packages/ic-http-gateway-protocol/src/client/http_gateway_client.rs @@ -0,0 +1,32 @@ +use crate::{ + HttpGatewayClientBuilder, HttpGatewayRequestArgs, HttpGatewayRequestBuilder, + HttpGatewayRequestBuilderArgs, +}; +use ic_agent::Agent; + +#[derive(Clone)] +pub struct HttpGatewayClientArgs { + pub agent: Agent, +} + +#[derive(Clone)] +pub struct HttpGatewayClient { + agent: Agent, +} + +impl<'a> HttpGatewayClient { + pub fn new(args: HttpGatewayClientArgs) -> Self { + Self { agent: args.agent } + } + + pub fn builder() -> HttpGatewayClientBuilder { + Default::default() + } + + pub fn request(&'a self, args: HttpGatewayRequestArgs) -> HttpGatewayRequestBuilder<'a> { + HttpGatewayRequestBuilder::new(HttpGatewayRequestBuilderArgs { + request_args: args, + agent: &self.agent, + }) + } +} diff --git a/packages/ic-http-gateway-protocol/src/client/http_gateway_client_builder.rs b/packages/ic-http-gateway-protocol/src/client/http_gateway_client_builder.rs new file mode 100644 index 0000000..ea22ccd --- /dev/null +++ b/packages/ic-http-gateway-protocol/src/client/http_gateway_client_builder.rs @@ -0,0 +1,37 @@ +use crate::{ + HttpGatewayClient, HttpGatewayClientArgs, HttpGatewayResult, DEFAULT_BOUNDARY_NODE_ENDPOINT, +}; +use ic_agent::Agent; + +pub struct HttpGatewayClientBuilder { + agent: Option, +} + +impl HttpGatewayClientBuilder { + pub fn new() -> Self { + Self { agent: None } + } + + pub fn with_agent(mut self, agent: Agent) -> Self { + self.agent = Some(agent); + + self + } + + pub fn build(self) -> HttpGatewayResult { + let agent = match self.agent { + Some(agent) => agent, + None => Agent::builder() + .with_url(DEFAULT_BOUNDARY_NODE_ENDPOINT) + .build()?, + }; + + Ok(HttpGatewayClient::new(HttpGatewayClientArgs { agent })) + } +} + +impl Default for HttpGatewayClientBuilder { + fn default() -> Self { + Self::new() + } +} diff --git a/packages/ic-http-gateway-protocol/src/client/mod.rs b/packages/ic-http-gateway-protocol/src/client/mod.rs new file mode 100644 index 0000000..e07acb4 --- /dev/null +++ b/packages/ic-http-gateway-protocol/src/client/mod.rs @@ -0,0 +1,5 @@ +mod http_gateway_client; +pub use http_gateway_client::*; + +mod http_gateway_client_builder; +pub use http_gateway_client_builder::*; diff --git a/packages/ic-http-gateway-protocol/src/consts.rs b/packages/ic-http-gateway-protocol/src/consts.rs new file mode 100644 index 0000000..329c2ff --- /dev/null +++ b/packages/ic-http-gateway-protocol/src/consts.rs @@ -0,0 +1,4 @@ +pub(crate) static CACHE_HEADER_NAME: &str = "cache-control"; +pub(crate) static ACCEPT_ENCODING_HEADER_NAME: &str = "accept-encoding"; + +pub(crate) static DEFAULT_BOUNDARY_NODE_ENDPOINT: &str = "https://icp-api.io"; diff --git a/packages/ic-http-gateway-protocol/src/error.rs b/packages/ic-http-gateway-protocol/src/error.rs new file mode 100644 index 0000000..bac9b84 --- /dev/null +++ b/packages/ic-http-gateway-protocol/src/error.rs @@ -0,0 +1,42 @@ +//! The error module contains types for common errors that may be thrown +//! by other modules in this crate. + +use ic_agent::AgentError; +use ic_response_verification::ResponseVerificationError; +use std::sync::Arc; + +/// HTTP gateway result type. +pub type HttpGatewayResult = Result; + +/// HTTP gateway error type. +#[derive(thiserror::Error, Debug, Clone)] +pub enum HttpGatewayError { + #[error(transparent)] + ResponseVerificationError(#[from] ResponseVerificationError), + + /// Inner error from agent. + #[error(transparent)] + AgentError(#[from] Arc), + + /// HTTP error. + #[error(transparent)] + HttpError(#[from] Arc), + + #[error(r#"Failed to parse the "{header_name}" header value: "{header_value:?}""#)] + HeaderValueParsingError { + header_name: String, + header_value: String, + }, +} + +impl From for HttpGatewayError { + fn from(err: AgentError) -> Self { + HttpGatewayError::AgentError(Arc::new(err)) + } +} + +impl From for HttpGatewayError { + fn from(err: http::Error) -> Self { + HttpGatewayError::HttpError(Arc::new(err)) + } +} diff --git a/packages/ic-http-gateway-protocol/src/lib.rs b/packages/ic-http-gateway-protocol/src/lib.rs new file mode 100644 index 0000000..3f13ac6 --- /dev/null +++ b/packages/ic-http-gateway-protocol/src/lib.rs @@ -0,0 +1,20 @@ +/*! +# HTTP Gateway Protocol +*/ + +mod client; +pub use client::*; + +mod protocol; + +mod request; +pub use request::*; + +mod response; +pub use response::*; + +mod consts; +pub(crate) use consts::*; + +mod error; +pub use error::*; diff --git a/packages/ic-http-gateway-protocol/src/protocol/handler.rs b/packages/ic-http-gateway-protocol/src/protocol/handler.rs new file mode 100644 index 0000000..9ea5aa3 --- /dev/null +++ b/packages/ic-http-gateway-protocol/src/protocol/handler.rs @@ -0,0 +1,512 @@ +use super::validate; +use crate::{ + get_206_stream_response_body_and_total_length, get_body_and_streaming_body, CanisterRequest, + CanisterResponse, HttpGatewayError, HttpGatewayResponse, HttpGatewayResponseBody, + HttpGatewayResponseMetadata, HttpGatewayResult, ACCEPT_ENCODING_HEADER_NAME, CACHE_HEADER_NAME, +}; +use candid::Principal; +use http::header as http_header; +use http::{Response, StatusCode}; +use http_body_util::{BodyExt, Either, Full}; +use ic_agent::{ + agent::{RejectCode, RejectResponse}, + Agent, AgentError, +}; +use ic_http_certification::{HttpRequest, HttpResponse}; +use ic_response_verification::MAX_VERIFICATION_VERSION; +use ic_utils::{ + call::{AsyncCall, SyncCall}, + interfaces::{http_request::HeaderField, HttpRequestCanister}, +}; + +fn create_err_response(status_code: StatusCode, msg: &str) -> CanisterResponse { + let mut response = Response::new(HttpGatewayResponseBody::Right(Full::from( + msg.as_bytes().to_vec(), + ))); + *response.status_mut() = status_code; + + response +} + +fn convert_request(request: CanisterRequest) -> HttpGatewayResult> { + let uri = request.uri(); + let mut url = uri.path().to_string(); + if let Some(query) = uri.query() { + url.push('?'); + url.push_str(query); + } + + Ok(HttpRequest::builder() + .with_method(request.method().clone()) + .with_url(url) + .with_headers( + request + .headers() + .into_iter() + .map(|(name, value)| { + Ok(( + name.to_string(), + value + .to_str() + .map_err(|_| HttpGatewayError::HeaderValueParsingError { + header_name: name.to_string(), + header_value: String::from_utf8_lossy(value.as_bytes()).to_string(), + })? + .to_string(), + )) + }) + .collect::>>()?, + ) + .with_body(request.body().to_vec()) + .build()) +} + +pub async fn process_request( + agent: &Agent, + request: CanisterRequest, + canister_id: Principal, + skip_verification: bool, +) -> HttpGatewayResponse { + let http_request = match convert_request(request) { + Ok(http_request) => http_request, + Err(e) => { + return HttpGatewayResponse { + canister_response: create_err_response( + StatusCode::BAD_REQUEST, + &format!("Failed to parse request: {}", e), + ), + metadata: HttpGatewayResponseMetadata { + upgraded_to_update_call: false, + response_verification_version: None, + internal_error: Some(e), + }, + } + } + }; + + let canister = HttpRequestCanister::create(agent, canister_id); + let mut is_range_request = false; + let header_fields = http_request + .headers() + .iter() + .filter(|(name, _)| name != "x-request-id") + .map(|(name, value)| { + if name.eq_ignore_ascii_case(ACCEPT_ENCODING_HEADER_NAME) { + let mut encodings = value.split(',').map(|s| s.trim()).collect::>(); + if !encodings.iter().any(|s| s.eq_ignore_ascii_case("identity")) { + encodings.push("identity"); + }; + + let value = encodings.join(", "); + return HeaderField(name.into(), value.into()); + } else if name.eq_ignore_ascii_case(http_header::RANGE.as_ref()) + || name.eq_ignore_ascii_case(http_header::IF_RANGE.as_ref()) + { + is_range_request = true; + } + + HeaderField(name.into(), value.into()) + }) + .collect::>() + .into_iter(); + + let query_result = canister + .http_request_custom( + http_request.method().as_str(), + http_request.url(), + header_fields.clone(), + http_request.body(), + Some(&u16::from(MAX_VERIFICATION_VERSION)), + ) + .call() + .await; + + let agent_response = match query_result { + Ok((response,)) => response, + Err(e) => { + return HttpGatewayResponse { + canister_response: handle_agent_error(&e), + metadata: HttpGatewayResponseMetadata { + upgraded_to_update_call: false, + response_verification_version: None, + internal_error: Some(e.into()), + }, + }; + } + }; + + let is_update_call = agent_response.upgrade == Some(true); + let agent_response = if is_update_call { + let update_result = canister + .http_request_update_custom( + http_request.method().as_str(), + http_request.url(), + header_fields.clone(), + http_request.body(), + ) + .call_and_wait() + .await; + + match update_result { + Ok((response,)) => response, + Err(e) => { + return HttpGatewayResponse { + canister_response: handle_agent_error(&e), + metadata: HttpGatewayResponseMetadata { + upgraded_to_update_call: true, + response_verification_version: None, + internal_error: Some(e.into()), + }, + }; + } + } + } else { + agent_response + }; + + let response_body = match get_body_and_streaming_body(agent, &agent_response).await { + Ok(response_body) => response_body, + Err(e) => { + return HttpGatewayResponse { + canister_response: create_err_response( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("Failed to parse response body: {}", e), + ), + metadata: HttpGatewayResponseMetadata { + upgraded_to_update_call: is_update_call, + response_verification_version: None, + internal_error: Some(e.into()), + }, + } + } + }; + + // There is no need to verify the response if the request was upgraded to an update call. + let validation_info = if !is_update_call { + // At the moment verification is only performed if the response is not using a streaming + // strategy. Performing verification for those requests would require to join all the chunks + // and this could cause memory issues and possibly create DOS attack vectors. + match &response_body { + Either::Right(body) => { + // this unwrap should never panic because `Either::Right` will always have a full body + let body = body.clone().collect().await.unwrap().to_bytes().to_vec(); + + let status_code = match StatusCode::from_u16(agent_response.status_code) { + Ok(status) => status, + Err(e) => { + return HttpGatewayResponse { + canister_response: create_err_response( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("Invalid canister response status code: {}", e), + ), + metadata: HttpGatewayResponseMetadata { + upgraded_to_update_call: is_update_call, + response_verification_version: None, + internal_error: Some(http::Error::from(e).into()), + }, + }; + } + }; + let response = HttpResponse::builder() + .with_status_code(status_code) + .with_headers( + agent_response + .headers + .iter() + .map(|HeaderField(k, v)| (k.to_string(), v.to_string())) + .collect(), + ) + .with_body(body) + .build(); + + let validation_result = validate( + agent, + &canister_id, + http_request.clone(), + response, + skip_verification, + ); + + match validation_result { + Err(e) => { + return HttpGatewayResponse { + canister_response: create_err_response( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("Response verification failed: {}", e), + ), + metadata: HttpGatewayResponseMetadata { + upgraded_to_update_call: is_update_call, + response_verification_version: None, + internal_error: Some(e), + }, + }; + } + Ok(validation_info) => validation_info, + } + } + _ => None, + } + } else { + None + }; + + let response_verification_version = validation_info.as_ref().map(|e| e.verification_version); + + let status_code = match StatusCode::from_u16(agent_response.status_code) { + Ok(status_code) => status_code, + Err(e) => { + return HttpGatewayResponse { + canister_response: create_err_response( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("Invalid canister response status code: {}", e), + ), + metadata: HttpGatewayResponseMetadata { + upgraded_to_update_call: is_update_call, + response_verification_version, + internal_error: Some(http::Error::from(e).into()), + }, + } + } + }; + + let mut response_builder = Response::builder().status(status_code); + match &validation_info { + // if there is no validation info, that means we've skipped verification, + // this should only happen for raw domains. + None => { + for HeaderField(name, value) in &agent_response.headers { + response_builder = response_builder.header(name.as_ref(), value.as_ref()); + } + } + + Some(validation_info) => { + if validation_info.verification_version < 2 { + // status codes are not certified in v1, reject known dangerous status codes + if agent_response.status_code >= 300 && agent_response.status_code < 400 { + return HttpGatewayResponse { + canister_response: create_err_response( + StatusCode::INTERNAL_SERVER_ERROR, + "Response verification v1 does not allow redirects", + ), + metadata: HttpGatewayResponseMetadata { + upgraded_to_update_call: is_update_call, + response_verification_version, + internal_error: None, + }, + }; + } + + // headers are also not certified in v1, filter known dangerous headers + for HeaderField(name, value) in &agent_response.headers { + if !name.eq_ignore_ascii_case(CACHE_HEADER_NAME) { + response_builder = response_builder.header(name.as_ref(), value.as_ref()); + } + } + } else { + match &validation_info.response { + // if there is no response, the canister has decided to certifiably skip verification, + // assume the developer knows what they're doing and return the response as-is + None => { + for HeaderField(name, value) in &agent_response.headers { + // If the request is not a range-request, but got range-response, + // do not copy "Content-Range" and "Content-Length" headers, + // as clients obtain the full asset via a streaming response. + if !is_range_request + && status_code == 206 + && (name.eq_ignore_ascii_case(http_header::CONTENT_RANGE.as_ref()) + || name + .eq_ignore_ascii_case(http_header::CONTENT_LENGTH.as_ref())) + { + // skip copying + } else { + response_builder = + response_builder.header(name.as_ref(), value.as_ref()); + } + } + } + // if there is a response, the canister has decided to certify some (but not necessarily all) headers, + // return only the certified headers + Some(certified_http_response) => { + for (name, value) in &certified_http_response.headers { + // If the request is not a range-request, but got range-response, + // do not copy "Content-Range" and "Content-Length" headers, + // as clients obtain the full asset via a streaming response. + if !is_range_request + && status_code == 206 + && (name.eq_ignore_ascii_case(http_header::CONTENT_RANGE.as_ref()) + || name + .eq_ignore_ascii_case(http_header::CONTENT_LENGTH.as_ref())) + { + // skip copying + } else { + response_builder = response_builder.header(name, value); + } + } + } + } + } + } + } + + let response_body: HttpGatewayResponseBody = if status_code == 206 && !is_range_request { + // We got only the first chunk, add a correct content-length-header, + // and turn the response into a streaming response. + let (stream_response_body, content_length) = + match get_206_stream_response_body_and_total_length( + agent, + http_request, + canister_id, + &agent_response.headers, + response_body, + skip_verification, + ) + .await + { + Ok((stream_response_body, content_length)) => { + (stream_response_body, content_length) + } + Err(e) => { + return HttpGatewayResponse { + canister_response: create_err_response( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("Failed to create streaming response: {}", e), + ), + metadata: HttpGatewayResponseMetadata { + upgraded_to_update_call: is_update_call, + response_verification_version, + internal_error: Some(e.into()), + }, + } + } + }; + response_builder = + response_builder.header(http_header::CONTENT_LENGTH, content_length.to_string()); + response_builder = response_builder.status(200); + stream_response_body + } else { + response_body + }; + + let response = match response_builder.body(response_body) { + Ok(response) => response, + Err(e) => { + return HttpGatewayResponse { + canister_response: create_err_response( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("Failed to build response: {}", e), + ), + metadata: HttpGatewayResponseMetadata { + upgraded_to_update_call: is_update_call, + response_verification_version, + internal_error: Some(e.into()), + }, + } + } + }; + + HttpGatewayResponse { + canister_response: response, + metadata: HttpGatewayResponseMetadata { + upgraded_to_update_call: is_update_call, + response_verification_version, + internal_error: None, + }, + } +} + +fn handle_agent_error(error: &AgentError) -> CanisterResponse { + match error { + // Turn all `DestinationInvalid`s into 404 + AgentError::CertifiedReject { + reject: + RejectResponse { + reject_code: RejectCode::DestinationInvalid, + reject_message, + .. + }, + .. + } => create_err_response(StatusCode::NOT_FOUND, reject_message), + + // If the result is a Replica error, returns the 500 code and message. There is no information + // leak here because a user could use `dfx` to get the same reply. + AgentError::CertifiedReject { reject, .. } => create_err_response( + StatusCode::BAD_GATEWAY, + &format!( + "Replica Error: reject code {:?}, message {}, error code {:?}", + reject.reject_code, reject.reject_message, reject.error_code, + ), + ), + + AgentError::UncertifiedReject { + reject: + RejectResponse { + reject_code: RejectCode::DestinationInvalid, + reject_message, + .. + }, + .. + } => create_err_response(StatusCode::NOT_FOUND, reject_message), + + // If the result is a Replica error, returns the 500 code and message. There is no information + // leak here because a user could use `dfx` to get the same reply. + AgentError::UncertifiedReject { reject, .. } => create_err_response( + StatusCode::BAD_GATEWAY, + &format!( + "Replica Error: reject code {:?}, message {}, error code {:?}", + reject.reject_code, reject.reject_message, reject.error_code, + ), + ), + + AgentError::ResponseSizeExceededLimit() => create_err_response( + StatusCode::INSUFFICIENT_STORAGE, + "Response size exceeds limit", + ), + + AgentError::HttpError(payload) => match StatusCode::from_u16(payload.status) { + Ok(status) => create_err_response(status, &format!("{:?}", payload)), + Err(_) => create_err_response( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("Received invalid status code {:?}", payload), + ), + }, + + // Handle all other errors + _ => create_err_response( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("Internal Server Error: {:?}", error), + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bytes::Bytes; + use http::Request; + + #[test] + fn test_convert_request() { + let request = Request::builder() + .uri("http://example.com/foo/bar/baz?q=hello+world&t=1") + .header("Accept", "text/html") + .header("Accept-Encoding", "gzip, deflate, br, zstd") + .body(Bytes::from("body")) + .unwrap(); + + let http_request = convert_request(request).unwrap(); + + assert_eq!( + http_request, + HttpRequest::get("/foo/bar/baz?q=hello+world&t=1") + .with_headers(vec![ + ("accept".to_string(), "text/html".to_string()), + ( + "accept-encoding".to_string(), + "gzip, deflate, br, zstd".to_string() + ), + ]) + .with_body(b"body".to_vec()) + .build() + ); + } +} diff --git a/packages/ic-http-gateway-protocol/src/protocol/mod.rs b/packages/ic-http-gateway-protocol/src/protocol/mod.rs new file mode 100644 index 0000000..1b72e01 --- /dev/null +++ b/packages/ic-http-gateway-protocol/src/protocol/mod.rs @@ -0,0 +1,5 @@ +mod handler; +pub(crate) use handler::*; + +mod validate; +pub(crate) use validate::*; diff --git a/packages/ic-http-gateway-protocol/src/protocol/validate.rs b/packages/ic-http-gateway-protocol/src/protocol/validate.rs new file mode 100644 index 0000000..a8e468f --- /dev/null +++ b/packages/ic-http-gateway-protocol/src/protocol/validate.rs @@ -0,0 +1,46 @@ +use crate::HttpGatewayResult; +use candid::Principal; +use ic_agent::Agent; +use ic_http_certification::{HttpRequest, HttpResponse}; +use ic_response_verification::{ + types::VerificationInfo, verify_request_response_pair, MIN_VERIFICATION_VERSION, +}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const MAX_CERT_TIME_OFFSET_NS: u128 = 300_000_000_000; + +pub fn validate( + agent: &Agent, + canister_id: &Principal, + request: HttpRequest, + response: HttpResponse, + skip_verification: bool, +) -> HttpGatewayResult> { + if skip_verification { + // TODO: Remove this (FOLLOW-483) + // Canisters don't have to provide certified variables + // This should change in the future, grandfathering in current implementations + return Ok(None); + } + + let ic_public_key = agent.read_root_key(); + let verification_info = verify_request_response_pair( + request, + response, + canister_id.as_slice(), + get_current_time_in_ns(), + MAX_CERT_TIME_OFFSET_NS, + ic_public_key.as_slice(), + MIN_VERIFICATION_VERSION, + )?; + Ok(Some(verification_info)) +} + +fn get_current_time_in_ns() -> u128 { + let start = SystemTime::now(); + + start + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_nanos() +} diff --git a/packages/ic-http-gateway-protocol/src/request/http_gateway_request_builder.rs b/packages/ic-http-gateway-protocol/src/request/http_gateway_request_builder.rs new file mode 100644 index 0000000..6e5bc70 --- /dev/null +++ b/packages/ic-http-gateway-protocol/src/request/http_gateway_request_builder.rs @@ -0,0 +1,50 @@ +use crate::{protocol::process_request, HttpGatewayResponse}; +use bytes::Bytes; +use candid::Principal; +use http::Request; +use ic_agent::Agent; + +pub struct HttpGatewayRequestArgs { + /// The request to make to the canister. + pub canister_request: CanisterRequest, + + /// The id of the canister to make a request to. + pub canister_id: Principal, +} + +pub type CanisterRequest = Request; + +pub struct HttpGatewayRequestBuilderArgs<'a> { + pub request_args: HttpGatewayRequestArgs, + pub agent: &'a Agent, +} + +pub struct HttpGatewayRequestBuilder<'a> { + args: HttpGatewayRequestBuilderArgs<'a>, + skip_verification: bool, +} + +impl<'a> HttpGatewayRequestBuilder<'a> { + pub fn new(args: HttpGatewayRequestBuilderArgs<'a>) -> Self { + Self { + args, + skip_verification: false, + } + } + + pub fn unsafe_set_skip_verification(&mut self, skip_verification: bool) -> &mut Self { + self.skip_verification = skip_verification; + + self + } + + pub async fn send(self) -> HttpGatewayResponse { + process_request( + self.args.agent, + self.args.request_args.canister_request, + self.args.request_args.canister_id, + self.skip_verification, + ) + .await + } +} diff --git a/packages/ic-http-gateway-protocol/src/request/mod.rs b/packages/ic-http-gateway-protocol/src/request/mod.rs new file mode 100644 index 0000000..c6a182e --- /dev/null +++ b/packages/ic-http-gateway-protocol/src/request/mod.rs @@ -0,0 +1,2 @@ +mod http_gateway_request_builder; +pub use http_gateway_request_builder::*; diff --git a/packages/ic-http-gateway-protocol/src/response/http_gateway_response.rs b/packages/ic-http-gateway-protocol/src/response/http_gateway_response.rs new file mode 100644 index 0000000..8459cf6 --- /dev/null +++ b/packages/ic-http-gateway-protocol/src/response/http_gateway_response.rs @@ -0,0 +1,43 @@ +use bytes::Bytes; +use futures::stream::BoxStream; +use http::Response; +use http_body::Frame; +use http_body_util::{Either, Full, StreamBody}; +use ic_agent::AgentError; +use std::fmt::Debug; + +use crate::HttpGatewayError; + +pub type CanisterResponse = Response; + +/// A response from the HTTP gateway. +pub struct HttpGatewayResponse { + /// The certified response, excluding uncertified headers. + /// If response verification v1 is used, the original, uncertified headers are returned. + pub canister_response: CanisterResponse, + + /// Additional metadata regarding the response. + pub metadata: HttpGatewayResponseMetadata, +} + +/// Additional metadata regarding the response. +#[derive(Debug, Clone)] +pub struct HttpGatewayResponseMetadata { + /// Whether the original query call was upgraded to an update call. + pub upgraded_to_update_call: bool, + + /// The version of response verification that was used to verify the response. + /// If the protocol fails before getting to the verification step, or the + /// original query call is upgraded to an update call, this field will be `None`. + pub response_verification_version: Option, + + /// The internal error that resulted in the HTTP response being an error response. + pub internal_error: Option, +} + +pub type HttpGatewayResponseBody = Either>; + +pub type ResponseBodyStream = StreamBody>; + +/// An item in a response body stream. +pub type ResponseBodyStreamItem = Result, AgentError>; diff --git a/packages/ic-http-gateway-protocol/src/response/mod.rs b/packages/ic-http-gateway-protocol/src/response/mod.rs new file mode 100644 index 0000000..0a58f25 --- /dev/null +++ b/packages/ic-http-gateway-protocol/src/response/mod.rs @@ -0,0 +1,5 @@ +mod http_gateway_response; +pub use http_gateway_response::*; + +mod response_handler; +pub use response_handler::*; diff --git a/packages/ic-http-gateway-protocol/src/response/response_handler.rs b/packages/ic-http-gateway-protocol/src/response/response_handler.rs new file mode 100644 index 0000000..4a64a73 --- /dev/null +++ b/packages/ic-http-gateway-protocol/src/response/response_handler.rs @@ -0,0 +1,548 @@ +use crate::protocol::validate; +use crate::{HttpGatewayResponseBody, ResponseBodyStream}; +use bytes::Bytes; +use candid::Principal; +use futures::{stream, Stream, StreamExt, TryStreamExt}; +use http_body::Frame; +use http_body_util::{BodyExt, Full}; +use ic_agent::{Agent, AgentError}; +use ic_http_certification::{HttpRequest, HttpResponse, StatusCode}; +use ic_response_verification::MAX_VERIFICATION_VERSION; +use ic_utils::interfaces::http_request::HeaderField; +use ic_utils::{ + call::SyncCall, + interfaces::http_request::{ + HttpRequestCanister, HttpRequestStreamingCallbackAny, HttpResponse as AgentResponse, + StreamingCallbackHttpResponse, StreamingStrategy, Token, + }, +}; + +// Limit the total number of calls to an HTTP Request loop to 1000 for now. +static MAX_HTTP_REQUEST_STREAM_CALLBACK_CALL_COUNT: usize = 1000; + +// Limit the total number of calls to an HTTP request look that can be verified +static MAX_VERIFIED_HTTP_REQUEST_STREAM_CALLBACK_CALL_COUNT: usize = 4; + +// Limit the number of Stream Callbacks buffered +static STREAM_CALLBACK_BUFFER: usize = 2; + +pub type AgentResponseAny = AgentResponse; + +pub async fn get_body_and_streaming_body( + agent: &Agent, + response: &AgentResponseAny, +) -> Result { + // if we already have the full body, we can return it early + let Some(StreamingStrategy::Callback(callback_strategy)) = response.streaming_strategy.clone() + else { + return Ok(HttpGatewayResponseBody::Right(Full::from( + response.body.clone(), + ))); + }; + + let (streamed_body, token) = create_stream( + agent.clone(), + callback_strategy.callback.clone(), + Some(callback_strategy.token), + ) + .take(MAX_VERIFIED_HTTP_REQUEST_STREAM_CALLBACK_CALL_COUNT) + .map(|x| async move { x }) + .buffered(STREAM_CALLBACK_BUFFER) + .try_fold( + (vec![], None::), + |mut accum, (mut body, token)| async move { + accum.0.append(&mut body); + accum.1 = token; + + Ok(accum) + }, + ) + .await?; + + let streamed_body = [response.body.clone(), streamed_body].concat(); + + // if we still have a token at this point, + // we were unable to collect the response within the allowed certified callback limit, + // fallback to uncertified streaming using what we've streamed so far as the initial body + if token.is_some() { + let body_stream = create_body_stream( + agent.clone(), + callback_strategy.callback, + token, + streamed_body, + ); + + return Ok(HttpGatewayResponseBody::Left(body_stream)); + }; + + // if we no longer have a token at this point, + // we were able to collect the response within the allow certified callback limit, + // return this collected response as a standard response body so it will be verified + Ok(HttpGatewayResponseBody::Right(Full::from(streamed_body))) +} + +fn create_body_stream( + agent: Agent, + callback: HttpRequestStreamingCallbackAny, + token: Option, + initial_body: Vec, +) -> ResponseBodyStream { + let chunks_stream = create_stream(agent, callback, token) + .map(|chunk| chunk.map(|(body, _)| Frame::data(Bytes::from(body)))); + + let body_stream = stream::once(async move { Ok(Frame::data(Bytes::from(initial_body))) }) + .chain(chunks_stream) + .take(MAX_HTTP_REQUEST_STREAM_CALLBACK_CALL_COUNT) + .map(|x| async move { x }) + .buffered(STREAM_CALLBACK_BUFFER); + + ResponseBodyStream::new(Box::pin(body_stream)) +} + +fn create_stream( + agent: Agent, + callback: HttpRequestStreamingCallbackAny, + token: Option, +) -> impl Stream, Option), AgentError>> { + futures::stream::try_unfold( + (agent, callback, token), + |(agent, callback, token)| async move { + let Some(token) = token else { + return Ok(None); + }; + + let canister = HttpRequestCanister::create(&agent, callback.0.principal); + match canister + .http_request_stream_callback(&callback.0.method, token) + .call() + .await + { + Ok((StreamingCallbackHttpResponse { body, token },)) => { + Ok(Some(((body, token.clone()), (agent, callback, token)))) + } + Err(e) => Err(e), + } + }, + ) +} + +#[derive(Clone, Debug)] +struct StreamState<'a> { + pub http_request: HttpRequest<'a>, + pub canister_id: Principal, + pub total_length: usize, + pub fetched_length: usize, + pub skip_verification: bool, +} + +pub async fn get_206_stream_response_body_and_total_length( + agent: &Agent, + http_request: HttpRequest<'static>, + canister_id: Principal, + response_headers: &Vec>, + response_206_body: HttpGatewayResponseBody, + skip_verification: bool, +) -> Result<(HttpGatewayResponseBody, usize), AgentError> { + let HttpGatewayResponseBody::Right(body) = response_206_body else { + return Err(AgentError::InvalidHttpResponse( + "Expected full 206 response".to_string(), + )); + }; + // The expect below should never panic because `Either::Right` will always have a full body + let streamed_body = body + .collect() + .await + .expect("missing streamed chunk body") + .to_bytes() + .to_vec(); + let stream_state = get_initial_stream_state( + http_request, + canister_id, + response_headers, + skip_verification, + )?; + let content_length = stream_state.total_length; + + let body_stream = create_206_body_stream(agent.clone(), stream_state, streamed_body); + Ok((HttpGatewayResponseBody::Left(body_stream), content_length)) +} + +#[derive(Debug)] +struct ContentRangeValues { + pub range_begin: usize, + pub range_end: usize, + pub total_length: usize, +} + +fn parse_content_range_header_str( + content_range_str: &str, +) -> Result { + // expected format: `bytes 21010-47021/47022` + let str_value = content_range_str.trim(); + if !str_value.starts_with("bytes ") { + return Err(AgentError::InvalidHttpResponse(format!( + "Invalid Content-Range header '{}'", + content_range_str + ))); + } + let str_value = str_value.trim_start_matches("bytes "); + + let str_value_parts = str_value.split('-').collect::>(); + if str_value_parts.len() != 2 { + return Err(AgentError::InvalidHttpResponse(format!( + "Invalid bytes spec in Content-Range header '{}'", + content_range_str + ))); + } + let range_begin = str_value_parts[0].parse::().map_err(|e| { + AgentError::InvalidHttpResponse(format!( + "Invalid range_begin in '{}': {}", + content_range_str, e + )) + })?; + + let other_value_parts = str_value_parts[1].split('/').collect::>(); + if other_value_parts.len() != 2 { + return Err(AgentError::InvalidHttpResponse(format!( + "Invalid bytes spec in Content-Range header '{}'", + content_range_str + ))); + } + let range_end = other_value_parts[0].parse::().map_err(|e| { + AgentError::InvalidHttpResponse(format!( + "Invalid range_end in '{}': {}", + content_range_str, e + )) + })?; + let total_length = other_value_parts[1].parse::().map_err(|e| { + AgentError::InvalidHttpResponse(format!( + "Invalid total_length in '{}': {}", + content_range_str, e + )) + })?; + + let rv = ContentRangeValues { + range_begin, + range_end, + total_length, + }; + if rv.range_begin > rv.range_end + || rv.range_begin >= rv.total_length + || rv.range_end >= rv.total_length + { + Err(AgentError::InvalidHttpResponse(format!( + "inconsistent Content-Range header {}: {:?}", + content_range_str, rv + ))) + } else { + Ok(rv) + } +} + +fn get_content_range_header_str( + response_headers: &Vec>, +) -> Result { + for HeaderField(name, value) in response_headers { + if name.eq_ignore_ascii_case(http::header::CONTENT_RANGE.as_ref()) { + return Ok(value.to_string()); + } + } + Err(AgentError::InvalidHttpResponse( + "missing Content-Range header in 206 response".to_string(), + )) +} + +fn get_content_range_values( + response_headers: &Vec>, + fetched_length: usize, +) -> Result { + let str_value = get_content_range_header_str(response_headers)?; + let range_values = parse_content_range_header_str(&str_value)?; + + if range_values.range_begin > fetched_length { + return Err(AgentError::InvalidHttpResponse(format!( + "chunk out-of-order: range_begin={} is larger than expected begin={} ", + range_values.range_begin, fetched_length + ))); + } + if range_values.range_end < fetched_length { + return Err(AgentError::InvalidHttpResponse(format!( + "chunk out-of-order: range_end={} is smaller than length fetched so far={} ", + range_values.range_begin, fetched_length + ))); + } + Ok(range_values) +} + +fn get_initial_stream_state<'a>( + http_request: HttpRequest<'a>, + canister_id: Principal, + response_headers: &Vec>, + skip_verification: bool, +) -> Result, AgentError> { + let range_values = get_content_range_values(response_headers, 0)?; + + Ok(StreamState { + http_request, + canister_id, + total_length: range_values.total_length, + fetched_length: range_values + .range_end + .saturating_sub(range_values.range_begin) + + 1, + skip_verification, + }) +} + +fn create_206_body_stream( + agent: Agent, + stream_state: StreamState<'static>, + initial_body: Vec, +) -> ResponseBodyStream { + let chunks_stream = create_206_stream(agent, Some(stream_state)) + .map(|chunk| chunk.map(|(body, _)| Frame::data(Bytes::from(body)))); + + let body_stream = stream::once(async move { Ok(Frame::data(Bytes::from(initial_body))) }) + .chain(chunks_stream) + .take(MAX_HTTP_REQUEST_STREAM_CALLBACK_CALL_COUNT) + .map(|x| async move { x }) + .buffered(STREAM_CALLBACK_BUFFER); + + ResponseBodyStream::new(Box::pin(body_stream)) +} + +fn create_206_stream( + agent: Agent, + maybe_stream_state: Option, +) -> impl Stream, Option), AgentError>> { + futures::stream::try_unfold( + (agent, maybe_stream_state), + |(agent, maybe_stream_state)| async move { + let Some(stream_state) = maybe_stream_state else { + return Ok(None); + }; + let canister = HttpRequestCanister::create(&agent, stream_state.canister_id); + let next_chunk_begin = stream_state.fetched_length; + + let range_header = ("Range".to_string(), format!("bytes={}-", next_chunk_begin)); + let mut updated_headers = stream_state.http_request.headers().to_vec(); + updated_headers.push(range_header.clone()); + let headers = updated_headers + .iter() + .map(|(name, value)| HeaderField(name.into(), value.into())) + .collect::>() + .into_iter(); + let query_result = canister + .http_request( + &stream_state.http_request.method(), + &stream_state.http_request.url(), + headers, + &stream_state.http_request.body(), + Some(&u16::from(MAX_VERIFICATION_VERSION)), + ) + .call() + .await; + let agent_response = match query_result { + Ok((response,)) => response, + Err(e) => return Err(e), + }; + let range_values = + get_content_range_values(&agent_response.headers, stream_state.fetched_length)?; + let new_bytes_begin = stream_state + .fetched_length + .saturating_sub(range_values.range_begin); + let chunk_length = range_values + .range_end + .saturating_sub(stream_state.fetched_length) + + 1; + let current_fetched_length = stream_state.fetched_length + chunk_length; + // Verify the chunk from the range response. + if agent_response.streaming_strategy.is_some() { + return Err(AgentError::InvalidHttpResponse( + "unexpected StreamingStrategy".to_string(), + )); + } + + let Ok(status_code) = StatusCode::from_u16(agent_response.status_code) else { + return Err(AgentError::InvalidHttpResponse(format!( + "Invalid canister response status code: {}", + agent_response.status_code + ))); + }; + let response = HttpResponse::builder() + .with_status_code(status_code) + .with_headers( + agent_response + .headers + .iter() + .map(|HeaderField(k, v)| (k.to_string(), v.to_string())) + .collect(), + ) + .with_body(agent_response.body.clone()) + .build(); + let mut http_request = stream_state.http_request.clone(); + http_request.headers_mut().push(range_header); + let validation_result = validate( + &agent, + &stream_state.canister_id, + http_request, + response, + stream_state.skip_verification, + ); + + if let Err(e) = validation_result { + return Err(AgentError::InvalidHttpResponse(format!( + "CertificateVerificationFailed for a chunk starting at {}, error: {}", + stream_state.fetched_length, e + ))); + } + let maybe_new_state = if current_fetched_length < stream_state.total_length { + Some(StreamState { + fetched_length: current_fetched_length, + ..stream_state + }) + } else { + None + }; + Ok(Some(( + ( + agent_response.body[new_bytes_begin..].to_vec(), + maybe_new_state.clone(), + ), + (agent, maybe_new_state), + ))) + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use assert_matches::assert_matches; + use std::borrow::Cow; + + #[test] + fn should_parse_content_range_header_str() { + let header_values = [ + ContentRangeValues { + range_begin: 0, + range_end: 0, + total_length: 1, + }, + ContentRangeValues { + range_begin: 100, + range_end: 2000, + total_length: 3000, + }, + ContentRangeValues { + range_begin: 10_000, + range_end: 300_000, + total_length: 500_000, + }, + ]; + for v in header_values { + let input = format!("bytes {}-{}/{}", v.range_begin, v.range_end, v.total_length); + let result = parse_content_range_header_str(&input); + let output = result.unwrap_or_else(|_| panic!("failed parsing '{}'", input)); + assert_eq!(v.range_begin, output.range_begin); + assert_eq!(v.range_end, output.range_end); + assert_eq!(v.total_length, output.total_length); + } + } + + #[test] + fn should_fail_parse_content_range_header_str_on_malformed_input() { + let malformed_inputs = [ + "byte 1-2/3", + "bites 2-4/8", + "bytes 100-200/asdf", + "bytes 12345", + "something else", + "bytes dead-beef/123456", + ]; + for input in malformed_inputs { + let result = parse_content_range_header_str(input); + assert_matches!(result, Err(e) if format!("{}", e).contains("Invalid ")); + } + } + + #[test] + fn should_fail_parse_content_range_header_str_on_inconsistent_input() { + let inconsistent_inputs = ["bytes 100-200/190", "bytes 200-150/400", "bytes 100-110/40"]; + for input in inconsistent_inputs { + let result = parse_content_range_header_str(input); + assert_matches!(result, Err(e) if format!("{}", e).contains("inconsistent Content-Range header")); + } + } + + #[test] + fn should_get_initial_stream_state() { + let http_request = HttpRequest::get("http://example.com/some_file") + .with_headers(vec![("Xyz".to_string(), "some value".to_string())]) + .with_body(vec![42]) + .build(); + let canister_id = Principal::from_slice(&[1, 2, 3, 4]); + let response_headers = vec![HeaderField( + Cow::from("Content-Range"), + Cow::from("bytes 0-2/10"), // fetched 3 bytes, total length is 10 + )]; + let skip_verification = false; + let state = get_initial_stream_state( + http_request.clone(), + canister_id, + &response_headers, + skip_verification, + ) + .expect("failed constructing StreamState"); + assert_eq!(state.http_request, http_request); + assert_eq!(state.canister_id, canister_id); + assert_eq!(state.fetched_length, 3); + assert_eq!(state.total_length, 10); + assert_eq!(state.skip_verification, skip_verification); + } + + #[test] + fn should_fail_get_initial_stream_state_without_content_range_header() { + let http_request = HttpRequest::get("http://example.com/some_file") + .with_headers(vec![("Xyz".to_string(), "some value".to_string())]) + .with_body(vec![42]) + .build(); + let canister_id = Principal::from_slice(&[1, 2, 3, 4]); + let response_headers = vec![HeaderField( + Cow::from("other header"), + Cow::from("other value"), + )]; + let result = get_initial_stream_state(http_request, canister_id, &response_headers, false); + assert_matches!(result, Err(e) if format!("{}", e).contains("missing Content-Range header")); + } + + #[test] + fn should_fail_get_initial_stream_state_with_malformed_content_range_header() { + let http_request = HttpRequest::get("http://example.com/some_file") + .with_headers(vec![("Xyz".to_string(), "some value".to_string())]) + .with_body(vec![42]) + .build(); + let canister_id = Principal::from_slice(&[1, 2, 3, 4]); + let response_headers = vec![HeaderField( + Cow::from("Content-Range"), + Cow::from("bytes 42/10"), + )]; + let result = get_initial_stream_state(http_request, canister_id, &response_headers, false); + assert_matches!(result, Err(e) if format!("{}", e).contains("Invalid bytes spec in Content-Range header")); + } + + #[test] + fn should_fail_get_initial_stream_state_with_inconsistent_content_range_header() { + let http_request = HttpRequest::get("http://example.com/some_file") + .with_headers(vec![("Xyz".to_string(), "some value".to_string())]) + .with_body(vec![42]) + .build(); + let canister_id = Principal::from_slice(&[1, 2, 3, 4]); + let response_headers = vec![HeaderField( + Cow::from("Content-Range"), + Cow::from("bytes 40-100/90"), + )]; + let result = get_initial_stream_state(http_request, canister_id, &response_headers, false); + assert_matches!(result, Err(e) if format!("{}", e).contains("inconsistent Content-Range header")); + } +} diff --git a/packages/ic-http-gateway-protocol/test-container/Dockerfile b/packages/ic-http-gateway-protocol/test-container/Dockerfile new file mode 100644 index 0000000..97dca8b --- /dev/null +++ b/packages/ic-http-gateway-protocol/test-container/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.13.0-bookworm + +# Prevents Python from buffering stdout and stderr +ENV PYTHONUNBUFFERED=1 + +# Set the working directory in the container +WORKDIR /app + +# Move everything into the container +COPY . . + +# Install dependencies +RUN pip install -r requirements.txt + +# expose the server port +EXPOSE 8000 + +# a health check so we can wait for testcontainers +HEALTHCHECK --interval=1s --timeout=1s --start-period=1s --retries=3 \ + CMD curl --fail http://localhost:8000/healthcheck || exit 1 + +# Run the flask server +CMD ["python", "app.py"] diff --git a/packages/ic-http-gateway-protocol/test-container/app.py b/packages/ic-http-gateway-protocol/test-container/app.py new file mode 100644 index 0000000..b5efbde --- /dev/null +++ b/packages/ic-http-gateway-protocol/test-container/app.py @@ -0,0 +1,18 @@ +''' +a simple http server that returns 429s for all post requests +''' +from flask import Flask, request, jsonify + +app = Flask(__name__) + +@app.route('/healthcheck', methods=['GET']) +def healthcheck(): + return "ok" + +@app.route('/', methods=['POST']) +def handle_post(any_path): + return "You're making too many requests", 429 + +if __name__ == '__main__': + app.run('0.0.0.0', port=8000) + diff --git a/packages/ic-http-gateway-protocol/test-container/requirements.txt b/packages/ic-http-gateway-protocol/test-container/requirements.txt new file mode 100644 index 0000000..3ac2119 --- /dev/null +++ b/packages/ic-http-gateway-protocol/test-container/requirements.txt @@ -0,0 +1,7 @@ +blinker==1.8.2 +click==8.1.7 +Flask==3.1.3 +itsdangerous==2.2.0 +Jinja2==3.1.4 +MarkupSafe==3.0.1 +Werkzeug==3.1.6 diff --git a/packages/ic-http-gateway-protocol/tests/custom_assets.rs b/packages/ic-http-gateway-protocol/tests/custom_assets.rs new file mode 100644 index 0000000..05c3a29 --- /dev/null +++ b/packages/ic-http-gateway-protocol/tests/custom_assets.rs @@ -0,0 +1,130 @@ +use bytes::Bytes; +use http::Request; +use http_body_util::BodyExt; +use ic_agent::Agent; +use ic_http_gateway_protocol::{HttpGatewayClient, HttpGatewayRequestArgs, HttpGatewayResponseMetadata}; +use pocket_ic::PocketIcBuilder; + +mod utils; + +#[test] +fn test_custom_assets_index_html() { + let rt = tokio::runtime::Runtime::new().unwrap(); + let wasm_bytes = rt.block_on(async { utils::load_custom_assets_wasm().await }); + + let pic = PocketIcBuilder::new() + .with_nns_subnet() + .with_application_subnet() + .build(); + + let canister_id = pic.create_canister(); + pic.add_cycles(canister_id, 2_000_000_000_000_000); + pic.install_canister(canister_id, wasm_bytes, vec![], None); + + let url = pic.auto_progress(); + + let agent = Agent::builder().with_url(url).build().unwrap(); + rt.block_on(async { + agent.fetch_root_key().await.unwrap(); + }); + + let http_gateway = HttpGatewayClient::builder() + .with_agent(agent) + .build() + .unwrap(); + + let response = rt.block_on(async { + http_gateway + .request(HttpGatewayRequestArgs { + canister_id, + canister_request: Request::builder().uri("/").body(Bytes::new()).unwrap(), + }) + .send() + .await + }); + + let index_html = b"Hello, world!"; + let response_headers = response + .canister_response + .headers() + .iter() + .map(|(k, v)| (k.as_str(), v.to_str().unwrap())) + .collect::>(); + + assert_eq!(response.canister_response.status(), 200); + + // check that the response contains the certificate headers + assert!( + contains_header("ic-certificate", response_headers.clone()), + "response does not contain 'ic-certificate' header" + ); + + assert!( + contains_header("ic-certificateexpression", response_headers.clone()), + "response does not contain 'ic-certificateexpression' header" + ); + + // remove certificate headers before checking the certified headers + let certified_headers: Vec<(&str, &str)> = response_headers + .iter() + .filter(|(key, _)| *key != "ic-certificate" && *key != "ic-certificateexpression") + .cloned() // To convert from iterator of references to an iterator of owned values + .collect(); + + assert_eq!( + certified_headers, + vec![ + ("content-length", index_html.len().to_string().as_str()), + ("strict-transport-security", "max-age=31536000; includeSubDomains"), + ("x-frame-options", "DENY"), + ("x-content-type-options", "nosniff"), + ("content-security-policy", "default-src 'self'; form-action 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests; block-all-mixed-content"), + ("referrer-policy", "no-referrer"), + ("permissions-policy", "accelerometer=(),ambient-light-sensor=(),autoplay=(),battery=(),camera=(),display-capture=(),document-domain=(),encrypted-media=(),fullscreen=(),gamepad=(),geolocation=(),gyroscope=(),layout-animations=(self),legacy-image-formats=(self),magnetometer=(),microphone=(),midi=(),oversized-images=(self),payment=(),picture-in-picture=(),publickey-credentials-get=(),speaker-selection=(),sync-xhr=(self),unoptimized-images=(self),unsized-media=(self),usb=(),screen-wake-lock=(),web-share=(),xr-spatial-tracking=()"), + ("cross-origin-embedder-policy", "require-corp"), + ("cross-origin-opener-policy", "same-origin"), + ("cache-control", "public, no-cache, no-store"), + ("content-type", "text/html"), + ] + ); + + rt.block_on(async { + let body = response + .canister_response + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .to_vec(); + + assert_eq!(body, index_html); + }); + + assert_response_metadata( + response.metadata, + HttpGatewayResponseMetadata { + upgraded_to_update_call: false, + response_verification_version: Some(2), + internal_error: None, + }, + ); +} + +fn assert_response_metadata( + response_metadata: HttpGatewayResponseMetadata, + expected_response_metadata: HttpGatewayResponseMetadata, +) { + assert_eq!( + response_metadata.upgraded_to_update_call, + expected_response_metadata.upgraded_to_update_call + ); + assert_eq!( + response_metadata.response_verification_version, + expected_response_metadata.response_verification_version + ); +} + +fn contains_header(header_name: &str, headers: Vec<(&str, &str)>) -> bool { + headers.iter().any(|(key, _)| *key == header_name) +} diff --git a/packages/ic-http-gateway-protocol/tests/protocol_error_tests.rs b/packages/ic-http-gateway-protocol/tests/protocol_error_tests.rs new file mode 100644 index 0000000..d8de278 --- /dev/null +++ b/packages/ic-http-gateway-protocol/tests/protocol_error_tests.rs @@ -0,0 +1,94 @@ +use bytes::Bytes; +use http::{status::StatusCode, Request}; +use ic_agent::{export::Principal, Agent}; +use ic_http_gateway_protocol::{HttpGatewayClient, HttpGatewayRequestArgs}; +use reqwest::Client; +use std::{env, error::Error, process::Command, str::FromStr}; +use testcontainers::{ + core::{IntoContainerPort, WaitFor}, + runners::AsyncRunner, + GenericImage, +}; + +const IMAGE_NAME: &str = "ic-mock-busy-replica"; +const IMAGE_TAG: &str = "latest"; + +fn build_gateway_image() -> Result<(), Box> { + let cwd = env::var("CARGO_MANIFEST_DIR")?; + + let output = Command::new("docker") + .current_dir(format!("{cwd}/test-container")) + .arg("build") + .arg("--file") + .arg("Dockerfile") + .arg("--force-rm") + .arg("--tag") + .arg(format!("{IMAGE_NAME}:{IMAGE_TAG}")) + .arg(".") + .output()?; + + if !output.status.success() { + eprintln!("stderr: {}", String::from_utf8(output.stderr)?); + return Err("unable to build mock busy replica image.".into()); + } + + Ok(()) +} + +#[tokio::test] +async fn test_rate_limiting_error() -> Result<(), Box> { + build_gateway_image()?; + + // run the mock backend container + let container = GenericImage::new(IMAGE_NAME, IMAGE_TAG) + .with_exposed_port(8000.tcp()) + .with_wait_for(WaitFor::healthcheck()) + .start() + .await?; + + // Retrieve the mapped port + let backend_port = container.get_host_port_ipv4(8000).await?; + let backend_host = container.get_host().await?.to_string(); + + // Check that the mock canister is up + let backend_base_url = format!("http://{}:{}", backend_host, backend_port); + let healthcheck_url = format!("{}/healthcheck", backend_base_url); + let response = Client::new().get(&healthcheck_url).send().await?; + assert_eq!( + response.status().as_u16(), + 200, + "Expected to receive 200 from /healthcheck but received {}", + response.status().as_u16() + ); + + // Make a gateway + let agent = Agent::builder().with_url(backend_base_url).build().unwrap(); + let http_gateway = HttpGatewayClient::builder() + .with_agent(agent) + .build() + .unwrap(); + + // Fake a `GET /example` request coming into the gateway + let canister_request = Request::builder() + .uri("/example") + .method("GET") + .body(Bytes::new()) + .unwrap(); + + let gateway_response = http_gateway + .request(HttpGatewayRequestArgs { + canister_id: Principal::from_str("qoctq-giaaa-aaaaa-aaaea-cai")?, + canister_request, + }) + .send() + .await; + + assert_eq!( + gateway_response.canister_response.status(), + StatusCode::TOO_MANY_REQUESTS, + "Expected to receive a 429 from the node but received {}", + gateway_response.canister_response.status() + ); + + Ok(()) +} diff --git a/packages/ic-http-gateway-protocol/tests/range_request_stream.rs b/packages/ic-http-gateway-protocol/tests/range_request_stream.rs new file mode 100644 index 0000000..22cd6de --- /dev/null +++ b/packages/ic-http-gateway-protocol/tests/range_request_stream.rs @@ -0,0 +1,521 @@ +use assert_matches::assert_matches; +use bytes::Bytes; +use http::Request; +use http_body_util::BodyExt; +use ic_agent::hash_tree::Hash; +use ic_agent::Agent; +use ic_http_gateway_protocol::{HttpGatewayClient, HttpGatewayRequestArgs, HttpGatewayResponseMetadata}; +use pocket_ic::PocketIcBuilder; +use rand_chacha::rand_core::{RngCore, SeedableRng}; +use rand_chacha::ChaCha20Rng; +use rstest::*; +use sha2::{Digest, Sha256}; +use std::cmp::min; + +mod utils; + +const ASSET_CHUNK_SIZE: usize = 2_000_000; + +const ONE_CHUNK_ASSET_LEN: usize = ASSET_CHUNK_SIZE; +const TWO_CHUNKS_ASSET_LEN: usize = ASSET_CHUNK_SIZE + 1; +const SIX_CHUNKS_ASSET_LEN: usize = 5 * ASSET_CHUNK_SIZE + 12; +const TEN_CHUNKS_ASSET_LEN: usize = 10 * ASSET_CHUNK_SIZE; + +const ONE_CHUNK_ASSET_NAME: &str = "long_asset_one_chunk"; +const TWO_CHUNKS_ASSET_NAME: &str = "long_asset_two_chunks"; +const SIX_CHUNKS_ASSET_NAME: &str = "long_asset_six_chunks"; +const TEN_CHUNKS_ASSET_NAME: &str = "long_asset_ten_chunks"; + +pub fn hash(data: T) -> Hash +where + T: AsRef<[u8]>, +{ + let mut hasher = Sha256::new(); + hasher.update(data); + hasher.finalize().into() +} + +fn long_asset_body(asset_name: &str) -> Vec { + let asset_length = match asset_name { + ONE_CHUNK_ASSET_NAME => ONE_CHUNK_ASSET_LEN, + TWO_CHUNKS_ASSET_NAME => TWO_CHUNKS_ASSET_LEN, + SIX_CHUNKS_ASSET_NAME => SIX_CHUNKS_ASSET_LEN, + TEN_CHUNKS_ASSET_NAME => TEN_CHUNKS_ASSET_LEN, + _ => ASSET_CHUNK_SIZE * 3 + 1, + }; + let mut rng = ChaCha20Rng::from_seed(hash(asset_name)); + let mut body = vec![0u8; asset_length]; + rng.fill_bytes(&mut body); + body +} + +#[rstest] +#[case(TWO_CHUNKS_ASSET_NAME)] +#[case(SIX_CHUNKS_ASSET_NAME)] +#[case(TEN_CHUNKS_ASSET_NAME)] +fn test_long_asset_request_yields_entire_asset(#[case] asset_name: &str) { + let rt = tokio::runtime::Runtime::new().unwrap(); + let wasm_bytes = rt.block_on(async { utils::load_custom_assets_wasm().await }); + + let pic = PocketIcBuilder::new() + .with_nns_subnet() + .with_application_subnet() + .build(); + + let canister_id = pic.create_canister(); + pic.add_cycles(canister_id, 2_000_000_000_000_000); + pic.install_canister(canister_id, wasm_bytes, vec![], None); + + let url = pic.auto_progress(); + + let agent = Agent::builder().with_url(url).build().unwrap(); + rt.block_on(async { + agent.fetch_root_key().await.unwrap(); + }); + + let http_gateway = HttpGatewayClient::builder() + .with_agent(agent) + .build() + .unwrap(); + + let response = rt.block_on(async { + http_gateway + .request(HttpGatewayRequestArgs { + canister_id, + canister_request: Request::builder() + .uri(format!("/{asset_name}")) + .body(Bytes::new()) + .unwrap(), + }) + .send() + .await + }); + + let response_headers = response + .canister_response + .headers() + .iter() + .map(|(k, v)| (k.as_str(), v.to_str().unwrap())) + .collect::>(); + + assert_eq!(response.canister_response.status(), 200); + + // check that the response contains the certificate headers + assert!( + contains_header("ic-certificate", response_headers.clone()), + "response does not contain 'ic-certificate' header" + ); + + assert!( + contains_header("ic-certificateexpression", response_headers.clone()), + "response does not contain 'ic-certificateexpression' header" + ); + + // remove certificate headers before checking the certified headers + let certified_headers: Vec<(&str, &str)> = response_headers + .iter() + .filter(|(key, _)| *key != "ic-certificate" && *key != "ic-certificateexpression") + .cloned() // To convert from iterator of references to an iterator of owned values + .collect(); + + let expected_body = long_asset_body(asset_name); + + assert_eq!( + certified_headers, + vec![ + ("strict-transport-security", "max-age=31536000; includeSubDomains"), + ("x-frame-options", "DENY"), + ("x-content-type-options", "nosniff"), + ("content-security-policy", "default-src 'self'; form-action 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests; block-all-mixed-content"), + ("referrer-policy", "no-referrer"), + ("permissions-policy", "accelerometer=(),ambient-light-sensor=(),autoplay=(),battery=(),camera=(),display-capture=(),document-domain=(),encrypted-media=(),fullscreen=(),gamepad=(),geolocation=(),gyroscope=(),layout-animations=(self),legacy-image-formats=(self),magnetometer=(),microphone=(),midi=(),oversized-images=(self),payment=(),picture-in-picture=(),publickey-credentials-get=(),speaker-selection=(),sync-xhr=(self),unoptimized-images=(self),unsized-media=(self),usb=(),screen-wake-lock=(),web-share=(),xr-spatial-tracking=()"), + ("cross-origin-embedder-policy", "require-corp"), + ("cross-origin-opener-policy", "same-origin"), + ("cache-control", "public, no-cache, no-store"), + ("content-type", "application/octet-stream"), + ("content-length", expected_body.len().to_string().as_str()), + ] + ); + + rt.block_on(async { + let body = response + .canister_response + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .to_vec(); + + assert_eq!(body, expected_body); + }); + + assert_response_metadata( + response.metadata, + HttpGatewayResponseMetadata { + upgraded_to_update_call: false, + response_verification_version: Some(2), + internal_error: None, + }, + ); +} + +#[rstest] +#[case(TWO_CHUNKS_ASSET_NAME, 0)] +#[case(TWO_CHUNKS_ASSET_NAME, 1)] +#[case(SIX_CHUNKS_ASSET_NAME, 3)] +#[case(SIX_CHUNKS_ASSET_NAME, 5)] +fn test_corrupted_long_asset_request_fails( + #[case] asset_name: &str, + #[case] corrupted_chunk_index: usize, +) { + let rt = tokio::runtime::Runtime::new().unwrap(); + let wasm_bytes = rt.block_on(async { utils::load_custom_assets_wasm().await }); + + let pic = PocketIcBuilder::new() + .with_nns_subnet() + .with_application_subnet() + .build(); + + let canister_id = pic.create_canister(); + pic.add_cycles(canister_id, 2_000_000_000_000_000); + pic.install_canister(canister_id, wasm_bytes, vec![], None); + + let url = pic.auto_progress(); + + let agent = Agent::builder().with_url(url).build().unwrap(); + rt.block_on(async { + agent.fetch_root_key().await.unwrap(); + }); + + let http_gateway = HttpGatewayClient::builder() + .with_agent(agent) + .build() + .unwrap(); + + let response = rt.block_on(async { + http_gateway + .request(HttpGatewayRequestArgs { + canister_id, + canister_request: Request::builder() + .header( + "Test-CorruptChunkAtIndex", + corrupted_chunk_index.to_string(), + ) + .uri(format!("/{asset_name}")) + .body(Bytes::new()) + .unwrap(), + }) + .send() + .await + }); + let expected_status = match corrupted_chunk_index { + 0 => 500, + _ => 200, + }; + assert_eq!(response.canister_response.status(), expected_status); + rt.block_on(async { + let body_result = response.canister_response.into_body().collect().await; + if corrupted_chunk_index == 0 { + // If the first chunk is corrupted, the status indicates the failure + // and the full body contains the error message. + assert_matches!(body_result, + Ok(body) if format!("{:?}", body).contains( + "Response verification failed: The hash of the request and response was not found in the tree") + ); + } else { + // If the first chunk is ok, but some other chunk is corrupted, the response has 200-status, + // but fetching the full body fails with an error for the corrupted chunk. + assert_matches!(body_result, + Err(e) if e.to_string().contains(&format!( + "CertificateVerificationFailed for a chunk starting at {}", + ASSET_CHUNK_SIZE*corrupted_chunk_index)) + ); + } + }); +} + +#[rstest] +#[case(TWO_CHUNKS_ASSET_NAME, 0)] +#[case(SIX_CHUNKS_ASSET_NAME, 0)] +#[case(SIX_CHUNKS_ASSET_NAME, 1)] +#[case(SIX_CHUNKS_ASSET_NAME, 3)] +fn test_long_asset_with_chunks_out_of_order_fails( + #[case] asset_name: &str, + #[case] chunk_to_swap: usize, +) { + let rt = tokio::runtime::Runtime::new().unwrap(); + let wasm_bytes = rt.block_on(async { utils::load_custom_assets_wasm().await }); + + let pic = PocketIcBuilder::new() + .with_nns_subnet() + .with_application_subnet() + .build(); + + let canister_id = pic.create_canister(); + pic.add_cycles(canister_id, 2_000_000_000_000_000); + pic.install_canister(canister_id, wasm_bytes, vec![], None); + + let url = pic.auto_progress(); + + let agent = Agent::builder().with_url(url).build().unwrap(); + rt.block_on(async { + agent.fetch_root_key().await.unwrap(); + }); + + let http_gateway = HttpGatewayClient::builder() + .with_agent(agent) + .build() + .unwrap(); + + let response = rt.block_on(async { + http_gateway + .request(HttpGatewayRequestArgs { + canister_id, + canister_request: Request::builder() + .header("Test-SwapChunkAtIndexWithNext", chunk_to_swap.to_string()) + .uri(format!("/{asset_name}")) + .body(Bytes::new()) + .unwrap(), + }) + .send() + .await + }); + let expected_status = match chunk_to_swap { + 0 => 500, + _ => 200, + }; + assert_eq!(response.canister_response.status(), expected_status); + rt.block_on(async { + let body_result = response.canister_response.into_body().collect().await; + if chunk_to_swap == 0 { + // If the first chunk is swapped (i.e. instead of getting an + // initial portion of the asset we got a different one, + // the status indicates the failure and the full body contains + // the error message. In this case the verification fails, + // as we're getting a chunk for which the certification includes + // "Range"-header, while the original request did not include + // that header (because it requested the entire asset). + assert_matches!(body_result, + Ok(body) if format!("{:?}", body).contains("Response verification failed") + ); + } else { + // If the first chunk is ok, but some other chunk is swapped, the response has 200-status, + // but fetching the full body fails with an error for the corrupted chunk. + assert_matches!(body_result, + Err(e) if e.to_string().contains(&format!( + "chunk out-of-order: range_begin={}", + ASSET_CHUNK_SIZE*(chunk_to_swap+1))) + ); + } + }); +} + +#[rstest] +#[case(TWO_CHUNKS_ASSET_NAME, 0)] +#[case(TWO_CHUNKS_ASSET_NAME, 1)] +#[case(SIX_CHUNKS_ASSET_NAME, 3)] +#[case(SIX_CHUNKS_ASSET_NAME, 5)] +fn test_corrupted_chunk_certificate_for_long_asset_request_fails( + #[case] asset_name: &str, + #[case] corrupted_chunk_index: usize, +) { + let rt = tokio::runtime::Runtime::new().unwrap(); + let wasm_bytes = rt.block_on(async { utils::load_custom_assets_wasm().await }); + + let pic = PocketIcBuilder::new() + .with_nns_subnet() + .with_application_subnet() + .build(); + + let canister_id = pic.create_canister(); + pic.add_cycles(canister_id, 2_000_000_000_000_000); + pic.install_canister(canister_id, wasm_bytes, vec![], None); + + let url = pic.auto_progress(); + + let agent = Agent::builder().with_url(url).build().unwrap(); + rt.block_on(async { + agent.fetch_root_key().await.unwrap(); + }); + + let http_gateway = HttpGatewayClient::builder() + .with_agent(agent) + .build() + .unwrap(); + + let response = rt.block_on(async { + http_gateway + .request(HttpGatewayRequestArgs { + canister_id, + canister_request: Request::builder() + .header( + "Test-CorruptCertificateAtIndex", + corrupted_chunk_index.to_string(), + ) + .uri(format!("/{asset_name}")) + .body(Bytes::new()) + .unwrap(), + }) + .send() + .await + }); + let expected_status = match corrupted_chunk_index { + 0 => 500, + _ => 200, + }; + assert_eq!(response.canister_response.status(), expected_status); + rt.block_on(async { + let body_result = response.canister_response.into_body().collect().await; + if corrupted_chunk_index == 0 { + // If the first chunk is corrupted, the status indicates the failure + // and the full body contains the error message. + let body = body_result.expect("failed getting full body").to_bytes(); + assert_matches!(String::from_utf8_lossy(&body), s if s.contains("Response verification failed")); + } else { + // If the first chunk is ok, but some other chunk is corrupted, the response has 200-status, + // but fetching the full body fails with an error for the corrupted chunk. + assert_matches!(body_result, + Err(e) if e.to_string().contains(&format!( + "CertificateVerificationFailed for a chunk starting at {}", + ASSET_CHUNK_SIZE*corrupted_chunk_index)) + ); + } + }); +} + +#[rstest] +#[case(TWO_CHUNKS_ASSET_NAME)] +#[case(SIX_CHUNKS_ASSET_NAME)] +fn test_range_request_yields_range_response(#[case] asset_name: &str) { + let rt = tokio::runtime::Runtime::new().unwrap(); + let wasm_bytes = rt.block_on(async { utils::load_custom_assets_wasm().await }); + + let pic = PocketIcBuilder::new() + .with_nns_subnet() + .with_application_subnet() + .build(); + + let canister_id = pic.create_canister(); + pic.add_cycles(canister_id, 2_000_000_000_000_000); + pic.install_canister(canister_id, wasm_bytes, vec![], None); + + let url = pic.auto_progress(); + + let agent = Agent::builder().with_url(url).build().unwrap(); + rt.block_on(async { + agent.fetch_root_key().await.unwrap(); + }); + + let http_gateway = HttpGatewayClient::builder() + .with_agent(agent) + .build() + .unwrap(); + + let response = rt.block_on(async { + http_gateway + .request(HttpGatewayRequestArgs { + canister_id, + canister_request: Request::builder() + .uri(format!("/{asset_name}")) + .header("Range", format!("bytes={}-", ASSET_CHUNK_SIZE)) + .body(Bytes::new()) + .unwrap(), + }) + .send() + .await + }); + + let expected_full_body = long_asset_body(asset_name); + let expected_response_body = + &expected_full_body[ASSET_CHUNK_SIZE..min(expected_full_body.len(), 2 * ASSET_CHUNK_SIZE)]; + let response_headers = response + .canister_response + .headers() + .iter() + .map(|(k, v)| (k.as_str(), v.to_str().unwrap())) + .collect::>(); + + assert_eq!(response.canister_response.status(), 206); + + // check that the response contains the certificate headers + assert!( + contains_header("ic-certificate", response_headers.clone()), + "response does not contain 'ic-certificate' header" + ); + + assert!( + contains_header("ic-certificateexpression", response_headers.clone()), + "response does not contain 'ic-certificateexpression' header" + ); + + // remove certificate headers before checking the certified headers + let certified_headers: Vec<(&str, &str)> = response_headers + .iter() + .filter(|(key, _)| *key != "ic-certificate" && *key != "ic-certificateexpression") + .cloned() // To convert from iterator of references to an iterator of owned values + .collect(); + + assert_eq!( + certified_headers, + vec![ + ("content-length", expected_response_body.len().to_string().as_str()), + ("strict-transport-security", "max-age=31536000; includeSubDomains"), + ("x-frame-options", "DENY"), + ("x-content-type-options", "nosniff"), + ("content-security-policy", "default-src 'self'; form-action 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests; block-all-mixed-content"), + ("referrer-policy", "no-referrer"), + ("permissions-policy", "accelerometer=(),ambient-light-sensor=(),autoplay=(),battery=(),camera=(),display-capture=(),document-domain=(),encrypted-media=(),fullscreen=(),gamepad=(),geolocation=(),gyroscope=(),layout-animations=(self),legacy-image-formats=(self),magnetometer=(),microphone=(),midi=(),oversized-images=(self),payment=(),picture-in-picture=(),publickey-credentials-get=(),speaker-selection=(),sync-xhr=(self),unoptimized-images=(self),unsized-media=(self),usb=(),screen-wake-lock=(),web-share=(),xr-spatial-tracking=()"), + ("cross-origin-embedder-policy", "require-corp"), + ("cross-origin-opener-policy", "same-origin"), + ("cache-control", "public, no-cache, no-store"), + ("content-type", "application/octet-stream"), + ("content-range", &format!( + "bytes {}-{}/{}", + ASSET_CHUNK_SIZE, + min(expected_full_body.len(), 2*ASSET_CHUNK_SIZE) - 1, + expected_full_body.len() + )) + ] + ); + + rt.block_on(async { + let body = response + .canister_response + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .to_vec(); + + assert_eq!(body, expected_response_body); + }); + + assert_response_metadata( + response.metadata, + HttpGatewayResponseMetadata { + upgraded_to_update_call: false, + response_verification_version: Some(2), + internal_error: None, + }, + ); +} + +fn assert_response_metadata( + response_metadata: HttpGatewayResponseMetadata, + expected_response_metadata: HttpGatewayResponseMetadata, +) { + assert_eq!( + response_metadata.upgraded_to_update_call, + expected_response_metadata.upgraded_to_update_call + ); + assert_eq!( + response_metadata.response_verification_version, + expected_response_metadata.response_verification_version + ); +} + +fn contains_header(header_name: &str, headers: Vec<(&str, &str)>) -> bool { + headers.iter().any(|(key, _)| *key == header_name) +} diff --git a/packages/ic-http-gateway-protocol/tests/utils/mod.rs b/packages/ic-http-gateway-protocol/tests/utils/mod.rs new file mode 100644 index 0000000..b482cfd --- /dev/null +++ b/packages/ic-http-gateway-protocol/tests/utils/mod.rs @@ -0,0 +1,26 @@ +use std::path::PathBuf; +use tokio::{fs::File, io::AsyncReadExt}; + +pub async fn load_custom_assets_wasm() -> Vec { + load_wasm("http_gateway_canister_custom_assets").await +} + +async fn load_wasm(canister: &str) -> Vec { + let file_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../.dfx/local/canisters") + .join(canister) + .join(format!("{}.wasm.gz", canister)); + + load_file(file_path).await +} + +async fn load_file(file_path: PathBuf) -> Vec { + let mut file = File::open(&file_path) + .await + .unwrap_or_else(|_| panic!("error opening file {:?}", file_path)); + + let mut buffer = Vec::new(); + file.read_to_end(&mut buffer).await.unwrap(); + + buffer +} diff --git a/packages/ic-http-gateway/Cargo.toml b/packages/ic-http-gateway/Cargo.toml index 70adb60..2ecbec3 100644 --- a/packages/ic-http-gateway/Cargo.toml +++ b/packages/ic-http-gateway/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ic-http-gateway" -description = "An HTTP Gateway implementation for interfacing with the Internet Computer over HTTP" +description = "Compatibility crate for ic-http-gateway-protocol" readme = "README.md" documentation = "https://docs.rs/ic-http-gateway" categories = [ @@ -20,19 +20,7 @@ license.workspace = true homepage.workspace = true [dependencies] -thiserror.workspace = true -futures.workspace = true -http.workspace = true -http-body.workspace = true -http-body-util.workspace = true -bytes.workspace = true - -ic-agent.workspace = true -ic-utils.workspace = true -candid.workspace = true - -ic-http-certification.workspace = true -ic-response-verification.workspace = true +ic-http-gateway-protocol.workspace = true [dev-dependencies] assert_matches.workspace = true diff --git a/packages/ic-http-gateway/README.md b/packages/ic-http-gateway/README.md index 511659c..930fb1a 100644 --- a/packages/ic-http-gateway/README.md +++ b/packages/ic-http-gateway/README.md @@ -1 +1,5 @@ -# HTTP Gateway +# HTTP Gateway (Compatibility Crate) + +Use `ic-http-gateway-protocol` for new integrations. + +# TODO(NODE-1907): Remove once ic-http-gateway is fully deprecated. diff --git a/packages/ic-http-gateway/src/lib.rs b/packages/ic-http-gateway/src/lib.rs index 59874e8..97b3ff6 100644 --- a/packages/ic-http-gateway/src/lib.rs +++ b/packages/ic-http-gateway/src/lib.rs @@ -1,20 +1,5 @@ /*! -# HTTP Gateway +# HTTP Gateway (Compatibility Crate) */ -mod client; -pub use client::*; - -mod protocol; - -mod request; -pub use request::*; - -mod response; -pub use response::*; - -mod consts; -pub(crate) use consts::*; - -mod error; -pub use error::*; +pub use ic_http_gateway_protocol::*; From bf0e1b60af862d22c4464766fee7bf4728526e90 Mon Sep 17 00:00:00 2001 From: Andrew Battat Date: Thu, 12 Mar 2026 01:23:02 +0000 Subject: [PATCH 2/5] Fix ic-http-gateway-protocol build --- .../ic-http-gateway-protocol/test-container/requirements.txt | 2 +- packages/ic-http-gateway/test-container/requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ic-http-gateway-protocol/test-container/requirements.txt b/packages/ic-http-gateway-protocol/test-container/requirements.txt index 3ac2119..4a51173 100644 --- a/packages/ic-http-gateway-protocol/test-container/requirements.txt +++ b/packages/ic-http-gateway-protocol/test-container/requirements.txt @@ -1,4 +1,4 @@ -blinker==1.8.2 +blinker==1.9.0 click==8.1.7 Flask==3.1.3 itsdangerous==2.2.0 diff --git a/packages/ic-http-gateway/test-container/requirements.txt b/packages/ic-http-gateway/test-container/requirements.txt index 3ac2119..4a51173 100644 --- a/packages/ic-http-gateway/test-container/requirements.txt +++ b/packages/ic-http-gateway/test-container/requirements.txt @@ -1,4 +1,4 @@ -blinker==1.8.2 +blinker==1.9.0 click==8.1.7 Flask==3.1.3 itsdangerous==2.2.0 From be4c04460e472388ed874adc0ed7243bcd714ee6 Mon Sep 17 00:00:00 2001 From: Andrew Battat Date: Thu, 12 Mar 2026 01:30:20 +0000 Subject: [PATCH 3/5] Rust fmt --- examples/http-gateway/rust/src/main.rs | 4 +++- packages/ic-http-gateway-protocol/tests/custom_assets.rs | 4 +++- .../ic-http-gateway-protocol/tests/range_request_stream.rs | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/examples/http-gateway/rust/src/main.rs b/examples/http-gateway/rust/src/main.rs index 1cf6708..3cb78ff 100644 --- a/examples/http-gateway/rust/src/main.rs +++ b/examples/http-gateway/rust/src/main.rs @@ -2,7 +2,9 @@ use http_body_util::BodyExt; use hyper::{body::Incoming, server::conn::http2, service::service_fn, Request, Response}; use hyper_util::rt::TokioIo; use ic_agent::Agent; -use ic_http_gateway_protocol::{HttpGatewayClient, HttpGatewayRequestArgs, HttpGatewayResponseBody}; +use ic_http_gateway_protocol::{ + HttpGatewayClient, HttpGatewayRequestArgs, HttpGatewayResponseBody, +}; use pocket_ic::PocketIcBuilder; use std::{convert::Infallible, net::SocketAddr, path::PathBuf, sync::Arc}; use tokio::{fs::File, io::AsyncReadExt, net::TcpListener, task}; diff --git a/packages/ic-http-gateway-protocol/tests/custom_assets.rs b/packages/ic-http-gateway-protocol/tests/custom_assets.rs index 05c3a29..6299a62 100644 --- a/packages/ic-http-gateway-protocol/tests/custom_assets.rs +++ b/packages/ic-http-gateway-protocol/tests/custom_assets.rs @@ -2,7 +2,9 @@ use bytes::Bytes; use http::Request; use http_body_util::BodyExt; use ic_agent::Agent; -use ic_http_gateway_protocol::{HttpGatewayClient, HttpGatewayRequestArgs, HttpGatewayResponseMetadata}; +use ic_http_gateway_protocol::{ + HttpGatewayClient, HttpGatewayRequestArgs, HttpGatewayResponseMetadata, +}; use pocket_ic::PocketIcBuilder; mod utils; diff --git a/packages/ic-http-gateway-protocol/tests/range_request_stream.rs b/packages/ic-http-gateway-protocol/tests/range_request_stream.rs index 22cd6de..3c93d07 100644 --- a/packages/ic-http-gateway-protocol/tests/range_request_stream.rs +++ b/packages/ic-http-gateway-protocol/tests/range_request_stream.rs @@ -4,7 +4,9 @@ use http::Request; use http_body_util::BodyExt; use ic_agent::hash_tree::Hash; use ic_agent::Agent; -use ic_http_gateway_protocol::{HttpGatewayClient, HttpGatewayRequestArgs, HttpGatewayResponseMetadata}; +use ic_http_gateway_protocol::{ + HttpGatewayClient, HttpGatewayRequestArgs, HttpGatewayResponseMetadata, +}; use pocket_ic::PocketIcBuilder; use rand_chacha::rand_core::{RngCore, SeedableRng}; use rand_chacha::ChaCha20Rng; From fcae1c56a30866876e8e60e44a114e1d4eb1c892 Mon Sep 17 00:00:00 2001 From: Andrew Battat Date: Thu, 12 Mar 2026 01:42:46 +0000 Subject: [PATCH 4/5] reduce ic-http-gateway to a minimal compatibility shim --- .github/workflows/build-and-test.yml | 4 + Cargo.lock | 8 - packages/ic-http-gateway/Cargo.toml | 10 - .../src/client/http_gateway_client.rs | 32 - .../src/client/http_gateway_client_builder.rs | 37 -- packages/ic-http-gateway/src/client/mod.rs | 5 - packages/ic-http-gateway/src/consts.rs | 4 - packages/ic-http-gateway/src/error.rs | 42 -- .../ic-http-gateway/src/protocol/handler.rs | 512 ---------------- packages/ic-http-gateway/src/protocol/mod.rs | 5 - .../ic-http-gateway/src/protocol/validate.rs | 46 -- .../request/http_gateway_request_builder.rs | 50 -- packages/ic-http-gateway/src/request/mod.rs | 2 - .../src/response/http_gateway_response.rs | 43 -- packages/ic-http-gateway/src/response/mod.rs | 5 - .../src/response/response_handler.rs | 548 ------------------ .../ic-http-gateway/test-container/Dockerfile | 23 - .../ic-http-gateway/test-container/app.py | 18 - .../test-container/requirements.txt | 7 - .../ic-http-gateway/tests/custom_assets.rs | 130 ----- .../tests/protocol_error_tests.rs | 94 --- .../tests/range_request_stream.rs | 521 ----------------- packages/ic-http-gateway/tests/utils/mod.rs | 26 - 23 files changed, 4 insertions(+), 2168 deletions(-) delete mode 100644 packages/ic-http-gateway/src/client/http_gateway_client.rs delete mode 100644 packages/ic-http-gateway/src/client/http_gateway_client_builder.rs delete mode 100644 packages/ic-http-gateway/src/client/mod.rs delete mode 100644 packages/ic-http-gateway/src/consts.rs delete mode 100644 packages/ic-http-gateway/src/error.rs delete mode 100644 packages/ic-http-gateway/src/protocol/handler.rs delete mode 100644 packages/ic-http-gateway/src/protocol/mod.rs delete mode 100644 packages/ic-http-gateway/src/protocol/validate.rs delete mode 100644 packages/ic-http-gateway/src/request/http_gateway_request_builder.rs delete mode 100644 packages/ic-http-gateway/src/request/mod.rs delete mode 100644 packages/ic-http-gateway/src/response/http_gateway_response.rs delete mode 100644 packages/ic-http-gateway/src/response/mod.rs delete mode 100644 packages/ic-http-gateway/src/response/response_handler.rs delete mode 100644 packages/ic-http-gateway/test-container/Dockerfile delete mode 100644 packages/ic-http-gateway/test-container/app.py delete mode 100644 packages/ic-http-gateway/test-container/requirements.txt delete mode 100644 packages/ic-http-gateway/tests/custom_assets.rs delete mode 100644 packages/ic-http-gateway/tests/protocol_error_tests.rs delete mode 100644 packages/ic-http-gateway/tests/range_request_stream.rs delete mode 100644 packages/ic-http-gateway/tests/utils/mod.rs diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 3f5a424..2baaf68 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -33,6 +33,10 @@ jobs: - name: Test Cargo crates run: cargo test --all-features + # TODO(NODE-1907): Remove once ic-http-gateway is fully deprecated. + - name: Check compatibility shim crate + run: cargo check -p ic-http-gateway --all-features + - name: Build Cargo docs run: cargo doc --no-deps diff --git a/Cargo.lock b/Cargo.lock index d20bedf..b723535 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1630,15 +1630,7 @@ dependencies = [ name = "ic-http-gateway" version = "0.0.0-git" dependencies = [ - "assert_matches", "ic-http-gateway-protocol", - "pocket-ic", - "rand_chacha 0.3.1", - "reqwest", - "rstest", - "sha2 0.10.9", - "testcontainers", - "tokio", ] [[package]] diff --git a/packages/ic-http-gateway/Cargo.toml b/packages/ic-http-gateway/Cargo.toml index 2ecbec3..674730e 100644 --- a/packages/ic-http-gateway/Cargo.toml +++ b/packages/ic-http-gateway/Cargo.toml @@ -21,13 +21,3 @@ homepage.workspace = true [dependencies] ic-http-gateway-protocol.workspace = true - -[dev-dependencies] -assert_matches.workspace = true -pocket-ic.workspace = true -reqwest.workspace = true -testcontainers.workspace = true -tokio.workspace = true -rand_chacha.workspace = true -rstest.workspace = true -sha2.workspace = true diff --git a/packages/ic-http-gateway/src/client/http_gateway_client.rs b/packages/ic-http-gateway/src/client/http_gateway_client.rs deleted file mode 100644 index 3ab5e29..0000000 --- a/packages/ic-http-gateway/src/client/http_gateway_client.rs +++ /dev/null @@ -1,32 +0,0 @@ -use crate::{ - HttpGatewayClientBuilder, HttpGatewayRequestArgs, HttpGatewayRequestBuilder, - HttpGatewayRequestBuilderArgs, -}; -use ic_agent::Agent; - -#[derive(Clone)] -pub struct HttpGatewayClientArgs { - pub agent: Agent, -} - -#[derive(Clone)] -pub struct HttpGatewayClient { - agent: Agent, -} - -impl<'a> HttpGatewayClient { - pub fn new(args: HttpGatewayClientArgs) -> Self { - Self { agent: args.agent } - } - - pub fn builder() -> HttpGatewayClientBuilder { - Default::default() - } - - pub fn request(&'a self, args: HttpGatewayRequestArgs) -> HttpGatewayRequestBuilder<'a> { - HttpGatewayRequestBuilder::new(HttpGatewayRequestBuilderArgs { - request_args: args, - agent: &self.agent, - }) - } -} diff --git a/packages/ic-http-gateway/src/client/http_gateway_client_builder.rs b/packages/ic-http-gateway/src/client/http_gateway_client_builder.rs deleted file mode 100644 index ea22ccd..0000000 --- a/packages/ic-http-gateway/src/client/http_gateway_client_builder.rs +++ /dev/null @@ -1,37 +0,0 @@ -use crate::{ - HttpGatewayClient, HttpGatewayClientArgs, HttpGatewayResult, DEFAULT_BOUNDARY_NODE_ENDPOINT, -}; -use ic_agent::Agent; - -pub struct HttpGatewayClientBuilder { - agent: Option, -} - -impl HttpGatewayClientBuilder { - pub fn new() -> Self { - Self { agent: None } - } - - pub fn with_agent(mut self, agent: Agent) -> Self { - self.agent = Some(agent); - - self - } - - pub fn build(self) -> HttpGatewayResult { - let agent = match self.agent { - Some(agent) => agent, - None => Agent::builder() - .with_url(DEFAULT_BOUNDARY_NODE_ENDPOINT) - .build()?, - }; - - Ok(HttpGatewayClient::new(HttpGatewayClientArgs { agent })) - } -} - -impl Default for HttpGatewayClientBuilder { - fn default() -> Self { - Self::new() - } -} diff --git a/packages/ic-http-gateway/src/client/mod.rs b/packages/ic-http-gateway/src/client/mod.rs deleted file mode 100644 index e07acb4..0000000 --- a/packages/ic-http-gateway/src/client/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod http_gateway_client; -pub use http_gateway_client::*; - -mod http_gateway_client_builder; -pub use http_gateway_client_builder::*; diff --git a/packages/ic-http-gateway/src/consts.rs b/packages/ic-http-gateway/src/consts.rs deleted file mode 100644 index 329c2ff..0000000 --- a/packages/ic-http-gateway/src/consts.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub(crate) static CACHE_HEADER_NAME: &str = "cache-control"; -pub(crate) static ACCEPT_ENCODING_HEADER_NAME: &str = "accept-encoding"; - -pub(crate) static DEFAULT_BOUNDARY_NODE_ENDPOINT: &str = "https://icp-api.io"; diff --git a/packages/ic-http-gateway/src/error.rs b/packages/ic-http-gateway/src/error.rs deleted file mode 100644 index bac9b84..0000000 --- a/packages/ic-http-gateway/src/error.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! The error module contains types for common errors that may be thrown -//! by other modules in this crate. - -use ic_agent::AgentError; -use ic_response_verification::ResponseVerificationError; -use std::sync::Arc; - -/// HTTP gateway result type. -pub type HttpGatewayResult = Result; - -/// HTTP gateway error type. -#[derive(thiserror::Error, Debug, Clone)] -pub enum HttpGatewayError { - #[error(transparent)] - ResponseVerificationError(#[from] ResponseVerificationError), - - /// Inner error from agent. - #[error(transparent)] - AgentError(#[from] Arc), - - /// HTTP error. - #[error(transparent)] - HttpError(#[from] Arc), - - #[error(r#"Failed to parse the "{header_name}" header value: "{header_value:?}""#)] - HeaderValueParsingError { - header_name: String, - header_value: String, - }, -} - -impl From for HttpGatewayError { - fn from(err: AgentError) -> Self { - HttpGatewayError::AgentError(Arc::new(err)) - } -} - -impl From for HttpGatewayError { - fn from(err: http::Error) -> Self { - HttpGatewayError::HttpError(Arc::new(err)) - } -} diff --git a/packages/ic-http-gateway/src/protocol/handler.rs b/packages/ic-http-gateway/src/protocol/handler.rs deleted file mode 100644 index 9ea5aa3..0000000 --- a/packages/ic-http-gateway/src/protocol/handler.rs +++ /dev/null @@ -1,512 +0,0 @@ -use super::validate; -use crate::{ - get_206_stream_response_body_and_total_length, get_body_and_streaming_body, CanisterRequest, - CanisterResponse, HttpGatewayError, HttpGatewayResponse, HttpGatewayResponseBody, - HttpGatewayResponseMetadata, HttpGatewayResult, ACCEPT_ENCODING_HEADER_NAME, CACHE_HEADER_NAME, -}; -use candid::Principal; -use http::header as http_header; -use http::{Response, StatusCode}; -use http_body_util::{BodyExt, Either, Full}; -use ic_agent::{ - agent::{RejectCode, RejectResponse}, - Agent, AgentError, -}; -use ic_http_certification::{HttpRequest, HttpResponse}; -use ic_response_verification::MAX_VERIFICATION_VERSION; -use ic_utils::{ - call::{AsyncCall, SyncCall}, - interfaces::{http_request::HeaderField, HttpRequestCanister}, -}; - -fn create_err_response(status_code: StatusCode, msg: &str) -> CanisterResponse { - let mut response = Response::new(HttpGatewayResponseBody::Right(Full::from( - msg.as_bytes().to_vec(), - ))); - *response.status_mut() = status_code; - - response -} - -fn convert_request(request: CanisterRequest) -> HttpGatewayResult> { - let uri = request.uri(); - let mut url = uri.path().to_string(); - if let Some(query) = uri.query() { - url.push('?'); - url.push_str(query); - } - - Ok(HttpRequest::builder() - .with_method(request.method().clone()) - .with_url(url) - .with_headers( - request - .headers() - .into_iter() - .map(|(name, value)| { - Ok(( - name.to_string(), - value - .to_str() - .map_err(|_| HttpGatewayError::HeaderValueParsingError { - header_name: name.to_string(), - header_value: String::from_utf8_lossy(value.as_bytes()).to_string(), - })? - .to_string(), - )) - }) - .collect::>>()?, - ) - .with_body(request.body().to_vec()) - .build()) -} - -pub async fn process_request( - agent: &Agent, - request: CanisterRequest, - canister_id: Principal, - skip_verification: bool, -) -> HttpGatewayResponse { - let http_request = match convert_request(request) { - Ok(http_request) => http_request, - Err(e) => { - return HttpGatewayResponse { - canister_response: create_err_response( - StatusCode::BAD_REQUEST, - &format!("Failed to parse request: {}", e), - ), - metadata: HttpGatewayResponseMetadata { - upgraded_to_update_call: false, - response_verification_version: None, - internal_error: Some(e), - }, - } - } - }; - - let canister = HttpRequestCanister::create(agent, canister_id); - let mut is_range_request = false; - let header_fields = http_request - .headers() - .iter() - .filter(|(name, _)| name != "x-request-id") - .map(|(name, value)| { - if name.eq_ignore_ascii_case(ACCEPT_ENCODING_HEADER_NAME) { - let mut encodings = value.split(',').map(|s| s.trim()).collect::>(); - if !encodings.iter().any(|s| s.eq_ignore_ascii_case("identity")) { - encodings.push("identity"); - }; - - let value = encodings.join(", "); - return HeaderField(name.into(), value.into()); - } else if name.eq_ignore_ascii_case(http_header::RANGE.as_ref()) - || name.eq_ignore_ascii_case(http_header::IF_RANGE.as_ref()) - { - is_range_request = true; - } - - HeaderField(name.into(), value.into()) - }) - .collect::>() - .into_iter(); - - let query_result = canister - .http_request_custom( - http_request.method().as_str(), - http_request.url(), - header_fields.clone(), - http_request.body(), - Some(&u16::from(MAX_VERIFICATION_VERSION)), - ) - .call() - .await; - - let agent_response = match query_result { - Ok((response,)) => response, - Err(e) => { - return HttpGatewayResponse { - canister_response: handle_agent_error(&e), - metadata: HttpGatewayResponseMetadata { - upgraded_to_update_call: false, - response_verification_version: None, - internal_error: Some(e.into()), - }, - }; - } - }; - - let is_update_call = agent_response.upgrade == Some(true); - let agent_response = if is_update_call { - let update_result = canister - .http_request_update_custom( - http_request.method().as_str(), - http_request.url(), - header_fields.clone(), - http_request.body(), - ) - .call_and_wait() - .await; - - match update_result { - Ok((response,)) => response, - Err(e) => { - return HttpGatewayResponse { - canister_response: handle_agent_error(&e), - metadata: HttpGatewayResponseMetadata { - upgraded_to_update_call: true, - response_verification_version: None, - internal_error: Some(e.into()), - }, - }; - } - } - } else { - agent_response - }; - - let response_body = match get_body_and_streaming_body(agent, &agent_response).await { - Ok(response_body) => response_body, - Err(e) => { - return HttpGatewayResponse { - canister_response: create_err_response( - StatusCode::INTERNAL_SERVER_ERROR, - &format!("Failed to parse response body: {}", e), - ), - metadata: HttpGatewayResponseMetadata { - upgraded_to_update_call: is_update_call, - response_verification_version: None, - internal_error: Some(e.into()), - }, - } - } - }; - - // There is no need to verify the response if the request was upgraded to an update call. - let validation_info = if !is_update_call { - // At the moment verification is only performed if the response is not using a streaming - // strategy. Performing verification for those requests would require to join all the chunks - // and this could cause memory issues and possibly create DOS attack vectors. - match &response_body { - Either::Right(body) => { - // this unwrap should never panic because `Either::Right` will always have a full body - let body = body.clone().collect().await.unwrap().to_bytes().to_vec(); - - let status_code = match StatusCode::from_u16(agent_response.status_code) { - Ok(status) => status, - Err(e) => { - return HttpGatewayResponse { - canister_response: create_err_response( - StatusCode::INTERNAL_SERVER_ERROR, - &format!("Invalid canister response status code: {}", e), - ), - metadata: HttpGatewayResponseMetadata { - upgraded_to_update_call: is_update_call, - response_verification_version: None, - internal_error: Some(http::Error::from(e).into()), - }, - }; - } - }; - let response = HttpResponse::builder() - .with_status_code(status_code) - .with_headers( - agent_response - .headers - .iter() - .map(|HeaderField(k, v)| (k.to_string(), v.to_string())) - .collect(), - ) - .with_body(body) - .build(); - - let validation_result = validate( - agent, - &canister_id, - http_request.clone(), - response, - skip_verification, - ); - - match validation_result { - Err(e) => { - return HttpGatewayResponse { - canister_response: create_err_response( - StatusCode::INTERNAL_SERVER_ERROR, - &format!("Response verification failed: {}", e), - ), - metadata: HttpGatewayResponseMetadata { - upgraded_to_update_call: is_update_call, - response_verification_version: None, - internal_error: Some(e), - }, - }; - } - Ok(validation_info) => validation_info, - } - } - _ => None, - } - } else { - None - }; - - let response_verification_version = validation_info.as_ref().map(|e| e.verification_version); - - let status_code = match StatusCode::from_u16(agent_response.status_code) { - Ok(status_code) => status_code, - Err(e) => { - return HttpGatewayResponse { - canister_response: create_err_response( - StatusCode::INTERNAL_SERVER_ERROR, - &format!("Invalid canister response status code: {}", e), - ), - metadata: HttpGatewayResponseMetadata { - upgraded_to_update_call: is_update_call, - response_verification_version, - internal_error: Some(http::Error::from(e).into()), - }, - } - } - }; - - let mut response_builder = Response::builder().status(status_code); - match &validation_info { - // if there is no validation info, that means we've skipped verification, - // this should only happen for raw domains. - None => { - for HeaderField(name, value) in &agent_response.headers { - response_builder = response_builder.header(name.as_ref(), value.as_ref()); - } - } - - Some(validation_info) => { - if validation_info.verification_version < 2 { - // status codes are not certified in v1, reject known dangerous status codes - if agent_response.status_code >= 300 && agent_response.status_code < 400 { - return HttpGatewayResponse { - canister_response: create_err_response( - StatusCode::INTERNAL_SERVER_ERROR, - "Response verification v1 does not allow redirects", - ), - metadata: HttpGatewayResponseMetadata { - upgraded_to_update_call: is_update_call, - response_verification_version, - internal_error: None, - }, - }; - } - - // headers are also not certified in v1, filter known dangerous headers - for HeaderField(name, value) in &agent_response.headers { - if !name.eq_ignore_ascii_case(CACHE_HEADER_NAME) { - response_builder = response_builder.header(name.as_ref(), value.as_ref()); - } - } - } else { - match &validation_info.response { - // if there is no response, the canister has decided to certifiably skip verification, - // assume the developer knows what they're doing and return the response as-is - None => { - for HeaderField(name, value) in &agent_response.headers { - // If the request is not a range-request, but got range-response, - // do not copy "Content-Range" and "Content-Length" headers, - // as clients obtain the full asset via a streaming response. - if !is_range_request - && status_code == 206 - && (name.eq_ignore_ascii_case(http_header::CONTENT_RANGE.as_ref()) - || name - .eq_ignore_ascii_case(http_header::CONTENT_LENGTH.as_ref())) - { - // skip copying - } else { - response_builder = - response_builder.header(name.as_ref(), value.as_ref()); - } - } - } - // if there is a response, the canister has decided to certify some (but not necessarily all) headers, - // return only the certified headers - Some(certified_http_response) => { - for (name, value) in &certified_http_response.headers { - // If the request is not a range-request, but got range-response, - // do not copy "Content-Range" and "Content-Length" headers, - // as clients obtain the full asset via a streaming response. - if !is_range_request - && status_code == 206 - && (name.eq_ignore_ascii_case(http_header::CONTENT_RANGE.as_ref()) - || name - .eq_ignore_ascii_case(http_header::CONTENT_LENGTH.as_ref())) - { - // skip copying - } else { - response_builder = response_builder.header(name, value); - } - } - } - } - } - } - } - - let response_body: HttpGatewayResponseBody = if status_code == 206 && !is_range_request { - // We got only the first chunk, add a correct content-length-header, - // and turn the response into a streaming response. - let (stream_response_body, content_length) = - match get_206_stream_response_body_and_total_length( - agent, - http_request, - canister_id, - &agent_response.headers, - response_body, - skip_verification, - ) - .await - { - Ok((stream_response_body, content_length)) => { - (stream_response_body, content_length) - } - Err(e) => { - return HttpGatewayResponse { - canister_response: create_err_response( - StatusCode::INTERNAL_SERVER_ERROR, - &format!("Failed to create streaming response: {}", e), - ), - metadata: HttpGatewayResponseMetadata { - upgraded_to_update_call: is_update_call, - response_verification_version, - internal_error: Some(e.into()), - }, - } - } - }; - response_builder = - response_builder.header(http_header::CONTENT_LENGTH, content_length.to_string()); - response_builder = response_builder.status(200); - stream_response_body - } else { - response_body - }; - - let response = match response_builder.body(response_body) { - Ok(response) => response, - Err(e) => { - return HttpGatewayResponse { - canister_response: create_err_response( - StatusCode::INTERNAL_SERVER_ERROR, - &format!("Failed to build response: {}", e), - ), - metadata: HttpGatewayResponseMetadata { - upgraded_to_update_call: is_update_call, - response_verification_version, - internal_error: Some(e.into()), - }, - } - } - }; - - HttpGatewayResponse { - canister_response: response, - metadata: HttpGatewayResponseMetadata { - upgraded_to_update_call: is_update_call, - response_verification_version, - internal_error: None, - }, - } -} - -fn handle_agent_error(error: &AgentError) -> CanisterResponse { - match error { - // Turn all `DestinationInvalid`s into 404 - AgentError::CertifiedReject { - reject: - RejectResponse { - reject_code: RejectCode::DestinationInvalid, - reject_message, - .. - }, - .. - } => create_err_response(StatusCode::NOT_FOUND, reject_message), - - // If the result is a Replica error, returns the 500 code and message. There is no information - // leak here because a user could use `dfx` to get the same reply. - AgentError::CertifiedReject { reject, .. } => create_err_response( - StatusCode::BAD_GATEWAY, - &format!( - "Replica Error: reject code {:?}, message {}, error code {:?}", - reject.reject_code, reject.reject_message, reject.error_code, - ), - ), - - AgentError::UncertifiedReject { - reject: - RejectResponse { - reject_code: RejectCode::DestinationInvalid, - reject_message, - .. - }, - .. - } => create_err_response(StatusCode::NOT_FOUND, reject_message), - - // If the result is a Replica error, returns the 500 code and message. There is no information - // leak here because a user could use `dfx` to get the same reply. - AgentError::UncertifiedReject { reject, .. } => create_err_response( - StatusCode::BAD_GATEWAY, - &format!( - "Replica Error: reject code {:?}, message {}, error code {:?}", - reject.reject_code, reject.reject_message, reject.error_code, - ), - ), - - AgentError::ResponseSizeExceededLimit() => create_err_response( - StatusCode::INSUFFICIENT_STORAGE, - "Response size exceeds limit", - ), - - AgentError::HttpError(payload) => match StatusCode::from_u16(payload.status) { - Ok(status) => create_err_response(status, &format!("{:?}", payload)), - Err(_) => create_err_response( - StatusCode::INTERNAL_SERVER_ERROR, - &format!("Received invalid status code {:?}", payload), - ), - }, - - // Handle all other errors - _ => create_err_response( - StatusCode::INTERNAL_SERVER_ERROR, - &format!("Internal Server Error: {:?}", error), - ), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use bytes::Bytes; - use http::Request; - - #[test] - fn test_convert_request() { - let request = Request::builder() - .uri("http://example.com/foo/bar/baz?q=hello+world&t=1") - .header("Accept", "text/html") - .header("Accept-Encoding", "gzip, deflate, br, zstd") - .body(Bytes::from("body")) - .unwrap(); - - let http_request = convert_request(request).unwrap(); - - assert_eq!( - http_request, - HttpRequest::get("/foo/bar/baz?q=hello+world&t=1") - .with_headers(vec![ - ("accept".to_string(), "text/html".to_string()), - ( - "accept-encoding".to_string(), - "gzip, deflate, br, zstd".to_string() - ), - ]) - .with_body(b"body".to_vec()) - .build() - ); - } -} diff --git a/packages/ic-http-gateway/src/protocol/mod.rs b/packages/ic-http-gateway/src/protocol/mod.rs deleted file mode 100644 index 1b72e01..0000000 --- a/packages/ic-http-gateway/src/protocol/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod handler; -pub(crate) use handler::*; - -mod validate; -pub(crate) use validate::*; diff --git a/packages/ic-http-gateway/src/protocol/validate.rs b/packages/ic-http-gateway/src/protocol/validate.rs deleted file mode 100644 index a8e468f..0000000 --- a/packages/ic-http-gateway/src/protocol/validate.rs +++ /dev/null @@ -1,46 +0,0 @@ -use crate::HttpGatewayResult; -use candid::Principal; -use ic_agent::Agent; -use ic_http_certification::{HttpRequest, HttpResponse}; -use ic_response_verification::{ - types::VerificationInfo, verify_request_response_pair, MIN_VERIFICATION_VERSION, -}; -use std::time::{SystemTime, UNIX_EPOCH}; - -const MAX_CERT_TIME_OFFSET_NS: u128 = 300_000_000_000; - -pub fn validate( - agent: &Agent, - canister_id: &Principal, - request: HttpRequest, - response: HttpResponse, - skip_verification: bool, -) -> HttpGatewayResult> { - if skip_verification { - // TODO: Remove this (FOLLOW-483) - // Canisters don't have to provide certified variables - // This should change in the future, grandfathering in current implementations - return Ok(None); - } - - let ic_public_key = agent.read_root_key(); - let verification_info = verify_request_response_pair( - request, - response, - canister_id.as_slice(), - get_current_time_in_ns(), - MAX_CERT_TIME_OFFSET_NS, - ic_public_key.as_slice(), - MIN_VERIFICATION_VERSION, - )?; - Ok(Some(verification_info)) -} - -fn get_current_time_in_ns() -> u128 { - let start = SystemTime::now(); - - start - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_nanos() -} diff --git a/packages/ic-http-gateway/src/request/http_gateway_request_builder.rs b/packages/ic-http-gateway/src/request/http_gateway_request_builder.rs deleted file mode 100644 index 6e5bc70..0000000 --- a/packages/ic-http-gateway/src/request/http_gateway_request_builder.rs +++ /dev/null @@ -1,50 +0,0 @@ -use crate::{protocol::process_request, HttpGatewayResponse}; -use bytes::Bytes; -use candid::Principal; -use http::Request; -use ic_agent::Agent; - -pub struct HttpGatewayRequestArgs { - /// The request to make to the canister. - pub canister_request: CanisterRequest, - - /// The id of the canister to make a request to. - pub canister_id: Principal, -} - -pub type CanisterRequest = Request; - -pub struct HttpGatewayRequestBuilderArgs<'a> { - pub request_args: HttpGatewayRequestArgs, - pub agent: &'a Agent, -} - -pub struct HttpGatewayRequestBuilder<'a> { - args: HttpGatewayRequestBuilderArgs<'a>, - skip_verification: bool, -} - -impl<'a> HttpGatewayRequestBuilder<'a> { - pub fn new(args: HttpGatewayRequestBuilderArgs<'a>) -> Self { - Self { - args, - skip_verification: false, - } - } - - pub fn unsafe_set_skip_verification(&mut self, skip_verification: bool) -> &mut Self { - self.skip_verification = skip_verification; - - self - } - - pub async fn send(self) -> HttpGatewayResponse { - process_request( - self.args.agent, - self.args.request_args.canister_request, - self.args.request_args.canister_id, - self.skip_verification, - ) - .await - } -} diff --git a/packages/ic-http-gateway/src/request/mod.rs b/packages/ic-http-gateway/src/request/mod.rs deleted file mode 100644 index c6a182e..0000000 --- a/packages/ic-http-gateway/src/request/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -mod http_gateway_request_builder; -pub use http_gateway_request_builder::*; diff --git a/packages/ic-http-gateway/src/response/http_gateway_response.rs b/packages/ic-http-gateway/src/response/http_gateway_response.rs deleted file mode 100644 index 8459cf6..0000000 --- a/packages/ic-http-gateway/src/response/http_gateway_response.rs +++ /dev/null @@ -1,43 +0,0 @@ -use bytes::Bytes; -use futures::stream::BoxStream; -use http::Response; -use http_body::Frame; -use http_body_util::{Either, Full, StreamBody}; -use ic_agent::AgentError; -use std::fmt::Debug; - -use crate::HttpGatewayError; - -pub type CanisterResponse = Response; - -/// A response from the HTTP gateway. -pub struct HttpGatewayResponse { - /// The certified response, excluding uncertified headers. - /// If response verification v1 is used, the original, uncertified headers are returned. - pub canister_response: CanisterResponse, - - /// Additional metadata regarding the response. - pub metadata: HttpGatewayResponseMetadata, -} - -/// Additional metadata regarding the response. -#[derive(Debug, Clone)] -pub struct HttpGatewayResponseMetadata { - /// Whether the original query call was upgraded to an update call. - pub upgraded_to_update_call: bool, - - /// The version of response verification that was used to verify the response. - /// If the protocol fails before getting to the verification step, or the - /// original query call is upgraded to an update call, this field will be `None`. - pub response_verification_version: Option, - - /// The internal error that resulted in the HTTP response being an error response. - pub internal_error: Option, -} - -pub type HttpGatewayResponseBody = Either>; - -pub type ResponseBodyStream = StreamBody>; - -/// An item in a response body stream. -pub type ResponseBodyStreamItem = Result, AgentError>; diff --git a/packages/ic-http-gateway/src/response/mod.rs b/packages/ic-http-gateway/src/response/mod.rs deleted file mode 100644 index 0a58f25..0000000 --- a/packages/ic-http-gateway/src/response/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod http_gateway_response; -pub use http_gateway_response::*; - -mod response_handler; -pub use response_handler::*; diff --git a/packages/ic-http-gateway/src/response/response_handler.rs b/packages/ic-http-gateway/src/response/response_handler.rs deleted file mode 100644 index 4a64a73..0000000 --- a/packages/ic-http-gateway/src/response/response_handler.rs +++ /dev/null @@ -1,548 +0,0 @@ -use crate::protocol::validate; -use crate::{HttpGatewayResponseBody, ResponseBodyStream}; -use bytes::Bytes; -use candid::Principal; -use futures::{stream, Stream, StreamExt, TryStreamExt}; -use http_body::Frame; -use http_body_util::{BodyExt, Full}; -use ic_agent::{Agent, AgentError}; -use ic_http_certification::{HttpRequest, HttpResponse, StatusCode}; -use ic_response_verification::MAX_VERIFICATION_VERSION; -use ic_utils::interfaces::http_request::HeaderField; -use ic_utils::{ - call::SyncCall, - interfaces::http_request::{ - HttpRequestCanister, HttpRequestStreamingCallbackAny, HttpResponse as AgentResponse, - StreamingCallbackHttpResponse, StreamingStrategy, Token, - }, -}; - -// Limit the total number of calls to an HTTP Request loop to 1000 for now. -static MAX_HTTP_REQUEST_STREAM_CALLBACK_CALL_COUNT: usize = 1000; - -// Limit the total number of calls to an HTTP request look that can be verified -static MAX_VERIFIED_HTTP_REQUEST_STREAM_CALLBACK_CALL_COUNT: usize = 4; - -// Limit the number of Stream Callbacks buffered -static STREAM_CALLBACK_BUFFER: usize = 2; - -pub type AgentResponseAny = AgentResponse; - -pub async fn get_body_and_streaming_body( - agent: &Agent, - response: &AgentResponseAny, -) -> Result { - // if we already have the full body, we can return it early - let Some(StreamingStrategy::Callback(callback_strategy)) = response.streaming_strategy.clone() - else { - return Ok(HttpGatewayResponseBody::Right(Full::from( - response.body.clone(), - ))); - }; - - let (streamed_body, token) = create_stream( - agent.clone(), - callback_strategy.callback.clone(), - Some(callback_strategy.token), - ) - .take(MAX_VERIFIED_HTTP_REQUEST_STREAM_CALLBACK_CALL_COUNT) - .map(|x| async move { x }) - .buffered(STREAM_CALLBACK_BUFFER) - .try_fold( - (vec![], None::), - |mut accum, (mut body, token)| async move { - accum.0.append(&mut body); - accum.1 = token; - - Ok(accum) - }, - ) - .await?; - - let streamed_body = [response.body.clone(), streamed_body].concat(); - - // if we still have a token at this point, - // we were unable to collect the response within the allowed certified callback limit, - // fallback to uncertified streaming using what we've streamed so far as the initial body - if token.is_some() { - let body_stream = create_body_stream( - agent.clone(), - callback_strategy.callback, - token, - streamed_body, - ); - - return Ok(HttpGatewayResponseBody::Left(body_stream)); - }; - - // if we no longer have a token at this point, - // we were able to collect the response within the allow certified callback limit, - // return this collected response as a standard response body so it will be verified - Ok(HttpGatewayResponseBody::Right(Full::from(streamed_body))) -} - -fn create_body_stream( - agent: Agent, - callback: HttpRequestStreamingCallbackAny, - token: Option, - initial_body: Vec, -) -> ResponseBodyStream { - let chunks_stream = create_stream(agent, callback, token) - .map(|chunk| chunk.map(|(body, _)| Frame::data(Bytes::from(body)))); - - let body_stream = stream::once(async move { Ok(Frame::data(Bytes::from(initial_body))) }) - .chain(chunks_stream) - .take(MAX_HTTP_REQUEST_STREAM_CALLBACK_CALL_COUNT) - .map(|x| async move { x }) - .buffered(STREAM_CALLBACK_BUFFER); - - ResponseBodyStream::new(Box::pin(body_stream)) -} - -fn create_stream( - agent: Agent, - callback: HttpRequestStreamingCallbackAny, - token: Option, -) -> impl Stream, Option), AgentError>> { - futures::stream::try_unfold( - (agent, callback, token), - |(agent, callback, token)| async move { - let Some(token) = token else { - return Ok(None); - }; - - let canister = HttpRequestCanister::create(&agent, callback.0.principal); - match canister - .http_request_stream_callback(&callback.0.method, token) - .call() - .await - { - Ok((StreamingCallbackHttpResponse { body, token },)) => { - Ok(Some(((body, token.clone()), (agent, callback, token)))) - } - Err(e) => Err(e), - } - }, - ) -} - -#[derive(Clone, Debug)] -struct StreamState<'a> { - pub http_request: HttpRequest<'a>, - pub canister_id: Principal, - pub total_length: usize, - pub fetched_length: usize, - pub skip_verification: bool, -} - -pub async fn get_206_stream_response_body_and_total_length( - agent: &Agent, - http_request: HttpRequest<'static>, - canister_id: Principal, - response_headers: &Vec>, - response_206_body: HttpGatewayResponseBody, - skip_verification: bool, -) -> Result<(HttpGatewayResponseBody, usize), AgentError> { - let HttpGatewayResponseBody::Right(body) = response_206_body else { - return Err(AgentError::InvalidHttpResponse( - "Expected full 206 response".to_string(), - )); - }; - // The expect below should never panic because `Either::Right` will always have a full body - let streamed_body = body - .collect() - .await - .expect("missing streamed chunk body") - .to_bytes() - .to_vec(); - let stream_state = get_initial_stream_state( - http_request, - canister_id, - response_headers, - skip_verification, - )?; - let content_length = stream_state.total_length; - - let body_stream = create_206_body_stream(agent.clone(), stream_state, streamed_body); - Ok((HttpGatewayResponseBody::Left(body_stream), content_length)) -} - -#[derive(Debug)] -struct ContentRangeValues { - pub range_begin: usize, - pub range_end: usize, - pub total_length: usize, -} - -fn parse_content_range_header_str( - content_range_str: &str, -) -> Result { - // expected format: `bytes 21010-47021/47022` - let str_value = content_range_str.trim(); - if !str_value.starts_with("bytes ") { - return Err(AgentError::InvalidHttpResponse(format!( - "Invalid Content-Range header '{}'", - content_range_str - ))); - } - let str_value = str_value.trim_start_matches("bytes "); - - let str_value_parts = str_value.split('-').collect::>(); - if str_value_parts.len() != 2 { - return Err(AgentError::InvalidHttpResponse(format!( - "Invalid bytes spec in Content-Range header '{}'", - content_range_str - ))); - } - let range_begin = str_value_parts[0].parse::().map_err(|e| { - AgentError::InvalidHttpResponse(format!( - "Invalid range_begin in '{}': {}", - content_range_str, e - )) - })?; - - let other_value_parts = str_value_parts[1].split('/').collect::>(); - if other_value_parts.len() != 2 { - return Err(AgentError::InvalidHttpResponse(format!( - "Invalid bytes spec in Content-Range header '{}'", - content_range_str - ))); - } - let range_end = other_value_parts[0].parse::().map_err(|e| { - AgentError::InvalidHttpResponse(format!( - "Invalid range_end in '{}': {}", - content_range_str, e - )) - })?; - let total_length = other_value_parts[1].parse::().map_err(|e| { - AgentError::InvalidHttpResponse(format!( - "Invalid total_length in '{}': {}", - content_range_str, e - )) - })?; - - let rv = ContentRangeValues { - range_begin, - range_end, - total_length, - }; - if rv.range_begin > rv.range_end - || rv.range_begin >= rv.total_length - || rv.range_end >= rv.total_length - { - Err(AgentError::InvalidHttpResponse(format!( - "inconsistent Content-Range header {}: {:?}", - content_range_str, rv - ))) - } else { - Ok(rv) - } -} - -fn get_content_range_header_str( - response_headers: &Vec>, -) -> Result { - for HeaderField(name, value) in response_headers { - if name.eq_ignore_ascii_case(http::header::CONTENT_RANGE.as_ref()) { - return Ok(value.to_string()); - } - } - Err(AgentError::InvalidHttpResponse( - "missing Content-Range header in 206 response".to_string(), - )) -} - -fn get_content_range_values( - response_headers: &Vec>, - fetched_length: usize, -) -> Result { - let str_value = get_content_range_header_str(response_headers)?; - let range_values = parse_content_range_header_str(&str_value)?; - - if range_values.range_begin > fetched_length { - return Err(AgentError::InvalidHttpResponse(format!( - "chunk out-of-order: range_begin={} is larger than expected begin={} ", - range_values.range_begin, fetched_length - ))); - } - if range_values.range_end < fetched_length { - return Err(AgentError::InvalidHttpResponse(format!( - "chunk out-of-order: range_end={} is smaller than length fetched so far={} ", - range_values.range_begin, fetched_length - ))); - } - Ok(range_values) -} - -fn get_initial_stream_state<'a>( - http_request: HttpRequest<'a>, - canister_id: Principal, - response_headers: &Vec>, - skip_verification: bool, -) -> Result, AgentError> { - let range_values = get_content_range_values(response_headers, 0)?; - - Ok(StreamState { - http_request, - canister_id, - total_length: range_values.total_length, - fetched_length: range_values - .range_end - .saturating_sub(range_values.range_begin) - + 1, - skip_verification, - }) -} - -fn create_206_body_stream( - agent: Agent, - stream_state: StreamState<'static>, - initial_body: Vec, -) -> ResponseBodyStream { - let chunks_stream = create_206_stream(agent, Some(stream_state)) - .map(|chunk| chunk.map(|(body, _)| Frame::data(Bytes::from(body)))); - - let body_stream = stream::once(async move { Ok(Frame::data(Bytes::from(initial_body))) }) - .chain(chunks_stream) - .take(MAX_HTTP_REQUEST_STREAM_CALLBACK_CALL_COUNT) - .map(|x| async move { x }) - .buffered(STREAM_CALLBACK_BUFFER); - - ResponseBodyStream::new(Box::pin(body_stream)) -} - -fn create_206_stream( - agent: Agent, - maybe_stream_state: Option, -) -> impl Stream, Option), AgentError>> { - futures::stream::try_unfold( - (agent, maybe_stream_state), - |(agent, maybe_stream_state)| async move { - let Some(stream_state) = maybe_stream_state else { - return Ok(None); - }; - let canister = HttpRequestCanister::create(&agent, stream_state.canister_id); - let next_chunk_begin = stream_state.fetched_length; - - let range_header = ("Range".to_string(), format!("bytes={}-", next_chunk_begin)); - let mut updated_headers = stream_state.http_request.headers().to_vec(); - updated_headers.push(range_header.clone()); - let headers = updated_headers - .iter() - .map(|(name, value)| HeaderField(name.into(), value.into())) - .collect::>() - .into_iter(); - let query_result = canister - .http_request( - &stream_state.http_request.method(), - &stream_state.http_request.url(), - headers, - &stream_state.http_request.body(), - Some(&u16::from(MAX_VERIFICATION_VERSION)), - ) - .call() - .await; - let agent_response = match query_result { - Ok((response,)) => response, - Err(e) => return Err(e), - }; - let range_values = - get_content_range_values(&agent_response.headers, stream_state.fetched_length)?; - let new_bytes_begin = stream_state - .fetched_length - .saturating_sub(range_values.range_begin); - let chunk_length = range_values - .range_end - .saturating_sub(stream_state.fetched_length) - + 1; - let current_fetched_length = stream_state.fetched_length + chunk_length; - // Verify the chunk from the range response. - if agent_response.streaming_strategy.is_some() { - return Err(AgentError::InvalidHttpResponse( - "unexpected StreamingStrategy".to_string(), - )); - } - - let Ok(status_code) = StatusCode::from_u16(agent_response.status_code) else { - return Err(AgentError::InvalidHttpResponse(format!( - "Invalid canister response status code: {}", - agent_response.status_code - ))); - }; - let response = HttpResponse::builder() - .with_status_code(status_code) - .with_headers( - agent_response - .headers - .iter() - .map(|HeaderField(k, v)| (k.to_string(), v.to_string())) - .collect(), - ) - .with_body(agent_response.body.clone()) - .build(); - let mut http_request = stream_state.http_request.clone(); - http_request.headers_mut().push(range_header); - let validation_result = validate( - &agent, - &stream_state.canister_id, - http_request, - response, - stream_state.skip_verification, - ); - - if let Err(e) = validation_result { - return Err(AgentError::InvalidHttpResponse(format!( - "CertificateVerificationFailed for a chunk starting at {}, error: {}", - stream_state.fetched_length, e - ))); - } - let maybe_new_state = if current_fetched_length < stream_state.total_length { - Some(StreamState { - fetched_length: current_fetched_length, - ..stream_state - }) - } else { - None - }; - Ok(Some(( - ( - agent_response.body[new_bytes_begin..].to_vec(), - maybe_new_state.clone(), - ), - (agent, maybe_new_state), - ))) - }, - ) -} - -#[cfg(test)] -mod tests { - use super::*; - use assert_matches::assert_matches; - use std::borrow::Cow; - - #[test] - fn should_parse_content_range_header_str() { - let header_values = [ - ContentRangeValues { - range_begin: 0, - range_end: 0, - total_length: 1, - }, - ContentRangeValues { - range_begin: 100, - range_end: 2000, - total_length: 3000, - }, - ContentRangeValues { - range_begin: 10_000, - range_end: 300_000, - total_length: 500_000, - }, - ]; - for v in header_values { - let input = format!("bytes {}-{}/{}", v.range_begin, v.range_end, v.total_length); - let result = parse_content_range_header_str(&input); - let output = result.unwrap_or_else(|_| panic!("failed parsing '{}'", input)); - assert_eq!(v.range_begin, output.range_begin); - assert_eq!(v.range_end, output.range_end); - assert_eq!(v.total_length, output.total_length); - } - } - - #[test] - fn should_fail_parse_content_range_header_str_on_malformed_input() { - let malformed_inputs = [ - "byte 1-2/3", - "bites 2-4/8", - "bytes 100-200/asdf", - "bytes 12345", - "something else", - "bytes dead-beef/123456", - ]; - for input in malformed_inputs { - let result = parse_content_range_header_str(input); - assert_matches!(result, Err(e) if format!("{}", e).contains("Invalid ")); - } - } - - #[test] - fn should_fail_parse_content_range_header_str_on_inconsistent_input() { - let inconsistent_inputs = ["bytes 100-200/190", "bytes 200-150/400", "bytes 100-110/40"]; - for input in inconsistent_inputs { - let result = parse_content_range_header_str(input); - assert_matches!(result, Err(e) if format!("{}", e).contains("inconsistent Content-Range header")); - } - } - - #[test] - fn should_get_initial_stream_state() { - let http_request = HttpRequest::get("http://example.com/some_file") - .with_headers(vec![("Xyz".to_string(), "some value".to_string())]) - .with_body(vec![42]) - .build(); - let canister_id = Principal::from_slice(&[1, 2, 3, 4]); - let response_headers = vec![HeaderField( - Cow::from("Content-Range"), - Cow::from("bytes 0-2/10"), // fetched 3 bytes, total length is 10 - )]; - let skip_verification = false; - let state = get_initial_stream_state( - http_request.clone(), - canister_id, - &response_headers, - skip_verification, - ) - .expect("failed constructing StreamState"); - assert_eq!(state.http_request, http_request); - assert_eq!(state.canister_id, canister_id); - assert_eq!(state.fetched_length, 3); - assert_eq!(state.total_length, 10); - assert_eq!(state.skip_verification, skip_verification); - } - - #[test] - fn should_fail_get_initial_stream_state_without_content_range_header() { - let http_request = HttpRequest::get("http://example.com/some_file") - .with_headers(vec![("Xyz".to_string(), "some value".to_string())]) - .with_body(vec![42]) - .build(); - let canister_id = Principal::from_slice(&[1, 2, 3, 4]); - let response_headers = vec![HeaderField( - Cow::from("other header"), - Cow::from("other value"), - )]; - let result = get_initial_stream_state(http_request, canister_id, &response_headers, false); - assert_matches!(result, Err(e) if format!("{}", e).contains("missing Content-Range header")); - } - - #[test] - fn should_fail_get_initial_stream_state_with_malformed_content_range_header() { - let http_request = HttpRequest::get("http://example.com/some_file") - .with_headers(vec![("Xyz".to_string(), "some value".to_string())]) - .with_body(vec![42]) - .build(); - let canister_id = Principal::from_slice(&[1, 2, 3, 4]); - let response_headers = vec![HeaderField( - Cow::from("Content-Range"), - Cow::from("bytes 42/10"), - )]; - let result = get_initial_stream_state(http_request, canister_id, &response_headers, false); - assert_matches!(result, Err(e) if format!("{}", e).contains("Invalid bytes spec in Content-Range header")); - } - - #[test] - fn should_fail_get_initial_stream_state_with_inconsistent_content_range_header() { - let http_request = HttpRequest::get("http://example.com/some_file") - .with_headers(vec![("Xyz".to_string(), "some value".to_string())]) - .with_body(vec![42]) - .build(); - let canister_id = Principal::from_slice(&[1, 2, 3, 4]); - let response_headers = vec![HeaderField( - Cow::from("Content-Range"), - Cow::from("bytes 40-100/90"), - )]; - let result = get_initial_stream_state(http_request, canister_id, &response_headers, false); - assert_matches!(result, Err(e) if format!("{}", e).contains("inconsistent Content-Range header")); - } -} diff --git a/packages/ic-http-gateway/test-container/Dockerfile b/packages/ic-http-gateway/test-container/Dockerfile deleted file mode 100644 index 97dca8b..0000000 --- a/packages/ic-http-gateway/test-container/Dockerfile +++ /dev/null @@ -1,23 +0,0 @@ -FROM python:3.13.0-bookworm - -# Prevents Python from buffering stdout and stderr -ENV PYTHONUNBUFFERED=1 - -# Set the working directory in the container -WORKDIR /app - -# Move everything into the container -COPY . . - -# Install dependencies -RUN pip install -r requirements.txt - -# expose the server port -EXPOSE 8000 - -# a health check so we can wait for testcontainers -HEALTHCHECK --interval=1s --timeout=1s --start-period=1s --retries=3 \ - CMD curl --fail http://localhost:8000/healthcheck || exit 1 - -# Run the flask server -CMD ["python", "app.py"] diff --git a/packages/ic-http-gateway/test-container/app.py b/packages/ic-http-gateway/test-container/app.py deleted file mode 100644 index b5efbde..0000000 --- a/packages/ic-http-gateway/test-container/app.py +++ /dev/null @@ -1,18 +0,0 @@ -''' -a simple http server that returns 429s for all post requests -''' -from flask import Flask, request, jsonify - -app = Flask(__name__) - -@app.route('/healthcheck', methods=['GET']) -def healthcheck(): - return "ok" - -@app.route('/', methods=['POST']) -def handle_post(any_path): - return "You're making too many requests", 429 - -if __name__ == '__main__': - app.run('0.0.0.0', port=8000) - diff --git a/packages/ic-http-gateway/test-container/requirements.txt b/packages/ic-http-gateway/test-container/requirements.txt deleted file mode 100644 index 4a51173..0000000 --- a/packages/ic-http-gateway/test-container/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -blinker==1.9.0 -click==8.1.7 -Flask==3.1.3 -itsdangerous==2.2.0 -Jinja2==3.1.4 -MarkupSafe==3.0.1 -Werkzeug==3.1.6 diff --git a/packages/ic-http-gateway/tests/custom_assets.rs b/packages/ic-http-gateway/tests/custom_assets.rs deleted file mode 100644 index d15e516..0000000 --- a/packages/ic-http-gateway/tests/custom_assets.rs +++ /dev/null @@ -1,130 +0,0 @@ -use bytes::Bytes; -use http::Request; -use http_body_util::BodyExt; -use ic_agent::Agent; -use ic_http_gateway::{HttpGatewayClient, HttpGatewayRequestArgs, HttpGatewayResponseMetadata}; -use pocket_ic::PocketIcBuilder; - -mod utils; - -#[test] -fn test_custom_assets_index_html() { - let rt = tokio::runtime::Runtime::new().unwrap(); - let wasm_bytes = rt.block_on(async { utils::load_custom_assets_wasm().await }); - - let pic = PocketIcBuilder::new() - .with_nns_subnet() - .with_application_subnet() - .build(); - - let canister_id = pic.create_canister(); - pic.add_cycles(canister_id, 2_000_000_000_000_000); - pic.install_canister(canister_id, wasm_bytes, vec![], None); - - let url = pic.auto_progress(); - - let agent = Agent::builder().with_url(url).build().unwrap(); - rt.block_on(async { - agent.fetch_root_key().await.unwrap(); - }); - - let http_gateway = HttpGatewayClient::builder() - .with_agent(agent) - .build() - .unwrap(); - - let response = rt.block_on(async { - http_gateway - .request(HttpGatewayRequestArgs { - canister_id, - canister_request: Request::builder().uri("/").body(Bytes::new()).unwrap(), - }) - .send() - .await - }); - - let index_html = b"Hello, world!"; - let response_headers = response - .canister_response - .headers() - .iter() - .map(|(k, v)| (k.as_str(), v.to_str().unwrap())) - .collect::>(); - - assert_eq!(response.canister_response.status(), 200); - - // check that the response contains the certificate headers - assert!( - contains_header("ic-certificate", response_headers.clone()), - "response does not contain 'ic-certificate' header" - ); - - assert!( - contains_header("ic-certificateexpression", response_headers.clone()), - "response does not contain 'ic-certificateexpression' header" - ); - - // remove certificate headers before checking the certified headers - let certified_headers: Vec<(&str, &str)> = response_headers - .iter() - .filter(|(key, _)| *key != "ic-certificate" && *key != "ic-certificateexpression") - .cloned() // To convert from iterator of references to an iterator of owned values - .collect(); - - assert_eq!( - certified_headers, - vec![ - ("content-length", index_html.len().to_string().as_str()), - ("strict-transport-security", "max-age=31536000; includeSubDomains"), - ("x-frame-options", "DENY"), - ("x-content-type-options", "nosniff"), - ("content-security-policy", "default-src 'self'; form-action 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests; block-all-mixed-content"), - ("referrer-policy", "no-referrer"), - ("permissions-policy", "accelerometer=(),ambient-light-sensor=(),autoplay=(),battery=(),camera=(),display-capture=(),document-domain=(),encrypted-media=(),fullscreen=(),gamepad=(),geolocation=(),gyroscope=(),layout-animations=(self),legacy-image-formats=(self),magnetometer=(),microphone=(),midi=(),oversized-images=(self),payment=(),picture-in-picture=(),publickey-credentials-get=(),speaker-selection=(),sync-xhr=(self),unoptimized-images=(self),unsized-media=(self),usb=(),screen-wake-lock=(),web-share=(),xr-spatial-tracking=()"), - ("cross-origin-embedder-policy", "require-corp"), - ("cross-origin-opener-policy", "same-origin"), - ("cache-control", "public, no-cache, no-store"), - ("content-type", "text/html"), - ] - ); - - rt.block_on(async { - let body = response - .canister_response - .into_body() - .collect() - .await - .unwrap() - .to_bytes() - .to_vec(); - - assert_eq!(body, index_html); - }); - - assert_response_metadata( - response.metadata, - HttpGatewayResponseMetadata { - upgraded_to_update_call: false, - response_verification_version: Some(2), - internal_error: None, - }, - ); -} - -fn assert_response_metadata( - response_metadata: HttpGatewayResponseMetadata, - expected_response_metadata: HttpGatewayResponseMetadata, -) { - assert_eq!( - response_metadata.upgraded_to_update_call, - expected_response_metadata.upgraded_to_update_call - ); - assert_eq!( - response_metadata.response_verification_version, - expected_response_metadata.response_verification_version - ); -} - -fn contains_header(header_name: &str, headers: Vec<(&str, &str)>) -> bool { - headers.iter().any(|(key, _)| *key == header_name) -} diff --git a/packages/ic-http-gateway/tests/protocol_error_tests.rs b/packages/ic-http-gateway/tests/protocol_error_tests.rs deleted file mode 100644 index 96d28f8..0000000 --- a/packages/ic-http-gateway/tests/protocol_error_tests.rs +++ /dev/null @@ -1,94 +0,0 @@ -use bytes::Bytes; -use http::{status::StatusCode, Request}; -use ic_agent::{export::Principal, Agent}; -use ic_http_gateway::{HttpGatewayClient, HttpGatewayRequestArgs}; -use reqwest::Client; -use std::{env, error::Error, process::Command, str::FromStr}; -use testcontainers::{ - core::{IntoContainerPort, WaitFor}, - runners::AsyncRunner, - GenericImage, -}; - -const IMAGE_NAME: &str = "ic-mock-busy-replica"; -const IMAGE_TAG: &str = "latest"; - -fn build_gateway_image() -> Result<(), Box> { - let cwd = env::var("CARGO_MANIFEST_DIR")?; - - let output = Command::new("docker") - .current_dir(format!("{cwd}/test-container")) - .arg("build") - .arg("--file") - .arg("Dockerfile") - .arg("--force-rm") - .arg("--tag") - .arg(format!("{IMAGE_NAME}:{IMAGE_TAG}")) - .arg(".") - .output()?; - - if !output.status.success() { - eprintln!("stderr: {}", String::from_utf8(output.stderr)?); - return Err("unable to build mock busy replica image.".into()); - } - - Ok(()) -} - -#[tokio::test] -async fn test_rate_limiting_error() -> Result<(), Box> { - build_gateway_image()?; - - // run the mock backend container - let container = GenericImage::new(IMAGE_NAME, IMAGE_TAG) - .with_exposed_port(8000.tcp()) - .with_wait_for(WaitFor::healthcheck()) - .start() - .await?; - - // Retrieve the mapped port - let backend_port = container.get_host_port_ipv4(8000).await?; - let backend_host = container.get_host().await?.to_string(); - - // Check that the mock canister is up - let backend_base_url = format!("http://{}:{}", backend_host, backend_port); - let healthcheck_url = format!("{}/healthcheck", backend_base_url); - let response = Client::new().get(&healthcheck_url).send().await?; - assert_eq!( - response.status().as_u16(), - 200, - "Expected to receive 200 from /healthcheck but received {}", - response.status().as_u16() - ); - - // Make a gateway - let agent = Agent::builder().with_url(backend_base_url).build().unwrap(); - let http_gateway = HttpGatewayClient::builder() - .with_agent(agent) - .build() - .unwrap(); - - // Fake a `GET /example` request coming into the gateway - let canister_request = Request::builder() - .uri("/example") - .method("GET") - .body(Bytes::new()) - .unwrap(); - - let gateway_response = http_gateway - .request(HttpGatewayRequestArgs { - canister_id: Principal::from_str("qoctq-giaaa-aaaaa-aaaea-cai")?, - canister_request, - }) - .send() - .await; - - assert_eq!( - gateway_response.canister_response.status(), - StatusCode::TOO_MANY_REQUESTS, - "Expected to receive a 429 from the node but received {}", - gateway_response.canister_response.status() - ); - - Ok(()) -} diff --git a/packages/ic-http-gateway/tests/range_request_stream.rs b/packages/ic-http-gateway/tests/range_request_stream.rs deleted file mode 100644 index 71aa8a4..0000000 --- a/packages/ic-http-gateway/tests/range_request_stream.rs +++ /dev/null @@ -1,521 +0,0 @@ -use assert_matches::assert_matches; -use bytes::Bytes; -use http::Request; -use http_body_util::BodyExt; -use ic_agent::hash_tree::Hash; -use ic_agent::Agent; -use ic_http_gateway::{HttpGatewayClient, HttpGatewayRequestArgs, HttpGatewayResponseMetadata}; -use pocket_ic::PocketIcBuilder; -use rand_chacha::rand_core::{RngCore, SeedableRng}; -use rand_chacha::ChaCha20Rng; -use rstest::*; -use sha2::{Digest, Sha256}; -use std::cmp::min; - -mod utils; - -const ASSET_CHUNK_SIZE: usize = 2_000_000; - -const ONE_CHUNK_ASSET_LEN: usize = ASSET_CHUNK_SIZE; -const TWO_CHUNKS_ASSET_LEN: usize = ASSET_CHUNK_SIZE + 1; -const SIX_CHUNKS_ASSET_LEN: usize = 5 * ASSET_CHUNK_SIZE + 12; -const TEN_CHUNKS_ASSET_LEN: usize = 10 * ASSET_CHUNK_SIZE; - -const ONE_CHUNK_ASSET_NAME: &str = "long_asset_one_chunk"; -const TWO_CHUNKS_ASSET_NAME: &str = "long_asset_two_chunks"; -const SIX_CHUNKS_ASSET_NAME: &str = "long_asset_six_chunks"; -const TEN_CHUNKS_ASSET_NAME: &str = "long_asset_ten_chunks"; - -pub fn hash(data: T) -> Hash -where - T: AsRef<[u8]>, -{ - let mut hasher = Sha256::new(); - hasher.update(data); - hasher.finalize().into() -} - -fn long_asset_body(asset_name: &str) -> Vec { - let asset_length = match asset_name { - ONE_CHUNK_ASSET_NAME => ONE_CHUNK_ASSET_LEN, - TWO_CHUNKS_ASSET_NAME => TWO_CHUNKS_ASSET_LEN, - SIX_CHUNKS_ASSET_NAME => SIX_CHUNKS_ASSET_LEN, - TEN_CHUNKS_ASSET_NAME => TEN_CHUNKS_ASSET_LEN, - _ => ASSET_CHUNK_SIZE * 3 + 1, - }; - let mut rng = ChaCha20Rng::from_seed(hash(asset_name)); - let mut body = vec![0u8; asset_length]; - rng.fill_bytes(&mut body); - body -} - -#[rstest] -#[case(TWO_CHUNKS_ASSET_NAME)] -#[case(SIX_CHUNKS_ASSET_NAME)] -#[case(TEN_CHUNKS_ASSET_NAME)] -fn test_long_asset_request_yields_entire_asset(#[case] asset_name: &str) { - let rt = tokio::runtime::Runtime::new().unwrap(); - let wasm_bytes = rt.block_on(async { utils::load_custom_assets_wasm().await }); - - let pic = PocketIcBuilder::new() - .with_nns_subnet() - .with_application_subnet() - .build(); - - let canister_id = pic.create_canister(); - pic.add_cycles(canister_id, 2_000_000_000_000_000); - pic.install_canister(canister_id, wasm_bytes, vec![], None); - - let url = pic.auto_progress(); - - let agent = Agent::builder().with_url(url).build().unwrap(); - rt.block_on(async { - agent.fetch_root_key().await.unwrap(); - }); - - let http_gateway = HttpGatewayClient::builder() - .with_agent(agent) - .build() - .unwrap(); - - let response = rt.block_on(async { - http_gateway - .request(HttpGatewayRequestArgs { - canister_id, - canister_request: Request::builder() - .uri(format!("/{asset_name}")) - .body(Bytes::new()) - .unwrap(), - }) - .send() - .await - }); - - let response_headers = response - .canister_response - .headers() - .iter() - .map(|(k, v)| (k.as_str(), v.to_str().unwrap())) - .collect::>(); - - assert_eq!(response.canister_response.status(), 200); - - // check that the response contains the certificate headers - assert!( - contains_header("ic-certificate", response_headers.clone()), - "response does not contain 'ic-certificate' header" - ); - - assert!( - contains_header("ic-certificateexpression", response_headers.clone()), - "response does not contain 'ic-certificateexpression' header" - ); - - // remove certificate headers before checking the certified headers - let certified_headers: Vec<(&str, &str)> = response_headers - .iter() - .filter(|(key, _)| *key != "ic-certificate" && *key != "ic-certificateexpression") - .cloned() // To convert from iterator of references to an iterator of owned values - .collect(); - - let expected_body = long_asset_body(asset_name); - - assert_eq!( - certified_headers, - vec![ - ("strict-transport-security", "max-age=31536000; includeSubDomains"), - ("x-frame-options", "DENY"), - ("x-content-type-options", "nosniff"), - ("content-security-policy", "default-src 'self'; form-action 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests; block-all-mixed-content"), - ("referrer-policy", "no-referrer"), - ("permissions-policy", "accelerometer=(),ambient-light-sensor=(),autoplay=(),battery=(),camera=(),display-capture=(),document-domain=(),encrypted-media=(),fullscreen=(),gamepad=(),geolocation=(),gyroscope=(),layout-animations=(self),legacy-image-formats=(self),magnetometer=(),microphone=(),midi=(),oversized-images=(self),payment=(),picture-in-picture=(),publickey-credentials-get=(),speaker-selection=(),sync-xhr=(self),unoptimized-images=(self),unsized-media=(self),usb=(),screen-wake-lock=(),web-share=(),xr-spatial-tracking=()"), - ("cross-origin-embedder-policy", "require-corp"), - ("cross-origin-opener-policy", "same-origin"), - ("cache-control", "public, no-cache, no-store"), - ("content-type", "application/octet-stream"), - ("content-length", expected_body.len().to_string().as_str()), - ] - ); - - rt.block_on(async { - let body = response - .canister_response - .into_body() - .collect() - .await - .unwrap() - .to_bytes() - .to_vec(); - - assert_eq!(body, expected_body); - }); - - assert_response_metadata( - response.metadata, - HttpGatewayResponseMetadata { - upgraded_to_update_call: false, - response_verification_version: Some(2), - internal_error: None, - }, - ); -} - -#[rstest] -#[case(TWO_CHUNKS_ASSET_NAME, 0)] -#[case(TWO_CHUNKS_ASSET_NAME, 1)] -#[case(SIX_CHUNKS_ASSET_NAME, 3)] -#[case(SIX_CHUNKS_ASSET_NAME, 5)] -fn test_corrupted_long_asset_request_fails( - #[case] asset_name: &str, - #[case] corrupted_chunk_index: usize, -) { - let rt = tokio::runtime::Runtime::new().unwrap(); - let wasm_bytes = rt.block_on(async { utils::load_custom_assets_wasm().await }); - - let pic = PocketIcBuilder::new() - .with_nns_subnet() - .with_application_subnet() - .build(); - - let canister_id = pic.create_canister(); - pic.add_cycles(canister_id, 2_000_000_000_000_000); - pic.install_canister(canister_id, wasm_bytes, vec![], None); - - let url = pic.auto_progress(); - - let agent = Agent::builder().with_url(url).build().unwrap(); - rt.block_on(async { - agent.fetch_root_key().await.unwrap(); - }); - - let http_gateway = HttpGatewayClient::builder() - .with_agent(agent) - .build() - .unwrap(); - - let response = rt.block_on(async { - http_gateway - .request(HttpGatewayRequestArgs { - canister_id, - canister_request: Request::builder() - .header( - "Test-CorruptChunkAtIndex", - corrupted_chunk_index.to_string(), - ) - .uri(format!("/{asset_name}")) - .body(Bytes::new()) - .unwrap(), - }) - .send() - .await - }); - let expected_status = match corrupted_chunk_index { - 0 => 500, - _ => 200, - }; - assert_eq!(response.canister_response.status(), expected_status); - rt.block_on(async { - let body_result = response.canister_response.into_body().collect().await; - if corrupted_chunk_index == 0 { - // If the first chunk is corrupted, the status indicates the failure - // and the full body contains the error message. - assert_matches!(body_result, - Ok(body) if format!("{:?}", body).contains( - "Response verification failed: The hash of the request and response was not found in the tree") - ); - } else { - // If the first chunk is ok, but some other chunk is corrupted, the response has 200-status, - // but fetching the full body fails with an error for the corrupted chunk. - assert_matches!(body_result, - Err(e) if e.to_string().contains(&format!( - "CertificateVerificationFailed for a chunk starting at {}", - ASSET_CHUNK_SIZE*corrupted_chunk_index)) - ); - } - }); -} - -#[rstest] -#[case(TWO_CHUNKS_ASSET_NAME, 0)] -#[case(SIX_CHUNKS_ASSET_NAME, 0)] -#[case(SIX_CHUNKS_ASSET_NAME, 1)] -#[case(SIX_CHUNKS_ASSET_NAME, 3)] -fn test_long_asset_with_chunks_out_of_order_fails( - #[case] asset_name: &str, - #[case] chunk_to_swap: usize, -) { - let rt = tokio::runtime::Runtime::new().unwrap(); - let wasm_bytes = rt.block_on(async { utils::load_custom_assets_wasm().await }); - - let pic = PocketIcBuilder::new() - .with_nns_subnet() - .with_application_subnet() - .build(); - - let canister_id = pic.create_canister(); - pic.add_cycles(canister_id, 2_000_000_000_000_000); - pic.install_canister(canister_id, wasm_bytes, vec![], None); - - let url = pic.auto_progress(); - - let agent = Agent::builder().with_url(url).build().unwrap(); - rt.block_on(async { - agent.fetch_root_key().await.unwrap(); - }); - - let http_gateway = HttpGatewayClient::builder() - .with_agent(agent) - .build() - .unwrap(); - - let response = rt.block_on(async { - http_gateway - .request(HttpGatewayRequestArgs { - canister_id, - canister_request: Request::builder() - .header("Test-SwapChunkAtIndexWithNext", chunk_to_swap.to_string()) - .uri(format!("/{asset_name}")) - .body(Bytes::new()) - .unwrap(), - }) - .send() - .await - }); - let expected_status = match chunk_to_swap { - 0 => 500, - _ => 200, - }; - assert_eq!(response.canister_response.status(), expected_status); - rt.block_on(async { - let body_result = response.canister_response.into_body().collect().await; - if chunk_to_swap == 0 { - // If the first chunk is swapped (i.e. instead of getting an - // initial portion of the asset we got a different one, - // the status indicates the failure and the full body contains - // the error message. In this case the verification fails, - // as we're getting a chunk for which the certification includes - // "Range"-header, while the original request did not include - // that header (because it requested the entire asset). - assert_matches!(body_result, - Ok(body) if format!("{:?}", body).contains("Response verification failed") - ); - } else { - // If the first chunk is ok, but some other chunk is swapped, the response has 200-status, - // but fetching the full body fails with an error for the corrupted chunk. - assert_matches!(body_result, - Err(e) if e.to_string().contains(&format!( - "chunk out-of-order: range_begin={}", - ASSET_CHUNK_SIZE*(chunk_to_swap+1))) - ); - } - }); -} - -#[rstest] -#[case(TWO_CHUNKS_ASSET_NAME, 0)] -#[case(TWO_CHUNKS_ASSET_NAME, 1)] -#[case(SIX_CHUNKS_ASSET_NAME, 3)] -#[case(SIX_CHUNKS_ASSET_NAME, 5)] -fn test_corrupted_chunk_certificate_for_long_asset_request_fails( - #[case] asset_name: &str, - #[case] corrupted_chunk_index: usize, -) { - let rt = tokio::runtime::Runtime::new().unwrap(); - let wasm_bytes = rt.block_on(async { utils::load_custom_assets_wasm().await }); - - let pic = PocketIcBuilder::new() - .with_nns_subnet() - .with_application_subnet() - .build(); - - let canister_id = pic.create_canister(); - pic.add_cycles(canister_id, 2_000_000_000_000_000); - pic.install_canister(canister_id, wasm_bytes, vec![], None); - - let url = pic.auto_progress(); - - let agent = Agent::builder().with_url(url).build().unwrap(); - rt.block_on(async { - agent.fetch_root_key().await.unwrap(); - }); - - let http_gateway = HttpGatewayClient::builder() - .with_agent(agent) - .build() - .unwrap(); - - let response = rt.block_on(async { - http_gateway - .request(HttpGatewayRequestArgs { - canister_id, - canister_request: Request::builder() - .header( - "Test-CorruptCertificateAtIndex", - corrupted_chunk_index.to_string(), - ) - .uri(format!("/{asset_name}")) - .body(Bytes::new()) - .unwrap(), - }) - .send() - .await - }); - let expected_status = match corrupted_chunk_index { - 0 => 500, - _ => 200, - }; - assert_eq!(response.canister_response.status(), expected_status); - rt.block_on(async { - let body_result = response.canister_response.into_body().collect().await; - if corrupted_chunk_index == 0 { - // If the first chunk is corrupted, the status indicates the failure - // and the full body contains the error message. - let body = body_result.expect("failed getting full body").to_bytes(); - assert_matches!(String::from_utf8_lossy(&body), s if s.contains("Response verification failed")); - } else { - // If the first chunk is ok, but some other chunk is corrupted, the response has 200-status, - // but fetching the full body fails with an error for the corrupted chunk. - assert_matches!(body_result, - Err(e) if e.to_string().contains(&format!( - "CertificateVerificationFailed for a chunk starting at {}", - ASSET_CHUNK_SIZE*corrupted_chunk_index)) - ); - } - }); -} - -#[rstest] -#[case(TWO_CHUNKS_ASSET_NAME)] -#[case(SIX_CHUNKS_ASSET_NAME)] -fn test_range_request_yields_range_response(#[case] asset_name: &str) { - let rt = tokio::runtime::Runtime::new().unwrap(); - let wasm_bytes = rt.block_on(async { utils::load_custom_assets_wasm().await }); - - let pic = PocketIcBuilder::new() - .with_nns_subnet() - .with_application_subnet() - .build(); - - let canister_id = pic.create_canister(); - pic.add_cycles(canister_id, 2_000_000_000_000_000); - pic.install_canister(canister_id, wasm_bytes, vec![], None); - - let url = pic.auto_progress(); - - let agent = Agent::builder().with_url(url).build().unwrap(); - rt.block_on(async { - agent.fetch_root_key().await.unwrap(); - }); - - let http_gateway = HttpGatewayClient::builder() - .with_agent(agent) - .build() - .unwrap(); - - let response = rt.block_on(async { - http_gateway - .request(HttpGatewayRequestArgs { - canister_id, - canister_request: Request::builder() - .uri(format!("/{asset_name}")) - .header("Range", format!("bytes={}-", ASSET_CHUNK_SIZE)) - .body(Bytes::new()) - .unwrap(), - }) - .send() - .await - }); - - let expected_full_body = long_asset_body(asset_name); - let expected_response_body = - &expected_full_body[ASSET_CHUNK_SIZE..min(expected_full_body.len(), 2 * ASSET_CHUNK_SIZE)]; - let response_headers = response - .canister_response - .headers() - .iter() - .map(|(k, v)| (k.as_str(), v.to_str().unwrap())) - .collect::>(); - - assert_eq!(response.canister_response.status(), 206); - - // check that the response contains the certificate headers - assert!( - contains_header("ic-certificate", response_headers.clone()), - "response does not contain 'ic-certificate' header" - ); - - assert!( - contains_header("ic-certificateexpression", response_headers.clone()), - "response does not contain 'ic-certificateexpression' header" - ); - - // remove certificate headers before checking the certified headers - let certified_headers: Vec<(&str, &str)> = response_headers - .iter() - .filter(|(key, _)| *key != "ic-certificate" && *key != "ic-certificateexpression") - .cloned() // To convert from iterator of references to an iterator of owned values - .collect(); - - assert_eq!( - certified_headers, - vec![ - ("content-length", expected_response_body.len().to_string().as_str()), - ("strict-transport-security", "max-age=31536000; includeSubDomains"), - ("x-frame-options", "DENY"), - ("x-content-type-options", "nosniff"), - ("content-security-policy", "default-src 'self'; form-action 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests; block-all-mixed-content"), - ("referrer-policy", "no-referrer"), - ("permissions-policy", "accelerometer=(),ambient-light-sensor=(),autoplay=(),battery=(),camera=(),display-capture=(),document-domain=(),encrypted-media=(),fullscreen=(),gamepad=(),geolocation=(),gyroscope=(),layout-animations=(self),legacy-image-formats=(self),magnetometer=(),microphone=(),midi=(),oversized-images=(self),payment=(),picture-in-picture=(),publickey-credentials-get=(),speaker-selection=(),sync-xhr=(self),unoptimized-images=(self),unsized-media=(self),usb=(),screen-wake-lock=(),web-share=(),xr-spatial-tracking=()"), - ("cross-origin-embedder-policy", "require-corp"), - ("cross-origin-opener-policy", "same-origin"), - ("cache-control", "public, no-cache, no-store"), - ("content-type", "application/octet-stream"), - ("content-range", &format!( - "bytes {}-{}/{}", - ASSET_CHUNK_SIZE, - min(expected_full_body.len(), 2*ASSET_CHUNK_SIZE) - 1, - expected_full_body.len() - )) - ] - ); - - rt.block_on(async { - let body = response - .canister_response - .into_body() - .collect() - .await - .unwrap() - .to_bytes() - .to_vec(); - - assert_eq!(body, expected_response_body); - }); - - assert_response_metadata( - response.metadata, - HttpGatewayResponseMetadata { - upgraded_to_update_call: false, - response_verification_version: Some(2), - internal_error: None, - }, - ); -} - -fn assert_response_metadata( - response_metadata: HttpGatewayResponseMetadata, - expected_response_metadata: HttpGatewayResponseMetadata, -) { - assert_eq!( - response_metadata.upgraded_to_update_call, - expected_response_metadata.upgraded_to_update_call - ); - assert_eq!( - response_metadata.response_verification_version, - expected_response_metadata.response_verification_version - ); -} - -fn contains_header(header_name: &str, headers: Vec<(&str, &str)>) -> bool { - headers.iter().any(|(key, _)| *key == header_name) -} diff --git a/packages/ic-http-gateway/tests/utils/mod.rs b/packages/ic-http-gateway/tests/utils/mod.rs deleted file mode 100644 index b482cfd..0000000 --- a/packages/ic-http-gateway/tests/utils/mod.rs +++ /dev/null @@ -1,26 +0,0 @@ -use std::path::PathBuf; -use tokio::{fs::File, io::AsyncReadExt}; - -pub async fn load_custom_assets_wasm() -> Vec { - load_wasm("http_gateway_canister_custom_assets").await -} - -async fn load_wasm(canister: &str) -> Vec { - let file_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../.dfx/local/canisters") - .join(canister) - .join(format!("{}.wasm.gz", canister)); - - load_file(file_path).await -} - -async fn load_file(file_path: PathBuf) -> Vec { - let mut file = File::open(&file_path) - .await - .unwrap_or_else(|_| panic!("error opening file {:?}", file_path)); - - let mut buffer = Vec::new(); - file.read_to_end(&mut buffer).await.unwrap(); - - buffer -} From 12667745d7d1e7cd0b94ed83010fc1ce5eb2148c Mon Sep 17 00:00:00 2001 From: Andrew Battat Date: Thu, 12 Mar 2026 01:49:13 +0000 Subject: [PATCH 5/5] Fix build-and-test.yml --- .github/workflows/build-and-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 2baaf68..9f3a45f 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -34,7 +34,7 @@ jobs: run: cargo test --all-features # TODO(NODE-1907): Remove once ic-http-gateway is fully deprecated. - - name: Check compatibility shim crate + - name: Check compatibility shim crate run: cargo check -p ic-http-gateway --all-features - name: Build Cargo docs