Skip to main content

max / makenotwork

s3-storage: kill the two filed mutation survivors, and give CORS a readback Both survivors were reachable in principle and unreachable from a live MinIO, which is why they were filed as tasks rather than excluded (infra f7f13914, a536db81). configure_cors returned () over a setting the crate offered no way to read, so replacing its whole body was invisible to any test. bucket_cors() is the readback that was missing from the wrapper's surface: a caller asking what CORS is set to now has somewhere to ask, and NoSuchCORSConfiguration reads back as no rules rather than as an error every caller has to pattern-match itself. The truncated-without-markers guard needs a response S3 does not currently produce. A StaticReplayClient serves one, so the guard is exercised rather than deleted -- without it that response is an infinite loop against a real endpoint, and the crate is the last thing between the orphan reaper and a spin. The paired test asserts a marker IS followed, or the first is satisfied by a loop that never iterates. Both mutants verified dead by hand before the run: body->() fails the request assertion, and deleting the break fails on the second request having no canned response, immediately rather than by timeout.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01DwpiantpUgohzML4xr6KeQ
Author: Max Johnson <me@maxj.phd> · 2026-08-31 15:16 UTC
Signed with PGP, not checked
Commit: 4574c29798af5a1114669b6db7d5e39cd61ec2fa
Parent: acb04d7
3 files changed, +639 insertions, -9 deletions
@@ -8,6 +8,16 @@
8 8 source = "registry+https://github.com/rust-lang/crates.io-index"
9 9 checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
10 10
11 + [[package]]
12 + name = "assert-json-diff"
13 + version = "2.0.2"
14 + source = "registry+https://github.com/rust-lang/crates.io-index"
15 + checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
16 + dependencies = [
17 + "serde",
18 + "serde_json",
19 + ]
20 +
11 21 [[package]]
12 22 name = "atomic-waker"
13 23 version = "1.1.2"
@@ -245,17 +255,23 @@
245 255 checksum = "6a2f165a7feee6f263028b899d0a181987f4fa7179a6411a32a439fba7c5f769"
246 256 dependencies = [
247 257 "aws-smithy-async",
258 + "aws-smithy-protocol-test",
248 259 "aws-smithy-runtime-api",
249 260 "aws-smithy-types",
261 + "bytes",
250 262 "h2",
251 263 "http 1.4.0",
264 + "http-body 1.0.1",
252 265 "hyper",
253 266 "hyper-rustls",
254 267 "hyper-util",
268 + "indexmap",
255 269 "pin-project-lite",
256 270 "rustls",
257 271 "rustls-native-certs",
258 272 "rustls-pki-types",
273 + "serde",
274 + "serde_json",
259 275 "tokio",
260 276 "tokio-rustls",
261 277 "tower",
@@ -280,6 +296,25 @@
280 296 "aws-smithy-runtime-api",
281 297 ]
282 298
299 + [[package]]
300 + name = "aws-smithy-protocol-test"
301 + version = "0.63.14"
302 + source = "registry+https://github.com/rust-lang/crates.io-index"
303 + checksum = "5b227aa94af99a8e5ee52551cc7e3ee30a217019ef99207b6f0b7a1527685941"
304 + dependencies = [
305 + "assert-json-diff",
306 + "aws-smithy-runtime-api",
307 + "base64-simd",
308 + "cbor-diag",
309 + "ciborium",
310 + "http 0.2.12",
311 + "pretty_assertions",
312 + "regex-lite",
313 + "roxmltree",
314 + "serde_json",
315 + "thiserror",
316 + ]
317 +
283 318 [[package]]
284 319 name = "aws-smithy-query"
285 320 version = "0.60.15"
@@ -340,7 +375,7 @@
340 375 dependencies = [
341 376 "proc-macro2",
342 377 "quote",
343 - "syn",
378 + "syn 2.0.117",
344 379 ]
345 380
346 381 [[package]]
@@ -444,6 +479,15 @@
444 479 "hybrid-array",
445 480 ]
446 481
482 + [[package]]
483 + name = "bs58"
484 + version = "0.5.1"
485 + source = "registry+https://github.com/rust-lang/crates.io-index"
486 + checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
487 + dependencies = [
488 + "tinyvec",
489 + ]
490 +
447 491 [[package]]
448 492 name = "bumpalo"
449 493 version = "3.20.2"
@@ -466,6 +510,25 @@
466 510 "either",
467 511 ]
468 512
513 + [[package]]
514 + name = "cbor-diag"
515 + version = "0.1.12"
516 + source = "registry+https://github.com/rust-lang/crates.io-index"
517 + checksum = "dc245b6ecd09b23901a4fbad1ad975701fd5061ceaef6afa93a2d70605a64429"
518 + dependencies = [
519 + "bs58",
520 + "chrono",
521 + "data-encoding",
522 + "half",
523 + "nom",
524 + "num-bigint",
525 + "num-rational",
526 + "num-traits",
527 + "separator",
528 + "url",
529 + "uuid",
530 + ]
531 +
469 532 [[package]]
470 533 name = "cc"
471 534 version = "1.2.59"
@@ -482,6 +545,42 @@
482 545 source = "registry+https://github.com/rust-lang/crates.io-index"
483 546 checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
484 547
548 + [[package]]
549 + name = "chrono"
550 + version = "0.4.45"
551 + source = "registry+https://github.com/rust-lang/crates.io-index"
552 + checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
553 + dependencies = [
554 + "num-traits",
555 + ]
556 +
557 + [[package]]
558 + name = "ciborium"
559 + version = "0.2.2"
560 + source = "registry+https://github.com/rust-lang/crates.io-index"
561 + checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
562 + dependencies = [
563 + "ciborium-io",
564 + "ciborium-ll",
565 + "serde",
566 + ]
567 +
568 + [[package]]
569 + name = "ciborium-io"
570 + version = "0.2.2"
571 + source = "registry+https://github.com/rust-lang/crates.io-index"
572 + checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
573 +
574 + [[package]]
575 + name = "ciborium-ll"
576 + version = "0.2.2"
577 + source = "registry+https://github.com/rust-lang/crates.io-index"
578 + checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
579 + dependencies = [
580 + "ciborium-io",
581 + "half",
582 + ]
583 +
485 584 [[package]]
486 585 name = "cmov"
487 586 version = "0.5.3"
@@ -570,6 +669,12 @@
570 669 "cfg-if",
571 670 ]
572 671
672 + [[package]]
673 + name = "crunchy"
674 + version = "0.2.4"
675 + source = "registry+https://github.com/rust-lang/crates.io-index"
676 + checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
677 +
573 678 [[package]]
574 679 name = "crypto-bigint"
575 680 version = "0.4.9"
@@ -620,6 +725,12 @@
620 725 "cmov",
621 726 ]
622 727
728 + [[package]]
729 + name = "data-encoding"
730 + version = "2.11.1"
731 + source = "registry+https://github.com/rust-lang/crates.io-index"
732 + checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
733 +
623 734 [[package]]
624 735 name = "der"
625 736 version = "0.6.1"
@@ -639,6 +750,12 @@
639 750 "powerfmt",
640 751 ]
641 752
753 + [[package]]
754 + name = "diff"
755 + version = "0.1.13"
756 + source = "registry+https://github.com/rust-lang/crates.io-index"
757 + checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
758 +
642 759 [[package]]
643 760 name = "digest"
644 761 version = "0.10.7"
@@ -670,7 +787,7 @@
670 787 dependencies = [
671 788 "proc-macro2",
672 789 "quote",
673 - "syn",
790 + "syn 2.0.117",
674 791 ]
675 792
676 793 [[package]]
@@ -849,6 +966,17 @@
849 966 "tracing",
850 967 ]
851 968
969 + [[package]]
970 + name = "half"
971 + version = "2.7.1"
972 + source = "registry+https://github.com/rust-lang/crates.io-index"
973 + checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
974 + dependencies = [
975 + "cfg-if",
976 + "crunchy",
977 + "zerocopy",
978 + ]
979 +
852 980 [[package]]
853 981 name = "hashbrown"
854 982 version = "0.16.1"
@@ -1126,6 +1254,8 @@
1126 1254 dependencies = [
1127 1255 "equivalent",
1128 1256 "hashbrown",
1257 + "serde",
1258 + "serde_core",
1129 1259 ]
1130 1260
1131 1261 [[package]]
@@ -1181,6 +1311,18 @@
1181 1311 "digest 0.11.3",
1182 1312 ]
1183 1313
1314 + [[package]]
1315 + name = "memchr"
1316 + version = "2.8.3"
1317 + source = "registry+https://github.com/rust-lang/crates.io-index"
1318 + checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
1319 +
1320 + [[package]]
1321 + name = "minimal-lexical"
1322 + version = "0.2.1"
1323 + source = "registry+https://github.com/rust-lang/crates.io-index"
1324 + checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
1325 +
1184 1326 [[package]]
1185 1327 name = "mio"
1186 1328 version = "1.2.0"
@@ -1192,6 +1334,26 @@
1192 1334 "windows-sys 0.61.2",
1193 1335 ]
1194 1336
1337 + [[package]]
1338 + name = "nom"
1339 + version = "7.1.3"
1340 + source = "registry+https://github.com/rust-lang/crates.io-index"
1341 + checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
1342 + dependencies = [
1343 + "memchr",
1344 + "minimal-lexical",
1345 + ]
1346 +
1347 + [[package]]
1348 + name = "num-bigint"
1349 + version = "0.4.8"
1350 + source = "registry+https://github.com/rust-lang/crates.io-index"
1351 + checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
1352 + dependencies = [
1353 + "num-integer",
1354 + "num-traits",
1355 + ]
1356 +
1195 1357 [[package]]
1196 1358 name = "num-conv"
1197 1359 version = "0.2.1"
@@ -1207,6 +1369,17 @@
1207 1369 "num-traits",
1208 1370 ]
1209 1371
1372 + [[package]]
1373 + name = "num-rational"
1374 + version = "0.4.2"
1375 + source = "registry+https://github.com/rust-lang/crates.io-index"
1376 + checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
1377 + dependencies = [
1378 + "num-bigint",
1379 + "num-integer",
1380 + "num-traits",
1381 + ]
1382 +
1210 1383 [[package]]
1211 1384 name = "num-traits"
1212 1385 version = "0.2.19"
@@ -1288,6 +1461,16 @@
1288 1461 source = "registry+https://github.com/rust-lang/crates.io-index"
1289 1462 checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
1290 1463
1464 + [[package]]
1465 + name = "pretty_assertions"
1466 + version = "1.4.1"
1467 + source = "registry+https://github.com/rust-lang/crates.io-index"
1468 + checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d"
1469 + dependencies = [
1470 + "diff",
1471 + "yansi",
1472 + ]
1473 +
1291 1474 [[package]]
1292 1475 name = "proc-macro2"
1293 1476 version = "1.0.106"
@@ -1346,6 +1529,15 @@
1346 1529 "windows-sys 0.52.0",
1347 1530 ]
1348 1531
1532 + [[package]]
1533 + name = "roxmltree"
1534 + version = "0.14.1"
1535 + source = "registry+https://github.com/rust-lang/crates.io-index"
1536 + checksum = "921904a62e410e37e215c40381b7117f830d9d89ba60ab5236170541dd25646b"
1537 + dependencies = [
1538 + "xmlparser",
1539 + ]
1540 +
1349 1541 [[package]]
1350 1542 name = "rustc_version"
1351 1543 version = "0.4.1"
@@ -1420,7 +1612,9 @@
1420 1612 "aws-config",
1421 1613 "aws-sdk-s3",
1422 1614 "aws-smithy-http-client",
1615 + "aws-smithy-types",
1423 1616 "bytes",
1617 + "http 1.4.0",
1424 1618 "tokio",
1425 1619 "tracing",
1426 1620 ]
@@ -1477,6 +1671,12 @@
1477 1671 source = "registry+https://github.com/rust-lang/crates.io-index"
1478 1672 checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
1479 1673
1674 + [[package]]
1675 + name = "separator"
1676 + version = "0.4.1"
1677 + source = "registry+https://github.com/rust-lang/crates.io-index"
1678 + checksum = "f97841a747eef040fcd2e7b3b9a220a7205926e60488e673d9e4926d27772ce5"
1679 +
1480 1680 [[package]]
1481 1681 name = "serde"
1482 1682 version = "1.0.228"
@@ -1504,7 +1704,21 @@
1504 1704 dependencies = [
1505 1705 "proc-macro2",
1506 1706 "quote",
1507 - "syn",
1707 + "syn 2.0.117",
1708 + ]
1709 +
1710 + [[package]]
1711 + name = "serde_json"
1712 + version = "1.0.151"
1713 + source = "registry+https://github.com/rust-lang/crates.io-index"
1714 + checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
1715 + dependencies = [
1716 + "indexmap",
1717 + "itoa",
1718 + "memchr",
1719 + "serde",
1720 + "serde_core",
1721 + "zmij",
1508 1722 ]
1509 1723
1510 1724 [[package]]
@@ -1617,6 +1831,17 @@
1617 1831 "unicode-ident",
1618 1832 ]
1619 1833
1834 + [[package]]
1835 + name = "syn"
1836 + version = "3.0.4"
1837 + source = "registry+https://github.com/rust-lang/crates.io-index"
1838 + checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
1839 + dependencies = [
1840 + "proc-macro2",
1841 + "quote",
1842 + "unicode-ident",
1843 + ]
1844 +
1620 1845 [[package]]
1621 1846 name = "synstructure"
1622 1847 version = "0.13.2"
@@ -1625,7 +1850,27 @@
1625 1850 dependencies = [
1626 1851 "proc-macro2",
1627 1852 "quote",
1628 - "syn",
1853 + "syn 2.0.117",
1854 + ]
1855 +
1856 + [[package]]
1857 + name = "thiserror"
1858 + version = "2.0.20"
1859 + source = "registry+https://github.com/rust-lang/crates.io-index"
1860 + checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
1861 + dependencies = [
1862 + "thiserror-impl",
1863 + ]
1864 +
1865 + [[package]]
1866 + name = "thiserror-impl"
1867 + version = "2.0.20"
1868 + source = "registry+https://github.com/rust-lang/crates.io-index"
1869 + checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
1870 + dependencies = [
1871 + "proc-macro2",
1872 + "quote",
1873 + "syn 3.0.4",
1629 1874 ]
1630 1875
1631 1876 [[package]]
@@ -1668,6 +1913,21 @@
1668 1913 "zerovec",
1669 1914 ]
1670 1915
1916 + [[package]]
1917 + name = "tinyvec"
1918 + version = "1.12.0"
1919 + source = "registry+https://github.com/rust-lang/crates.io-index"
1920 + checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
1921 + dependencies = [
1922 + "tinyvec_macros",
1923 + ]
1924 +
1925 + [[package]]
1926 + name = "tinyvec_macros"
1927 + version = "0.1.1"
1928 + source = "registry+https://github.com/rust-lang/crates.io-index"
1929 + checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
1930 +
1671 1931 [[package]]
1672 1932 name = "tokio"
1673 1933 version = "1.51.0"
@@ -1691,7 +1951,7 @@
1691 1951 dependencies = [
1692 1952 "proc-macro2",
1693 1953 "quote",
1694 - "syn",
1954 + "syn 2.0.117",
1695 1955 ]
1696 1956
1697 1957 [[package]]
@@ -1758,7 +2018,7 @@
1758 2018 dependencies = [
1759 2019 "proc-macro2",
1760 2020 "quote",
1761 - "syn",
2021 + "syn 2.0.117",
1762 2022 ]
1763 2023
1764 2024 [[package]]
@@ -1887,7 +2147,7 @@
1887 2147 "bumpalo",
1888 2148 "proc-macro2",
1889 2149 "quote",
1890 - "syn",
2150 + "syn 2.0.117",
1891 2151 "wasm-bindgen-shared",
1892 2152 ]
1893 2153
@@ -2000,6 +2260,12 @@
2000 2260 source = "registry+https://github.com/rust-lang/crates.io-index"
2001 2261 checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4"
2002 2262
2263 + [[package]]
2264 + name = "yansi"
2265 + version = "1.0.1"
2266 + source = "registry+https://github.com/rust-lang/crates.io-index"
2267 + checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
2268 +
2003 2269 [[package]]
2004 2270 name = "yoke"
2005 2271 version = "0.8.2"
@@ -2019,10 +2285,30 @@
2019 2285 dependencies = [
2020 2286 "proc-macro2",
2021 2287 "quote",
2022 - "syn",
2288 + "syn 2.0.117",
2023 2289 "synstructure",
2024 2290 ]
2025 2291
2292 + [[package]]
2293 + name = "zerocopy"
2294 + version = "0.8.56"
2295 + source = "registry+https://github.com/rust-lang/crates.io-index"
2296 + checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
2297 + dependencies = [
2298 + "zerocopy-derive",
2299 + ]
2300 +
2301 + [[package]]
2302 + name = "zerocopy-derive"
2303 + version = "0.8.56"
2304 + source = "registry+https://github.com/rust-lang/crates.io-index"
2305 + checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
2306 + dependencies = [
2307 + "proc-macro2",
2308 + "quote",
2309 + "syn 2.0.117",
2310 + ]
2311 +
2026 2312 [[package]]
2027 2313 name = "zerofrom"
2028 2314 version = "0.1.7"
@@ -2040,7 +2326,7 @@
2040 2326 dependencies = [
2041 2327 "proc-macro2",
2042 2328 "quote",
2043 - "syn",
2329 + "syn 2.0.117",
2044 2330 "synstructure",
2045 2331 ]
2046 2332
@@ -2080,32 +2366,42 @@
2080 2366 dependencies = [
Lines truncated
@@ -26,6 +26,14 @@
26 26
27 27 [dev-dependencies]
28 28 tokio = { version = "1", features = ["macros", "rt", "time"] }
29 + # The replay client, for the two paths a live MinIO cannot reach: a response
30 + # shape S3 does not currently produce (the truncated-without-markers guard), and
31 + # a setting the object store offers no way to read back under the test bucket's
32 + # credentials. Same crate as the runtime dependency above; cargo unifies the
33 + # features, so this only adds `test-util` to the test build.
34 + aws-smithy-http-client = { version = "1.1", features = ["test-util"] }
35 + aws-smithy-types = "1.4"
36 + http = "1"
29 37
30 38 [lints.rust]
31 39 unused = "warn"
@@ -12,6 +12,7 @@
12 12 use aws_config::BehaviorVersion;
13 13 use aws_sdk_s3::Client;
14 14 use aws_sdk_s3::config::{Credentials, Region};
15 + use aws_sdk_s3::error::ProvideErrorMetadata;
15 16 use aws_sdk_s3::presigning::PresigningConfig;
16 17 use aws_sdk_s3::types::{
17 18 CompletedMultipartUpload, CompletedPart, CorsConfiguration, CorsRule, Delete, ObjectIdentifier,
@@ -49,6 +50,27 @@
49 50 pub region: String,
50 51 }
51 52
53 + /// One CORS rule as the bucket reports it, in this crate's own types.
54 + ///
55 + /// A caller that wants to know what CORS the bucket is set to has nowhere to
56 + /// ask otherwise: [`S3Client::configure_cors`] returns `()` and the SDK's
57 + /// `CorsRule` is not re-exported, so the setting was write-only from outside
58 + /// this crate. Owning the shape here also keeps the SDK out of the callers'
59 + /// signatures, which is the reason the wrapper exists at all.
60 + #[derive(Debug, Clone, PartialEq, Eq, Default)]
61 + pub struct CorsRuleView {
62 + /// Origins the rule allows, verbatim as stored.
63 + pub allowed_origins: Vec<String>,
64 + /// Methods the rule allows.
65 + pub allowed_methods: Vec<String>,
66 + /// Request headers the rule allows.
67 + pub allowed_headers: Vec<String>,
68 + /// Response headers the rule exposes to the browser.
69 + pub expose_headers: Vec<String>,
70 + /// Preflight cache lifetime in seconds, if the rule sets one.
71 + pub max_age_seconds: Option<i32>,
72 + }
73 +
52 74 /// S3 client wrapper.
53 75 #[derive(Clone)]
54 76 pub struct S3Client {
@@ -1351,6 +1373,42 @@
1351 1373 }
1352 1374 }
1353 1375
1376 + /// Read the bucket's CORS configuration back.
1377 + ///
1378 + /// A bucket with no CORS configuration is `Ok(vec![])`, not an error: S3
1379 + /// answers `GetBucketCors` with `NoSuchCORSConfiguration` in that case, and
1380 + /// "no rules" is the honest reading of it rather than a failure. Anything
1381 + /// else is an error.
1382 + pub async fn bucket_cors(&self) -> Result<Vec<CorsRuleView>, String> {
1383 + match self
1384 + .client
1385 + .get_bucket_cors()
1386 + .bucket(&self.bucket)
1387 + .send()
1388 + .await
1389 + {
1390 + Ok(resp) => Ok(resp
1391 + .cors_rules()
1392 + .iter()
1393 + .map(|r| CorsRuleView {
1394 + allowed_origins: r.allowed_origins().to_vec(),
1395 + allowed_methods: r.allowed_methods().to_vec(),
1396 + allowed_headers: r.allowed_headers().to_vec(),
1397 + expose_headers: r.expose_headers().to_vec(),
1398 + max_age_seconds: r.max_age_seconds(),
1399 + })
1400 + .collect()),
1401 + Err(e) => {
1402 + let service_error = e.into_service_error();
1403 + if service_error.code() == Some("NoSuchCORSConfiguration") {
1404 + Ok(Vec::new())
1405 + } else {
1406 + Err(format!("S3 get_bucket_cors failed: {service_error}"))
1407 + }
1408 + }
1409 + }
1410 + }
1411 +
1354 1412 /// Lightweight connectivity check — `list_objects_v2` with `max_keys(0)`.
1355 1413 pub async fn check_connectivity(&self) -> Result<(), String> {
1356 1414 self.client
@@ -1368,6 +1426,10 @@
1368 1426 mod tests {
1369 1427 use super::*;
1370 1428
1429 + use aws_sdk_s3::config::retry::RetryConfig;
1430 + use aws_smithy_http_client::test_util::{ReplayEvent, StaticReplayClient};
1431 + use aws_smithy_types::body::SdkBody;
1432 +
1371 1433 fn test_client() -> S3Client {
1372 1434 // `from_conf` is local — no network until a request is sent — so this
1373 1435 // builds a usable client without reaching any endpoint.
@@ -1385,6 +1447,280 @@
1385 1447 }
1386 1448 }
1387 1449
1450 + /// An `S3Client` whose HTTP layer replays canned responses.
1451 + ///
1452 + /// WHY THIS EXISTS. Two paths in this crate cannot be reached from a live
1453 + /// object store, and both were mutation survivors (infra `f7f13914`,
1454 + /// `a536db81`). One is defensive code against a response shape S3 does not
1455 + /// currently produce; the other writes a bucket setting the test bucket's
1456 + /// credentials cannot read back. A canned response reaches the first and a
1457 + /// recorded request reaches the second, with no live infrastructure and no
1458 + /// bucket-owner permission.
1459 + ///
1460 + /// Retries are off so an exhausted replay list fails immediately: a mutant
1461 + /// that makes the crate send one request too many should show up as a
1462 + /// failed assertion rather than as seconds of SDK backoff.
1463 + fn replay_client(events: Vec<ReplayEvent>) -> (S3Client, StaticReplayClient) {
1464 + let replay = StaticReplayClient::new(events);
1465 + let s3_config = aws_sdk_s3::Config::builder()
1466 + .behavior_version(BehaviorVersion::latest())
1467 + .http_client(replay.clone())
1468 + .retry_config(RetryConfig::disabled())
1469 + .region(Region::new("test"))
1470 + .endpoint_url("http://127.0.0.1:1")
1471 + .credentials_provider(Credentials::new("ak", "sk", None, None, "test"))
1472 + .force_path_style(true)
1473 + .build();
1474 + (
1475 + S3Client {
1476 + client: Client::from_conf(s3_config),
1477 + bucket: "test-bucket".to_string(),
1478 + },
1479 + replay,
1480 + )
1481 + }
1482 +
1483 + /// A canned 200 with an XML body, which is what every S3 read returns.
1484 + fn xml_ok(body: &str) -> ReplayEvent {
1485 + ReplayEvent::new(
1486 + http::Request::builder()
1487 + .uri("http://test-bucket.localhost/")
1488 + .body(SdkBody::empty())
1489 + .unwrap(),
1490 + http::Response::builder()
1491 + .status(200)
1492 + .header("content-type", "application/xml")
1493 + .body(SdkBody::from(body.to_string()))
1494 + .unwrap(),
1495 + )
1496 + }
1497 +
1498 + /// A canned S3 error document, which is how S3 says "no CORS here".
1499 + fn xml_err(status: u16, code: &str) -> ReplayEvent {
1500 + let body = format!(
1501 + "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
1502 + <Error><Code>{code}</Code><Message>canned</Message>\
1503 + <RequestId>r</RequestId><HostId>h</HostId></Error>"
1504 + );
1505 + ReplayEvent::new(
1506 + http::Request::builder()
1507 + .uri("http://test-bucket.localhost/")
1508 + .body(SdkBody::empty())
1509 + .unwrap(),
1510 + http::Response::builder()
1511 + .status(status)
1512 + .header("content-type", "application/xml")
1513 + .body(SdkBody::from(body))
1514 + .unwrap(),
1515 + )
1516 + }
1517 +
1518 + /// infra `f7f13914`. The "truncated but no continuation markers" guard in
1519 + /// `list_multipart_uploads_for_key` survived mutation because reaching it
1520 + /// needs a response S3 does not produce: truncated, yet naming nothing to
1521 + /// continue from. Live MinIO will not build it, and >1000 pending uploads on
1522 + /// one key is not a fixture anyone should own. A canned body is.
1523 + ///
1524 + /// The guard is kept rather than deleted because without it that response
1525 + /// is an infinite loop against a real endpoint, and the crate is the last
1526 + /// thing standing between the orphan reaper and a spin.
1527 + ///
1528 + /// The single-request assertion is the one that matters. Removing the
1529 + /// `break` makes the loop ask again with the same (absent) markers, and
1530 + /// there is no second canned response, so it also fails the `Ok` — either
1531 + /// way the mutant dies.
1532 + #[tokio::test]
1533 + async fn a_truncated_listing_with_no_markers_stops_instead_of_looping() {
1534 + let (client, replay) = replay_client(vec![xml_ok(
1535 + r#"<?xml version="1.0" encoding="UTF-8"?>
1536 + <ListMultipartUploadsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
1537 + <Bucket>test-bucket</Bucket>
1538 + <Prefix>staging/abc</Prefix>
1539 + <MaxUploads>1000</MaxUploads>
1540 + <IsTruncated>true</IsTruncated>
1541 + <Upload>
1542 + <Key>staging/abc</Key>
1543 + <UploadId>upload-one</UploadId>
1544 + <Initiated>2026-08-31T00:00:00.000Z</Initiated>
1545 + </Upload>
1546 + <Upload>
1547 + <Key>staging/abcdef</Key>
1548 + <UploadId>not-ours</UploadId>
1549 + <Initiated>2026-08-31T00:00:00.000Z</Initiated>
1550 + </Upload>
1551 + </ListMultipartUploadsResult>"#,
1552 + )]);
1553 +
1554 + let ids = client
1555 + .list_multipart_uploads_for_key("staging/abc")
1556 + .await
1557 + .expect("a truncated page with nothing to continue from is a complete answer");
1558 +
1559 + // The exact-key filter travels with it: `ListMultipartUploads` matches a
1560 + // prefix, so `staging/abcdef` is a live session the reaper must not abort.
1561 + assert_eq!(ids, vec!["upload-one".to_string()]);
1562 + assert_eq!(
1563 + replay.actual_requests().count(),
1564 + 1,
1565 + "the guard exists to stop a second identical request"
1566 + );
1567 + }
1568 +
1569 + /// The other half of the guard: a truncated page that DOES name a marker is
1570 + /// followed. Without this the test above is satisfied by a loop that never
1571 + /// iterates at all, which is a different bug wearing the same result.
1572 + #[tokio::test]
1573 + async fn a_truncated_listing_with_a_marker_asks_for_the_next_page() {
1574 + let (client, replay) = replay_client(vec![
1575 + xml_ok(
1576 + r#"<?xml version="1.0" encoding="UTF-8"?>
1577 + <ListMultipartUploadsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
1578 + <Bucket>test-bucket</Bucket>
1579 + <IsTruncated>true</IsTruncated>
1580 + <NextKeyMarker>staging/abc</NextKeyMarker>
1581 + <NextUploadIdMarker>upload-one</NextUploadIdMarker>
1582 + <Upload>
1583 + <Key>staging/abc</Key>
1584 + <UploadId>upload-one</UploadId>
1585 + <Initiated>2026-08-31T00:00:00.000Z</Initiated>
1586 + </Upload>
1587 + </ListMultipartUploadsResult>"#,
1588 + ),
1589 + xml_ok(
1590 + r#"<?xml version="1.0" encoding="UTF-8"?>
1591 + <ListMultipartUploadsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
1592 + <Bucket>test-bucket</Bucket>
1593 + <IsTruncated>false</IsTruncated>
1594 + <Upload>
1595 + <Key>staging/abc</Key>
1596 + <UploadId>upload-two</UploadId>
1597 + <Initiated>2026-08-31T00:00:00.000Z</Initiated>
1598 + </Upload>
1599 + </ListMultipartUploadsResult>"#,
1600 + ),
1601 + ]);
1602 +
1603 + let ids = client
1604 + .list_multipart_uploads_for_key("staging/abc")
1605 + .await
1606 + .expect("two pages is an ordinary listing");
1607 +
1608 + assert_eq!(
1609 + ids,
1610 + vec!["upload-one".to_string(), "upload-two".to_string()]
1611 + );
1612 + let second = replay
1613 + .actual_requests()
1614 + .nth(1)
1615 + .expect("the second page was requested")
1616 + .uri()
1617 + .to_string();
1618 + assert!(
1619 + second.contains("upload-id-marker=upload-one"),
1620 + "the marker from page one carries into page two: {second}"
1621 + );
1622 + }
1623 +
1624 + /// infra `a536db81`. `configure_cors` returns `()` and the crate exposed no
1625 + /// readback, so replacing its whole body with `()` was invisible to every
1626 + /// test that could be written against it. The request it sends is the
1627 + /// observable, and it is the right observable: what the object store ends up
1628 + /// configured with is decided entirely by that one PUT.
1629 + #[tokio::test]
1630 + async fn configuring_cors_sends_the_rule_the_browser_upload_needs() {
1631 + let (client, replay) = replay_client(vec![xml_ok("")]);
1632 +
1633 + client.configure_cors("https://example.test/").await;
1634 +
1635 + let requests: Vec<_> = replay.actual_requests().collect();
1636 + assert_eq!(requests.len(), 1, "configure_cors must send a PUT");
1637 + let uri = requests[0].uri().to_string();
1638 + assert!(uri.contains("cors"), "put_bucket_cors, not some other PUT");
1639 +
1640 + let body = String::from_utf8(
1641 + requests[0]
1642 + .body()
1643 + .bytes()
1644 + .expect("an in-memory XML body")
1645 + .to_vec(),
1646 + )
1647 + .expect("the CORS document is UTF-8");
1648 +
1649 + // The trailing slash is trimmed: an origin is a scheme-host-port and a
1650 + // browser never sends the slash, so a rule carrying one matches nothing.
1651 + assert!(body.contains("<AllowedOrigin>https://example.test</AllowedOrigin>"));
1652 + for method in ["PUT", "GET", "HEAD"] {
1653 + assert!(
1654 + body.contains(&format!("<AllowedMethod>{method}</AllowedMethod>")),
1655 + "{method} is missing from {body}"
1656 + );
1657 + }
1658 + for header in ["Content-Type", "Cache-Control", "Content-Disposition"] {
1659 + assert!(
1660 + body.contains(&format!("<AllowedHeader>{header}</AllowedHeader>")),
1661 + "{header} is missing from {body}"
1662 + );
1663 + }
1664 + // ETag is what a direct-to-S3 part upload reads back to complete the
1665 + // upload; without exposing it the browser cannot finish a multipart.
1666 + assert!(body.contains("<ExposeHeader>ETag</ExposeHeader>"));
1667 + assert!(body.contains("<MaxAgeSeconds>3600</MaxAgeSeconds>"));
1668 + }
1669 +
1670 + /// The readback the wrapper was missing, over a canned response.
1671 + #[tokio::test]
1672 + async fn cors_rules_come_back_in_the_crates_own_shape() {
1673 + let (client, _replay) = replay_client(vec![xml_ok(
1674 + r#"<?xml version="1.0" encoding="UTF-8"?>
1675 + <CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
1676 + <CORSRule>
1677 + <AllowedOrigin>https://example.test</AllowedOrigin>
1678 + <AllowedMethod>PUT</AllowedMethod>
1679 + <AllowedMethod>GET</AllowedMethod>
1680 + <AllowedHeader>Content-Type</AllowedHeader>
1681 + <ExposeHeader>ETag</ExposeHeader>
1682 + <MaxAgeSeconds>3600</MaxAgeSeconds>
1683 + </CORSRule>
1684 + </CORSConfiguration>"#,
1685 + )]);
1686 +
1687 + let rules = client.bucket_cors().await.expect("a configured bucket");
1688 + assert_eq!(
1689 + rules,
1690 + vec![CorsRuleView {
1691 + allowed_origins: vec!["https://example.test".to_string()],
1692 + allowed_methods: vec!["PUT".to_string(), "GET".to_string()],
1693 + allowed_headers: vec!["Content-Type".to_string()],
1694 + expose_headers: vec!["ETag".to_string()],
1695 + max_age_seconds: Some(3600),
1696 + }]
1697 + );
1698 + }
1699 +
1700 + /// A bucket with no CORS is not a failure. S3 answers `GetBucketCors` with
1701 + /// `NoSuchCORSConfiguration`, and a caller asking "what is set" is entitled
1702 + /// to hear "nothing" rather than an error it has to pattern-match itself.
1703 + #[tokio::test]
1704 + async fn a_bucket_with_no_cors_reads_back_as_no_rules() {
1705 + let (client, _replay) = replay_client(vec![xml_err(404, "NoSuchCORSConfiguration")]);
1706 +
1707 + let rules = client.bucket_cors().await.expect("absence is not an error");
1708 + assert!(rules.is_empty());
1709 + }
1710 +
1711 + /// Any other error still is one, or the method above would report a broken
1712 + /// endpoint as an unconfigured bucket.
1713 + #[tokio::test]
1714 + async fn a_cors_read_that_fails_for_another_reason_is_an_error() {
1715 + let (client, _replay) = replay_client(vec![xml_err(403, "AccessDenied")]);
1716 +
1717 + let err = client
1718 + .bucket_cors()
1719 + .await
1720 + .expect_err("AccessDenied is not an empty CORS configuration");
1721 + assert!(err.contains("get_bucket_cors"), "{err}");
1722 + }
1723 +
1388 1724 const MIB: u64 = 1024 * 1024;
1389 1725
1390 1726 #[test]