diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d7df233..c720117d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -194,7 +194,12 @@ jobs: - uses: actions/checkout@v6 - uses: ./.github/actions/setup - name: Clone s3-tests - run: git clone --depth 1 https://github.com/ceph/s3-tests.git /tmp/s3-tests + run: | + . scripts/source-s3tests-ref.sh + git init /tmp/s3-tests + git -C /tmp/s3-tests remote add origin https://github.com/ceph/s3-tests.git + git -C /tmp/s3-tests fetch --depth 1 origin "$S3TESTS_REF" + git -C /tmp/s3-tests reset --hard FETCH_HEAD - name: Get s3-tests requirements hash id: s3tests-hash run: echo "hash=$(sha256sum /tmp/s3-tests/requirements.txt | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 27ba4dfa..45e18ac6 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -29,7 +29,7 @@ jobs: - name: Add .nojekyll file run: touch target/doc/.nojekyll - name: Upload artifact - uses: actions/upload-pages-artifact@v4 + uses: actions/upload-pages-artifact@v5 with: path: ./target/doc diff --git a/Cargo.lock b/Cargo.lock index b71bd010..1a432698 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -77,7 +77,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -88,7 +88,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -146,9 +146,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-config" -version = "1.8.15" +version = "1.8.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11493b0bad143270fb8ad284a096dd529ba91924c5409adeac856cc1bf047dbc" +checksum = "50f156acdd2cf55f5aa53ee416c4ac851cf1222694506c0b1f78c85695e9ca9d" dependencies = [ "aws-credential-types", "aws-runtime", @@ -183,9 +183,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.16.2" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", "zeroize", @@ -193,9 +193,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.39.1" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" dependencies = [ "cc", "cmake", @@ -205,9 +205,9 @@ dependencies = [ [[package]] name = "aws-runtime" -version = "1.7.2" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fc0651c57e384202e47153c1260b84a9936e19803d747615edf199dc3b98d17" +checksum = "5dcd93c82209ac7413532388067dce79be5a8780c1786e5fae3df22e4dee2864" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -233,9 +233,9 @@ dependencies = [ [[package]] name = "aws-sdk-s3" -version = "1.128.0" +version = "1.132.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99304b64672e0d81a3c100a589b93d9ef5e9c0ce12e21c848fd39e50f493c2a1" +checksum = "5575840a3a6b11f6011463ebe359320dfe5b67babb5e9b06fed6ddf809a9ab40" dependencies = [ "aws-credential-types", "aws-runtime", @@ -254,23 +254,23 @@ dependencies = [ "bytes", "fastrand", "hex", - "hmac 0.12.1", + "hmac 0.13.0", "http 0.2.12", "http 1.4.0", "http-body 1.0.1", "lru", "percent-encoding", "regex-lite", - "sha2 0.10.9", + "sha2 0.11.0", "tracing", "url", ] [[package]] name = "aws-sdk-sts" -version = "1.101.0" +version = "1.103.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab41ad64e4051ecabeea802d6a17845a91e83287e1dd249e6963ea1ba78c428a" +checksum = "c2249b81a2e73a8027c41c378463a81ec39b8510f184f2caab87de912af0f49b" dependencies = [ "aws-credential-types", "aws-runtime", @@ -293,9 +293,9 @@ dependencies = [ [[package]] name = "aws-sigv4" -version = "1.4.2" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0b660013a6683ab23797778e21f1f854744fdf05f68204b4cca4c8c04b5d1f4" +checksum = "68dc0b907359b120170613b5c09ccc61304eac3998ff6274b97d93ee6490115a" dependencies = [ "aws-credential-types", "aws-smithy-eventstream", @@ -306,13 +306,13 @@ dependencies = [ "crypto-bigint 0.5.5", "form_urlencoded", "hex", - "hmac 0.12.1", + "hmac 0.13.0", "http 0.2.12", "http 1.4.0", "p256", "percent-encoding", "ring", - "sha2 0.10.9", + "sha2 0.11.0", "subtle", "time", "tracing", @@ -332,9 +332,9 @@ dependencies = [ [[package]] name = "aws-smithy-checksums" -version = "0.64.6" +version = "0.64.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6750f3dd509b0694a4377f0293ed2f9630d710b1cebe281fa8bac8f099f88bc6" +checksum = "e9e8e65f4f81fcccdeb6c3eca2af17ac21d421a1786a26a394aecf421d616d3a" dependencies = [ "aws-smithy-http", "aws-smithy-types", @@ -344,10 +344,10 @@ dependencies = [ "http 1.4.0", "http-body 1.0.1", "http-body-util", - "md-5 0.10.6", + "md-5 0.11.0", "pin-project-lite", - "sha1 0.10.6", - "sha2 0.10.9", + "sha1 0.11.0", + "sha2 0.11.0", "tracing", ] @@ -410,10 +410,12 @@ dependencies = [ [[package]] name = "aws-smithy-json" -version = "0.62.5" +version = "0.62.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9648b0bb82a2eedd844052c6ad2a1a822d1f8e3adee5fbf668366717e428856a" +checksum = "517089205f18ab4adc5a3e02888cb139bbbbb2e168eac9f396216925d1fbeaf5" dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", ] @@ -438,15 +440,16 @@ dependencies = [ [[package]] name = "aws-smithy-runtime" -version = "1.10.3" +version = "1.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "028999056d2d2fd58a697232f9eec4a643cf73a71cf327690a7edad1d2af2110" +checksum = "b8e6f5caf6fea86f8c2206541ab5857cfcda9013426cdbe8fa0098b9e2d32182" dependencies = [ "aws-smithy-async", "aws-smithy-http", "aws-smithy-http-client", "aws-smithy-observability", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "bytes", "fastrand", @@ -463,11 +466,12 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api" -version = "1.11.6" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "876ab3c9c29791ba4ba02b780a3049e21ec63dabda09268b175272c3733a79e6" +checksum = "dc117c179ecf39a62a0a3f49f600e9ac26a7ad7dd172177999f83933af776c32" dependencies = [ "aws-smithy-async", + "aws-smithy-runtime-api-macros", "aws-smithy-types", "bytes", "http 0.2.12", @@ -478,11 +482,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "aws-smithy-schema" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7442cb268338f0eb8278140a107c046756aa01093d8ef5e99628d34ae09c94f5" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "http 1.4.0", +] + [[package]] name = "aws-smithy-types" -version = "1.4.7" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d73dbfbaa8e4bc57b9045137680b958d274823509a360abfd8e1d514d40c95c" +checksum = "056b66dbce2f81cc0c1e2b05bb402eb58f8a3530479d650efadd5bbae9a4050b" dependencies = [ "base64-simd", "bytes", @@ -525,9 +551,9 @@ dependencies = [ [[package]] name = "aws-types" -version = "1.3.14" +version = "1.3.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47c8323699dd9b3c8d5b3c13051ae9cdef58fd179957c882f8374dd8725962d9" +checksum = "2f4bbcaa9304ea40902d3d5f42a0428d1bd895a2b0f6999436fb279ffddc58ac" dependencies = [ "aws-credential-types", "aws-smithy-async", @@ -539,9 +565,9 @@ dependencies = [ [[package]] name = "axum" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "bytes", @@ -589,17 +615,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "backon" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" -dependencies = [ - "fastrand", - "gloo-timers", - "tokio", -] - [[package]] name = "backtrace" version = "0.3.76" @@ -645,9 +660,9 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "block-buffer" @@ -691,9 +706,9 @@ dependencies = [ [[package]] name = "bytestring" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "113b4343b5f6617e7ad401ced8de3cc8b012e73a594347c307b90db3e9271289" +checksum = "86566c496f2f47d9b8147a4c8b02ffdb69c919fe0c2b2e7195d22cbba0e635c9" dependencies = [ "bytes", ] @@ -706,9 +721,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.59" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "jobserver", @@ -716,12 +731,6 @@ dependencies = [ "shlex", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cfg-if" version = "1.0.4" @@ -741,17 +750,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", - "js-sys", "num-traits", - "wasm-bindgen", "windows-link", ] [[package]] name = "clap" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -771,9 +778,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck", "proc-macro2", @@ -814,7 +821,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -913,30 +920,13 @@ dependencies = [ "libc", ] -[[package]] -name = "crc" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" - [[package]] name = "crc-fast" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fd92aca2c6001b1bf5ba0ff84ee74ec8501b52bbef0cac80bf25a6c1d87a83d" +checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5" dependencies = [ - "crc", "digest 0.10.7", - "rustversion", "spin", ] @@ -998,9 +988,9 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ "hybrid-array", ] @@ -1047,13 +1037,13 @@ dependencies = [ [[package]] name = "digest" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.0", "const-oid 0.10.2", - "crypto-common 0.2.1", + "crypto-common 0.2.2", "ctutils", ] @@ -1140,14 +1130,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "fastrand" -version = "2.4.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a043dc74da1e37d6afe657061213aa6f425f855399a11d3463c6ecccc4dfda1f" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "ff" @@ -1359,18 +1349,6 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" -[[package]] -name = "gloo-timers" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" -dependencies = [ - "futures-channel", - "futures-core", - "js-sys", - "wasm-bindgen", -] - [[package]] name = "group" version = "0.12.1" @@ -1384,9 +1362,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", @@ -1427,6 +1405,12 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" @@ -1464,16 +1448,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest 0.11.2", -] - -[[package]] -name = "home" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" -dependencies = [ - "windows-sys 0.61.2", + "digest 0.11.3", ] [[package]] @@ -1545,9 +1520,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hybrid-array" -version = "0.4.10" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" dependencies = [ "typenum", ] @@ -1576,20 +1551,18 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http 1.4.0", "hyper", "hyper-util", "rustls", "rustls-native-certs", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", - "webpki-roots", ] [[package]] @@ -1740,9 +1713,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -1750,12 +1723,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.1" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -1766,16 +1739,6 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1790,24 +1753,26 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.23" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" dependencies = [ "jiff-static", "jiff-tzdb-platform", + "js-sys", "log", "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.61.2", + "wasm-bindgen", + "windows-sys 0.60.2", ] [[package]] name = "jiff-static" -version = "0.2.23" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" dependencies = [ "proc-macro2", "quote", @@ -1831,27 +1796,32 @@ dependencies = [ [[package]] name = "jni" -version = "0.21.1" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "cesu8", "cfg-if", "combine", - "jni-sys 0.3.1", + "jni-macros", + "jni-sys", "log", - "thiserror 1.0.69", + "simd_cesu8", + "thiserror", "walkdir", - "windows-sys 0.45.0", + "windows-link", ] [[package]] -name = "jni-sys" -version = "0.3.1" +name = "jni-macros" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ - "jni-sys 0.4.1", + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", ] [[package]] @@ -1885,9 +1855,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.94" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ "cfg-if", "futures-util", @@ -1909,9 +1879,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.184" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libm" @@ -1942,9 +1912,9 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru" -version = "0.16.3" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" dependencies = [ "hashbrown 0.16.1", ] @@ -1987,7 +1957,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", - "digest 0.11.2", + "digest 0.11.3", +] + +[[package]] +name = "mea" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6747f54621d156e1b47eb6b25f39a941b9fc347f98f67d25d8881ff99e8ed832" +dependencies = [ + "slab", ] [[package]] @@ -2047,14 +2026,14 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" @@ -2110,44 +2089,73 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "opendal" -version = "0.55.0" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97b31d3d8e99a85d83b73ec26647f5607b80578ed9375810b6e44ffa3590a236" +dependencies = [ + "opendal-core", + "opendal-service-s3", +] + +[[package]] +name = "opendal-core" +version = "0.56.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d075ab8a203a6ab4bc1bce0a4b9fe486a72bf8b939037f4b78d95386384bc80a" +checksum = "1849dd2687e173e776d3af5fce1ba3ae47b9dd37a09d1c4deba850ef45fe00ca" dependencies = [ "anyhow", - "backon", "base64", "bytes", - "crc32c", "futures", - "getrandom 0.2.17", "http 1.4.0", "http-body 1.0.1", "jiff", "log", "md-5 0.10.6", + "mea", "percent-encoding", "quick-xml 0.38.4", - "reqsign", - "reqwest 0.12.28", + "reqsign-core", + "reqwest", "serde", "serde_json", "tokio", "url", "uuid", + "web-time", +] + +[[package]] +name = "opendal-service-s3" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dadddeb9bb50b0d30927dd914c298c4ddca47e4c1cfa7674d311f0cf9b051c8" +dependencies = [ + "base64", + "bytes", + "crc32c", + "http 1.4.0", + "log", + "md-5 0.10.6", + "opendal-core", + "quick-xml 0.38.4", + "reqsign-aws-v4", + "reqsign-core", + "reqsign-file-read-tokio", + "serde", + "url", ] [[package]] name = "openssl" -version = "0.10.76" +version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ "bitflags", "cfg-if", "foreign-types", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -2171,9 +2179,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.112" +version = "0.9.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ "cc", "libc", @@ -2279,9 +2287,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "portable-atomic" @@ -2291,9 +2299,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" dependencies = [ "portable-atomic", ] @@ -2361,6 +2369,16 @@ dependencies = [ "serde", ] +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quinn" version = "0.11.9" @@ -2375,7 +2393,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.18", + "thiserror", "tokio", "tracing", "web-time", @@ -2391,13 +2409,13 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror", "tinyvec", "tracing", "web-time", @@ -2440,35 +2458,14 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha 0.9.0", + "rand_chacha", "rand_core 0.9.5", ] -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - [[package]] name = "rand_chacha" version = "0.9.0" @@ -2542,84 +2539,69 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] -name = "reqsign" -version = "0.16.5" +name = "reqsign-aws-v4" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43451dbf3590a7590684c25fb8d12ecdcc90ed3ac123433e500447c7d77ed701" +checksum = "44eaca382e94505a49f1a4849658d153aebf79d9c1a58e5dd3b10361511e9f43" dependencies = [ "anyhow", - "async-trait", - "base64", - "chrono", + "bytes", "form_urlencoded", - "getrandom 0.2.17", - "hex", - "hmac 0.12.1", - "home", "http 1.4.0", "log", "percent-encoding", - "quick-xml 0.37.5", - "rand 0.8.5", - "reqwest 0.12.28", + "quick-xml 0.39.4", + "reqsign-core", "rust-ini", "serde", "serde_json", + "serde_urlencoded", "sha1 0.10.6", - "sha2 0.10.9", - "tokio", ] [[package]] -name = "reqwest" -version = "0.12.28" +name = "reqsign-core" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +checksum = "b10302cf0a7d7e7352ba211fc92c3c5bebf1286153e49cc5aa87348078a8e102" dependencies = [ + "anyhow", "base64", "bytes", - "futures-core", - "futures-util", + "form_urlencoded", + "futures", + "hex", + "hmac 0.12.1", "http 1.4.0", - "http-body 1.0.1", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", + "jiff", "log", "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", + "sha1 0.10.6", + "sha2 0.10.9", + "windows-sys 0.61.2", +] + +[[package]] +name = "reqsign-file-read-tokio" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2d89295b3d17abea31851cc8de55d843d89c52132c864963c38d41920613dc5" +dependencies = [ + "anyhow", + "reqsign-core", "tokio", - "tokio-rustls", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", - "webpki-roots", ] [[package]] name = "reqwest" -version = "0.13.2" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" dependencies = [ "base64", "bytes", "futures-core", + "futures-util", "http 1.4.0", "http-body 1.0.1", "http-body-util", @@ -2639,12 +2621,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", ] @@ -2706,14 +2690,13 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", "log", "once_cell", - "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -2734,9 +2717,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "web-time", "zeroize", @@ -2744,9 +2727,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation", "core-foundation-sys", @@ -2760,7 +2743,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2771,9 +2754,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -2836,7 +2819,7 @@ dependencies = [ "std-next", "subtle", "sync_wrapper", - "thiserror 2.0.18", + "thiserror", "time", "tokio", "tokio-rustls", @@ -2897,7 +2880,7 @@ dependencies = [ "http-body 1.0.1", "http-body-util", "md-5 0.11.0", - "reqwest 0.13.2", + "reqwest", "s3s-test", "tracing", ] @@ -2931,7 +2914,7 @@ dependencies = [ "serde", "serde_json", "std-next", - "thiserror 2.0.18", + "thiserror", "time", "tokio", "tokio-util", @@ -2959,7 +2942,7 @@ dependencies = [ "indexmap", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror", ] [[package]] @@ -3166,7 +3149,7 @@ checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "digest 0.11.2", + "digest 0.11.3", ] [[package]] @@ -3188,7 +3171,7 @@ checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "digest 0.11.2", + "digest 0.11.3", ] [[package]] @@ -3226,6 +3209,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -3251,7 +3244,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3283,7 +3276,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04082e93ed1a06debd9148c928234b46d2cf260bc65f44e1d1d3fa594c5beebc" dependencies = [ "simdutf8", - "thiserror 2.0.18", + "thiserror", ] [[package]] @@ -3329,33 +3322,13 @@ dependencies = [ "syn", ] -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "thiserror-impl", ] [[package]] @@ -3445,9 +3418,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.51.0" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -3512,20 +3485,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags", "bytes", "futures-util", "http 1.4.0", "http-body 1.0.1", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -3630,9 +3603,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "unicode-ident" @@ -3684,9 +3657,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.0" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -3745,11 +3718,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", ] [[package]] @@ -3758,14 +3731,14 @@ 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", + "wit-bindgen 0.51.0", ] [[package]] name = "wasm-bindgen" -version = "0.2.117" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", @@ -3776,9 +3749,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.67" +version = "0.4.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" dependencies = [ "js-sys", "wasm-bindgen", @@ -3786,9 +3759,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.117" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3796,9 +3769,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.117" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", @@ -3809,18 +3782,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.117" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] [[package]] name = "wasm-bindgen-test" -version = "0.3.67" +version = "0.3.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "941c102b3f0c15b6d72a53205e09e6646aafcf2991e18412cc331dbac1806bc0" +checksum = "af5ec93229ad9ccd0a545a516dec76dc276613f278f6a91aa6b463d5b33d42d0" dependencies = [ "async-trait", "cast", @@ -3840,9 +3813,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-test-macro" -version = "0.3.67" +version = "0.3.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a26bd6570f39bb1440fd8f01b63461faaf2a3f6078a508e4e54efa99363108d2" +checksum = "3c81b9fef827e575e0e54431736d1baa0d700315d8c62cfef1f61fa3aad0cbeb" dependencies = [ "proc-macro2", "quote", @@ -3851,9 +3824,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-test-shared" -version = "0.2.117" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c29582b14d5bf030b02fa232b9b57faf2afc322d2c61964dd80bad02bf76207" +checksum = "4f4d8ae7ad5440360e9799dfd42857d126454a88441ddf72d288ef83fa47f527" [[package]] name = "wasm-encoder" @@ -3879,9 +3852,9 @@ dependencies = [ [[package]] name = "wasm-streams" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" dependencies = [ "futures-util", "js-sys", @@ -3904,9 +3877,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.94" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" dependencies = [ "js-sys", "wasm-bindgen", @@ -3924,18 +3897,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "webpki-roots" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" dependencies = [ "rustls-pki-types", ] @@ -3946,7 +3910,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -4008,15 +3972,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -4044,21 +3999,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -4092,12 +4032,6 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4110,12 +4044,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -4128,12 +4056,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -4158,12 +4080,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -4176,12 +4092,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -4194,12 +4104,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -4212,12 +4116,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -4239,6 +4137,12 @@ dependencies = [ "wit-bindgen-rust-macro", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wit-bindgen-core" version = "0.51.0" @@ -4375,9 +4279,9 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] diff --git a/Cargo.toml b/Cargo.toml index 57e4a097..b4502703 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,11 +31,11 @@ debug = "line-tables-only" arc-swap = "1.9.1" arrayvec = "0.7.6" bytes = "1.11.1" -bytestring = "1.5.0" -indexmap = "2.13.1" +bytestring = "1.5.1" +indexmap = "2.14.0" once_cell = "1.21.4" smallvec = "1.15.1" -uuid = "1.23.0" +uuid = "1.23.1" # Text encoding atoi = { version = "2.0.0", default-features = false } @@ -57,11 +57,11 @@ path-absolutize = "3.1.1" std-next = "0.1.9" # Crypto -crc-fast = "1.9.0" +crc-fast = "1.10.0" crc32c = "0.6.8" hmac = "0.13.0" md-5 = "0.11.0" -openssl = "0.10.76" +openssl = "0.10.80" sha1 = "0.11.0" sha2 = "0.11.0" subtle = "2.6.1" @@ -83,12 +83,12 @@ futures = { version = "0.3.32", default-features = false } futures-util = "0.3.32" pin-project-lite = "0.2.17" sync_wrapper = { version = "1.0.2", default-features = false } -tokio = "1.51.0" +tokio = "1.52.3" tokio-util = "0.7.18" transform-stream = "0.3.1" # HTTP -axum = "0.8.8" +axum = "0.8.9" http = "1.4.0" http-body = "1.0.1" http-body-util = "0.1.3" @@ -100,7 +100,7 @@ tokio-rustls = "0.26.4" tower = { version = "0.5.3", default-features = false } url = "2.5.8" urlencoding = "2.1.3" -reqwest = { version = "0.13.2", default-features = false, features = ["rustls", "form", "query"] } +reqwest = { version = "0.13.3", default-features = false, features = ["rustls", "form", "query"] } # Logging & observability tracing = "0.1.44" @@ -108,16 +108,16 @@ tracing-error = "0.2.1" tracing-subscriber = { version = "0.3.23", features = ["env-filter", "time"] } # CLI -clap = { version = "4.6.0", features = ["derive"] } +clap = { version = "4.6.1", features = ["derive"] } # Storage backends -opendal = "0.55.0" +opendal = { version = "0.56.0", default-features = false } # AWS SDK -aws-config = { version = "1.8.15", default-features = false } +aws-config = { version = "1.8.16", default-features = false } aws-credential-types = "1.2.14" -aws-sdk-s3 = { version = "1.128.0", default-features = false, features = ["default-https-client", "http-1x", "rt-tokio", "sigv4a"] } -aws-sdk-sts = { version = "1.101.0", default-features = false, features = ["default-https-client", "rt-tokio"] } -aws-smithy-runtime-api = "1.11.6" -aws-smithy-types = "1.4.7" +aws-sdk-s3 = { version = "1.132.0", default-features = false, features = ["default-https-client", "http-1x", "rt-tokio", "sigv4a"] } +aws-sdk-sts = { version = "1.103.0", default-features = false, features = ["default-https-client", "rt-tokio"] } +aws-smithy-runtime-api = "1.12.1" +aws-smithy-types = "1.4.8" aws-smithy-types-convert = "0.60.14" diff --git a/crates/s3s-aws/Cargo.toml b/crates/s3s-aws/Cargo.toml index ac384d1d..98c38537 100644 --- a/crates/s3s-aws/Cargo.toml +++ b/crates/s3s-aws/Cargo.toml @@ -18,7 +18,7 @@ minio = ["s3s/minio"] [dependencies] async-trait.workspace = true -aws-sdk-s3.workspace = true +aws-sdk-s3 = { workspace = true, default-features = false, features = ["default-https-client", "http-1x", "rt-tokio", "sigv4a"] } aws-smithy-runtime-api = { workspace = true, features = ["client", "http-1x"] } aws-smithy-types = { workspace = true, features = ["http-body-1-x"] } aws-smithy-types-convert = { workspace = true, features = ["convert-time"] } diff --git a/crates/s3s-fs/Cargo.toml b/crates/s3s-fs/Cargo.toml index 13fad63e..a8ba8640 100644 --- a/crates/s3s-fs/Cargo.toml +++ b/crates/s3s-fs/Cargo.toml @@ -62,7 +62,7 @@ aws-sdk-sts = { workspace = true, features = ["behavior-version-latest"] } futures-util.workspace = true hyper = { workspace = true, features = ["http1", "http2"] } hyper-util = { workspace = true, features = ["server-auto", "server-graceful", "http1", "http2", "tokio"] } -opendal = { workspace = true, features = ["services-s3"] } +opendal = { workspace = true, default-features = false, features = ["services-s3", "executors-tokio", "reqwest-rustls-tls"] } s3s-aws = { version = "0.14.0-dev", path = "../s3s-aws" } tokio = { workspace = true, features = ["full"] } tracing-subscriber.workspace = true diff --git a/crates/s3s-fs/src/s3.rs b/crates/s3s-fs/src/s3.rs index 67cdb41b..f56244bf 100644 --- a/crates/s3s-fs/src/s3.rs +++ b/crates/s3s-fs/src/s3.rs @@ -11,11 +11,13 @@ use s3s::s3_error; use s3s::{S3Request, S3Response}; use std::collections::VecDeque; +use std::fs::FileTimes; use std::io; use std::ops::Neg; use std::ops::Not; use std::path::Component; use std::path::{Path, PathBuf}; +use std::time::SystemTime; use tokio::fs; use tokio::io::AsyncReadExt; @@ -91,34 +93,128 @@ impl S3 for FileSystem { return Err(s3_error!(NoSuchBucket)); } + let file_metadata = try_!(fs::metadata(&src_path).await); + let src_last_modified = Timestamp::from(try_!(file_metadata.modified())); + + // Always load internal info – needed for ETag derivation and checksum propagation. + let src_info = self.load_internal_info(bucket, key).await?; + + // Derive source ETag from stored internal info when available. + // For ETag-based conditions, fall back to MD5 only when no stored ETag exists. + let mut src_etag: Option = src_info.as_ref().and_then(crate::checksum::load_e_tag).map(ETag::Strong); + + // S3 precedence: If-Match overrides If-Unmodified-Since. + if let Some(ref condition) = input.copy_source_if_match { + if src_etag.is_none() { + src_etag = Some(ETag::Strong(self.get_md5_sum(bucket, key).await?)); + } + let src = src_etag.as_ref().ok_or_else(|| s3_error!(InternalError))?; + let matches = match condition { + ETagCondition::Any => true, + ETagCondition::ETag(etag) => src.strong_cmp(etag), + }; + if !matches { + return Err(s3_error!(PreconditionFailed)); + } + } else if let Some(ref if_unmodified_since) = input.copy_source_if_unmodified_since + && src_last_modified > *if_unmodified_since + { + return Err(s3_error!(PreconditionFailed)); + } + + // S3 precedence: If-None-Match overrides If-Modified-Since. + if let Some(ref condition) = input.copy_source_if_none_match { + if src_etag.is_none() { + src_etag = Some(ETag::Strong(self.get_md5_sum(bucket, key).await?)); + } + let src = src_etag.as_ref().ok_or_else(|| s3_error!(InternalError))?; + let matches = match condition { + ETagCondition::Any => true, + ETagCondition::ETag(etag) => src.weak_cmp(etag), + }; + if matches { + return Err(s3_error!(PreconditionFailed)); + } + } else if let Some(ref if_modified_since) = input.copy_source_if_modified_since + && src_last_modified <= *if_modified_since + { + return Err(s3_error!(PreconditionFailed)); + } + if let Some(dir_path) = dst_path.parent() { try_!(fs::create_dir_all(&dir_path).await); } - let file_metadata = try_!(fs::metadata(&src_path).await); - let last_modified = Timestamp::from(try_!(file_metadata.modified())); - - let _ = try_!(fs::copy(&src_path, &dst_path).await); + // `fs::copy(p, p)` truncates the file before reading it, so self-replace + // must preserve bytes in place while still updating LastModified. + let dst_last_modified = if src_path == dst_path { + let now = SystemTime::now(); + let file = try_!(std::fs::OpenOptions::new().write(true).open(&dst_path)); + try_!(file.set_times(FileTimes::new().set_modified(now))); + debug!(path = %dst_path.display(), "replace file in place"); + Timestamp::from(now) + } else { + let _ = try_!(fs::copy(&src_path, &dst_path).await); + debug!(from = %src_path.display(), to = %dst_path.display(), "copy file"); + let dst_metadata = try_!(fs::metadata(&dst_path).await); + Timestamp::from(try_!(dst_metadata.modified())) + }; - debug!(from = %src_path.display(), to = %dst_path.display(), "copy file"); + // Derive the destination ETag from the source ETag when available. + // This preserves non-MD5 ETag formats (e.g., multipart `{hash}-{part_count}`) + // and avoids re-hashing the destination file. + let dst_etag_str = match src_etag { + Some(etag) => etag.into_value(), + None => self.get_md5_sum(&input.bucket, &input.key).await?, + }; - let src_metadata_path = self.get_metadata_path(bucket, key, None)?; - if src_metadata_path.exists() { - let dst_metadata_path = self.get_metadata_path(&input.bucket, &input.key, None)?; - let _ = try_!(fs::copy(src_metadata_path, dst_metadata_path).await); + // `MetadataDirective` defaults to `COPY` per AWS API: when the + // header is absent the destination should inherit the source's + // metadata sidecar verbatim. When set to `REPLACE`, the + // destination's metadata is built fresh from the request and + // anything from the source is dropped (matching the behaviour + // documented at + // https://docs.aws.amazon.com/AmazonS3/latest/API/API_CopyObject.html). + let replace_metadata = input + .metadata_directive + .as_ref() + .is_some_and(|d| d.as_str() == MetadataDirective::REPLACE); + + if replace_metadata { + let mut dst_attrs = crate::fs::ObjectAttributes { + user_metadata: input.metadata, + content_encoding: input.content_encoding, + content_type: input.content_type, + content_disposition: input.content_disposition, + content_language: input.content_language, + cache_control: input.cache_control, + expires: None, + website_redirect_location: input.website_redirect_location, + }; + dst_attrs.set_expires_timestamp(input.expires); + self.save_object_attributes(&input.bucket, &input.key, &dst_attrs, None) + .await?; + } else { + let src_metadata_path = self.get_metadata_path(bucket, key, None)?; + if src_metadata_path.exists() { + let dst_metadata_path = self.get_metadata_path(&input.bucket, &input.key, None)?; + // Same self-replace guard as for the payload above — `fs::copy` + // would zero the metadata sidecar when src == dst. + if src_metadata_path != dst_metadata_path { + let _ = try_!(fs::copy(src_metadata_path, dst_metadata_path).await); + } + } } - let md5_sum = self.get_md5_sum(bucket, key).await?; - { - let mut info = self.load_internal_info(bucket, key).await?.unwrap_or_default(); - crate::checksum::save_e_tag(&mut info, &md5_sum); + let mut info = src_info.unwrap_or_default(); + crate::checksum::save_e_tag(&mut info, &dst_etag_str); self.save_internal_info(&input.bucket, &input.key, &info).await?; } let copy_object_result = CopyObjectResult { - e_tag: Some(ETag::Strong(md5_sum)), - last_modified: Some(last_modified), + e_tag: Some(ETag::Strong(dst_etag_str)), + last_modified: Some(dst_last_modified), ..Default::default() }; @@ -579,20 +675,36 @@ impl S3 for FileSystem { cache_control, expires, website_redirect_location, + if_match, if_none_match, .. } = input; let Some(body) = body else { return Err(s3_error!(IncompleteBody)) }; - // Check If-None-Match condition - // If-None-Match: * means "only create if the object doesn't exist" + // Check conditional headers before modifying any state. + // If-None-Match: * means "only create if the object doesn't exist". + // If-Match: means "only overwrite if ETag matches" (CAS). + let object_path = self.get_object_path(&bucket, &key)?; if let Some(ref condition) = if_none_match && condition.is_any() + && object_path.exists() { - let object_path = self.get_object_path(&bucket, &key)?; - if object_path.exists() { - return Err(s3_error!(PreconditionFailed, "Object already exists")); + return Err(s3_error!(PreconditionFailed, "Object already exists")); + } + if let Some(ref condition) = if_match { + if !object_path.exists() { + return Err(s3_error!(PreconditionFailed, "Object does not exist")); + } + if let ETagCondition::ETag(expected) = condition { + let info = self.load_internal_info(&bucket, &key).await?; + let etag_value = match info.as_ref().and_then(crate::checksum::load_e_tag) { + Some(v) => v, + None => self.get_md5_sum(&bucket, &key).await?, + }; + if !ETag::Strong(etag_value).strong_cmp(expected) { + return Err(s3_error!(PreconditionFailed, "ETag does not match")); + } } } @@ -629,13 +741,11 @@ impl S3 for FileSystem { { return Err(s3_error!(UnexpectedContent, "Unexpected request body when creating a directory object.")); } - let object_path = self.get_object_path(&bucket, &key)?; try_!(fs::create_dir_all(&object_path).await); let output = PutObjectOutput::default(); return Ok(S3Response::new(output)); } - let object_path = self.get_object_path(&bucket, &key)?; let mut file_writer = self.prepare_file_write(&object_path).await?; let mut md5_hash = Md5::new(); diff --git a/crates/s3s-fs/tests/it_aws.rs b/crates/s3s-fs/tests/it_aws.rs index 24af7a9f..a5228d74 100644 --- a/crates/s3s-fs/tests/it_aws.rs +++ b/crates/s3s-fs/tests/it_aws.rs @@ -1563,6 +1563,345 @@ async fn test_if_none_match_wildcard() -> Result<()> { Ok(()) } +/// Test that `PutObject` with `If-Match: *` succeeds when the object exists +/// and fails with `PreconditionFailed` (412) when the object is absent. +#[tokio::test] +#[tracing::instrument] +async fn test_put_object_if_match_wildcard() -> Result<()> { + let _guard = serial().await; + + let c = Client::new(config()); + let bucket = format!("if-match-wc-{}", Uuid::new_v4()); + let bucket = bucket.as_str(); + let key = "test-file.txt"; + let initial = "initial content"; + let updated = "updated content"; + + create_bucket(&c, bucket).await?; + + // Test 1: PUT with If-Match: * should fail when the object doesn't exist + { + let body = ByteStream::from_static(initial.as_bytes()); + let err = c + .put_object() + .bucket(bucket) + .key(key) + .body(body) + .if_match("*") + .send() + .await + .expect_err("Expected If-Match: * on absent object to fail"); + + let service_err = err.into_service_error(); + assert_eq!( + service_err.code(), + Some("PreconditionFailed"), + "Expected PreconditionFailed, got: {:?}", + service_err.code() + ); + } + + // Seed the object so the next steps have something to match against. + c.put_object() + .bucket(bucket) + .key(key) + .body(ByteStream::from_static(initial.as_bytes())) + .send() + .await?; + + // Test 2: PUT with If-Match: * should succeed when the object exists + { + let body = ByteStream::from_static(updated.as_bytes()); + c.put_object() + .bucket(bucket) + .key(key) + .body(body) + .if_match("*") + .send() + .await + .expect("Expected If-Match: * on existing object to succeed"); + } + + { + let result = c.get_object().bucket(bucket).key(key).send().await?; + let body = result.body.collect().await?.into_bytes(); + assert_eq!(body.as_ref(), updated.as_bytes()); + } + + delete_object(&c, bucket, key).await?; + delete_bucket(&c, bucket).await?; + + Ok(()) +} + +/// Test that `PutObject` with `If-Match: ` overwrites only when the +/// stored `ETag` matches and returns `PreconditionFailed` (412) otherwise. +#[tokio::test] +#[tracing::instrument] +async fn test_put_object_if_match_etag() -> Result<()> { + let _guard = serial().await; + + let c = Client::new(config()); + let bucket = format!("if-match-etag-{}", Uuid::new_v4()); + let bucket = bucket.as_str(); + let key = "test-file.txt"; + let initial = "initial content"; + let updated = "updated content"; + + create_bucket(&c, bucket).await?; + + // Test 1: PUT with If-Match: should fail when the object doesn't exist + { + let body = ByteStream::from_static(initial.as_bytes()); + let err = c + .put_object() + .bucket(bucket) + .key(key) + .body(body) + .if_match("\"some-etag\"") + .send() + .await + .expect_err("Expected If-Match on absent object to fail"); + + let service_err = err.into_service_error(); + assert_eq!( + service_err.code(), + Some("PreconditionFailed"), + "Expected PreconditionFailed, got: {:?}", + service_err.code() + ); + } + + // Seed the object and capture the real ETag. + let initial_etag = { + let body = ByteStream::from_static(initial.as_bytes()); + let result = c.put_object().bucket(bucket).key(key).body(body).send().await?; + result.e_tag().expect("put_object should return e_tag").to_owned() + }; + + // Test 2: PUT with a wrong ETag should fail and not overwrite + { + let body = ByteStream::from_static(updated.as_bytes()); + let err = c + .put_object() + .bucket(bucket) + .key(key) + .body(body) + .if_match("\"wrong-etag-value\"") + .send() + .await + .expect_err("Expected If-Match with wrong ETag to fail"); + + let service_err = err.into_service_error(); + assert_eq!( + service_err.code(), + Some("PreconditionFailed"), + "Expected PreconditionFailed, got: {:?}", + service_err.code() + ); + + let result = c.get_object().bucket(bucket).key(key).send().await?; + let body = result.body.collect().await?.into_bytes(); + assert_eq!(body.as_ref(), initial.as_bytes(), "Object should not be overwritten"); + } + + // Test 3: PUT with the matching ETag should succeed and replace the body + { + let body = ByteStream::from_static(updated.as_bytes()); + c.put_object() + .bucket(bucket) + .key(key) + .body(body) + .if_match(&initial_etag) + .send() + .await + .expect("Expected If-Match with matching ETag to succeed"); + + let result = c.get_object().bucket(bucket).key(key).send().await?; + let body = result.body.collect().await?.into_bytes(); + assert_eq!(body.as_ref(), updated.as_bytes()); + } + + delete_object(&c, bucket, key).await?; + delete_bucket(&c, bucket).await?; + + Ok(()) +} + +#[tokio::test] +#[tracing::instrument] +async fn test_put_object_if_match_multipart_etag() -> Result<()> { + let _guard = serial().await; + + let c = Client::new(config()); + let bucket = format!("if-match-multipart-{}", Uuid::new_v4()); + let bucket = bucket.as_str(); + let key = "multipart-source.txt"; + let initial = b"multipart initial content"; + let updated = b"updated through put object"; + + create_bucket(&c, bucket).await?; + + let multipart_etag = { + let (upload_id, upload_parts) = do_multipart_upload(&c, bucket, key, initial).await?; + let upload = CompletedMultipartUpload::builder().set_parts(Some(upload_parts)).build(); + let result = c + .complete_multipart_upload() + .bucket(bucket) + .key(key) + .upload_id(&upload_id) + .multipart_upload(upload) + .send() + .await?; + result + .e_tag() + .expect("complete_multipart_upload should return e_tag") + .to_owned() + }; + assert!(multipart_etag.contains('-'), "expected multipart ETag format"); + + let err = c + .put_object() + .bucket(bucket) + .key(key) + .body(ByteStream::from_static(b"wrong overwrite")) + .if_match("\"wrong-etag-value\"") + .send() + .await + .expect_err("wrong ETag should fail"); + assert_eq!(err.into_service_error().code(), Some("PreconditionFailed")); + + let result = c.get_object().bucket(bucket).key(key).send().await?; + let body = result.body.collect().await?.into_bytes(); + assert_eq!(body.as_ref(), initial); + + c.put_object() + .bucket(bucket) + .key(key) + .body(ByteStream::from_static(updated)) + .if_match(&multipart_etag) + .send() + .await?; + + let result = c.get_object().bucket(bucket).key(key).send().await?; + let body = result.body.collect().await?.into_bytes(); + assert_eq!(body.as_ref(), updated); + + delete_object(&c, bucket, key).await?; + delete_bucket(&c, bucket).await?; + + Ok(()) +} + +#[tokio::test] +#[tracing::instrument] +async fn test_put_object_if_match_legacy_md5_fallback() -> Result<()> { + let _guard = serial().await; + + let c = Client::new(config()); + let bucket = format!("if-match-legacy-{}", Uuid::new_v4()); + let bucket = bucket.as_str(); + let key = "legacy-object.txt"; + let initial = b"legacy initial content"; + let updated = b"legacy updated content"; + + create_bucket(&c, bucket).await?; + + let initial_etag = { + let result = c + .put_object() + .bucket(bucket) + .key(key) + .body(ByteStream::from_static(initial)) + .send() + .await?; + result.e_tag().expect("put_object should return e_tag").to_owned() + }; + + let encode = |s: &str| base64_simd::URL_SAFE_NO_PAD.encode_to_string(s); + let internal_info_path = + std::path::Path::new(FS_ROOT).join(format!(".bucket-{}.object-{}.internal.json", encode(bucket), encode(key))); + fs::remove_file(internal_info_path)?; + + let err = c + .put_object() + .bucket(bucket) + .key(key) + .body(ByteStream::from_static(b"wrong overwrite")) + .if_match("\"wrong-etag-value\"") + .send() + .await + .expect_err("wrong ETag should fail through MD5 fallback"); + assert_eq!(err.into_service_error().code(), Some("PreconditionFailed")); + + let result = c.get_object().bucket(bucket).key(key).send().await?; + let body = result.body.collect().await?.into_bytes(); + assert_eq!(body.as_ref(), initial); + + c.put_object() + .bucket(bucket) + .key(key) + .body(ByteStream::from_static(updated)) + .if_match(&initial_etag) + .send() + .await?; + + let result = c.get_object().bucket(bucket).key(key).send().await?; + let body = result.body.collect().await?.into_bytes(); + assert_eq!(body.as_ref(), updated); + + delete_object(&c, bucket, key).await?; + delete_bucket(&c, bucket).await?; + + Ok(()) +} + +#[tokio::test] +#[tracing::instrument] +async fn test_put_object_if_match_rejects_weak_etag() -> Result<()> { + let _guard = serial().await; + + let c = Client::new(config()); + let bucket = format!("if-match-weak-{}", Uuid::new_v4()); + let bucket = bucket.as_str(); + let key = "weak-etag.txt"; + let initial = b"weak etag initial content"; + + create_bucket(&c, bucket).await?; + + let initial_etag = { + let result = c + .put_object() + .bucket(bucket) + .key(key) + .body(ByteStream::from_static(initial)) + .send() + .await?; + result.e_tag().expect("put_object should return e_tag").to_owned() + }; + let weak_etag = format!("W/{initial_etag}"); + + let err = c + .put_object() + .bucket(bucket) + .key(key) + .body(ByteStream::from_static(b"weak overwrite")) + .if_match(weak_etag) + .send() + .await + .expect_err("weak ETag must not satisfy If-Match"); + assert_eq!(err.into_service_error().code(), Some("PreconditionFailed")); + + let result = c.get_object().bucket(bucket).key(key).send().await?; + let body = result.body.collect().await?.into_bytes(); + assert_eq!(body.as_ref(), initial); + + delete_object(&c, bucket, key).await?; + delete_bucket(&c, bucket).await?; + + Ok(()) +} + /// Regression test for /// /// `copy_object` should create parent directories when the destination key contains "/" @@ -1610,6 +1949,603 @@ async fn test_copy_object_nested_dst() -> Result<()> { Ok(()) } +/// Regression test: `CopyObject` with `src == dst` (self-replace) must +/// preserve the on-disk content. AWS S3 supports this shape as the +/// canonical way to update an object's metadata in place. Before the +/// fix, `tokio::fs::copy(src, dst)` opened the destination with +/// `O_TRUNC` before reading the source, zeroing the file. +#[tokio::test] +#[tracing::instrument] +async fn test_copy_object_self_replace_preserves_content() -> Result<()> { + let _guard = serial().await; + + let c = Client::new(config()); + let bucket = format!("test-self-replace-{}", Uuid::new_v4()); + let bucket = bucket.as_str(); + + create_bucket(&c, bucket).await?; + + let key = "obj.bin"; + let content = "original content that must survive a self-replace"; + c.put_object() + .bucket(bucket) + .key(key) + .body(ByteStream::from_static(content.as_bytes())) + .send() + .await?; + + let before_head = c.head_object().bucket(bucket).key(key).send().await?; + let before_last_modified = before_head + .last_modified() + .expect("head_object should return last_modified") + .to_owned(); + + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + let copy_source = format!("{bucket}/{key}"); + c.copy_object() + .bucket(bucket) + .key(key) + .copy_source(©_source) + .send() + .await?; + + let after_head = c.head_object().bucket(bucket).key(key).send().await?; + let after_last_modified = after_head + .last_modified() + .expect("head_object should return last_modified") + .to_owned(); + assert!( + after_last_modified > before_last_modified, + "CopyObject self-replace must update LastModified" + ); + + let got = c.get_object().bucket(bucket).key(key).send().await?; + let body = got.body.collect().await?.into_bytes(); + assert_eq!(body.as_ref(), content.as_bytes(), "CopyObject self-replace must not zero the file"); + + delete_object(&c, bucket, key).await?; + delete_bucket(&c, bucket).await?; + + Ok(()) +} + +/// `MetadataDirective: REPLACE` must drop the source metadata and +/// install the request's metadata + `content_type` on the destination. +/// Before the fix, `copy_object` ignored both `metadata_directive` +/// and `metadata` and unconditionally copied the source sidecar +/// verbatim. +#[tokio::test] +#[tracing::instrument] +async fn test_copy_object_metadata_directive_replace() -> Result<()> { + use aws_sdk_s3::types::MetadataDirective; + + let _guard = serial().await; + + let c = Client::new(config()); + let bucket = format!("test-meta-replace-{}", Uuid::new_v4()); + let bucket = bucket.as_str(); + + create_bucket(&c, bucket).await?; + + let src_key = "src.bin"; + c.put_object() + .bucket(bucket) + .key(src_key) + .body(ByteStream::from_static(b"x")) + .content_type("application/octet-stream") + .metadata("origin", "v1") + .metadata("rev", "1") + .metadata("source-only", "keep-out") + .send() + .await?; + + let dst_key = "dst.bin"; + let copy_source = format!("{bucket}/{src_key}"); + c.copy_object() + .bucket(bucket) + .key(dst_key) + .copy_source(©_source) + .metadata_directive(MetadataDirective::Replace) + .content_type("application/pdf") + .metadata("origin", "v2") + .metadata("rev", "2") + .send() + .await?; + + let head = c.head_object().bucket(bucket).key(dst_key).send().await?; + assert_eq!( + head.content_type().unwrap_or(""), + "application/pdf", + "REPLACE must install the request's content_type on the destination" + ); + let dst_meta = head.metadata().cloned().unwrap_or_default(); + assert_eq!(dst_meta.get("origin").map(String::as_str), Some("v2")); + assert_eq!(dst_meta.get("rev").map(String::as_str), Some("2")); + assert_eq!( + dst_meta.get("source-only"), + None, + "REPLACE must drop metadata that only exists on the source object" + ); + + delete_object(&c, bucket, src_key).await?; + delete_object(&c, bucket, dst_key).await?; + delete_bucket(&c, bucket).await?; + + Ok(()) +} + +/// Omitting `MetadataDirective` must use S3's default `COPY` behavior: +/// propagate source metadata and ignore replacement fields from the request. +#[tokio::test] +#[tracing::instrument] +async fn test_copy_object_metadata_directive_default_copies_source() -> Result<()> { + let _guard = serial().await; + + let c = Client::new(config()); + let bucket = format!("test-meta-default-copy-{}", Uuid::new_v4()); + let bucket = bucket.as_str(); + + create_bucket(&c, bucket).await?; + + let src_key = "src.bin"; + c.put_object() + .bucket(bucket) + .key(src_key) + .body(ByteStream::from_static(b"x")) + .content_type("application/octet-stream") + .metadata("origin", "v1") + .send() + .await?; + + let dst_key = "dst.bin"; + let copy_source = format!("{bucket}/{src_key}"); + c.copy_object() + .bucket(bucket) + .key(dst_key) + .copy_source(©_source) + .content_type("application/pdf") + .metadata("origin", "v2") + .send() + .await?; + + let head = c.head_object().bucket(bucket).key(dst_key).send().await?; + assert_eq!( + head.content_type().unwrap_or(""), + "application/octet-stream", + "default MetadataDirective must propagate the source content_type" + ); + let dst_meta = head.metadata().cloned().unwrap_or_default(); + assert_eq!( + dst_meta.get("origin").map(String::as_str), + Some("v1"), + "default MetadataDirective must propagate the source metadata" + ); + + delete_object(&c, bucket, src_key).await?; + delete_object(&c, bucket, dst_key).await?; + delete_bucket(&c, bucket).await?; + + Ok(()) +} + +/// `MetadataDirective: COPY` (the default) must propagate the source +/// metadata to the destination, ignoring any metadata fields supplied +/// in the request — exactly as documented at +/// . +#[allow(clippy::doc_markdown)] +#[tokio::test] +#[tracing::instrument] +async fn test_copy_object_metadata_directive_copy_ignores_request_fields() -> Result<()> { + use aws_sdk_s3::types::MetadataDirective; + + let _guard = serial().await; + + let c = Client::new(config()); + let bucket = format!("test-meta-copy-{}", Uuid::new_v4()); + let bucket = bucket.as_str(); + + create_bucket(&c, bucket).await?; + + let src_key = "src.bin"; + c.put_object() + .bucket(bucket) + .key(src_key) + .body(ByteStream::from_static(b"x")) + .content_type("application/octet-stream") + .metadata("origin", "v1") + .send() + .await?; + + let dst_key = "dst.bin"; + let copy_source = format!("{bucket}/{src_key}"); + c.copy_object() + .bucket(bucket) + .key(dst_key) + .copy_source(©_source) + .metadata_directive(MetadataDirective::Copy) + .content_type("application/pdf") // expected to be ignored under COPY + .metadata("origin", "v2") + .send() + .await?; + + let head = c.head_object().bucket(bucket).key(dst_key).send().await?; + assert_eq!( + head.content_type().unwrap_or(""), + "application/octet-stream", + "COPY must propagate the source content_type and ignore the request override" + ); + let dst_meta = head.metadata().cloned().unwrap_or_default(); + assert_eq!( + dst_meta.get("origin").map(String::as_str), + Some("v1"), + "COPY must propagate the source metadata, not the request fields" + ); + + delete_object(&c, bucket, src_key).await?; + delete_object(&c, bucket, dst_key).await?; + delete_bucket(&c, bucket).await?; + + Ok(()) +} + +/// Test conditional copy with `x-amz-copy-source-if-match`. +#[tokio::test] +#[tracing::instrument] +async fn test_copy_object_if_match() -> Result<()> { + use aws_sdk_s3::primitives::DateTime; + use aws_sdk_s3::primitives::DateTimeFormat; + + let _guard = serial().await; + + let c = Client::new(config()); + let bucket = format!("test-cond-copy-{}", Uuid::new_v4()); + let bucket = bucket.as_str(); + create_bucket(&c, bucket).await?; + + let src_key = "source.txt"; + let content = "conditional copy content"; + c.put_object() + .bucket(bucket) + .key(src_key) + .body(ByteStream::from_static(content.as_bytes())) + .send() + .await?; + + let get_result = c.get_object().bucket(bucket).key(src_key).send().await?; + let e_tag = get_result.e_tag().expect("get_object should return e_tag").to_owned(); + let _ = get_result.body.collect().await?; + + let copy_source = format!("{bucket}/{src_key}"); + + let dst_key = "dest-match.txt"; + c.copy_object() + .bucket(bucket) + .key(dst_key) + .copy_source(©_source) + .copy_source_if_match(&e_tag) + .send() + .await?; + + let ans = c.get_object().bucket(bucket).key(dst_key).send().await?; + let body = ans.body.collect().await?.into_bytes(); + assert_eq!(body.as_ref(), content.as_bytes()); + + let dst_key2 = "dest-match-wildcard.txt"; + let past = DateTime::from_str("Thu, 01 Jan 2000 00:00:00 GMT", DateTimeFormat::HttpDate)?; + c.copy_object() + .bucket(bucket) + .key(dst_key2) + .copy_source(©_source) + .copy_source_if_match("*") + .copy_source_if_unmodified_since(past) + .send() + .await?; + + let ans = c.get_object().bucket(bucket).key(dst_key2).send().await?; + let body = ans.body.collect().await?.into_bytes(); + assert_eq!(body.as_ref(), content.as_bytes()); + + let dst_key3 = "dest-nomatch.txt"; + let err = c + .copy_object() + .bucket(bucket) + .key(dst_key3) + .copy_source(©_source) + .copy_source_if_match("\"nonexistent-etag\"") + .send() + .await + .expect_err("Expected copy with non-matching If-Match to fail"); + let service_err = err.into_service_error(); + assert_eq!(service_err.code(), Some("PreconditionFailed")); + + delete_object(&c, bucket, src_key).await?; + delete_object(&c, bucket, dst_key).await?; + delete_object(&c, bucket, dst_key2).await?; + delete_bucket(&c, bucket).await?; + + Ok(()) +} + +/// Test conditional copy with `x-amz-copy-source-if-none-match`. +#[tokio::test] +#[tracing::instrument] +async fn test_copy_object_if_none_match() -> Result<()> { + use aws_sdk_s3::primitives::DateTime; + use aws_sdk_s3::primitives::DateTimeFormat; + + let _guard = serial().await; + + let c = Client::new(config()); + let bucket = format!("test-cond-copy-nm-{}", Uuid::new_v4()); + let bucket = bucket.as_str(); + create_bucket(&c, bucket).await?; + + let src_key = "source.txt"; + let content = "conditional copy none match"; + c.put_object() + .bucket(bucket) + .key(src_key) + .body(ByteStream::from_static(content.as_bytes())) + .send() + .await?; + + let get_result = c.get_object().bucket(bucket).key(src_key).send().await?; + let e_tag = get_result.e_tag().expect("get_object should return e_tag").to_owned(); + let _ = get_result.body.collect().await?; + + let copy_source = format!("{bucket}/{src_key}"); + + let dst_key = "dest-none-match-ok.txt"; + c.copy_object() + .bucket(bucket) + .key(dst_key) + .copy_source(©_source) + .copy_source_if_none_match("\"different-etag\"") + .send() + .await?; + + let ans = c.get_object().bucket(bucket).key(dst_key).send().await?; + let body = ans.body.collect().await?.into_bytes(); + assert_eq!(body.as_ref(), content.as_bytes()); + + let dst_key2 = "dest-none-match-fail.txt"; + let err = c + .copy_object() + .bucket(bucket) + .key(dst_key2) + .copy_source(©_source) + .copy_source_if_none_match(&e_tag) + .send() + .await + .expect_err("Expected copy with matching If-None-Match to fail"); + let service_err = err.into_service_error(); + assert_eq!(service_err.code(), Some("PreconditionFailed")); + + let dst_key3 = "dest-none-match-wildcard.txt"; + let err = c + .copy_object() + .bucket(bucket) + .key(dst_key3) + .copy_source(©_source) + .copy_source_if_none_match("*") + .send() + .await + .expect_err("Expected copy with wildcard If-None-Match to fail for existing source"); + let service_err = err.into_service_error(); + assert_eq!(service_err.code(), Some("PreconditionFailed")); + + let dst_key4 = "dest-none-match-precedence.txt"; + let future = DateTime::from_str("Thu, 01 Jan 2099 00:00:00 GMT", DateTimeFormat::HttpDate)?; + c.copy_object() + .bucket(bucket) + .key(dst_key4) + .copy_source(©_source) + .copy_source_if_none_match("\"different-etag\"") + .copy_source_if_modified_since(future) + .send() + .await?; + + let ans = c.get_object().bucket(bucket).key(dst_key4).send().await?; + let body = ans.body.collect().await?.into_bytes(); + assert_eq!(body.as_ref(), content.as_bytes()); + + delete_object(&c, bucket, src_key).await?; + delete_object(&c, bucket, dst_key).await?; + delete_object(&c, bucket, dst_key4).await?; + delete_bucket(&c, bucket).await?; + + Ok(()) +} + +/// Test conditional copy with `x-amz-copy-source-if-modified-since` and +/// `x-amz-copy-source-if-unmodified-since`. +#[tokio::test] +#[tracing::instrument] +async fn test_copy_object_if_modified_since() -> Result<()> { + use aws_sdk_s3::primitives::DateTime; + use aws_sdk_s3::primitives::DateTimeFormat; + + let _guard = serial().await; + + let c = Client::new(config()); + let bucket = format!("test-cond-copy-ts-{}", Uuid::new_v4()); + let bucket = bucket.as_str(); + create_bucket(&c, bucket).await?; + + let src_key = "source.txt"; + let content = "conditional copy timestamp"; + c.put_object() + .bucket(bucket) + .key(src_key) + .body(ByteStream::from_static(content.as_bytes())) + .send() + .await?; + + let copy_source = format!("{bucket}/{src_key}"); + + let dst_key = "dest-modified-ok.txt"; + let past = DateTime::from_str("Thu, 01 Jan 2000 00:00:00 GMT", DateTimeFormat::HttpDate)?; + c.copy_object() + .bucket(bucket) + .key(dst_key) + .copy_source(©_source) + .copy_source_if_modified_since(past) + .send() + .await?; + + let ans = c.get_object().bucket(bucket).key(dst_key).send().await?; + let body = ans.body.collect().await?.into_bytes(); + assert_eq!(body.as_ref(), content.as_bytes()); + + let dst_key2 = "dest-modified-fail.txt"; + let future = DateTime::from_str("Thu, 01 Jan 2099 00:00:00 GMT", DateTimeFormat::HttpDate)?; + let err = c + .copy_object() + .bucket(bucket) + .key(dst_key2) + .copy_source(©_source) + .copy_source_if_modified_since(future) + .send() + .await + .expect_err("Expected copy with future if-modified-since to fail"); + let service_err = err.into_service_error(); + assert_eq!(service_err.code(), Some("PreconditionFailed")); + + let dst_key3 = "dest-unmodified-ok.txt"; + let future = DateTime::from_str("Thu, 01 Jan 2099 00:00:00 GMT", DateTimeFormat::HttpDate)?; + c.copy_object() + .bucket(bucket) + .key(dst_key3) + .copy_source(©_source) + .copy_source_if_unmodified_since(future) + .send() + .await?; + + let ans = c.get_object().bucket(bucket).key(dst_key3).send().await?; + let body = ans.body.collect().await?.into_bytes(); + assert_eq!(body.as_ref(), content.as_bytes()); + + let dst_key4 = "dest-unmodified-fail.txt"; + let past = DateTime::from_str("Thu, 01 Jan 2000 00:00:00 GMT", DateTimeFormat::HttpDate)?; + let err = c + .copy_object() + .bucket(bucket) + .key(dst_key4) + .copy_source(©_source) + .copy_source_if_unmodified_since(past) + .send() + .await + .expect_err("Expected copy with past if-unmodified-since to fail"); + let service_err = err.into_service_error(); + assert_eq!(service_err.code(), Some("PreconditionFailed")); + + delete_object(&c, bucket, src_key).await?; + delete_object(&c, bucket, dst_key).await?; + delete_object(&c, bucket, dst_key3).await?; + delete_bucket(&c, bucket).await?; + + Ok(()) +} + +/// Test conditional copy against a multipart source object's persisted `ETag`. +#[tokio::test] +#[tracing::instrument] +async fn test_copy_object_conditional_with_multipart_source_etag() -> Result<()> { + let _guard = serial().await; + + let c = Client::new(config()); + let bucket = format!("test-cond-copy-multipart-{}", Uuid::new_v4()); + let bucket = bucket.as_str(); + create_bucket(&c, bucket).await?; + + let src_key = "source-multipart.txt"; + let content = "multipart conditional copy content"; + + let upload_id = c + .create_multipart_upload() + .bucket(bucket) + .key(src_key) + .send() + .await? + .upload_id + .expect("create_multipart_upload should return upload_id"); + + let upload_result = c + .upload_part() + .bucket(bucket) + .key(src_key) + .upload_id(&upload_id) + .body(ByteStream::from_static(content.as_bytes())) + .part_number(1) + .send() + .await?; + + let upload = CompletedMultipartUpload::builder() + .set_parts(Some(vec![ + CompletedPart::builder() + .e_tag(upload_result.e_tag.expect("upload_part should return e_tag")) + .part_number(1) + .build(), + ])) + .build(); + + let complete_result = c + .complete_multipart_upload() + .bucket(bucket) + .key(src_key) + .multipart_upload(upload) + .upload_id(&upload_id) + .send() + .await?; + let multipart_e_tag = complete_result + .e_tag() + .expect("complete_multipart_upload should return e_tag") + .to_owned(); + + let copy_source = format!("{bucket}/{src_key}"); + + let dst_key = "dest-multipart-match.txt"; + c.copy_object() + .bucket(bucket) + .key(dst_key) + .copy_source(©_source) + .copy_source_if_match(&multipart_e_tag) + .send() + .await?; + + let ans = c.get_object().bucket(bucket).key(dst_key).send().await?; + let body = ans.body.collect().await?.into_bytes(); + assert_eq!(body.as_ref(), content.as_bytes()); + + // The destination ETag should match the source multipart ETag (format preserved during copy). + let head = c.head_object().bucket(bucket).key(dst_key).send().await?; + let dst_etag = head.e_tag().expect("head_object should return e_tag"); + assert_eq!( + dst_etag, multipart_e_tag, + "destination ETag should match source multipart ETag after copy" + ); + + let dst_key2 = "dest-multipart-none-match.txt"; + let err = c + .copy_object() + .bucket(bucket) + .key(dst_key2) + .copy_source(©_source) + .copy_source_if_none_match(&multipart_e_tag) + .send() + .await + .expect_err("Expected matching multipart ETag to fail If-None-Match"); + let service_err = err.into_service_error(); + assert_eq!(service_err.code(), Some("PreconditionFailed")); + + delete_object(&c, bucket, src_key).await?; + delete_object(&c, bucket, dst_key).await?; + delete_bucket(&c, bucket).await?; + + Ok(()) +} + /// Regression test for /// /// `list_objects_v2` prefix matching should use string-based matching (not `Path::starts_with`) diff --git a/crates/s3s-wasm/Cargo.toml b/crates/s3s-wasm/Cargo.toml index 7058fd7d..5705474d 100644 --- a/crates/s3s-wasm/Cargo.toml +++ b/crates/s3s-wasm/Cargo.toml @@ -17,4 +17,4 @@ s3s = { version = "0.14.0-dev", path = "../s3s", default-features = false } workspace = true [dev-dependencies] -wasm-bindgen-test = "0.3.67" +wasm-bindgen-test = "0.3.71" diff --git a/crates/s3s/src/ops/mod.rs b/crates/s3s/src/ops/mod.rs index d002cbca..43488d26 100644 --- a/crates/s3s/src/ops/mod.rs +++ b/crates/s3s/src/ops/mod.rs @@ -364,7 +364,8 @@ async fn prepare(req: &mut Request, ccx: &CallContext<'_>) -> S3Result qs: req.s3ext.qs.as_ref(), hs, - decoded_uri_path, + decoded_uri_path: &decoded_uri_path, + raw_uri_path: req.uri.path(), vh_bucket, content_length, diff --git a/crates/s3s/src/ops/signature.rs b/crates/s3s/src/ops/signature.rs index 57009172..97c72068 100644 --- a/crates/s3s/src/ops/signature.rs +++ b/crates/s3s/src/ops/signature.rs @@ -80,7 +80,8 @@ pub struct SignatureContext<'a> { pub qs: Option<&'a OrderedQs>, pub hs: OrderedHeaders<'a>, - pub decoded_uri_path: String, + pub decoded_uri_path: &'a str, + pub raw_uri_path: &'a str, pub vh_bucket: Option<&'a str>, pub content_length: Option, @@ -105,6 +106,57 @@ fn require_auth(auth: Option<&dyn S3Auth>) -> S3Result<&dyn S3Auth> { auth.ok_or_else(|| s3_error!(NotImplemented, "This service has no authentication provider")) } +fn has_unencoded_reserved_path_char(path: &str) -> bool { + // Percent-encoded paths should be handled by normal S3 canonicalization. + path.bytes().any(|b| { + !matches!( + b, + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' | b'%' + ) + }) +} + +struct SignatureVerificationContext<'a> { + expected_signature: &'a str, + raw_uri_path: &'a str, + secret_key: &'a SecretKey, + amz_date: &'a AmzDate, + region: &'a str, + service: &'a str, +} + +impl SignatureVerificationContext<'_> { + fn verify_with_raw_path_fallback( + &self, + canonical_request: &str, + raw_canonical_request: impl FnOnce() -> String, + ) -> S3Result { + let string_to_sign = sig_v4::create_string_to_sign(canonical_request, self.amz_date, self.region, self.service); + let signature = sig_v4::calculate_signature(&string_to_sign, self.secret_key, self.amz_date, self.region, self.service); + + if signature == self.expected_signature { + return Ok(signature); + } + + if !has_unencoded_reserved_path_char(self.raw_uri_path) { + debug!(?signature, expected=?self.expected_signature, "signature mismatch"); + return Err(s3_error!(SignatureDoesNotMatch)); + } + + let canonical_request = raw_canonical_request(); + let string_to_sign = sig_v4::create_string_to_sign(&canonical_request, self.amz_date, self.region, self.service); + let raw_signature = + sig_v4::calculate_signature(&string_to_sign, self.secret_key, self.amz_date, self.region, self.service); + + if raw_signature != self.expected_signature { + debug!(?signature, ?raw_signature, expected=?self.expected_signature, "signature mismatch"); + return Err(s3_error!(SignatureDoesNotMatch)); + } + + Ok(raw_signature) + } +} + impl SignatureContext<'_> { pub async fn check(&mut self) -> S3Result> { if self.req_method == Method::POST @@ -295,41 +347,46 @@ impl SignatureContext<'_> { )); } - let signature = { - let headers = self.hs.find_multiple_with_on_missing(&presigned_url.signed_headers, |name| { - // HTTP/2 replaces `host` header with `:authority` - // but `:authority` is not in the request headers - // so we need to add it back if `host` is in the signed headers - if name == "host" - && matches!(self.req_version, ::http::Version::HTTP_2 | ::http::Version::HTTP_3) - && let Some(authority) = self.req_uri.authority() - { - return Some(authority.as_str()); - } - None - }); - - let method = &self.req_method; - let uri_path = &self.decoded_uri_path; - - let payload = match headers.get_unique(crate::header::X_AMZ_CONTENT_SHA256) { - Some(content_sha256) => sig_v4::Payload::SingleChunk(content_sha256), - None => sig_v4::Payload::Unsigned, - }; - - let canonical_request = sig_v4::create_presigned_canonical_request(method, uri_path, qs.as_ref(), &headers, payload); - - let amz_date = &presigned_url.amz_date; - let string_to_sign = sig_v4::create_string_to_sign(&canonical_request, amz_date, region, service); + let expected_signature = presigned_url.signature; + let headers = self.hs.find_multiple_with_on_missing(&presigned_url.signed_headers, |name| { + // HTTP/2 replaces `host` header with `:authority` + // but `:authority` is not in the request headers + // so we need to add it back if `host` is in the signed headers + if name == "host" + && matches!(self.req_version, ::http::Version::HTTP_2 | ::http::Version::HTTP_3) + && let Some(authority) = self.req_uri.authority() + { + return Some(authority.as_str()); + } + None + }); - sig_v4::calculate_signature(&string_to_sign, &secret_key, amz_date, region, service) + let payload = match headers.get_unique(crate::header::X_AMZ_CONTENT_SHA256) { + Some(content_sha256) => sig_v4::Payload::SingleChunk(content_sha256), + None => sig_v4::Payload::Unsigned, }; - let expected_signature = presigned_url.signature; - if signature != expected_signature { - debug!(?signature, expected=?expected_signature, "signature mismatch"); - return Err(s3_error!(SignatureDoesNotMatch)); - } + let method = &self.req_method; + let amz_date = &presigned_url.amz_date; + let verifier = SignatureVerificationContext { + expected_signature, + raw_uri_path: self.raw_uri_path, + secret_key: &secret_key, + amz_date, + region, + service, + }; + let canonical_request = + sig_v4::create_presigned_canonical_request(method, self.decoded_uri_path, qs.as_ref(), &headers, payload); + verifier.verify_with_raw_path_fallback(&canonical_request, || { + sig_v4::create_presigned_canonical_request_with_raw_uri_path( + method, + self.raw_uri_path, + qs.as_ref(), + &headers, + payload, + ) + })?; if let Some(content_sha256) = self.hs.get_unique(crate::header::X_AMZ_CONTENT_SHA256) && content_sha256 != "UNSIGNED-PAYLOAD" @@ -391,101 +448,74 @@ impl SignatureContext<'_> { let is_stream = amz_content_sha256.is_some_and(|v| v.is_streaming()); - let signature = { - let method = &self.req_method; - let uri_path = &self.decoded_uri_path; - let query_strings: &[(String, String)] = self.qs.as_ref().map_or(&[], AsRef::as_ref); - - // FIXME: throw error if any signed header is not in the request - // `host` header need to be special handled - - // here requires that `auth.signed_headers` is sorted - let headers = self.hs.find_multiple_with_on_missing(&authorization.signed_headers, |name| { - // HTTP/2 replaces `host` header with `:authority` - // but `:authority` is not in the request headers - // so we need to add it back if `host` is in the signed headers - if name == "host" - && self.req_version == ::http::Version::HTTP_2 - && let Some(authority) = self.req_uri.authority() - { - return Some(authority.as_str()); - } - None - }); - - let canonical_request = match amz_content_sha256 { - Some(AmzContentSha256::StreamingAws4HmacSha256Payload) => { - sig_v4::create_canonical_request(method, uri_path, query_strings, &headers, sig_v4::Payload::MultipleChunks) - } - Some(AmzContentSha256::StreamingAws4HmacSha256PayloadTrailer) => sig_v4::create_canonical_request( - method, - uri_path, - query_strings, - &headers, - sig_v4::Payload::MultipleChunksWithTrailer, - ), - Some(AmzContentSha256::UnsignedPayload) => { - sig_v4::create_canonical_request(method, uri_path, query_strings, &headers, sig_v4::Payload::Unsigned) - } - Some(AmzContentSha256::StreamingUnsignedPayloadTrailer) => sig_v4::create_canonical_request( - method, - uri_path, - query_strings, - &headers, - sig_v4::Payload::UnsignedMultipleChunksWithTrailer, - ), - Some(AmzContentSha256::SingleChunk(payload_checksum)) => sig_v4::create_canonical_request( - method, - uri_path, - query_strings, - &headers, - sig_v4::Payload::SingleChunk(payload_checksum), - ), - Some( - AmzContentSha256::StreamingAws4EcdsaP256Sha256Payload - | AmzContentSha256::StreamingAws4EcdsaP256Sha256PayloadTrailer, - ) => { - return Err(s3_error!(NotImplemented, "AWS4-ECDSA-P256-SHA256 signing method is not implemented yet")); - } - None => { - // For STS requests, x-amz-content-sha256 header is not required - // For S3 requests, this case should have been caught earlier (see lines 325-327) - if service == "sts" { - // STS requests require computing the payload hash from the body - // Read the body (it's small for STS requests like AssumeRole) - let body_bytes = self - .req_body - .store_all_limited(MAX_STS_BODY_SIZE) - .await - .map_err(|e| invalid_request!("failed to read STS request body: {}", e))?; - - // Compute SHA256 hash and convert to hex - let hash = hex_sha256(&body_bytes, str::to_owned); - - // Create canonical request with the computed hash - sig_v4::create_canonical_request( - method, - uri_path, - query_strings, - &headers, - sig_v4::Payload::SingleChunk(&hash), - ) - } else { - // According to AWS S3 protocol, x-amz-content-sha256 header is required for - // all S3 requests authenticated with Signature V4. Reject if missing. - return Err(invalid_request!("missing header: x-amz-content-sha256")); - } + let expected_signature = authorization.signature; + let method = &self.req_method; + let query_strings: &[(String, String)] = self.qs.as_ref().map_or(&[], AsRef::as_ref); + + // FIXME: throw error if any signed header is not in the request + // `host` header need to be special handled + + // here requires that `auth.signed_headers` is sorted + let headers = self.hs.find_multiple_with_on_missing(&authorization.signed_headers, |name| { + // HTTP/2 replaces `host` header with `:authority` + // but `:authority` is not in the request headers + // so we need to add it back if `host` is in the signed headers + if name == "host" + && self.req_version == ::http::Version::HTTP_2 + && let Some(authority) = self.req_uri.authority() + { + return Some(authority.as_str()); + } + None + }); + + let sts_payload_hash; + let payload = match amz_content_sha256 { + Some(AmzContentSha256::StreamingAws4HmacSha256Payload) => sig_v4::Payload::MultipleChunks, + Some(AmzContentSha256::StreamingAws4HmacSha256PayloadTrailer) => sig_v4::Payload::MultipleChunksWithTrailer, + Some(AmzContentSha256::UnsignedPayload) => sig_v4::Payload::Unsigned, + Some(AmzContentSha256::StreamingUnsignedPayloadTrailer) => sig_v4::Payload::UnsignedMultipleChunksWithTrailer, + Some(AmzContentSha256::SingleChunk(payload_checksum)) => sig_v4::Payload::SingleChunk(payload_checksum), + Some( + AmzContentSha256::StreamingAws4EcdsaP256Sha256Payload + | AmzContentSha256::StreamingAws4EcdsaP256Sha256PayloadTrailer, + ) => { + return Err(s3_error!(NotImplemented, "AWS4-ECDSA-P256-SHA256 signing method is not implemented yet")); + } + None => { + // For STS requests, x-amz-content-sha256 header is not required + // For S3 requests, this case should have been caught earlier. + if service == "sts" { + // STS requests require computing the payload hash from the body + // Read the body (it's small for STS requests like AssumeRole) + let body_bytes = self + .req_body + .store_all_limited(MAX_STS_BODY_SIZE) + .await + .map_err(|e| invalid_request!("failed to read STS request body: {}", e))?; + + sts_payload_hash = hex_sha256(&body_bytes, str::to_owned); + sig_v4::Payload::SingleChunk(&sts_payload_hash) + } else { + // According to AWS S3 protocol, x-amz-content-sha256 header is required for + // all S3 requests authenticated with Signature V4. Reject if missing. + return Err(invalid_request!("missing header: x-amz-content-sha256")); } - }; - let string_to_sign = sig_v4::create_string_to_sign(&canonical_request, &amz_date, region, service); - sig_v4::calculate_signature(&string_to_sign, &secret_key, &amz_date, region, service) + } }; - let expected_signature = authorization.signature; - if signature != expected_signature { - debug!(?signature, expected=?expected_signature, "signature mismatch"); - return Err(s3_error!(SignatureDoesNotMatch)); - } + let verifier = SignatureVerificationContext { + expected_signature, + raw_uri_path: self.raw_uri_path, + secret_key: &secret_key, + amz_date: &amz_date, + region, + service, + }; + let canonical_request = sig_v4::create_canonical_request(method, self.decoded_uri_path, query_strings, &headers, payload); + let signature = verifier.verify_with_raw_path_fallback(&canonical_request, || { + sig_v4::create_canonical_request_with_raw_uri_path(method, self.raw_uri_path, query_strings, &headers, payload) + })?; if is_stream { // For streaming with trailers, AWS requires x-amz-trailer header present. @@ -678,7 +708,7 @@ impl SignatureContext<'_> { mod tests { use super::*; use crate::S3ErrorCode; - use crate::auth::{SecretKey, SimpleAuth}; + use crate::auth::SecretKey; use crate::config::{S3ConfigProvider, StaticConfigProvider}; use crate::sig_v4; use crate::sig_v4::AmzDate; @@ -720,6 +750,66 @@ mod tests { assert!(err.message().unwrap().contains("x-amz-content-sha256")); } + #[test] + fn raw_path_fallback_rejects_missing_or_mismatched_signatures() { + let secret_key: SecretKey = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".into(); + let amz_date = AmzDate::parse("20130524T000000Z").unwrap(); + let method = Method::GET; + let headers = OrderedHeaders::from_slice_unchecked(&[ + ("host", "s3.amazonaws.com"), + ("x-amz-content-sha256", "UNSIGNED-PAYLOAD"), + ("x-amz-date", "20130524T000000Z"), + ]); + + let canonical_request = sig_v4::create_canonical_request( + &method, + "/test-bucket/path", + &[] as &[(&str, &str)], + &headers, + sig_v4::Payload::Unsigned, + ); + let verifier = SignatureVerificationContext { + expected_signature: "0000000000000000000000000000000000000000000000000000000000000000", + raw_uri_path: "/test-bucket/path", + secret_key: &secret_key, + amz_date: &amz_date, + region: "us-east-1", + service: "s3", + }; + let err = verifier + .verify_with_raw_path_fallback(&canonical_request, || panic!("raw fallback should not be attempted")) + .expect_err("signature mismatch without raw reserved characters should be rejected"); + assert_eq!(err.code(), &S3ErrorCode::SignatureDoesNotMatch); + + let canonical_request = sig_v4::create_canonical_request( + &method, + "/test-bucket/path=", + &[] as &[(&str, &str)], + &headers, + sig_v4::Payload::Unsigned, + ); + let verifier = SignatureVerificationContext { + expected_signature: "0000000000000000000000000000000000000000000000000000000000000000", + raw_uri_path: "/test-bucket/path=", + secret_key: &secret_key, + amz_date: &amz_date, + region: "us-east-1", + service: "s3", + }; + let err = verifier + .verify_with_raw_path_fallback(&canonical_request, || { + sig_v4::create_canonical_request_with_raw_uri_path( + &method, + "/test-bucket/path=", + &[] as &[(&str, &str)], + &headers, + sig_v4::Payload::Unsigned, + ) + }) + .expect_err("raw fallback signature mismatch should be rejected"); + assert_eq!(err.code(), &S3ErrorCode::SignatureDoesNotMatch); + } + #[tokio::test] async fn post_signature_allows_anonymous() { use crate::config::{S3ConfigProvider, StaticConfigProvider}; @@ -752,7 +842,8 @@ file content\r\n\ req_body: &mut body, qs: None, hs: OrderedHeaders::from_slice_unchecked(&[]), - decoded_uri_path: "/test-bucket".to_owned(), + decoded_uri_path: "/test-bucket", + raw_uri_path: "/test-bucket", vh_bucket: None, content_length: None, mime: Some(mime), @@ -868,7 +959,8 @@ file content\r\n\ req_body: &mut body, qs: Some(&qs), hs: OrderedHeaders::from_slice_unchecked(&[]), - decoded_uri_path: "/test.txt".to_owned(), + decoded_uri_path: "/test.txt", + raw_uri_path: "/test.txt", vh_bucket: None, content_length: None, mime: None, @@ -885,8 +977,177 @@ file content\r\n\ assert_eq!(err.code(), &S3ErrorCode::NotImplemented); } + #[tokio::test] + async fn v4_presigned_url_accepts_standard_and_raw_uri_path_signatures() { + use crate::auth::SecretKey; + use crate::auth::SimpleAuth; + use crate::config::{S3ConfigProvider, StaticConfigProvider}; + use std::sync::Arc; + + let access_key = "AKIAIOSFODNN7EXAMPLE"; + let secret_key: SecretKey = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".into(); + let auth = SimpleAuth::from_single(access_key, secret_key.clone()); + let config: Arc = Arc::new(StaticConfigProvider::default()); + + let method = Method::GET; + let uri = Uri::from_static("https://s3.amazonaws.com/test-bucket/path/sitemap.xmlage="); + let decoded_uri_path = "/test-bucket/path/sitemap.xmlage="; + let raw_uri_path = "/test-bucket/path/sitemap.xmlage="; + let amz_date = AmzDate::parse("20130524T000000Z").unwrap(); + let headers_for_signing = OrderedHeaders::from_slice_unchecked(&[("host", "s3.amazonaws.com")]); + + let query_strings_for_signing = &[ + ("X-Amz-Algorithm", "AWS4-HMAC-SHA256"), + ("X-Amz-Credential", "AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request"), + ("X-Amz-Date", "20130524T000000Z"), + ("X-Amz-Expires", "999999999"), + ("X-Amz-SignedHeaders", "host"), + ]; + + let canonical_requests = [ + sig_v4::create_presigned_canonical_request( + &method, + decoded_uri_path, + query_strings_for_signing, + &headers_for_signing, + sig_v4::Payload::Unsigned, + ), + sig_v4::create_presigned_canonical_request_with_raw_uri_path( + &method, + raw_uri_path, + query_strings_for_signing, + &headers_for_signing, + sig_v4::Payload::Unsigned, + ), + ]; + assert_ne!(canonical_requests[0], canonical_requests[1]); + + for canonical_request in canonical_requests { + let string_to_sign = sig_v4::create_string_to_sign(&canonical_request, &amz_date, "us-east-1", "s3"); + let signature = sig_v4::calculate_signature(&string_to_sign, &secret_key, &amz_date, "us-east-1", "s3"); + let qs = OrderedQs::parse(&format!( + "{}&X-Amz-Signature={signature}", + concat!( + "X-Amz-Algorithm=AWS4-HMAC-SHA256", + "&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request", + "&X-Amz-Date=20130524T000000Z", + "&X-Amz-Expires=999999999", + "&X-Amz-SignedHeaders=host" + ) + )) + .unwrap(); + let headers = OrderedHeaders::from_slice_unchecked(&[("host", "s3.amazonaws.com")]); + + let mut body = Body::empty(); + let mut cx = SignatureContext { + auth: Some(&auth), + config: &config, + req_version: ::http::Version::HTTP_11, + req_method: &method, + req_uri: &uri, + req_body: &mut body, + qs: Some(&qs), + hs: headers, + decoded_uri_path, + raw_uri_path, + vh_bucket: None, + content_length: None, + mime: None, + decoded_content_length: None, + transformed_body: None, + multipart: None, + trailing_headers: None, + }; + + let cred = cx + .v4_check_presigned_url() + .await + .expect("valid presigned URL with a raw '=' URI path should succeed"); + assert_eq!(cred.access_key, access_key); + } + } + + #[tokio::test] + async fn v4_presigned_url_uses_http2_authority_for_signed_host() { + use crate::auth::SecretKey; + use crate::auth::SimpleAuth; + use crate::config::{S3ConfigProvider, StaticConfigProvider}; + use std::sync::Arc; + + let access_key = "AKIAIOSFODNN7EXAMPLE"; + let secret_key: SecretKey = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".into(); + let auth = SimpleAuth::from_single(access_key, secret_key.clone()); + let config: Arc = Arc::new(StaticConfigProvider::default()); + + let method = Method::GET; + let uri = Uri::from_static("https://s3.amazonaws.com/test-bucket/path/sitemap.xmlage="); + let decoded_uri_path = "/test-bucket/path/sitemap.xmlage="; + let raw_uri_path = "/test-bucket/path/sitemap.xmlage="; + let amz_date = AmzDate::parse("20130524T000000Z").unwrap(); + let headers_for_signing = OrderedHeaders::from_slice_unchecked(&[("host", "s3.amazonaws.com")]); + let query_strings_for_signing = &[ + ("X-Amz-Algorithm", "AWS4-HMAC-SHA256"), + ("X-Amz-Credential", "AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request"), + ("X-Amz-Date", "20130524T000000Z"), + ("X-Amz-Expires", "999999999"), + ("X-Amz-SignedHeaders", "host"), + ]; + let canonical_request = sig_v4::create_presigned_canonical_request( + &method, + decoded_uri_path, + query_strings_for_signing, + &headers_for_signing, + sig_v4::Payload::Unsigned, + ); + let string_to_sign = sig_v4::create_string_to_sign(&canonical_request, &amz_date, "us-east-1", "s3"); + let signature = sig_v4::calculate_signature(&string_to_sign, &secret_key, &amz_date, "us-east-1", "s3"); + let qs = OrderedQs::parse(&format!( + "{}&X-Amz-Signature={signature}", + concat!( + "X-Amz-Algorithm=AWS4-HMAC-SHA256", + "&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request", + "&X-Amz-Date=20130524T000000Z", + "&X-Amz-Expires=999999999", + "&X-Amz-SignedHeaders=host" + ) + )) + .unwrap(); + + let mut body = Body::empty(); + let mut cx = SignatureContext { + auth: Some(&auth), + config: &config, + req_version: ::http::Version::HTTP_2, + req_method: &method, + req_uri: &uri, + req_body: &mut body, + qs: Some(&qs), + hs: OrderedHeaders::from_slice_unchecked(&[]), + decoded_uri_path, + raw_uri_path, + vh_bucket: None, + content_length: None, + mime: None, + decoded_content_length: None, + transformed_body: None, + multipart: None, + trailing_headers: None, + }; + + let cred = cx + .v4_check_presigned_url() + .await + .expect("HTTP/2 authority should be used for a signed host header"); + assert_eq!(cred.access_key, access_key); + } + #[tokio::test] async fn v4_presigned_url_invalid_content_sha256_returns_checksum_mismatch() { + use crate::auth::SecretKey; + use crate::auth::SimpleAuth; + use crate::config::{S3ConfigProvider, StaticConfigProvider}; + use std::sync::Arc; + let access_key = "AKIAIOSFODNN7EXAMPLE"; let secret_key: SecretKey = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".into(); let auth = SimpleAuth::from_single(access_key, secret_key.clone()); @@ -939,7 +1200,8 @@ file content\r\n\ req_body: &mut body, qs: Some(&qs), hs: headers, - decoded_uri_path: "/test.txt".to_owned(), + decoded_uri_path: "/test.txt", + raw_uri_path: "/test.txt", vh_bucket: None, content_length: Some(0), mime: None, @@ -957,6 +1219,261 @@ file content\r\n\ assert_eq!(err.status_code(), Some(hyper::StatusCode::BAD_REQUEST)); } + #[tokio::test] + async fn v4_header_auth_accepts_standard_and_raw_uri_path_signatures() { + use crate::auth::SecretKey; + use crate::auth::SimpleAuth; + use crate::config::{S3ConfigProvider, StaticConfigProvider}; + use std::sync::Arc; + + let access_key = "AKIAIOSFODNN7EXAMPLE"; + let secret_key: SecretKey = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".into(); + let auth = SimpleAuth::from_single(access_key, secret_key.clone()); + let config: Arc = Arc::new(StaticConfigProvider::default()); + + let method = Method::GET; + let uri = Uri::from_static("https://s3.amazonaws.com/test-bucket/path/sitemap.xmlage="); + let decoded_uri_path = "/test-bucket/path/sitemap.xmlage="; + let raw_uri_path = "/test-bucket/path/sitemap.xmlage="; + let amz_date = AmzDate::parse("20130524T000000Z").unwrap(); + let headers_for_signing = OrderedHeaders::from_slice_unchecked(&[ + ("host", "s3.amazonaws.com"), + ("x-amz-content-sha256", "UNSIGNED-PAYLOAD"), + ("x-amz-date", "20130524T000000Z"), + ]); + + let canonical_requests = [ + sig_v4::create_canonical_request( + &method, + decoded_uri_path, + &[] as &[(&str, &str)], + &headers_for_signing, + sig_v4::Payload::Unsigned, + ), + sig_v4::create_canonical_request_with_raw_uri_path( + &method, + raw_uri_path, + &[] as &[(&str, &str)], + &headers_for_signing, + sig_v4::Payload::Unsigned, + ), + ]; + + for canonical_request in canonical_requests { + let string_to_sign = sig_v4::create_string_to_sign(&canonical_request, &amz_date, "us-east-1", "s3"); + let signature = sig_v4::calculate_signature(&string_to_sign, &secret_key, &amz_date, "us-east-1", "s3"); + let authorization = format!( + "AWS4-HMAC-SHA256 Credential={access_key}/20130524/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature={signature}" + ); + let headers = OrderedHeaders::from_slice_unchecked(&[ + ("authorization", authorization.as_str()), + ("host", "s3.amazonaws.com"), + ("x-amz-content-sha256", "UNSIGNED-PAYLOAD"), + ("x-amz-date", "20130524T000000Z"), + ]); + + let mut body = Body::empty(); + let mut cx = SignatureContext { + auth: Some(&auth), + config: &config, + req_version: ::http::Version::HTTP_11, + req_method: &method, + req_uri: &uri, + req_body: &mut body, + qs: None, + hs: headers, + decoded_uri_path, + raw_uri_path, + vh_bucket: None, + content_length: Some(0), + mime: None, + decoded_content_length: None, + transformed_body: None, + multipart: None, + trailing_headers: None, + }; + + let cred = cx + .v4_check_header_auth() + .await + .expect("valid SigV4 auth with a raw '=' URI path should succeed"); + assert_eq!(cred.access_key, access_key); + } + } + + #[tokio::test] + async fn v4_header_auth_uses_http2_authority_for_signed_host() { + use crate::auth::SecretKey; + use crate::auth::SimpleAuth; + use crate::config::{S3ConfigProvider, StaticConfigProvider}; + use std::sync::Arc; + + let access_key = "AKIAIOSFODNN7EXAMPLE"; + let secret_key: SecretKey = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".into(); + let auth = SimpleAuth::from_single(access_key, secret_key.clone()); + let config: Arc = Arc::new(StaticConfigProvider::default()); + + let method = Method::GET; + let uri = Uri::from_static("https://s3.amazonaws.com/test-bucket/path/sitemap.xmlage="); + let decoded_uri_path = "/test-bucket/path/sitemap.xmlage="; + let raw_uri_path = "/test-bucket/path/sitemap.xmlage="; + let amz_date = AmzDate::parse("20130524T000000Z").unwrap(); + let headers_for_signing = OrderedHeaders::from_slice_unchecked(&[ + ("host", "s3.amazonaws.com"), + ("x-amz-content-sha256", "UNSIGNED-PAYLOAD"), + ("x-amz-date", "20130524T000000Z"), + ]); + let canonical_request = sig_v4::create_canonical_request( + &method, + decoded_uri_path, + &[] as &[(&str, &str)], + &headers_for_signing, + sig_v4::Payload::Unsigned, + ); + let string_to_sign = sig_v4::create_string_to_sign(&canonical_request, &amz_date, "us-east-1", "s3"); + let signature = sig_v4::calculate_signature(&string_to_sign, &secret_key, &amz_date, "us-east-1", "s3"); + let authorization = format!( + "AWS4-HMAC-SHA256 Credential={access_key}/20130524/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature={signature}" + ); + let headers = OrderedHeaders::from_slice_unchecked(&[ + ("authorization", authorization.as_str()), + ("x-amz-content-sha256", "UNSIGNED-PAYLOAD"), + ("x-amz-date", "20130524T000000Z"), + ]); + + let mut body = Body::empty(); + let mut cx = SignatureContext { + auth: Some(&auth), + config: &config, + req_version: ::http::Version::HTTP_2, + req_method: &method, + req_uri: &uri, + req_body: &mut body, + qs: None, + hs: headers, + decoded_uri_path, + raw_uri_path, + vh_bucket: None, + content_length: Some(0), + mime: None, + decoded_content_length: None, + transformed_body: None, + multipart: None, + trailing_headers: None, + }; + + let cred = cx + .v4_check_header_auth() + .await + .expect("HTTP/2 authority should be used for a signed host header"); + assert_eq!(cred.access_key, access_key); + } + + #[tokio::test] + async fn v4_header_auth_raw_uri_path_signature_seeds_streaming_body() { + use crate::auth::SecretKey; + use crate::auth::SimpleAuth; + use crate::config::{S3ConfigProvider, StaticConfigProvider}; + use bytes::Bytes; + use std::sync::Arc; + + let access_key = "AKIAIOSFODNN7EXAMPLE"; + let secret_key: SecretKey = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY".into(); + let auth = SimpleAuth::from_single(access_key, secret_key.clone()); + let config: Arc = Arc::new(StaticConfigProvider::default()); + + let method = Method::PUT; + let uri = Uri::from_static("https://s3.amazonaws.com/test-bucket/path/sitemap.xmlage="); + let decoded_uri_path = "/test-bucket/path/sitemap.xmlage="; + let raw_uri_path = "/test-bucket/path/sitemap.xmlage="; + let amz_date = AmzDate::parse("20130524T000000Z").unwrap(); + let chunk_data = Bytes::from_static(b"hello"); + let decoded_content_length = chunk_data.len(); + let headers_for_signing = OrderedHeaders::from_slice_unchecked(&[ + ("host", "s3.amazonaws.com"), + ("x-amz-content-sha256", "STREAMING-AWS4-HMAC-SHA256-PAYLOAD"), + ("x-amz-date", "20130524T000000Z"), + ("x-amz-decoded-content-length", "5"), + ]); + + let standard_canonical_request = sig_v4::create_canonical_request( + &method, + decoded_uri_path, + &[] as &[(&str, &str)], + &headers_for_signing, + sig_v4::Payload::MultipleChunks, + ); + let raw_canonical_request = sig_v4::create_canonical_request_with_raw_uri_path( + &method, + raw_uri_path, + &[] as &[(&str, &str)], + &headers_for_signing, + sig_v4::Payload::MultipleChunks, + ); + assert_ne!(standard_canonical_request, raw_canonical_request); + + let seed_string_to_sign = sig_v4::create_string_to_sign(&raw_canonical_request, &amz_date, "us-east-1", "s3"); + let seed_signature = sig_v4::calculate_signature(&seed_string_to_sign, &secret_key, &amz_date, "us-east-1", "s3"); + + let chunk_string_to_sign = + sig_v4::create_chunk_string_to_sign(&amz_date, "us-east-1", "s3", &seed_signature, std::slice::from_ref(&chunk_data)); + let chunk_signature = sig_v4::calculate_signature(&chunk_string_to_sign, &secret_key, &amz_date, "us-east-1", "s3"); + let final_string_to_sign = sig_v4::create_chunk_string_to_sign(&amz_date, "us-east-1", "s3", &chunk_signature, &[]); + let final_signature = sig_v4::calculate_signature(&final_string_to_sign, &secret_key, &amz_date, "us-east-1", "s3"); + + let mut streaming_body = Vec::new(); + streaming_body.extend_from_slice(format!("{:x};chunk-signature={chunk_signature}\r\n", chunk_data.len()).as_bytes()); + streaming_body.extend_from_slice(&chunk_data); + streaming_body.extend_from_slice(b"\r\n"); + streaming_body.extend_from_slice(format!("0;chunk-signature={final_signature}\r\n\r\n").as_bytes()); + let content_length = u64::try_from(streaming_body.len()).unwrap(); + + let authorization = format!( + "AWS4-HMAC-SHA256 Credential={access_key}/20130524/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-decoded-content-length, Signature={seed_signature}" + ); + let headers = OrderedHeaders::from_slice_unchecked(&[ + ("authorization", authorization.as_str()), + ("host", "s3.amazonaws.com"), + ("x-amz-content-sha256", "STREAMING-AWS4-HMAC-SHA256-PAYLOAD"), + ("x-amz-date", "20130524T000000Z"), + ("x-amz-decoded-content-length", "5"), + ]); + + let mut body = Body::from(Bytes::from(streaming_body)); + let mut cx = SignatureContext { + auth: Some(&auth), + config: &config, + req_version: ::http::Version::HTTP_11, + req_method: &method, + req_uri: &uri, + req_body: &mut body, + qs: None, + hs: headers, + decoded_uri_path, + raw_uri_path, + vh_bucket: None, + content_length: Some(content_length), + mime: None, + decoded_content_length: Some(decoded_content_length), + transformed_body: None, + multipart: None, + trailing_headers: None, + }; + + let cred = cx + .v4_check_header_auth() + .await + .expect("valid streaming SigV4 auth with a raw '=' URI path should succeed"); + assert_eq!(cred.access_key, access_key); + + let mut transformed_body = cx.transformed_body.take().expect("streaming body should be transformed"); + let decoded_body = transformed_body + .store_all_limited(decoded_content_length) + .await + .expect("raw-path seed signature should validate aws-chunked body"); + assert_eq!(decoded_body, chunk_data); + } + /// `SigV2` does not carry region in the credential scope, so `CredentialsExt.region` /// must always be `None` and `service` must always be `Some("s3")`. /// @@ -1004,7 +1521,8 @@ file content\r\n\ req_body: &mut body, qs: None, hs, - decoded_uri_path: "/test-bucket/test-key".to_owned(), + decoded_uri_path: "/test-bucket/test-key", + raw_uri_path: "/test-bucket/test-key", vh_bucket: None, content_length: None, mime: None, diff --git a/crates/s3s/src/ops/tests.rs b/crates/s3s/src/ops/tests.rs index 92a11de6..97c16f1b 100644 --- a/crates/s3s/src/ops/tests.rs +++ b/crates/s3s/src/ops/tests.rs @@ -392,7 +392,8 @@ async fn presigned_url_expires_0_should_be_expired() { req_body: &mut body, qs: Some(&qs), hs: OrderedHeaders::from_slice_unchecked(&[]), - decoded_uri_path: "/test.txt".to_owned(), + decoded_uri_path: "/test.txt", + raw_uri_path: "/test.txt", vh_bucket: None, content_length: None, mime: None, diff --git a/crates/s3s/src/sig_v4/methods.rs b/crates/s3s/src/sig_v4/methods.rs index 89a66868..1c0464b4 100644 --- a/crates/s3s/src/sig_v4/methods.rs +++ b/crates/s3s/src/sig_v4/methods.rs @@ -125,14 +125,13 @@ impl Payload<'_> { } } -/// create canonical request -#[must_use] -pub fn create_canonical_request( +fn create_canonical_request_with_uri_mode( method: &Method, uri_path: &str, decoded_query_strings: &[(impl AsRef, impl AsRef)], signed_headers: &OrderedHeaders<'_>, payload: Payload<'_>, + raw_uri_path: bool, ) -> String { let mut ans = String::with_capacity(256); @@ -144,7 +143,11 @@ pub fn create_canonical_request( { // \n - uri_encode(&mut ans, uri_path, false); + if raw_uri_path { + ans.push_str(uri_path); + } else { + uri_encode(&mut ans, uri_path, false); + } ans.push('\n'); } @@ -250,6 +253,28 @@ pub fn create_canonical_request( ans } +/// create canonical request +#[must_use] +pub fn create_canonical_request( + method: &Method, + uri_path: &str, + decoded_query_strings: &[(impl AsRef, impl AsRef)], + signed_headers: &OrderedHeaders<'_>, + payload: Payload<'_>, +) -> String { + create_canonical_request_with_uri_mode(method, uri_path, decoded_query_strings, signed_headers, payload, false) +} + +pub(crate) fn create_canonical_request_with_raw_uri_path( + method: &Method, + raw_uri_path: &str, + decoded_query_strings: &[(impl AsRef, impl AsRef)], + signed_headers: &OrderedHeaders<'_>, + payload: Payload<'_>, +) -> String { + create_canonical_request_with_uri_mode(method, raw_uri_path, decoded_query_strings, signed_headers, payload, true) +} + /// create string to sign #[must_use] pub fn create_string_to_sign(canonical_request: &str, amz_date: &AmzDate, region: &str, service: &str) -> String { @@ -402,12 +427,12 @@ pub fn calculate_signature( hex(hmac_sha256(signing_key, string_to_sign)) } -/// create presigned canonical request -pub fn create_presigned_canonical_request( +fn create_presigned_canonical_request_with_uri_mode( method: &Method, uri_path: &str, decoded_query_strings: &[(impl AsRef, impl AsRef)], signed_headers: &OrderedHeaders<'_>, + raw_uri_path: bool, payload: Payload<'_>, ) -> String { let mut ans = String::with_capacity(256); @@ -418,7 +443,11 @@ pub fn create_presigned_canonical_request( } { // \n - uri_encode(&mut ans, uri_path, false); + if raw_uri_path { + ans.push_str(uri_path); + } else { + uri_encode(&mut ans, uri_path, false); + } ans.push('\n'); } { @@ -520,6 +549,27 @@ pub fn create_presigned_canonical_request( ans } +/// create presigned canonical request +pub fn create_presigned_canonical_request( + method: &Method, + uri_path: &str, + decoded_query_strings: &[(impl AsRef, impl AsRef)], + signed_headers: &OrderedHeaders<'_>, + payload: Payload<'_>, +) -> String { + create_presigned_canonical_request_with_uri_mode(method, uri_path, decoded_query_strings, signed_headers, false, payload) +} + +pub(crate) fn create_presigned_canonical_request_with_raw_uri_path( + method: &Method, + raw_uri_path: &str, + decoded_query_strings: &[(impl AsRef, impl AsRef)], + signed_headers: &OrderedHeaders<'_>, + payload: Payload<'_>, +) -> String { + create_presigned_canonical_request_with_uri_mode(method, raw_uri_path, decoded_query_strings, signed_headers, true, payload) +} + #[cfg(test)] mod tests { use super::*; diff --git a/pyproject.toml b/pyproject.toml index 326aca0b..c3d5552b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ requires-python = ">=3.13" dependencies = [ "beautifulsoup4>=4.12.3", "boto3>=1.35.0", - "lxml>=5.3.0", + "lxml>=6.1.0", "requests>=2.32.3", "typer>=0.12.5", ] diff --git a/scripts/e2e-s3tests.sh b/scripts/e2e-s3tests.sh index 88bb9bdf..37e8c489 100755 --- a/scripts/e2e-s3tests.sh +++ b/scripts/e2e-s3tests.sh @@ -8,6 +8,8 @@ REPORT_DIR="/tmp/s3s-s3tests-report" MINIO_DIR="/tmp/s3s-s3tests-minio" S3S_PROXY_PID="" +. "$ROOT_DIR/scripts/source-s3tests-ref.sh" + mkdir -p "$TARGET_DIR" mkdir -p "$REPORT_DIR" mkdir -p "$MINIO_DIR" @@ -122,18 +124,14 @@ ensure_proxy_running ensure_minio_running "$MINIO_CONTAINER_ID" if [ -d "$S3TESTS_DIR/.git" ]; then - default_branch=$(git -C "$S3TESTS_DIR" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||') - if [ -z "$default_branch" ]; then - default_branch=$(git -C "$S3TESTS_DIR" remote show origin | awk '/HEAD branch/ {print $NF}') - fi - if [ -z "$default_branch" ]; then - default_branch="master" - fi - git -C "$S3TESTS_DIR" fetch --depth 1 origin "$default_branch" - git -C "$S3TESTS_DIR" reset --hard "origin/$default_branch" + git -C "$S3TESTS_DIR" fetch --depth 1 origin "$S3TESTS_REF" + git -C "$S3TESTS_DIR" reset --hard FETCH_HEAD else rm -rf "$S3TESTS_DIR" - git clone --depth 1 https://github.com/ceph/s3-tests.git "$S3TESTS_DIR" + git init "$S3TESTS_DIR" + git -C "$S3TESTS_DIR" remote add origin https://github.com/ceph/s3-tests.git + git -C "$S3TESTS_DIR" fetch --depth 1 origin "$S3TESTS_REF" + git -C "$S3TESTS_DIR" reset --hard FETCH_HEAD fi if command -v sha256sum >/dev/null 2>&1; then REQUIREMENTS_HASH=$(sha256sum "$S3TESTS_DIR/requirements.txt" | cut -d' ' -f1) diff --git a/scripts/s3tests.env b/scripts/s3tests.env new file mode 100644 index 00000000..2db0af4c --- /dev/null +++ b/scripts/s3tests.env @@ -0,0 +1,3 @@ +# Pinned ceph/s3-tests revision used by e2e CI. Update this when refreshing +# the s3-tests compatibility baseline. +S3TESTS_REF=fb8b73092bb1dd8db829f1205a9e52e73bf9a232 diff --git a/scripts/source-s3tests-ref.sh b/scripts/source-s3tests-ref.sh new file mode 100644 index 00000000..e8da6b40 --- /dev/null +++ b/scripts/source-s3tests-ref.sh @@ -0,0 +1,14 @@ +S3TESTS_REF_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +S3TESTS_REF_FILE="${S3TESTS_REF_FILE:-$S3TESTS_REF_SCRIPT_DIR/s3tests.env}" + +if [ -z "${S3TESTS_REF:-}" ]; then + if [ ! -r "$S3TESTS_REF_FILE" ]; then + echo "s3-tests ref file not readable: $S3TESTS_REF_FILE" >&2 + return 1 2>/dev/null || exit 1 + fi + . "$S3TESTS_REF_FILE" +fi +if [ -z "${S3TESTS_REF:-}" ]; then + echo "s3-tests ref is empty" >&2 + return 1 2>/dev/null || exit 1 +fi diff --git a/uv.lock b/uv.lock index 5bffce19..9437b937 100644 --- a/uv.lock +++ b/uv.lock @@ -99,11 +99,11 @@ wheels = [ [[package]] name = "idna" -version = "3.10" +version = "3.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] [[package]] @@ -117,27 +117,64 @@ wheels = [ [[package]] name = "lxml" -version = "5.3.1" +version = "6.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ef/f6/c15ca8e5646e937c148e147244817672cf920b56ac0bf2cc1512ae674be8/lxml-5.3.1.tar.gz", hash = "sha256:106b7b5d2977b339f1e97efe2778e2ab20e99994cbb0ec5e55771ed0795920c8", size = 3678591, upload-time = "2025-02-10T07:51:41.769Z" } +sdist = { url = "https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz", hash = "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13", size = 4197006, upload-time = "2026-04-18T04:32:51.613Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/1c/724931daa1ace168e0237b929e44062545bf1551974102a5762c349c668d/lxml-5.3.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c093c7088b40d8266f57ed71d93112bd64c6724d31f0794c1e52cc4857c28e0e", size = 8171881, upload-time = "2025-02-10T07:46:40.653Z" }, - { url = "https://files.pythonhosted.org/packages/67/0c/857b8fb6010c4246e66abeebb8639eaabba60a6d9b7c606554ecc5cbf1ee/lxml-5.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b0884e3f22d87c30694e625b1e62e6f30d39782c806287450d9dc2fdf07692fd", size = 4440394, upload-time = "2025-02-10T07:46:44.037Z" }, - { url = "https://files.pythonhosted.org/packages/61/72/c9e81de6a000f9682ccdd13503db26e973b24c68ac45a7029173237e3eed/lxml-5.3.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1637fa31ec682cd5760092adfabe86d9b718a75d43e65e211d5931809bc111e7", size = 5037860, upload-time = "2025-02-10T07:46:47.919Z" }, - { url = "https://files.pythonhosted.org/packages/24/26/942048c4b14835711b583b48cd7209bd2b5f0b6939ceed2381a494138b14/lxml-5.3.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a364e8e944d92dcbf33b6b494d4e0fb3499dcc3bd9485beb701aa4b4201fa414", size = 4782513, upload-time = "2025-02-10T07:46:50.696Z" }, - { url = "https://files.pythonhosted.org/packages/e2/65/27792339caf00f610cc5be32b940ba1e3009b7054feb0c4527cebac228d4/lxml-5.3.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:779e851fd0e19795ccc8a9bb4d705d6baa0ef475329fe44a13cf1e962f18ff1e", size = 5305227, upload-time = "2025-02-10T07:46:53.503Z" }, - { url = "https://files.pythonhosted.org/packages/18/e1/25f7aa434a4d0d8e8420580af05ea49c3e12db6d297cf5435ac0a054df56/lxml-5.3.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c4393600915c308e546dc7003d74371744234e8444a28622d76fe19b98fa59d1", size = 4829846, upload-time = "2025-02-10T07:46:56.262Z" }, - { url = "https://files.pythonhosted.org/packages/fe/ed/faf235e0792547d24f61ee1448159325448a7e4f2ab706503049d8e5df19/lxml-5.3.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:673b9d8e780f455091200bba8534d5f4f465944cbdd61f31dc832d70e29064a5", size = 4949495, upload-time = "2025-02-10T07:46:59.189Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e1/8f572ad9ed6039ba30f26dd4c2c58fb90f79362d2ee35ca3820284767672/lxml-5.3.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:2e4a570f6a99e96c457f7bec5ad459c9c420ee80b99eb04cbfcfe3fc18ec6423", size = 4773415, upload-time = "2025-02-10T07:47:03.53Z" }, - { url = "https://files.pythonhosted.org/packages/a3/75/6b57166b9d1983dac8f28f354e38bff8d6bcab013a241989c4d54c72701b/lxml-5.3.1-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:71f31eda4e370f46af42fc9f264fafa1b09f46ba07bdbee98f25689a04b81c20", size = 5337710, upload-time = "2025-02-10T07:47:06.385Z" }, - { url = "https://files.pythonhosted.org/packages/cc/71/4aa56e2daa83bbcc66ca27b5155be2f900d996f5d0c51078eaaac8df9547/lxml-5.3.1-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:42978a68d3825eaac55399eb37a4d52012a205c0c6262199b8b44fcc6fd686e8", size = 4897362, upload-time = "2025-02-10T07:47:09.24Z" }, - { url = "https://files.pythonhosted.org/packages/65/10/3fa2da152cd9b49332fd23356ed7643c9b74cad636ddd5b2400a9730d12b/lxml-5.3.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:8b1942b3e4ed9ed551ed3083a2e6e0772de1e5e3aca872d955e2e86385fb7ff9", size = 4977795, upload-time = "2025-02-10T07:47:12.101Z" }, - { url = "https://files.pythonhosted.org/packages/de/d2/e1da0f7b20827e7b0ce934963cb6334c1b02cf1bb4aecd218c4496880cb3/lxml-5.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85c4f11be9cf08917ac2a5a8b6e1ef63b2f8e3799cec194417e76826e5f1de9c", size = 4858104, upload-time = "2025-02-10T07:47:15.998Z" }, - { url = "https://files.pythonhosted.org/packages/a5/35/063420e1b33d3308f5aa7fcbdd19ef6c036f741c9a7a4bd5dc8032486b27/lxml-5.3.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:231cf4d140b22a923b1d0a0a4e0b4f972e5893efcdec188934cc65888fd0227b", size = 5416531, upload-time = "2025-02-10T07:47:19.862Z" }, - { url = "https://files.pythonhosted.org/packages/c3/83/93a6457d291d1e37adfb54df23498101a4701834258c840381dd2f6a030e/lxml-5.3.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5865b270b420eda7b68928d70bb517ccbe045e53b1a428129bb44372bf3d7dd5", size = 5273040, upload-time = "2025-02-10T07:47:24.29Z" }, - { url = "https://files.pythonhosted.org/packages/39/25/ad4ac8fac488505a2702656550e63c2a8db3a4fd63db82a20dad5689cecb/lxml-5.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7bebc2275016cddf3c997bf8a0f7044160714c64a9b83975670a04e6d2252", size = 5050951, upload-time = "2025-02-10T07:47:27.143Z" }, - { url = "https://files.pythonhosted.org/packages/82/74/f7d223c704c87e44b3d27b5e0dde173a2fcf2e89c0524c8015c2b3554876/lxml-5.3.1-cp313-cp313-win32.whl", hash = "sha256:d0751528b97d2b19a388b302be2a0ee05817097bab46ff0ed76feeec24951f78", size = 3485357, upload-time = "2025-02-10T07:47:29.738Z" }, - { url = "https://files.pythonhosted.org/packages/80/83/8c54533b3576f4391eebea88454738978669a6cad0d8e23266224007939d/lxml-5.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:91fb6a43d72b4f8863d21f347a9163eecbf36e76e2f51068d59cd004c506f332", size = 3814484, upload-time = "2025-02-10T07:47:33.3Z" }, + { url = "https://files.pythonhosted.org/packages/08/03/69347590f1cf4a6d5a4944bb6099e6d37f334784f16062234e1f892fdb1d/lxml-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a0092f2b107b69601adf562a57c956fbb596e05e3e6651cabd3054113b007e45", size = 8559689, upload-time = "2026-04-18T04:31:57.785Z" }, + { url = "https://files.pythonhosted.org/packages/3f/58/25e00bb40b185c974cfe156c110474d9a8a8390d5f7c92a4e328189bb60e/lxml-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc7140d7a7386e6b545d41b7358f4d02b656d4053f5fa6859f92f4b9c2572c4d", size = 4617892, upload-time = "2026-04-18T04:32:01.78Z" }, + { url = "https://files.pythonhosted.org/packages/f5/54/92ad98a94ac318dc4f97aaac22ff8d1b94212b2ae8af5b6e9b354bf825f7/lxml-6.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:419c58fc92cc3a2c3fa5f78c63dbf5da70c1fa9c1b25f25727ecee89a96c7de2", size = 4923489, upload-time = "2026-04-18T04:33:31.401Z" }, + { url = "https://files.pythonhosted.org/packages/15/3b/a20aecfab42bdf4f9b390590d345857ad3ffd7c51988d1c89c53a0c73faf/lxml-6.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:37fabd1452852636cf38ecdcc9dd5ca4bba7a35d6c53fa09725deeb894a87491", size = 5082162, upload-time = "2026-04-18T04:33:34.262Z" }, + { url = "https://files.pythonhosted.org/packages/45/26/2cdb3d281ac1bd175603e290cbe4bad6eff127c0f8de90bafd6f8548f0fd/lxml-6.1.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2853c8b2170cc6cd54a6b4d50d2c1a8a7aeca201f23804b4898525c7a152cfc", size = 4993247, upload-time = "2026-04-18T04:33:36.674Z" }, + { url = "https://files.pythonhosted.org/packages/f6/05/d735aef963740022a08185c84821f689fc903acb3d50326e6b1e9886cc22/lxml-6.1.0-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e369cbd690e788c8d15e56222d91a09c6a417f49cbc543040cba0fe2e25a79e", size = 5613042, upload-time = "2026-04-18T04:33:39.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b8/ead7c10efff731738c72e59ed6eb5791854879fbed7ae98781a12006263a/lxml-6.1.0-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e69aa6805905807186eb00e66c6d97a935c928275182eb02ee40ba00da9623b2", size = 5228304, upload-time = "2026-04-18T04:33:41.647Z" }, + { url = "https://files.pythonhosted.org/packages/6b/10/e9842d2ec322ea65f0a7270aa0315a53abed06058b88ef1b027f620e7a5f/lxml-6.1.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:4bd1bdb8a9e0e2dd229de19b5f8aebac80e916921b4b2c6ef8a52bc131d0c1f9", size = 5341578, upload-time = "2026-04-18T04:33:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/89/54/40d9403d7c2775fa7301d3ddd3464689bfe9ba71acc17dfff777071b4fdc/lxml-6.1.0-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:cbd7b79cdcb4986ad78a2662625882747f09db5e4cd7b2ae178a88c9c51b3dfe", size = 4700209, upload-time = "2026-04-18T04:33:47.552Z" }, + { url = "https://files.pythonhosted.org/packages/85/b2/bbdcc2cf45dfc7dfffef4fd97e5c47b15919b6a365247d95d6f684ef5e82/lxml-6.1.0-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:43e4d297f11080ec9d64a4b1ad7ac02b4484c9f0e2179d9c4ef78e886e747b88", size = 5232365, upload-time = "2026-04-18T04:33:50.249Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/b06875665e53aaba7127611a7bed3b7b9658e20b22bc2dd217a0b7ab0091/lxml-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cc16682cc987a3da00aa56a3aa3075b08edb10d9b1e476938cfdbee8f3b67181", size = 5043654, upload-time = "2026-04-18T04:33:52.71Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9c/e71a069d09641c1a7abeb30e693f828c7c90a41cbe3d650b2d734d876f85/lxml-6.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d6d8efe71429635f0559579092bb5e60560d7b9115ee38c4adbea35632e7fa24", size = 4769326, upload-time = "2026-04-18T04:33:55.244Z" }, + { url = "https://files.pythonhosted.org/packages/cc/06/7a9cd84b3d4ed79adf35f874750abb697dec0b4a81a836037b36e47c091a/lxml-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7e39ab3a28af7784e206d8606ec0e4bcad0190f63a492bca95e94e5a4aef7f6e", size = 5635879, upload-time = "2026-04-18T04:33:58.509Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f0/9d57916befc1e54c451712c7ee48e9e74e80ae4d03bdce49914e0aee42cd/lxml-6.1.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9eb667bf50856c4a58145f8ca2d5e5be160191e79eb9e30855a476191b3c3495", size = 5224048, upload-time = "2026-04-18T04:34:00.943Z" }, + { url = "https://files.pythonhosted.org/packages/99/75/90c4eefda0c08c92221fe0753db2d6699a4c628f76ff4465ec20dea84cc1/lxml-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7f4a77d6f7edf9230cee3e1f7f6764722a41604ee5681844f18db9a81ea0ec33", size = 5250241, upload-time = "2026-04-18T04:34:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/5e/73/16596f7e4e38fa33084b9ccbccc22a15f82a290a055126f2c1541236d2ff/lxml-6.1.0-cp313-cp313-win32.whl", hash = "sha256:28902146ffbe5222df411c5d19e5352490122e14447e98cd118907ee3fd6ee62", size = 3596938, upload-time = "2026-04-18T04:31:56.206Z" }, + { url = "https://files.pythonhosted.org/packages/8e/63/981401c5680c1eb30893f00a19641ac80db5d1e7086c62cb4b13ed813038/lxml-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:4a1503c56e4e2b38dc76f2f2da7bae69670c0f1933e27cfa34b2fa5876410b16", size = 3995728, upload-time = "2026-04-18T04:31:58.763Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e8/c358a38ac3e541d16a1b527e4e9cb78c0419b0506a070ace11777e5e8404/lxml-6.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:e0af85773850417d994d019741239b901b22c6680206f46a34766926e466141d", size = 3658372, upload-time = "2026-04-18T04:32:03.629Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/cee4cf203ef0bab5c52afc118da61d6b460c928f2893d40023cfa27e0b80/lxml-6.1.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ab863fd37458fed6456525f297d21239d987800c46e67da5ef04fc6b3dd93ac8", size = 8576713, upload-time = "2026-04-18T04:32:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a7/eda05babeb7e046839204eaf254cd4d7c9130ce2bbf0d9e90ea41af5654d/lxml-6.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6fd8b1df8254ff4fd93fd31da1fc15770bde23ac045be9bb1f87425702f61cc9", size = 4623874, upload-time = "2026-04-18T04:32:10.755Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e9/db5846de9b436b91890a62f29d80cd849ea17948a49bf532d5278ee69a9e/lxml-6.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:47024feaae386a92a146af0d2aeed65229bf6fff738e6a11dda6b0015fb8fd03", size = 4949535, upload-time = "2026-04-18T04:34:06.657Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ba/0d3593373dcae1d68f40dc3c41a5a92f2544e68115eb2f62319a4c2a6500/lxml-6.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3f00972f84450204cd5d93a5395965e348956aaceaadec693a22ec743f8ae3eb", size = 5086881, upload-time = "2026-04-18T04:34:09.556Z" }, + { url = "https://files.pythonhosted.org/packages/43/76/759a7484539ad1af0d125a9afe9c3fb5f82a8779fd1f5f56319d9e4ea2fd/lxml-6.1.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97faa0860e13b05b15a51fb4986421ef7a30f0b3334061c416e0981e9450ca4c", size = 5031305, upload-time = "2026-04-18T04:34:12.336Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b9/c1f0daf981a11e47636126901fd4ab82429e18c57aeb0fc3ad2940b42d8b/lxml-6.1.0-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:972a6451204798675407beaad97b868d0c733d9a74dafefc63120b81b8c2de28", size = 5647522, upload-time = "2026-04-18T04:34:14.89Z" }, + { url = "https://files.pythonhosted.org/packages/31/e6/1f533dcd205275363d9ba3511bcec52fa2df86abf8abe6a5f2c599f0dc31/lxml-6.1.0-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fe022f20bc4569ec66b63b3fb275a3d628d9d32da6326b2982584104db6d3086", size = 5239310, upload-time = "2026-04-18T04:34:17.652Z" }, + { url = "https://files.pythonhosted.org/packages/c3/8c/4175fb709c78a6e315ed814ed33be3defd8b8721067e70419a6cf6f971da/lxml-6.1.0-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:75c4c7c619a744f972f4451bf5adf6d0fb00992a1ffc9fd78e13b0bc817cc99f", size = 5350799, upload-time = "2026-04-18T04:34:20.529Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/6ffdebc5994975f0dde4acb59761902bd9d9bb84422b9a0bd239a7da9ca8/lxml-6.1.0-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3648f20d25102a22b6061c688beb3a805099ea4beb0a01ce62975d926944d292", size = 4697693, upload-time = "2026-04-18T04:34:23.541Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/565f36bd5c73294602d48e04d23f81ff4c8736be6ba5e1d1ec670ac9be80/lxml-6.1.0-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77b9f99b17cbf14026d1e618035077060fc7195dd940d025149f3e2e830fbfcb", size = 5250708, upload-time = "2026-04-18T04:34:26.001Z" }, + { url = "https://files.pythonhosted.org/packages/5a/11/a68ab9dd18c5c499404deb4005f4bc4e0e88e5b72cd755ad96efec81d18d/lxml-6.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32662519149fd7a9db354175aa5e417d83485a8039b8aaa62f873ceee7ea4cad", size = 5084737, upload-time = "2026-04-18T04:34:28.32Z" }, + { url = "https://files.pythonhosted.org/packages/ab/78/e8f41e2c74f4af564e6a0348aea69fb6daaefa64bc071ef469823d22cc18/lxml-6.1.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:73d658216fc173cf2c939e90e07b941c5e12736b0bf6a99e7af95459cfe8eabb", size = 4737817, upload-time = "2026-04-18T04:34:30.784Z" }, + { url = "https://files.pythonhosted.org/packages/06/2d/aa4e117aa2ce2f3b35d9ff246be74a2f8e853baba5d2a92c64744474603a/lxml-6.1.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ac4db068889f8772a4a698c5980ec302771bb545e10c4b095d4c8be26749616f", size = 5670753, upload-time = "2026-04-18T04:34:33.675Z" }, + { url = "https://files.pythonhosted.org/packages/08/f5/dd745d50c0409031dbfcc4881740542a01e54d6f0110bd420fa7782110b8/lxml-6.1.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:45e9dfbd1b661eb64ba0d4dbe762bd210c42d86dd1e5bd2bdf89d634231beb43", size = 5238071, upload-time = "2026-04-18T04:34:36.12Z" }, + { url = "https://files.pythonhosted.org/packages/3e/74/ad424f36d0340a904665867dab310a3f1f4c96ff4039698de83b77f44c1f/lxml-6.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:89e8d73d09ac696a5ba42ec69787913d53284f12092f651506779314f10ba585", size = 5264319, upload-time = "2026-04-18T04:34:39.035Z" }, + { url = "https://files.pythonhosted.org/packages/53/36/a15d8b3514ec889bfd6aa3609107fcb6c9189f8dc347f1c0b81eded8d87c/lxml-6.1.0-cp314-cp314-win32.whl", hash = "sha256:ebe33f4ec1b2de38ceb225a1749a2965855bffeef435ba93cd2d5d540783bf2f", size = 3657139, upload-time = "2026-04-18T04:32:20.006Z" }, + { url = "https://files.pythonhosted.org/packages/1a/a4/263ebb0710851a3c6c937180a9a86df1206fdfe53cc43005aa2237fd7736/lxml-6.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:398443df51c538bd578529aa7e5f7afc6c292644174b47961f3bf87fe5741120", size = 4064195, upload-time = "2026-04-18T04:32:23.876Z" }, + { url = "https://files.pythonhosted.org/packages/80/68/2000f29d323b6c286de077ad20b429fc52272e44eae6d295467043e56012/lxml-6.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:8c8984e1d8c4b3949e419158fda14d921ff703a9ed8a47236c6eb7a2b6cb4946", size = 3741870, upload-time = "2026-04-18T04:32:27.922Z" }, + { url = "https://files.pythonhosted.org/packages/30/e9/21383c7c8d43799f0da90224c0d7c921870d476ec9b3e01e1b2c0b8237c5/lxml-6.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1081dd10bc6fa437db2500e13993abf7cc30716d0a2f40e65abb935f02ec559c", size = 8827548, upload-time = "2026-04-18T04:32:15.094Z" }, + { url = "https://files.pythonhosted.org/packages/a5/01/c6bc11cd587030dd4f719f65c5657960649fe3e19196c844c75bf32cd0d6/lxml-6.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:dabecc48db5f42ba348d1f5d5afdc54c6c4cc758e676926c7cd327045749517d", size = 4735866, upload-time = "2026-04-18T04:32:18.924Z" }, + { url = "https://files.pythonhosted.org/packages/f3/01/757132fff5f4acf25463b5298f1a46099f3a94480b806547b29ce5e385de/lxml-6.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e3dd5fe19c9e0ac818a9c7f132a5e43c1339ec1cbbfecb1a938bd3a47875b7c9", size = 4969476, upload-time = "2026-04-18T04:34:41.889Z" }, + { url = "https://files.pythonhosted.org/packages/fd/fb/1bc8b9d27ed64be7c8903db6c89e74dc8c2cd9ec630a7462e4654316dc5b/lxml-6.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9e7b0a4ca6dcc007a4cef00a761bba2dea959de4bd2df98f926b33c92ca5dfb9", size = 5103719, upload-time = "2026-04-18T04:34:44.797Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e7/5bf82fa28133536a54601aae633b14988e89ed61d4c1eb6b899b023233aa/lxml-6.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d27bbe326c6b539c64b42638b18bc6003a8d88f76213a97ac9ed4f885efeab7", size = 5027890, upload-time = "2026-04-18T04:34:47.634Z" }, + { url = "https://files.pythonhosted.org/packages/2d/20/e048db5d4b4ea0366648aa595f26bb764b2670903fc585b87436d0a5032c/lxml-6.1.0-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4e425db0c5445ef0ad56b0eec54f89b88b2d884656e536a90b2f52aecb4ca86", size = 5596008, upload-time = "2026-04-18T04:34:51.503Z" }, + { url = "https://files.pythonhosted.org/packages/9a/c2/d10807bc8da4824b39e5bd01b5d05c077b6fd01bd91584167edf6b269d22/lxml-6.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b89b098105b8599dc57adac95d1813409ac476d3c948a498775d3d0c6124bfb", size = 5224451, upload-time = "2026-04-18T04:34:54.263Z" }, + { url = "https://files.pythonhosted.org/packages/3c/15/2ebea45bea427e7f0057e9ce7b2d62c5aba20c6b001cca89ed0aadb3ad41/lxml-6.1.0-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:c4a699432846df86cc3de502ee85f445ebad748a1c6021d445f3e514d2cd4b1c", size = 5312135, upload-time = "2026-04-18T04:34:56.818Z" }, + { url = "https://files.pythonhosted.org/packages/31/e2/87eeae151b0be2a308d49a7ec444ff3eb192b14251e62addb29d0bf3778f/lxml-6.1.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:30e7b2ed63b6c8e97cca8af048589a788ab5c9c905f36d9cf1c2bb549f450d2f", size = 4639126, upload-time = "2026-04-18T04:34:59.704Z" }, + { url = "https://files.pythonhosted.org/packages/a3/51/8a3f6a20902ad604dd746ec7b4000311b240d389dac5e9d95adefd349e0c/lxml-6.1.0-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:022981127642fe19866d2907d76241bb07ed21749601f727d5d5dd1ce5d1b773", size = 5232579, upload-time = "2026-04-18T04:35:02.658Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d2/650d619bdbe048d2c3f2c31edb00e35670a5e2d65b4fe3b61bce37b19121/lxml-6.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:23cad0cc86046d4222f7f418910e46b89971c5a45d3c8abfad0f64b7b05e4a9b", size = 5084206, upload-time = "2026-04-18T04:35:05.175Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8a/672ca1a3cbeabd1f511ca275a916c0514b747f4b85bdaae103b8fa92f307/lxml-6.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:21c3302068f50d1e8728c67c87ba92aa87043abee517aa2576cca1855326b405", size = 4758906, upload-time = "2026-04-18T04:35:08.098Z" }, + { url = "https://files.pythonhosted.org/packages/be/f1/ef4b691da85c916cb2feb1eec7414f678162798ac85e042fa164419ac05c/lxml-6.1.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:be10838781cb3be19251e276910cd508fe127e27c3242e50521521a0f3781690", size = 5620553, upload-time = "2026-04-18T04:35:11.23Z" }, + { url = "https://files.pythonhosted.org/packages/59/17/94e81def74107809755ac2782fdad4404420f1c92ca83433d117a6d5acf0/lxml-6.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2173a7bffe97667bbf0767f8a99e587740a8c56fdf3befac4b09cb29a80276fd", size = 5229458, upload-time = "2026-04-18T04:35:14.254Z" }, + { url = "https://files.pythonhosted.org/packages/21/55/c4be91b0f830a871fc1b0d730943d56013b683d4671d5198260e2eae722b/lxml-6.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c6854e9cf99c84beb004eecd7d3a3868ef1109bf2b1df92d7bc11e96a36c2180", size = 5247861, upload-time = "2026-04-18T04:35:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ca/77123e4d77df3cb1e968ade7b1f808f5d3a5c1c96b18a33895397de292c1/lxml-6.1.0-cp314-cp314t-win32.whl", hash = "sha256:00750d63ef0031a05331b9223463b1c7c02b9004cef2346a5b2877f0f9494dd2", size = 3897377, upload-time = "2026-04-18T04:32:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/64/ce/3554833989d074267c063209bae8b09815e5656456a2d332b947806b05ff/lxml-6.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:80410c3a7e3c617af04de17caa9f9f20adaa817093293d69eae7d7d0522836f5", size = 4392701, upload-time = "2026-04-18T04:32:12.113Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a0/9b916c68c0e57752c07f8f64b30138d9d4059dbeb27b90274dedbea128ff/lxml-6.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:26dd9f57ee3bd41e7d35b4c98a2ffd89ed11591649f421f0ec19f67d50ec67ac", size = 3817120, upload-time = "2026-04-18T04:32:15.803Z" }, ] [[package]] @@ -226,7 +263,7 @@ dependencies = [ requires-dist = [ { name = "beautifulsoup4", specifier = ">=4.12.3" }, { name = "boto3", specifier = ">=1.35.0" }, - { name = "lxml", specifier = ">=5.3.0" }, + { name = "lxml", specifier = ">=6.1.0" }, { name = "requests", specifier = ">=2.32.3" }, { name = "typer", specifier = ">=0.12.5" }, ] @@ -296,9 +333,9 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ]