diff --git a/Cargo.lock b/Cargo.lock index 9cf1a6d5..29312a4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -236,6 +236,43 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-config" +version = "1.8.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c456581cb3c77fafcc8c67204a70680d40b61112d6da78c77bd31d945b65f1b5" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 1.4.0", + "time", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "aws-credential-types" +version = "1.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cd362783681b15d136480ad555a099e82ecd8e2d10a841e14dfd0078d67fee3" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + [[package]] name = "aws-lc-rs" version = "1.17.0" @@ -259,6 +296,324 @@ dependencies = [ "fs_extra", ] +[[package]] +name = "aws-runtime" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c635c2dc792cb4a11ce1a4f392a925340d1bdf499289b5ec1ec6810954eb43f5" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-s3" +version = "1.122.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94c2ca0cba97e8e279eb6c0b2d0aa10db5959000e602ab2b7c02de6b85d4c19b" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-checksums", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "bytes", + "fastrand", + "hex", + "hmac 0.12.1", + "http 0.2.12", + "http 1.4.0", + "http-body 1.0.1", + "lru", + "percent-encoding", + "regex-lite", + "sha2 0.10.9", + "tracing", + "url", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6443ccadc777095d5ed13e21f5c364878c9f5bad4e35187a6cdbd863b0afcad" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efa49f3c607b92daae0c078d48a4571f599f966dce3caee5f1ea55c4d9073f99" +dependencies = [ + "aws-credential-types", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac 0.12.1", + "http 0.2.12", + "http 1.4.0", + "percent-encoding", + "sha2 0.10.9", + "time", + "tracing", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52eec3db979d18cb807fc1070961cc51d87d069abe9ab57917769687368a8c6c" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-checksums" +version = "0.64.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcf418858f9f3edd228acb8759d77394fed7531cce78d02bdda499025368439" +dependencies = [ + "aws-smithy-http", + "aws-smithy-types", + "bytes", + "crc-fast", + "hex", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "md-5 0.10.6", + "pin-project-lite", + "sha1 0.10.6", + "sha2 0.10.9", + "tracing", +] + +[[package]] +name = "aws-smithy-eventstream" +version = "0.60.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35b9c7354a3b13c66f60fe4616d6d1969c9fd36b1b5333a5dfb3ee716b33c588" +dependencies = [ + "aws-smithy-types", + "bytes", + "crc32fast", +] + +[[package]] +name = "aws-smithy-http" +version = "0.63.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "630e67f2a31094ffa51b210ae030855cb8f3b7ee1329bdd8d085aaf61e8b97fc" +dependencies = [ + "aws-smithy-eventstream", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12fb0abf49ff0cab20fd31ac1215ed7ce0ea92286ba09e2854b42ba5cabe7525" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2", + "http 1.4.0", + "hyper", + "hyper-rustls", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.62.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cb96aa208d62ee94104645f7b2ecaf77bf27edf161590b6224bfbac2832f979" +dependencies = [ + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a46543fbc94621080b3cf553eb4cbbdc41dd9780a30c4756400f0139440a1d" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.60.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cebbddb6f3a5bd81553643e9c7daf3cc3dc5b0b5f398ac668630e8a84e6fff0" +dependencies = [ + "aws-smithy-types", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3df87c14f0127a0d77eb261c3bc45d5b4833e2a1f63583ebfb728e4852134ee" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49952c52f7eebb72ce2a754d3866cc0f87b97d2a46146b79f80f3a93fb2b3716" +dependencies = [ + "aws-smithy-async", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-types" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3a26048eeab0ddeba4b4f9d51654c79af8c3b32357dc5f336cee85ab331c33" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11b2f670422ff42bf7065031e72b45bc52a3508bd089f743ea90731ca2b6ea57" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d980627d2dd7bfc32a3c025685a033eeab8d365cc840c631ef59d1b8f428164" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "rustc_version", + "tracing", +] + [[package]] name = "axum" version = "0.8.9" @@ -270,8 +625,8 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "hyper", "hyper-util", @@ -301,8 +656,8 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", @@ -333,8 +688,8 @@ dependencies = [ "bytes", "either", "fs-err", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "hyper", "hyper-util", "pin-project-lite", @@ -357,6 +712,16 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "base64ct" version = "1.8.3" @@ -504,6 +869,16 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + [[package]] name = "cc" version = "1.2.62" @@ -710,6 +1085,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -736,9 +1121,9 @@ dependencies = [ [[package]] name = "crc" -version = "3.4.0" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" dependencies = [ "crc-catalog", ] @@ -749,6 +1134,18 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crc-fast" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd92aca2c6001b1bf5ba0ff84ee74ec8501b52bbef0cac80bf25a6c1d87a83d" +dependencies = [ + "crc", + "digest 0.10.7", + "rustversion", + "spin 0.10.1", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -1165,6 +1562,25 @@ dependencies = [ "zeroize", ] +[[package]] +name = "extenddb-backup-store" +version = "0.1.11" +dependencies = [ + "aws-config", + "aws-sdk-s3", + "bytes", + "extenddb-backup-store", + "futures", + "serde", + "tempfile", + "thiserror", + "tokio", + "tokio-util", + "toml", + "tracing", + "uuid", +] + [[package]] name = "extenddb-cache" version = "0.1.11" @@ -1395,7 +1811,7 @@ checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" dependencies = [ "futures-core", "futures-sink", - "spin", + "spin 0.9.8", ] [[package]] @@ -1410,6 +1826,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1612,7 +2034,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.4.0", "indexmap", "slab", "tokio", @@ -1638,7 +2060,18 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", ] [[package]] @@ -1783,6 +2216,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.0" @@ -1793,6 +2237,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -1800,7 +2255,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -1811,8 +2266,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -1848,14 +2303,31 @@ dependencies = [ "futures-channel", "futures-core", "h2", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "httparse", "httpdate", "itoa", "pin-project-lite", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.4.0", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", ] [[package]] @@ -1864,13 +2336,21 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64 0.22.1", "bytes", - "http", - "http-body", + "futures-channel", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", ] [[package]] @@ -2131,7 +2611,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin", + "spin 0.9.8", ] [[package]] @@ -2202,6 +2682,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "macro_magic" version = "0.5.1" @@ -2548,6 +3037,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "ordered-multimap" version = "0.7.3" @@ -2558,6 +3053,12 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "parking" version = "2.2.1" @@ -2685,6 +3186,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "pkcs1" version = "0.7.5" @@ -2979,6 +3486,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.10" @@ -3104,6 +3617,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -3158,12 +3683,44 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -3413,6 +3970,12 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" + [[package]] name = "spki" version = "0.7.3" @@ -3700,7 +4263,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ "bitflags", - "core-foundation", + "core-foundation 0.9.4", "system-configuration-sys", ] @@ -3970,8 +4533,8 @@ dependencies = [ "bitflags", "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", "tokio", "tokio-util", @@ -4066,6 +4629,12 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typed-builder" version = "0.22.0" @@ -4225,6 +4794,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -4244,6 +4819,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -4393,7 +4977,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.48.0", ] [[package]] @@ -4715,6 +5299,12 @@ dependencies = [ "time", ] +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + [[package]] name = "yaml-rust2" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index 44ebd9fa..3fe61045 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ resolver = "2" members = [ "crates/core", "crates/cache", + "crates/backup-store", "crates/engine", "crates/storage", "crates/config", @@ -27,6 +28,7 @@ license = "Apache-2.0" # Internal crates extenddb-core = { path = "crates/core" } extenddb-cache = { path = "crates/cache" } +extenddb-backup-store = { path = "crates/backup-store" } extenddb-engine = { path = "crates/engine" } extenddb-storage = { path = "crates/storage" } extenddb-config = { path = "crates/config" } @@ -72,6 +74,16 @@ mongodb = "3" bson = "2.13" dashmap = "6" +# Backup store +# Exact pins: the aws-sdk crates release in lockstep, and these are the newest +# versions whose declared minimum supported rustc matches the workspace +# rust-version (1.88); later releases require a newer compiler. Their +# transitive aws-smithy-* crates are held in Cargo.lock to matching versions +# for the same reason. Feature set mirrors tests/rust/Cargo.toml. +aws-config = { version = "=1.8.13", default-features = false, features = ["behavior-version-latest", "rt-tokio", "default-https-client", "credentials-process"] } +aws-sdk-s3 = { version = "=1.122.0", default-features = false, features = ["behavior-version-latest", "rt-tokio", "default-https-client"] } +bytes = "1" + # Crypto & checksums crc32fast = "1" uuid = { version = "1", features = ["v4"] } diff --git a/SOFTWARE-LICENSE-NOTICES-DEV.html b/SOFTWARE-LICENSE-NOTICES-DEV.html index e18c5fc3..198643aa 100644 --- a/SOFTWARE-LICENSE-NOTICES-DEV.html +++ b/SOFTWARE-LICENSE-NOTICES-DEV.html @@ -1765,7 +1765,7 @@

Used by:

Apache License 2.0

Used by:

                              Apache License
                         Version 2.0 January 2004
diff --git a/SOFTWARE-LICENSE-NOTICES-MONGODB.html b/SOFTWARE-LICENSE-NOTICES-MONGODB.html
index 57d9567e..6ac65297 100644
--- a/SOFTWARE-LICENSE-NOTICES-MONGODB.html
+++ b/SOFTWARE-LICENSE-NOTICES-MONGODB.html
@@ -2191,7 +2191,7 @@ 

Used by:

Apache License 2.0

Used by:

                              Apache License
                         Version 2.0 January 2004
diff --git a/SOFTWARE-LICENSE-NOTICES.html b/SOFTWARE-LICENSE-NOTICES.html
index 2e6a38e4..84e55533 100644
--- a/SOFTWARE-LICENSE-NOTICES.html
+++ b/SOFTWARE-LICENSE-NOTICES.html
@@ -1766,7 +1766,7 @@ 

Used by:

Apache License 2.0

Used by:

                              Apache License
                         Version 2.0 January 2004
diff --git a/crates/backup-store/Cargo.toml b/crates/backup-store/Cargo.toml
new file mode 100644
index 00000000..bb07873e
--- /dev/null
+++ b/crates/backup-store/Cargo.toml
@@ -0,0 +1,31 @@
+# Copyright 2026 ExtendDB contributors
+# SPDX-License-Identifier: Apache-2.0
+[package]
+name = "extenddb-backup-store"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+description = "Backup object store abstraction with filesystem and S3 implementations."
+
+[dependencies]
+aws-config = { workspace = true }
+aws-sdk-s3 = { workspace = true }
+bytes = { workspace = true }
+futures = { workspace = true }
+serde = { workspace = true }
+thiserror = { workspace = true }
+tokio = { workspace = true }
+tokio-util = { workspace = true, features = ["io"] }
+tracing = { workspace = true }
+uuid = { workspace = true }
+
+[features]
+# Failure-injection seam for the crate's own test suite; not part of the
+# supported API surface and never enabled by production consumers.
+test-util = []
+
+[dev-dependencies]
+extenddb-backup-store = { path = ".", features = ["test-util"] }
+tempfile = "3"
+toml = { workspace = true }
diff --git a/crates/backup-store/README.md b/crates/backup-store/README.md
new file mode 100644
index 00000000..89f31ea9
--- /dev/null
+++ b/crates/backup-store/README.md
@@ -0,0 +1,55 @@
+# extenddb-backup-store
+
+Backup object store abstraction for ExtendDB: one `BackupStore` trait with a
+filesystem implementation and an S3 implementation built on the official
+`aws-sdk-s3` crate. The engine writes portable backups through this trait; see
+`docs/design/15-backup-restore.md` for the full design.
+
+## Behavior
+
+- Keys are `/`-separated component paths. Both stores reject empty
+  components, `.` and `..`, backslashes, control characters, and keys over
+  1024 bytes, so the two stores accept the same key space.
+- The filesystem store canonicalizes its root at construction and resolves
+  every key one component at a time without following symlinks: a symlink
+  anywhere in a key's path, pointing inside or outside the root, is refused.
+  This defeats symlinks present when an operation starts; it does not defend
+  against another local process mutating the tree during an operation, so
+  the backup root must not be writable by less-trusted users (write access
+  inside the root already grants full read, write, and delete over every
+  backup). Writes go through a temporary sibling file renamed into place so
+  a crash never leaves a truncated object under its final name.
+- The S3 store takes credentials from the standard AWS SDK chain, uses
+  multipart upload for bodies larger than the configured part size (default
+  8 MiB, minimum 5 MiB), aborts the multipart upload when a put fails,
+  paginates listings, and deletes prefixes in DeleteObjects batches of 1000.
+
+## Tests
+
+`cargo test -p extenddb-backup-store` runs the key validation and
+configuration unit tests, the filesystem conformance suite against a
+temporary directory, and the S3 conformance suite.
+
+The S3 suite needs a local S3-compatible endpoint and is skipped with a
+printed reason when `EXTENDDB_TEST_S3_ENDPOINT` is unset. To run it against a
+local MinIO:
+
+```sh
+docker run --rm -d -p 9000:9000 --name extenddb-backup-store-minio \
+  -e MINIO_ROOT_USER=minioadmin -e MINIO_ROOT_PASSWORD=minioadmin \
+  minio/minio server /data
+
+EXTENDDB_TEST_S3_ENDPOINT=http://127.0.0.1:9000 \
+AWS_ACCESS_KEY_ID=minioadmin \
+AWS_SECRET_ACCESS_KEY=minioadmin \
+cargo test -p extenddb-backup-store
+```
+
+A bare `minio server /data-dir` binary works the same way; only the endpoint
+and the two credential variables matter.
+
+`EXTENDDB_TEST_S3_BUCKET` (default `extenddb-backup-store-tests`) and
+`EXTENDDB_TEST_S3_REGION` (default `us-east-1`) can override the bucket and
+region. The suite creates the bucket when it does not exist and isolates each
+test under a unique key prefix. CI wiring for the MinIO service is tracked as
+its own task in the design doc.
diff --git a/crates/backup-store/src/config.rs b/crates/backup-store/src/config.rs
new file mode 100644
index 00000000..bbf37353
--- /dev/null
+++ b/crates/backup-store/src/config.rs
@@ -0,0 +1,271 @@
+// Copyright 2026 ExtendDB contributors
+// SPDX-License-Identifier: Apache-2.0
+
+//! Configuration shapes for the backup store.
+//!
+//! These model the store subset of the `[backup]` section:
+//!
+//! ```toml
+//! [backup]
+//! store = "filesystem"                  # "filesystem" | "s3"
+//!
+//! [backup.filesystem]
+//! path = "/var/lib/extenddb/backups"    # required when store = "filesystem"
+//!
+//! [backup.s3]
+//! bucket = "my-extenddb-backups"        # required when store = "s3"
+//! prefix = "extenddb/"                  # optional
+//! region = "us-east-1"                  # optional; SDK chain otherwise
+//! endpoint = "http://minio:9000"        # optional; S3-compatible stores
+//! force_path_style = false
+//! upload_part_size_bytes = 8388608
+//! max_concurrent_uploads = 4
+//! ```
+//!
+//! Wiring into `crates/config` and the server startup path is deliberately
+//! not done here; this crate only defines the shapes and the
+//! [`crate::open`] constructor over them.
+
+use std::path::PathBuf;
+
+use serde::Deserialize;
+
+/// Which store implementation the `[backup]` section selects.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum BackupStoreKind {
+    /// Local directory store. The default: local development works with no
+    /// configuration, and the backup manual states the failure-domain
+    /// consequence.
+    #[default]
+    Filesystem,
+    /// S3 bucket store.
+    S3,
+}
+
+/// The store subset of the `[backup]` configuration section.
+///
+/// Composition note for the wiring task: `deny_unknown_fields` does not
+/// survive `serde(flatten)`, so embedding this struct into a full `[backup]`
+/// section model via flatten would silently stop rejecting unknown fields.
+/// Model the full section as its own struct with these fields inline instead
+/// of composing by flattening.
+#[derive(Debug, Clone, Default, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct BackupStoreConfig {
+    /// Selected store kind; `filesystem` when absent.
+    #[serde(default)]
+    pub store: BackupStoreKind,
+    /// `[backup.filesystem]`, required when `store = "filesystem"`.
+    #[serde(default)]
+    pub filesystem: Option,
+    /// `[backup.s3]`, required when `store = "s3"`.
+    #[serde(default)]
+    pub s3: Option,
+}
+
+/// `[backup.filesystem]` settings.
+#[derive(Debug, Clone, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct FilesystemStoreConfig {
+    /// Root directory for backup objects. Must exist at startup.
+    pub path: PathBuf,
+}
+
+/// `[backup.s3]` settings. Credentials are absent by design: they come from
+/// the standard AWS SDK chain.
+#[derive(Debug, Clone, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct S3StoreConfig {
+    /// Destination bucket.
+    pub bucket: String,
+    /// Key prefix inside the bucket. Optional; empty means the bucket root.
+    #[serde(default)]
+    pub prefix: String,
+    /// Region override. When absent the SDK chain resolves the region.
+    #[serde(default)]
+    pub region: Option,
+    /// Endpoint override for S3-compatible stores.
+    #[serde(default)]
+    pub endpoint: Option,
+    /// Use path-style addressing; required by most S3-compatible stores.
+    #[serde(default)]
+    pub force_path_style: bool,
+    /// Multipart part size in bytes. Defaults to 8 MiB; the enforced minimum
+    /// is 5 MiB, the smallest part S3 accepts.
+    #[serde(default = "default_upload_part_size_bytes")]
+    pub upload_part_size_bytes: u64,
+    /// Maximum concurrently in-flight part uploads per put. Defaults to 4.
+    #[serde(default = "default_max_concurrent_uploads")]
+    pub max_concurrent_uploads: usize,
+}
+
+fn default_upload_part_size_bytes() -> u64 {
+    8 * 1024 * 1024
+}
+
+fn default_max_concurrent_uploads() -> usize {
+    4
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// `Arc` has no `Debug`, so `unwrap_err` cannot be used.
+    async fn open_err(config: &BackupStoreConfig) -> crate::StoreError {
+        match crate::open(config).await {
+            Err(e) => e,
+            Ok(_) => panic!("expected open to fail"),
+        }
+    }
+
+    #[test]
+    fn parses_the_documented_filesystem_shape() {
+        let config: BackupStoreConfig = toml::from_str(
+            r#"
+            store = "filesystem"
+
+            [filesystem]
+            path = "/var/lib/extenddb/backups"
+            "#,
+        )
+        .unwrap();
+        assert_eq!(config.store, BackupStoreKind::Filesystem);
+        assert_eq!(
+            config.filesystem.unwrap().path,
+            PathBuf::from("/var/lib/extenddb/backups")
+        );
+        assert!(config.s3.is_none());
+    }
+
+    #[test]
+    fn parses_the_documented_s3_shape_with_defaults() {
+        let config: BackupStoreConfig = toml::from_str(
+            r#"
+            store = "s3"
+
+            [s3]
+            bucket = "my-extenddb-backups"
+            prefix = "extenddb/"
+            region = "us-east-1"
+            endpoint = "http://minio:9000"
+            force_path_style = true
+            "#,
+        )
+        .unwrap();
+        assert_eq!(config.store, BackupStoreKind::S3);
+        let s3 = config.s3.unwrap();
+        assert_eq!(s3.bucket, "my-extenddb-backups");
+        assert_eq!(s3.prefix, "extenddb/");
+        assert_eq!(s3.region.as_deref(), Some("us-east-1"));
+        assert_eq!(s3.endpoint.as_deref(), Some("http://minio:9000"));
+        assert!(s3.force_path_style);
+        assert_eq!(s3.upload_part_size_bytes, 8 * 1024 * 1024);
+        assert_eq!(s3.max_concurrent_uploads, 4);
+    }
+
+    #[test]
+    fn store_kind_defaults_to_filesystem() {
+        let config: BackupStoreConfig = toml::from_str(
+            r#"
+            [filesystem]
+            path = "/backups"
+            "#,
+        )
+        .unwrap();
+        assert_eq!(config.store, BackupStoreKind::Filesystem);
+    }
+
+    #[test]
+    fn unknown_fields_are_rejected_at_every_level() {
+        assert!(toml::from_str::("stire = \"s3\"").is_err());
+        assert!(
+            toml::from_str::(
+                "store = \"filesystem\"\n[filesystem]\npath = \"/b\"\npth = \"/b\"\n"
+            )
+            .is_err()
+        );
+        assert!(
+            toml::from_str::(
+                "store = \"s3\"\n[s3]\nbucket = \"b\"\nbukcet = \"b\"\n"
+            )
+            .is_err()
+        );
+    }
+
+    #[test]
+    fn unknown_store_kind_is_rejected() {
+        assert!(toml::from_str::("store = \"gcs\"").is_err());
+    }
+
+    #[tokio::test]
+    async fn open_requires_the_matching_section() {
+        let missing_fs = BackupStoreConfig {
+            store: BackupStoreKind::Filesystem,
+            filesystem: None,
+            s3: None,
+        };
+        let err = open_err(&missing_fs).await;
+        assert!(err.to_string().contains("[backup.filesystem]"), "{err}");
+
+        let missing_s3 = BackupStoreConfig {
+            store: BackupStoreKind::S3,
+            filesystem: None,
+            s3: None,
+        };
+        let err = open_err(&missing_s3).await;
+        assert!(err.to_string().contains("[backup.s3]"), "{err}");
+    }
+
+    #[tokio::test]
+    async fn open_enforces_the_part_size_floor() {
+        let config = BackupStoreConfig {
+            store: BackupStoreKind::S3,
+            filesystem: None,
+            s3: Some(S3StoreConfig {
+                bucket: "b".to_owned(),
+                prefix: String::new(),
+                region: Some("us-east-1".to_owned()),
+                endpoint: None,
+                force_path_style: false,
+                upload_part_size_bytes: 4 * 1024 * 1024,
+                max_concurrent_uploads: 4,
+            }),
+        };
+        let err = open_err(&config).await;
+        assert!(err.to_string().contains("minimum is 5242880"), "{err}");
+    }
+
+    #[tokio::test]
+    async fn open_rejects_zero_concurrency() {
+        let config = BackupStoreConfig {
+            store: BackupStoreKind::S3,
+            filesystem: None,
+            s3: Some(S3StoreConfig {
+                bucket: "b".to_owned(),
+                prefix: String::new(),
+                region: Some("us-east-1".to_owned()),
+                endpoint: None,
+                force_path_style: false,
+                upload_part_size_bytes: 8 * 1024 * 1024,
+                max_concurrent_uploads: 0,
+            }),
+        };
+        let err = open_err(&config).await;
+        assert!(err.to_string().contains("max_concurrent_uploads"), "{err}");
+    }
+
+    #[tokio::test]
+    async fn open_rejects_a_missing_filesystem_root() {
+        let config = BackupStoreConfig {
+            store: BackupStoreKind::Filesystem,
+            filesystem: Some(FilesystemStoreConfig {
+                path: PathBuf::from("/nonexistent/extenddb-backup-root"),
+            }),
+            s3: None,
+        };
+        let err = open_err(&config).await;
+        assert!(err.to_string().contains("not usable"), "{err}");
+    }
+}
diff --git a/crates/backup-store/src/error.rs b/crates/backup-store/src/error.rs
new file mode 100644
index 00000000..239290b8
--- /dev/null
+++ b/crates/backup-store/src/error.rs
@@ -0,0 +1,60 @@
+// Copyright 2026 ExtendDB contributors
+// SPDX-License-Identifier: Apache-2.0
+
+//! Store error type.
+//!
+//! Display messages carry operation context, key names, and service error
+//! codes. They never carry credentials: S3 service errors are reduced to
+//! their error code and service message before formatting, and transport
+//! errors wrap the SDK error whose display contains no credential material.
+
+/// Error returned by every [`crate::BackupStore`] operation.
+#[derive(Debug, thiserror::Error)]
+pub enum StoreError {
+    /// The requested object does not exist.
+    #[error("object not found")]
+    NotFound,
+
+    /// The store refused the operation: filesystem permissions, a resolved
+    /// path escaping the configured root, or an S3 `AccessDenied`. The message
+    /// names what was refused.
+    #[error("permission denied: {0}")]
+    PermissionDenied(String),
+
+    /// The key or prefix failed validation. The message names the rule that
+    /// rejected it.
+    #[error("invalid key: {0}")]
+    InvalidKey(String),
+
+    /// A local I/O failure. `context` names the operation and path.
+    #[error("{context}: {source}")]
+    Io {
+        /// Operation and path the failure occurred on.
+        context: String,
+        /// Underlying I/O error.
+        #[source]
+        source: std::io::Error,
+    },
+
+    /// A network or protocol failure talking to a remote store.
+    #[error("transport error: {source}")]
+    Transport {
+        /// Underlying transport error.
+        #[source]
+        source: Box,
+    },
+
+    /// Any other failure. The message is self-contained.
+    #[error("{0}")]
+    Other(String),
+}
+
+impl StoreError {
+    /// Wrap an I/O error with the operation and path it occurred on.
+    pub(crate) fn io(context: impl Into, source: std::io::Error) -> Self {
+        Self::Io {
+            context: context.into(),
+            source,
+        }
+    }
+}
diff --git a/crates/backup-store/src/fs.rs b/crates/backup-store/src/fs.rs
new file mode 100644
index 00000000..dd29ca06
--- /dev/null
+++ b/crates/backup-store/src/fs.rs
@@ -0,0 +1,544 @@
+// Copyright 2026 ExtendDB contributors
+// SPDX-License-Identifier: Apache-2.0
+
+//! Filesystem-backed store.
+//!
+//! Keys map to paths under a root directory that is canonicalized at
+//! construction. Every operation resolves its key one component at a time
+//! with `symlink_metadata` and refuses any component that is a symlink,
+//! whether it points inside or outside the root, so a key never resolves
+//! through a link. These checks defeat symlinks present when an operation
+//! starts. They do not defend against another local process mutating the
+//! tree while an operation runs: between a component check and the next
+//! path step the kernel re-resolves the textual path, so a process with
+//! write access inside the root can swap a checked directory for a symlink
+//! during the operation. Closing that window requires resolving through
+//! directory handles (`openat2` with `RESOLVE_BENEATH`, or an `openat` walk
+//! with `O_NOFOLLOW` at every step), which the standard library does not expose;
+//! that is left as a follow-up. The backup root must therefore not be
+//! writable by less-trusted users: a process with write access inside the
+//! root already holds full read, write, and delete over every backup in it.
+//!
+//! Writes go to a temporary sibling file and rename into place, so a crash
+//! never leaves a truncated file under its final name.
+//!
+//! Two limits of this store narrow the shared key space. A key component
+//! longer than the filesystem's name limit (`NAME_MAX`, 255 bytes on Linux)
+//! fails with an I/O error even though key validation accepts it. An object
+//! whose file name matches the temporary-file pattern (`.put-*.tmp`) can be
+//! written and read but is hidden from listings.
+
+use std::path::{Path, PathBuf};
+
+use futures::future::BoxFuture;
+use futures::stream::BoxStream;
+use futures::{FutureExt, StreamExt, TryStreamExt};
+use tokio::io::AsyncWriteExt;
+
+use crate::key::{key_matches_prefix, normalize_prefix, validate_key, validate_prefix};
+use crate::{BackupStore, ByteStream, ObjectMeta, StoreError};
+
+/// Prefix and suffix marking in-flight temporary files, hidden from listings.
+const TEMP_PREFIX: &str = ".put-";
+const TEMP_SUFFIX: &str = ".tmp";
+
+/// Store writing objects under a local root directory.
+pub struct FilesystemStore {
+    /// Canonicalized root; every resolved target must stay under it.
+    root: PathBuf,
+}
+
+impl FilesystemStore {
+    /// Open a store rooted at `root`. The directory must already exist; it is
+    /// canonicalized here and the canonical form anchors every escape check.
+    ///
+    /// # Errors
+    ///
+    /// Returns an error when the root does not exist, cannot be resolved, or
+    /// is not a directory.
+    pub async fn open(root: impl AsRef) -> Result {
+        let root = root.as_ref();
+        let canonical = tokio::fs::canonicalize(root).await.map_err(|e| {
+            StoreError::io(format!("backup root {} is not usable", root.display()), e)
+        })?;
+        let meta = tokio::fs::metadata(&canonical)
+            .await
+            .map_err(|e| StoreError::io(format!("stat {}", canonical.display()), e))?;
+        if !meta.is_dir() {
+            return Err(StoreError::Other(format!(
+                "backup root {} is not a directory",
+                canonical.display()
+            )));
+        }
+        Ok(Self { root: canonical })
+    }
+
+    /// Resolve a validated key to its path under the root without following
+    /// symlinks: every component is checked with `symlink_metadata` and any
+    /// symlink, intermediate or final, is refused whether it points inside
+    /// or outside the root, so `get` and `head` agree with `put`, `list`,
+    /// and `delete_prefix` on what a key names. Key validation has already
+    /// refused `..`, empty components, and separators, so with no symlink in
+    /// the walk the path cannot leave the root. Missing paths surface as
+    /// `NotFound`. The returned metadata describes the final component.
+    async fn resolve_no_follow(
+        &self,
+        key: &str,
+    ) -> Result<(PathBuf, std::fs::Metadata), StoreError> {
+        let mut path = self.root.clone();
+        let mut meta = None;
+        for component in key.split('/') {
+            path.push(component);
+            let m = match tokio::fs::symlink_metadata(&path).await {
+                Ok(m) => m,
+                Err(e)
+                    if e.kind() == std::io::ErrorKind::NotFound
+                        || e.kind() == std::io::ErrorKind::NotADirectory =>
+                {
+                    return Err(StoreError::NotFound);
+                }
+                Err(e) => return Err(StoreError::io(format!("stat {}", path.display()), e)),
+            };
+            if m.is_symlink() {
+                return Err(symlink_refused(&path));
+            }
+            meta = Some(m);
+        }
+        let meta = meta.expect("a validated key has at least one component");
+        Ok((path, meta))
+    }
+
+    /// Prepare the directory a key's object will be written into, without
+    /// ever creating a directory outside the root.
+    ///
+    /// `create_dir_all` follows symlinks in existing path components, so
+    /// checking the parent only after creating it would already have created
+    /// directories on the far side of a planted symlink. Instead: walk to the
+    /// deepest existing ancestor of the destination, refusing any component
+    /// that is a symlink (in-root aliases included), and verify the ancestor
+    /// still resolves under the root. The remaining components are then
+    /// created one at a time with `create_dir`, re-verifying after each step
+    /// that the component is a real directory and not a symlink.
+    async fn prepare_parent(&self, parent_components: &[&str]) -> Result {
+        let mut existing = self.root.clone();
+        let mut created_from = parent_components.len();
+        for (index, component) in parent_components.iter().enumerate() {
+            let candidate = existing.join(component);
+            match tokio::fs::symlink_metadata(&candidate).await {
+                Ok(meta) if meta.is_symlink() => {
+                    return Err(symlink_refused(&candidate));
+                }
+                Ok(_) => existing = candidate,
+                // NotADirectory: an existing ancestor is a file, so the walk
+                // stops there and the checks below decide (an in-root file
+                // is an ancestor collision).
+                Err(e)
+                    if e.kind() == std::io::ErrorKind::NotFound
+                        || e.kind() == std::io::ErrorKind::NotADirectory =>
+                {
+                    created_from = index;
+                    break;
+                }
+                Err(e) => {
+                    return Err(StoreError::io(format!("stat {}", candidate.display()), e));
+                }
+            }
+        }
+
+        // The deepest existing ancestor is checked before anything is
+        // created: no component was a symlink at the walk above, and as
+        // defense in depth its canonical form must still sit under the root.
+        let canonical = tokio::fs::canonicalize(&existing)
+            .await
+            .map_err(|e| StoreError::io(format!("resolve {}", existing.display()), e))?;
+        if !canonical.starts_with(&self.root) {
+            return Err(StoreError::PermissionDenied(format!(
+                "{} resolves outside the backup root",
+                existing.display()
+            )));
+        }
+        let meta = tokio::fs::metadata(&canonical)
+            .await
+            .map_err(|e| StoreError::io(format!("stat {}", canonical.display()), e))?;
+        if !meta.is_dir() {
+            return Err(StoreError::io(
+                format!("create under {}", canonical.display()),
+                std::io::Error::new(
+                    std::io::ErrorKind::NotADirectory,
+                    "an object already exists at an ancestor of the key",
+                ),
+            ));
+        }
+
+        let mut current = canonical;
+        for component in &parent_components[created_from..] {
+            let next = current.join(component);
+            match tokio::fs::create_dir(&next).await {
+                Ok(()) => {}
+                // A concurrent put may have created it; the check below
+                // verifies what is actually there.
+                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
+                Err(e) => return Err(StoreError::io(format!("create {}", next.display()), e)),
+            }
+            let meta = tokio::fs::symlink_metadata(&next)
+                .await
+                .map_err(|e| StoreError::io(format!("stat {}", next.display()), e))?;
+            if meta.is_symlink() || !meta.is_dir() {
+                return Err(StoreError::PermissionDenied(format!(
+                    "{} is not a directory inside the backup root",
+                    next.display()
+                )));
+            }
+            current = next;
+        }
+        Ok(current)
+    }
+
+    async fn put_impl(&self, key: &str, mut body: ByteStream) -> Result<(), StoreError> {
+        validate_key(key)?;
+        let components: Vec<&str> = key.split('/').collect();
+        let (file_name, parent_components) = components
+            .split_last()
+            .expect("a validated key has at least one component");
+        let parent = self.prepare_parent(parent_components).await?;
+        let final_path = parent.join(file_name);
+
+        // A symlink at the destination, in-root or escaping, is refused: a
+        // put never resolves through a link.
+        match tokio::fs::symlink_metadata(&final_path).await {
+            Ok(meta) if meta.is_symlink() => {
+                return Err(symlink_refused(&final_path));
+            }
+            Ok(_) | Err(_) => {}
+        }
+
+        let temp_path = parent.join(format!(
+            "{TEMP_PREFIX}{}{TEMP_SUFFIX}",
+            uuid::Uuid::new_v4()
+        ));
+
+        // Durability contract: the temp file is synced before the rename and
+        // the parent directory is synced after it, so an Ok return means both
+        // the object bytes and its directory entry are on disk. Without the
+        // directory sync the rename itself could be lost on power failure
+        // even though the file data had been written.
+        let write_result = async {
+            let mut file = tokio::fs::File::create(&temp_path)
+                .await
+                .map_err(|e| StoreError::io(format!("create {}", temp_path.display()), e))?;
+            while let Some(chunk) = body.next().await {
+                let chunk = chunk?;
+                file.write_all(&chunk)
+                    .await
+                    .map_err(|e| StoreError::io(format!("write {}", temp_path.display()), e))?;
+            }
+            file.sync_all()
+                .await
+                .map_err(|e| StoreError::io(format!("sync {}", temp_path.display()), e))?;
+            drop(file);
+            tokio::fs::rename(&temp_path, &final_path)
+                .await
+                .map_err(|e| {
+                    StoreError::io(
+                        format!("rename {} to {}", temp_path.display(), final_path.display()),
+                        e,
+                    )
+                })?;
+            sync_dir(&parent).await
+        }
+        .await;
+
+        if write_result.is_err() {
+            // Best effort: never leave the temporary behind on failure.
+            let _ = tokio::fs::remove_file(&temp_path).await;
+        }
+        write_result
+    }
+
+    async fn get_impl(&self, key: &str) -> Result {
+        validate_key(key)?;
+        let (path, meta) = self.resolve_no_follow(key).await?;
+        if !meta.is_file() {
+            return Err(StoreError::NotFound);
+        }
+        let file = tokio::fs::File::open(&path)
+            .await
+            .map_err(|e| StoreError::io(format!("open {}", path.display()), e))?;
+        let display = path.display().to_string();
+        let stream = tokio_util::io::ReaderStream::new(file)
+            .map_err(move |e| StoreError::io(format!("read {display}"), e));
+        Ok(Box::pin(stream))
+    }
+
+    async fn head_impl(&self, key: &str) -> Result, StoreError> {
+        validate_key(key)?;
+        let (_, meta) = match self.resolve_no_follow(key).await {
+            Ok(resolved) => resolved,
+            Err(StoreError::NotFound) => return Ok(None),
+            Err(e) => return Err(e),
+        };
+        if !meta.is_file() {
+            return Ok(None);
+        }
+        Ok(Some(ObjectMeta {
+            key: key.to_owned(),
+            size: meta.len(),
+            last_modified: meta.modified().ok(),
+        }))
+    }
+
+    async fn list_impl(&self, prefix: &str) -> Result, StoreError> {
+        validate_prefix(prefix)?;
+        let normalized = normalize_prefix(prefix).to_owned();
+        let root = self.root.clone();
+        tokio::task::spawn_blocking(move || collect_objects(&root, &normalized))
+            .await
+            .map_err(|e| StoreError::Other(format!("listing task failed: {e}")))?
+    }
+
+    async fn delete_prefix_impl(&self, prefix: &str) -> Result {
+        validate_prefix(prefix)?;
+        let normalized = normalize_prefix(prefix).to_owned();
+        let root = self.root.clone();
+        tokio::task::spawn_blocking(move || delete_objects(&root, &normalized))
+            .await
+            .map_err(|e| StoreError::Other(format!("deletion task failed: {e}")))?
+    }
+
+    async fn validate_impl(&self) -> Result<(), StoreError> {
+        let meta = tokio::fs::metadata(&self.root).await.map_err(|e| {
+            StoreError::io(
+                format!("backup root {} is not usable", self.root.display()),
+                e,
+            )
+        })?;
+        if !meta.is_dir() {
+            return Err(StoreError::Other(format!(
+                "backup root {} is not a directory",
+                self.root.display()
+            )));
+        }
+        let probe = self.root.join(format!(
+            "{TEMP_PREFIX}validate-{}{TEMP_SUFFIX}",
+            uuid::Uuid::new_v4()
+        ));
+        tokio::fs::write(&probe, b"probe").await.map_err(|e| {
+            map_permission(
+                format!("backup root {} is not writable", self.root.display()),
+                e,
+            )
+        })?;
+        tokio::fs::remove_file(&probe)
+            .await
+            .map_err(|e| StoreError::io(format!("remove probe {}", probe.display()), e))?;
+        Ok(())
+    }
+}
+
+impl BackupStore for FilesystemStore {
+    fn put<'a>(&'a self, key: &'a str, body: ByteStream) -> BoxFuture<'a, Result<(), StoreError>> {
+        self.put_impl(key, body).boxed()
+    }
+
+    fn get<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Result> {
+        self.get_impl(key).boxed()
+    }
+
+    fn head<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Result, StoreError>> {
+        self.head_impl(key).boxed()
+    }
+
+    fn list<'a>(&'a self, prefix: &'a str) -> BoxStream<'a, Result> {
+        let items = async move {
+            match self.list_impl(prefix).await {
+                Ok(metas) => metas.into_iter().map(Ok).collect::>(),
+                Err(e) => vec![Err(e)],
+            }
+        };
+        Box::pin(
+            futures::stream::once(items)
+                .map(futures::stream::iter)
+                .flatten(),
+        )
+    }
+
+    fn delete_prefix<'a>(&'a self, prefix: &'a str) -> BoxFuture<'a, Result> {
+        self.delete_prefix_impl(prefix).boxed()
+    }
+
+    fn validate(&self) -> BoxFuture<'_, Result<(), StoreError>> {
+        self.validate_impl().boxed()
+    }
+}
+
+fn map_permission(context: String, e: std::io::Error) -> StoreError {
+    if e.kind() == std::io::ErrorKind::PermissionDenied {
+        StoreError::PermissionDenied(context)
+    } else {
+        StoreError::io(context, e)
+    }
+}
+
+/// Refusal for a symlink found in a key's path. The message names the path
+/// under the root that was refused, never the link's target.
+fn symlink_refused(path: &Path) -> StoreError {
+    StoreError::PermissionDenied(format!(
+        "{} is a symlink; keys never resolve through symlinks",
+        path.display()
+    ))
+}
+
+/// Fsync a directory so a rename inside it is durable. Windows has no
+/// directory handle to sync; there the rename's durability is left to the
+/// operating system.
+async fn sync_dir(dir: &std::path::Path) -> Result<(), StoreError> {
+    #[cfg(unix)]
+    {
+        let handle = tokio::fs::File::open(dir)
+            .await
+            .map_err(|e| StoreError::io(format!("open directory {}", dir.display()), e))?;
+        handle
+            .sync_all()
+            .await
+            .map_err(|e| StoreError::io(format!("sync directory {}", dir.display()), e))?;
+    }
+    #[cfg(not(unix))]
+    {
+        let _ = dir;
+    }
+    Ok(())
+}
+
+fn is_temp_name(name: &str) -> bool {
+    name.starts_with(TEMP_PREFIX) && name.ends_with(TEMP_SUFFIX)
+}
+
+/// Path for a normalized (possibly empty) prefix.
+fn prefix_path(root: &Path, prefix: &str) -> PathBuf {
+    if prefix.is_empty() {
+        return root.to_path_buf();
+    }
+    let mut path = root.to_path_buf();
+    for component in prefix.split('/') {
+        path.push(component);
+    }
+    path
+}
+
+/// Collect object metadata under a normalized prefix, sorted by key.
+fn collect_objects(root: &Path, prefix: &str) -> Result, StoreError> {
+    let base = prefix_path(root, prefix);
+    let mut out = Vec::new();
+    match std::fs::symlink_metadata(&base) {
+        Ok(meta) if meta.is_file() => {
+            // The prefix names an object directly (key == prefix).
+            out.push(ObjectMeta {
+                key: prefix.to_owned(),
+                size: meta.len(),
+                last_modified: meta.modified().ok(),
+            });
+        }
+        Ok(meta) if meta.is_dir() => {
+            walk_files(root, &base, &mut out)?;
+        }
+        // A symlink at the prefix, or nothing there at all: no objects.
+        Ok(_) | Err(_) => {}
+    }
+    // Defense in depth: the walk starts at the prefix directory, so every key
+    // matches by construction; keep the shared rule as the final filter.
+    out.retain(|meta| key_matches_prefix(&meta.key, prefix));
+    out.sort_by(|a, b| a.key.cmp(&b.key));
+    Ok(out)
+}
+
+fn walk_files(root: &Path, dir: &Path, out: &mut Vec) -> Result<(), StoreError> {
+    let entries = std::fs::read_dir(dir)
+        .map_err(|e| StoreError::io(format!("read directory {}", dir.display()), e))?;
+    for entry in entries {
+        let entry =
+            entry.map_err(|e| StoreError::io(format!("read directory {}", dir.display()), e))?;
+        let path = entry.path();
+        let file_type = entry
+            .file_type()
+            .map_err(|e| StoreError::io(format!("stat {}", path.display()), e))?;
+        if file_type.is_dir() {
+            walk_files(root, &path, out)?;
+        } else if file_type.is_file() {
+            let name = entry.file_name();
+            if is_temp_name(&name.to_string_lossy()) {
+                continue;
+            }
+            let meta = entry
+                .metadata()
+                .map_err(|e| StoreError::io(format!("stat {}", path.display()), e))?;
+            let key = path
+                .strip_prefix(root)
+                .expect("walk stays under the root")
+                .components()
+                .map(|c| c.as_os_str().to_string_lossy().into_owned())
+                .collect::>()
+                .join("/");
+            out.push(ObjectMeta {
+                key,
+                size: meta.len(),
+                last_modified: meta.modified().ok(),
+            });
+        }
+        // Symlinks are never objects this store wrote; skip them.
+    }
+    Ok(())
+}
+
+/// Delete everything under a normalized prefix; returns the number of files
+/// removed. Directories emptied by the deletion are removed too, including
+/// the prefix directory itself.
+fn delete_objects(root: &Path, prefix: &str) -> Result {
+    let base = prefix_path(root, prefix);
+    match std::fs::symlink_metadata(&base) {
+        Ok(meta) if meta.is_file() => {
+            std::fs::remove_file(&base)
+                .map_err(|e| StoreError::io(format!("remove {}", base.display()), e))?;
+            Ok(1)
+        }
+        Ok(meta) if meta.is_dir() => {
+            if prefix.is_empty() {
+                // Deleting everything must not remove the root itself.
+                let mut count = 0;
+                let entries = std::fs::read_dir(&base)
+                    .map_err(|e| StoreError::io(format!("read directory {}", base.display()), e))?;
+                for entry in entries {
+                    let entry = entry.map_err(|e| {
+                        StoreError::io(format!("read directory {}", base.display()), e)
+                    })?;
+                    count += delete_entry(&entry.path())?;
+                }
+                Ok(count)
+            } else {
+                delete_entry(&base)
+            }
+        }
+        Ok(_) | Err(_) => Ok(0),
+    }
+}
+
+fn delete_entry(path: &Path) -> Result {
+    let meta = std::fs::symlink_metadata(path)
+        .map_err(|e| StoreError::io(format!("stat {}", path.display()), e))?;
+    if meta.is_dir() {
+        let mut count = 0;
+        let entries = std::fs::read_dir(path)
+            .map_err(|e| StoreError::io(format!("read directory {}", path.display()), e))?;
+        for entry in entries {
+            let entry = entry
+                .map_err(|e| StoreError::io(format!("read directory {}", path.display()), e))?;
+            count += delete_entry(&entry.path())?;
+        }
+        std::fs::remove_dir(path)
+            .map_err(|e| StoreError::io(format!("remove directory {}", path.display()), e))?;
+        Ok(count)
+    } else {
+        std::fs::remove_file(path)
+            .map_err(|e| StoreError::io(format!("remove {}", path.display()), e))?;
+        Ok(1)
+    }
+}
diff --git a/crates/backup-store/src/key.rs b/crates/backup-store/src/key.rs
new file mode 100644
index 00000000..a6726102
--- /dev/null
+++ b/crates/backup-store/src/key.rs
@@ -0,0 +1,181 @@
+// Copyright 2026 ExtendDB contributors
+// SPDX-License-Identifier: Apache-2.0
+
+//! Key and prefix validation shared by both store implementations.
+//!
+//! A key is a `/`-separated component path. The rules exist so a key can be
+//! mapped onto a filesystem path without ever escaping the store root, and so
+//! the two stores accept exactly the same key space:
+//!
+//! - every component is non-empty (no leading, trailing, or doubled `/`)
+//! - no component is `.` or `..`
+//! - no component contains a backslash (a path separator on other platforms)
+//! - no component contains a control character
+//! - the whole key is at most [`MAX_KEY_LEN`] bytes
+
+use crate::StoreError;
+
+/// Maximum total key length in bytes, matching the S3 key length limit.
+pub const MAX_KEY_LEN: usize = 1024;
+
+/// Validate an object key.
+///
+/// # Errors
+///
+/// Returns [`StoreError::InvalidKey`] naming the rule the key violated.
+pub fn validate_key(key: &str) -> Result<(), StoreError> {
+    if key.is_empty() {
+        return Err(StoreError::InvalidKey("key is empty".to_owned()));
+    }
+    if key.len() > MAX_KEY_LEN {
+        return Err(StoreError::InvalidKey(format!(
+            "key is {} bytes; the maximum is {MAX_KEY_LEN}",
+            key.len()
+        )));
+    }
+    for component in key.split('/') {
+        validate_component(component)?;
+    }
+    Ok(())
+}
+
+/// Validate a listing or deletion prefix.
+///
+/// A prefix follows the key rules with two relaxations: it may be empty
+/// (matching every object) and it may carry one trailing `/`.
+///
+/// # Errors
+///
+/// Returns [`StoreError::InvalidKey`] naming the rule the prefix violated.
+pub fn validate_prefix(prefix: &str) -> Result<(), StoreError> {
+    if prefix.is_empty() {
+        return Ok(());
+    }
+    let normalized = prefix.strip_suffix('/').unwrap_or(prefix);
+    if normalized.is_empty() {
+        return Err(StoreError::InvalidKey(
+            "prefix consists only of a separator".to_owned(),
+        ));
+    }
+    validate_key(normalized)
+}
+
+/// Strip at most one trailing `/` from a validated prefix, yielding the form
+/// the matching rule operates on.
+pub(crate) fn normalize_prefix(prefix: &str) -> &str {
+    prefix.strip_suffix('/').unwrap_or(prefix)
+}
+
+/// Whether `key` is matched by the normalized prefix `prefix`: the empty
+/// prefix matches everything, otherwise the key must equal the prefix or sit
+/// under `prefix/`. Component-aligned, so prefix `a/b` never matches `a/bc`.
+pub(crate) fn key_matches_prefix(key: &str, prefix: &str) -> bool {
+    if prefix.is_empty() {
+        return true;
+    }
+    match key.strip_prefix(prefix) {
+        Some("") => true,
+        Some(rest) => rest.starts_with('/'),
+        None => false,
+    }
+}
+
+fn validate_component(component: &str) -> Result<(), StoreError> {
+    if component.is_empty() {
+        return Err(StoreError::InvalidKey(
+            "key has an empty component (leading, trailing, or doubled '/')".to_owned(),
+        ));
+    }
+    if component == "." || component == ".." {
+        return Err(StoreError::InvalidKey(format!(
+            "key component '{component}' is not allowed"
+        )));
+    }
+    if component.contains('\\') {
+        return Err(StoreError::InvalidKey(
+            "key component contains a backslash".to_owned(),
+        ));
+    }
+    if component.chars().any(char::is_control) {
+        return Err(StoreError::InvalidKey(
+            "key component contains a control character".to_owned(),
+        ));
+    }
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn accepts_well_formed_keys() {
+        for key in [
+            "a",
+            "a/b",
+            "123456789012/orders/backup-01J5/extenddb-manifest.json",
+            "data/000001.json.gz",
+            "with space/and.dots.json",
+            "unicode/ключ/键",
+        ] {
+            assert!(validate_key(key).is_ok(), "{key} should be valid");
+        }
+    }
+
+    #[test]
+    fn rejects_malformed_keys() {
+        let cases: &[&str] = &[
+            "", "/", "/a", "a/", "a//b", ".", "..", "a/./b", "a/../b", "../a", "a/..", "a\\b",
+            "a/b\\c", "a\x00b", "a/\x1fb", "a\x7f",
+        ];
+        for key in cases {
+            assert!(
+                matches!(validate_key(key), Err(StoreError::InvalidKey(_))),
+                "{key:?} should be rejected"
+            );
+        }
+    }
+
+    #[test]
+    fn rejects_overlong_keys() {
+        let key = "a/".repeat(512) + "b";
+        assert!(key.len() > MAX_KEY_LEN);
+        assert!(matches!(validate_key(&key), Err(StoreError::InvalidKey(_))));
+        let max = "a".repeat(MAX_KEY_LEN);
+        assert!(validate_key(&max).is_ok());
+    }
+
+    #[test]
+    fn prefix_rules() {
+        assert!(validate_prefix("").is_ok());
+        assert!(validate_prefix("a").is_ok());
+        assert!(validate_prefix("a/b/").is_ok());
+        assert!(matches!(
+            validate_prefix("/"),
+            Err(StoreError::InvalidKey(_))
+        ));
+        assert!(matches!(
+            validate_prefix("a//b"),
+            Err(StoreError::InvalidKey(_))
+        ));
+        assert!(matches!(
+            validate_prefix("a/../b"),
+            Err(StoreError::InvalidKey(_))
+        ));
+        assert!(matches!(
+            validate_prefix("a\x01"),
+            Err(StoreError::InvalidKey(_))
+        ));
+    }
+
+    #[test]
+    fn prefix_matching_is_component_aligned() {
+        assert!(key_matches_prefix("a/b/c", ""));
+        assert!(key_matches_prefix("a/b/c", "a"));
+        assert!(key_matches_prefix("a/b/c", "a/b"));
+        assert!(key_matches_prefix("a/b", "a/b"));
+        assert!(!key_matches_prefix("a/bc", "a/b"));
+        assert!(!key_matches_prefix("ab/c", "a"));
+        assert!(!key_matches_prefix("a", "a/b"));
+    }
+}
diff --git a/crates/backup-store/src/lib.rs b/crates/backup-store/src/lib.rs
new file mode 100644
index 00000000..f4e8716f
--- /dev/null
+++ b/crates/backup-store/src/lib.rs
@@ -0,0 +1,137 @@
+// Copyright 2026 ExtendDB contributors
+// SPDX-License-Identifier: Apache-2.0
+
+//! Backup object store abstraction.
+//!
+//! One trait, [`BackupStore`], and two implementations: [`FilesystemStore`]
+//! writes objects under a local root directory, [`S3Store`] writes them to an
+//! S3 bucket through the official AWS SDK. Both are exercised by the same
+//! conformance test module in `tests/conformance.rs`.
+//!
+//! Keys are `/`-separated component paths validated by [`key::validate_key`];
+//! the same rules apply to both stores so a backup written by one store can be
+//! read by the other. See `docs/design/15-backup-restore.md` for the full
+//! design.
+
+use std::sync::Arc;
+
+use bytes::Bytes;
+use futures::future::BoxFuture;
+use futures::stream::BoxStream;
+
+mod config;
+mod error;
+mod fs;
+pub mod key;
+mod s3;
+
+pub use config::{BackupStoreConfig, BackupStoreKind, FilesystemStoreConfig, S3StoreConfig};
+pub use error::StoreError;
+pub use fs::FilesystemStore;
+pub use s3::S3Store;
+
+/// Byte stream flowing into and out of a store.
+///
+/// `put` consumes one, `get` returns one. Errors from the underlying source
+/// propagate as [`StoreError`], which lets tests inject a mid-body failure to
+/// exercise abort paths.
+pub type ByteStream = BoxStream<'static, Result>;
+
+/// Build a [`ByteStream`] from an in-memory buffer.
+pub fn byte_stream_from(bytes: impl Into) -> ByteStream {
+    let bytes = bytes.into();
+    Box::pin(futures::stream::once(async move { Ok(bytes) }))
+}
+
+/// Metadata for one stored object.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ObjectMeta {
+    /// Object key, relative to the store root (and, for S3, the configured
+    /// bucket prefix).
+    pub key: String,
+    /// Object size in bytes.
+    pub size: u64,
+    /// Last modification time, when the store reports one.
+    pub last_modified: Option,
+}
+
+/// A destination for backup objects.
+///
+/// Implementations must be safe to share across tasks; the engine holds one
+/// behind an `Arc` for the lifetime of the process.
+pub trait BackupStore: Send + Sync {
+    /// Store `body` under `key`, replacing any existing object.
+    ///
+    /// A failed put must leave nothing observable under `key`: the filesystem
+    /// store writes to a temporary sibling and renames into place, the S3
+    /// store aborts an in-flight multipart upload.
+    ///
+    /// One key-space divergence between the stores: the filesystem store
+    /// cannot hold an object at `a` and another at `a/b` (a file and a
+    /// directory cannot share a path), while S3 can. The backup layout keys
+    /// objects only under per-backup prefixes, so it never produces that
+    /// shape.
+    fn put<'a>(&'a self, key: &'a str, body: ByteStream) -> BoxFuture<'a, Result<(), StoreError>>;
+
+    /// Stream the object stored under `key`.
+    ///
+    /// Returns [`StoreError::NotFound`] when no object exists.
+    fn get<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Result>;
+
+    /// Metadata for the object under `key`, or `None` when no object exists.
+    fn head<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Result, StoreError>>;
+
+    /// Stream metadata for every object whose key equals `prefix` or sits
+    /// under `prefix/`, in lexicographic key order. An empty prefix lists
+    /// every object. Prefix matching is component-aligned: prefix `a/b` does
+    /// not match key `a/bc`.
+    fn list<'a>(&'a self, prefix: &'a str) -> BoxStream<'a, Result>;
+
+    /// Delete every object matched by `prefix` (same matching rule as
+    /// [`BackupStore::list`]) and return how many objects were removed. The
+    /// filesystem store also removes directories the deletion emptied, and
+    /// its count includes any files under the prefix that listings hide
+    /// (in-flight temporaries, symlinks), so the count can exceed the number
+    /// of objects `list` reported. A prefix that matches nothing returns
+    /// `Ok(0)`.
+    fn delete_prefix<'a>(&'a self, prefix: &'a str) -> BoxFuture<'a, Result>;
+
+    /// Startup validation. The filesystem store checks the root exists and is
+    /// writable by writing and removing a probe file; the S3 store calls
+    /// `HeadBucket`. Called once at server startup so a misconfigured store
+    /// fails then, not at the first `CreateBackup`.
+    fn validate(&self) -> BoxFuture<'_, Result<(), StoreError>>;
+}
+
+/// Open the store described by `config`.
+///
+/// Validates the per-kind section is present and its values are usable, then
+/// constructs the store. S3 credentials come from the standard AWS SDK chain;
+/// they are never part of the configuration.
+///
+/// # Errors
+///
+/// Returns an error when the section matching the selected store kind is
+/// absent, a value fails validation (part size floor, zero concurrency,
+/// malformed prefix), or the filesystem root is unusable.
+pub async fn open(config: &BackupStoreConfig) -> Result, StoreError> {
+    match config.store {
+        BackupStoreKind::Filesystem => {
+            let fs_config = config.filesystem.as_ref().ok_or_else(|| {
+                StoreError::Other(
+                    "[backup.filesystem] with a path is required when store = \"filesystem\""
+                        .to_owned(),
+                )
+            })?;
+            Ok(Arc::new(FilesystemStore::open(&fs_config.path).await?))
+        }
+        BackupStoreKind::S3 => {
+            let s3_config = config.s3.as_ref().ok_or_else(|| {
+                StoreError::Other(
+                    "[backup.s3] with a bucket is required when store = \"s3\"".to_owned(),
+                )
+            })?;
+            Ok(Arc::new(S3Store::open(s3_config).await?))
+        }
+    }
+}
diff --git a/crates/backup-store/src/s3.rs b/crates/backup-store/src/s3.rs
new file mode 100644
index 00000000..4e686da3
--- /dev/null
+++ b/crates/backup-store/src/s3.rs
@@ -0,0 +1,596 @@
+// Copyright 2026 ExtendDB contributors
+// SPDX-License-Identifier: Apache-2.0
+
+//! S3-backed store built on the official `aws-sdk-s3` crate.
+//!
+//! Credentials come from the standard AWS SDK chain (environment, shared
+//! config, IMDS, IRSA, container credentials); the store never holds them
+//! itself. Uploads larger than the configured part size use multipart, and a
+//! failed multipart upload is aborted so no orphaned parts accrue storage.
+//! Listing paginates; prefix deletion batches `DeleteObjects` requests.
+
+use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
+use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart, Delete, ObjectIdentifier};
+use bytes::{Bytes, BytesMut};
+use futures::future::BoxFuture;
+use futures::stream::BoxStream;
+use futures::{FutureExt, StreamExt, TryStreamExt};
+
+use crate::config::S3StoreConfig;
+use crate::key::{key_matches_prefix, normalize_prefix, validate_key, validate_prefix};
+use crate::{BackupStore, ByteStream, ObjectMeta, StoreError};
+
+/// The smallest part size S3 accepts for any part except the last.
+pub const MIN_PART_SIZE_BYTES: u64 = 5 * 1024 * 1024;
+
+/// Largest number of keys one `DeleteObjects` request accepts.
+const DELETE_BATCH: usize = 1000;
+
+/// Store writing objects to an S3 bucket (or an S3-compatible endpoint).
+pub struct S3Store {
+    client: aws_sdk_s3::Client,
+    bucket: String,
+    /// Normalized bucket-side key prefix: empty, or ending in `/`.
+    prefix: String,
+    part_size: usize,
+    max_concurrent_uploads: usize,
+}
+
+impl S3Store {
+    /// Open a store against the configured bucket.
+    ///
+    /// Enforces the part size floor and resolves credentials, region, and
+    /// retry behavior through the SDK default chain. `endpoint` and
+    /// `force_path_style` support S3-compatible stores such as `MinIO`.
+    ///
+    /// # Errors
+    ///
+    /// Returns an error when the part size is below the 5 MiB floor, the
+    /// concurrency is zero, or the configured prefix fails key validation.
+    pub async fn open(config: &S3StoreConfig) -> Result {
+        Self::open_inner(config, None).await
+    }
+
+    /// Open with an additional client interceptor. This is the failure
+    /// injection seam the crate's own test suite uses to make a chosen
+    /// operation fail; it compiles only with the `test-util` feature and is
+    /// not part of the supported API surface.
+    #[cfg(feature = "test-util")]
+    #[doc(hidden)]
+    pub async fn open_with_interceptor(
+        config: &S3StoreConfig,
+        interceptor: impl aws_sdk_s3::config::Intercept + 'static,
+    ) -> Result {
+        Self::open_inner(
+            config,
+            Some(aws_sdk_s3::config::SharedInterceptor::new(interceptor)),
+        )
+        .await
+    }
+
+    async fn open_inner(
+        config: &S3StoreConfig,
+        interceptor: Option,
+    ) -> Result {
+        if config.upload_part_size_bytes < MIN_PART_SIZE_BYTES {
+            return Err(StoreError::Other(format!(
+                "upload_part_size_bytes is {}; the minimum is {MIN_PART_SIZE_BYTES} (5 MiB)",
+                config.upload_part_size_bytes
+            )));
+        }
+        if config.max_concurrent_uploads == 0 {
+            return Err(StoreError::Other(
+                "max_concurrent_uploads must be at least 1".to_owned(),
+            ));
+        }
+        let part_size = usize::try_from(config.upload_part_size_bytes).map_err(|_| {
+            StoreError::Other(format!(
+                "upload_part_size_bytes {} does not fit this platform",
+                config.upload_part_size_bytes
+            ))
+        })?;
+        let prefix = normalize_store_prefix(&config.prefix)?;
+
+        let mut loader = aws_config::defaults(aws_config::BehaviorVersion::latest());
+        if let Some(region) = &config.region {
+            loader = loader.region(aws_config::Region::new(region.clone()));
+        }
+        if let Some(endpoint) = &config.endpoint {
+            loader = loader.endpoint_url(endpoint.clone());
+        }
+        let shared = loader.load().await;
+        let mut builder = aws_sdk_s3::config::Builder::from(&shared);
+        if config.force_path_style {
+            builder = builder.force_path_style(true);
+        }
+        if let Some(interceptor) = interceptor {
+            builder.push_interceptor(interceptor);
+        }
+        let client = aws_sdk_s3::Client::from_conf(builder.build());
+
+        Ok(Self {
+            client,
+            bucket: config.bucket.clone(),
+            prefix,
+            part_size,
+            max_concurrent_uploads: config.max_concurrent_uploads,
+        })
+    }
+
+    /// Bucket-side key for a store key.
+    fn full_key(&self, key: &str) -> String {
+        format!("{}{key}", self.prefix)
+    }
+
+    async fn put_impl(&self, key: &str, body: ByteStream) -> Result<(), StoreError> {
+        validate_key(key)?;
+        let full_key = self.full_key(key);
+        let mut chunks = std::pin::pin!(chunk_stream(body, self.part_size));
+
+        let first = match chunks.try_next().await? {
+            Some(chunk) => chunk,
+            None => Bytes::new(),
+        };
+        let second = chunks.try_next().await?;
+
+        let Some(second) = second else {
+            // The whole body fits in one part: a single PutObject.
+            self.client
+                .put_object()
+                .bucket(&self.bucket)
+                .key(&full_key)
+                .body(first.into())
+                .send()
+                .await
+                .map_err(|e| map_sdk_error("PutObject", e))?;
+            return Ok(());
+        };
+
+        let upload_id = self
+            .client
+            .create_multipart_upload()
+            .bucket(&self.bucket)
+            .key(&full_key)
+            .send()
+            .await
+            .map_err(|e| map_sdk_error("CreateMultipartUpload", e))?
+            .upload_id()
+            .ok_or_else(|| {
+                StoreError::Other("CreateMultipartUpload returned no upload id".to_owned())
+            })?
+            .to_owned();
+
+        // Every failure after CreateMultipartUpload must reach the abort,
+        // including a failed CompleteMultipartUpload, so no orphaned parts
+        // accrue storage. A complete that failed after S3 committed it makes
+        // the abort answer NoSuchUpload, which the best-effort warn tolerates.
+        let outcome: Result<(), StoreError> = async {
+            let parts = self
+                .upload_parts(&full_key, &upload_id, first, second, chunks)
+                .await?;
+            self.client
+                .complete_multipart_upload()
+                .bucket(&self.bucket)
+                .key(&full_key)
+                .upload_id(&upload_id)
+                .multipart_upload(
+                    CompletedMultipartUpload::builder()
+                        .set_parts(Some(parts))
+                        .build(),
+                )
+                .send()
+                .await
+                .map_err(|e| map_sdk_error("CompleteMultipartUpload", e))?;
+            Ok(())
+        }
+        .await;
+
+        if let Err(e) = outcome {
+            // Best effort: the original failure is what the caller needs.
+            if let Err(abort_err) = self
+                .client
+                .abort_multipart_upload()
+                .bucket(&self.bucket)
+                .key(&full_key)
+                .upload_id(&upload_id)
+                .send()
+                .await
+            {
+                tracing::warn!(
+                    key = %full_key,
+                    error = %map_sdk_error("AbortMultipartUpload", abort_err),
+                    "failed to abort multipart upload after a put failure"
+                );
+            }
+            return Err(e);
+        }
+        Ok(())
+    }
+
+    /// Upload every part with bounded concurrency; returns the completed part
+    /// list sorted by part number.
+    async fn upload_parts(
+        &self,
+        full_key: &str,
+        upload_id: &str,
+        first: Bytes,
+        second: Bytes,
+        rest: impl futures::Stream> + Send,
+    ) -> Result, StoreError> {
+        let all_chunks = futures::stream::iter([Ok(first), Ok(second)]).chain(rest);
+        let mut parts: Vec = all_chunks
+            .enumerate()
+            .map(|(index, chunk)| async move {
+                let chunk = chunk?;
+                let part_number = i32::try_from(index + 1).map_err(|_| {
+                    StoreError::Other("multipart upload exceeds the part number range".to_owned())
+                })?;
+                let output = self
+                    .client
+                    .upload_part()
+                    .bucket(&self.bucket)
+                    .key(full_key)
+                    .upload_id(upload_id)
+                    .part_number(part_number)
+                    .body(chunk.into())
+                    .send()
+                    .await
+                    .map_err(|e| map_sdk_error("UploadPart", e))?;
+                let e_tag = output.e_tag().ok_or_else(|| {
+                    StoreError::Other("UploadPart response carried no ETag".to_owned())
+                })?;
+                Ok::<_, StoreError>(
+                    CompletedPart::builder()
+                        .part_number(part_number)
+                        .e_tag(e_tag)
+                        .build(),
+                )
+            })
+            .buffered(self.max_concurrent_uploads)
+            .try_collect()
+            .await?;
+        parts.sort_by_key(CompletedPart::part_number);
+        Ok(parts)
+    }
+
+    async fn get_impl(&self, key: &str) -> Result {
+        validate_key(key)?;
+        let output = self
+            .client
+            .get_object()
+            .bucket(&self.bucket)
+            .key(self.full_key(key))
+            .send()
+            .await
+            .map_err(|e| map_sdk_error("GetObject", e))?;
+        Ok(Box::pin(futures::stream::unfold(
+            output.body,
+            |mut body| async move {
+                match body.next().await {
+                    Some(Ok(bytes)) => Some((Ok(bytes), body)),
+                    Some(Err(e)) => Some((
+                        Err(StoreError::Transport {
+                            source: Box::new(e),
+                        }),
+                        body,
+                    )),
+                    None => None,
+                }
+            },
+        )))
+    }
+
+    async fn head_impl(&self, key: &str) -> Result, StoreError> {
+        validate_key(key)?;
+        let output = match self
+            .client
+            .head_object()
+            .bucket(&self.bucket)
+            .key(self.full_key(key))
+            .send()
+            .await
+        {
+            Ok(output) => output,
+            Err(e) => {
+                return match map_sdk_error("HeadObject", e) {
+                    StoreError::NotFound => Ok(None),
+                    other => Err(other),
+                };
+            }
+        };
+        let size = output
+            .content_length()
+            .and_then(|len| u64::try_from(len).ok());
+        Ok(Some(ObjectMeta {
+            key: key.to_owned(),
+            size: size.unwrap_or(0),
+            last_modified: output.last_modified().map(smithy_time_to_system),
+        }))
+    }
+
+    /// One page of matching objects plus the continuation token, already
+    /// filtered by the component-aligned matching rule and mapped back to
+    /// store keys.
+    async fn list_page(
+        &self,
+        match_prefix: &str,
+        token: Option,
+    ) -> Result<(Vec, Option), StoreError> {
+        let request_prefix = self.full_key(match_prefix);
+        let output = self
+            .client
+            .list_objects_v2()
+            .bucket(&self.bucket)
+            .prefix(request_prefix)
+            .set_continuation_token(token)
+            .send()
+            .await
+            .map_err(|e| map_sdk_error("ListObjectsV2", e))?;
+        let metas = output
+            .contents()
+            .iter()
+            .filter_map(|object| {
+                let full = object.key()?;
+                let key = full.strip_prefix(&self.prefix)?;
+                if !key_matches_prefix(key, match_prefix) {
+                    return None;
+                }
+                Some(ObjectMeta {
+                    key: key.to_owned(),
+                    size: object
+                        .size()
+                        .and_then(|s| u64::try_from(s).ok())
+                        .unwrap_or(0),
+                    last_modified: object.last_modified().map(smithy_time_to_system),
+                })
+            })
+            .collect();
+        Ok((metas, output.next_continuation_token().map(str::to_owned)))
+    }
+
+    async fn delete_prefix_impl(&self, prefix: &str) -> Result {
+        validate_prefix(prefix)?;
+        let match_prefix = normalize_prefix(prefix);
+        let mut deleted: u64 = 0;
+        let mut token: Option = None;
+        loop {
+            let (metas, next) = self.list_page(match_prefix, token).await?;
+            for batch in metas.chunks(DELETE_BATCH) {
+                if batch.is_empty() {
+                    continue;
+                }
+                let objects = batch
+                    .iter()
+                    .map(|meta| {
+                        ObjectIdentifier::builder()
+                            .key(self.full_key(&meta.key))
+                            .build()
+                            .map_err(|e| StoreError::Other(format!("building delete request: {e}")))
+                    })
+                    .collect::, _>>()?;
+                let count = objects.len() as u64;
+                let delete = Delete::builder()
+                    .set_objects(Some(objects))
+                    .quiet(true)
+                    .build()
+                    .map_err(|e| StoreError::Other(format!("building delete request: {e}")))?;
+                let output = self
+                    .client
+                    .delete_objects()
+                    .bucket(&self.bucket)
+                    .delete(delete)
+                    .send()
+                    .await
+                    .map_err(|e| map_sdk_error("DeleteObjects", e))?;
+                let errors = output.errors();
+                if let Some(first) = errors.first() {
+                    return Err(StoreError::Other(format!(
+                        "DeleteObjects failed for {} of {count} keys; first: {} on {}",
+                        errors.len(),
+                        first.code().unwrap_or("unknown"),
+                        first.key().unwrap_or("unknown"),
+                    )));
+                }
+                deleted += count;
+            }
+            match next {
+                Some(next) => token = Some(next),
+                None => break,
+            }
+        }
+        Ok(deleted)
+    }
+
+    async fn validate_impl(&self) -> Result<(), StoreError> {
+        self.client
+            .head_bucket()
+            .bucket(&self.bucket)
+            .send()
+            .await
+            .map_err(|e| {
+                StoreError::Other(format!(
+                    "backup bucket {} failed validation: {}",
+                    self.bucket,
+                    map_sdk_error("HeadBucket", e)
+                ))
+            })?;
+        Ok(())
+    }
+}
+
+impl BackupStore for S3Store {
+    fn put<'a>(&'a self, key: &'a str, body: ByteStream) -> BoxFuture<'a, Result<(), StoreError>> {
+        self.put_impl(key, body).boxed()
+    }
+
+    fn get<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Result> {
+        self.get_impl(key).boxed()
+    }
+
+    fn head<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Result, StoreError>> {
+        self.head_impl(key).boxed()
+    }
+
+    fn list<'a>(&'a self, prefix: &'a str) -> BoxStream<'a, Result> {
+        enum State {
+            Start,
+            Next(String),
+            Done,
+        }
+        let pages = futures::stream::try_unfold(State::Start, move |state| async move {
+            let token = match state {
+                State::Start => {
+                    validate_prefix(prefix)?;
+                    None
+                }
+                State::Next(token) => Some(token),
+                State::Done => return Ok(None),
+            };
+            let (metas, next) = self.list_page(normalize_prefix(prefix), token).await?;
+            let next_state = match next {
+                Some(token) => State::Next(token),
+                None => State::Done,
+            };
+            Ok(Some((metas, next_state)))
+        });
+        Box::pin(
+            pages
+                .map_ok(|metas| futures::stream::iter(metas.into_iter().map(Ok)))
+                .try_flatten(),
+        )
+    }
+
+    fn delete_prefix<'a>(&'a self, prefix: &'a str) -> BoxFuture<'a, Result> {
+        self.delete_prefix_impl(prefix).boxed()
+    }
+
+    fn validate(&self) -> BoxFuture<'_, Result<(), StoreError>> {
+        self.validate_impl().boxed()
+    }
+}
+
+/// Normalize the configured bucket prefix: strip leading `/`, require a
+/// trailing `/` when non-empty, and hold it to the same component rules as
+/// keys.
+fn normalize_store_prefix(prefix: &str) -> Result {
+    let trimmed = prefix.trim_start_matches('/');
+    if trimmed.is_empty() {
+        return Ok(String::new());
+    }
+    let without_slash = trimmed.strip_suffix('/').unwrap_or(trimmed);
+    validate_key(without_slash)
+        .map_err(|e| StoreError::Other(format!("invalid [backup.s3] prefix {prefix:?}: {e}")))?;
+    Ok(format!("{without_slash}/"))
+}
+
+/// Split a byte stream into chunks of exactly `size` bytes, with a final
+/// short chunk carrying the remainder. An empty body yields no chunks.
+fn chunk_stream(
+    body: ByteStream,
+    size: usize,
+) -> impl futures::Stream> + Send {
+    futures::stream::try_unfold(
+        (body, BytesMut::new(), false),
+        move |(mut body, mut buffer, mut exhausted)| async move {
+            loop {
+                if buffer.len() >= size {
+                    let chunk = buffer.split_to(size).freeze();
+                    return Ok(Some((chunk, (body, buffer, exhausted))));
+                }
+                if exhausted {
+                    if buffer.is_empty() {
+                        return Ok(None);
+                    }
+                    let chunk = buffer.split().freeze();
+                    return Ok(Some((chunk, (body, buffer, exhausted))));
+                }
+                match body.next().await {
+                    Some(Ok(bytes)) => buffer.extend_from_slice(&bytes),
+                    Some(Err(e)) => return Err(e),
+                    None => exhausted = true,
+                }
+            }
+        },
+    )
+}
+
+fn smithy_time_to_system(dt: &aws_sdk_s3::primitives::DateTime) -> std::time::SystemTime {
+    std::time::SystemTime::try_from(*dt).unwrap_or(std::time::UNIX_EPOCH)
+}
+
+/// Reduce an SDK error to a [`StoreError`].
+///
+/// Service errors are classified by HTTP status and error code and formatted
+/// from the code and service message only, so no request or credential
+/// material reaches the Display output. Everything else (connection,
+/// timeout, response deserialization) becomes [`StoreError::Transport`].
+fn map_sdk_error(operation: &'static str, err: SdkError) -> StoreError
+where
+    E: ProvideErrorMetadata + std::error::Error + Send + Sync + 'static,
+{
+    match err {
+        SdkError::ServiceError(ctx) => {
+            let status = ctx.raw().status().as_u16();
+            let code = ctx.err().code().unwrap_or("unknown").to_owned();
+            match (status, code.as_str()) {
+                (404, _) | (_, "NoSuchKey" | "NotFound" | "NoSuchBucket") => StoreError::NotFound,
+                (403, _) | (_, "AccessDenied") => {
+                    StoreError::PermissionDenied(format!("s3 {operation} was denied"))
+                }
+                _ => {
+                    let message = ctx.err().message().unwrap_or("no message").to_owned();
+                    StoreError::Other(format!("s3 {operation} failed: {code}: {message}"))
+                }
+            }
+        }
+        other => StoreError::Transport {
+            source: Box::new(other),
+        },
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn store_prefix_normalization() {
+        assert_eq!(normalize_store_prefix("").unwrap(), "");
+        assert_eq!(normalize_store_prefix("extenddb/").unwrap(), "extenddb/");
+        assert_eq!(normalize_store_prefix("extenddb").unwrap(), "extenddb/");
+        assert_eq!(normalize_store_prefix("/a/b").unwrap(), "a/b/");
+        assert!(normalize_store_prefix("a//b").is_err());
+        assert!(normalize_store_prefix("a/../b").is_err());
+    }
+
+    #[tokio::test]
+    async fn chunker_splits_exactly() {
+        let body = crate::byte_stream_from(vec![7u8; 10]);
+        let chunks: Vec = chunk_stream(body, 4).try_collect().await.unwrap();
+        assert_eq!(
+            chunks.iter().map(Bytes::len).collect::>(),
+            vec![4, 4, 2]
+        );
+    }
+
+    #[tokio::test]
+    async fn chunker_handles_exact_multiple_and_empty() {
+        let body = crate::byte_stream_from(vec![1u8; 8]);
+        let chunks: Vec = chunk_stream(body, 4).try_collect().await.unwrap();
+        assert_eq!(chunks.len(), 2);
+
+        let empty = crate::byte_stream_from(Vec::new());
+        let chunks: Vec = chunk_stream(empty, 4).try_collect().await.unwrap();
+        assert!(chunks.is_empty());
+    }
+
+    #[tokio::test]
+    async fn chunker_propagates_source_errors() {
+        let body: ByteStream = Box::pin(futures::stream::iter([
+            Ok(Bytes::from_static(b"abcd")),
+            Err(StoreError::Other("injected".to_owned())),
+        ]));
+        let result: Result, StoreError> = chunk_stream(body, 2).try_collect().await;
+        assert!(matches!(result, Err(StoreError::Other(msg)) if msg == "injected"));
+    }
+}
diff --git a/crates/backup-store/tests/conformance.rs b/crates/backup-store/tests/conformance.rs
new file mode 100644
index 00000000..7fc35690
--- /dev/null
+++ b/crates/backup-store/tests/conformance.rs
@@ -0,0 +1,929 @@
+// Copyright 2026 ExtendDB contributors
+// SPDX-License-Identifier: Apache-2.0
+
+//! Conformance suite run against both store implementations.
+//!
+//! The `suite` module holds store-agnostic checks; the `filesystem` and `s3`
+//! modules bind them to a tempdir-backed [`FilesystemStore`] and an
+//! [`S3Store`] against a local S3-compatible endpoint. S3 tests run only when
+//! `EXTENDDB_TEST_S3_ENDPOINT` is set and skip with a printed reason
+//! otherwise; see the crate README for how to run them.
+
+use bytes::Bytes;
+use extenddb_backup_store::{BackupStore, ByteStream, StoreError};
+use futures::TryStreamExt;
+
+/// Part size every conformance run uses: the smallest S3 accepts, so the
+/// multipart body stays cheap to generate.
+const PART_SIZE: u64 = 5 * 1024 * 1024;
+
+/// Deterministic pseudo-random bytes, so content equality proves the store
+/// reassembled parts in order.
+fn patterned_bytes(len: usize) -> Vec {
+    let mut state: u64 = 0x2545_f491_4f6c_dd1d;
+    let mut out = Vec::with_capacity(len);
+    while out.len() < len {
+        state ^= state << 13;
+        state ^= state >> 7;
+        state ^= state << 17;
+        out.extend_from_slice(&state.to_le_bytes());
+    }
+    out.truncate(len);
+    out
+}
+
+/// Stream a buffer in 1 MiB pieces so puts exercise real chunk reassembly
+/// rather than a single-frame fast path.
+fn chunked_body(data: impl AsRef<[u8]>) -> ByteStream {
+    let pieces: Vec> = data
+        .as_ref()
+        .chunks(1024 * 1024)
+        .map(|c| Ok(Bytes::copy_from_slice(c)))
+        .collect();
+    Box::pin(futures::stream::iter(pieces))
+}
+
+/// A body that yields `good` and then fails, for abort-path checks.
+fn failing_body(good: impl AsRef<[u8]>) -> ByteStream {
+    let pieces: Vec> = good
+        .as_ref()
+        .chunks(1024 * 1024)
+        .map(|c| Ok(Bytes::copy_from_slice(c)))
+        .chain(std::iter::once(Err(StoreError::Other(
+            "injected mid-body failure".to_owned(),
+        ))))
+        .collect();
+    Box::pin(futures::stream::iter(pieces))
+}
+
+async fn read_all(stream: ByteStream) -> Vec {
+    let chunks: Vec = stream.try_collect().await.expect("stream reads cleanly");
+    chunks.concat()
+}
+
+async fn list_keys(store: &dyn BackupStore, prefix: &str) -> Vec {
+    store
+        .list(prefix)
+        .map_ok(|meta| meta.key)
+        .try_collect()
+        .await
+        .expect("list succeeds")
+}
+
+mod suite {
+    use super::*;
+
+    pub async fn round_trip(store: &dyn BackupStore) {
+        let body = b"hello backup store".to_vec();
+        store
+            .put(
+                "account/table/backup/object.json",
+                chunked_body(body.clone()),
+            )
+            .await
+            .expect("put succeeds");
+
+        let read = read_all(
+            store
+                .get("account/table/backup/object.json")
+                .await
+                .expect("get succeeds"),
+        )
+        .await;
+        assert_eq!(read, body);
+
+        let meta = store
+            .head("account/table/backup/object.json")
+            .await
+            .expect("head succeeds")
+            .expect("object exists");
+        assert_eq!(meta.key, "account/table/backup/object.json");
+        assert_eq!(meta.size, body.len() as u64);
+        assert!(meta.last_modified.is_some(), "both stores report mtime");
+    }
+
+    pub async fn overwrite_replaces_content(store: &dyn BackupStore) {
+        store
+            .put("over/write.bin", chunked_body(b"first"))
+            .await
+            .unwrap();
+        store
+            .put("over/write.bin", chunked_body(b"second and longer"))
+            .await
+            .unwrap();
+        let read = read_all(store.get("over/write.bin").await.unwrap()).await;
+        assert_eq!(read, b"second and longer");
+    }
+
+    pub async fn empty_body_round_trips(store: &dyn BackupStore) {
+        store
+            .put("empty/object", chunked_body(Vec::new()))
+            .await
+            .unwrap();
+        let read = read_all(store.get("empty/object").await.unwrap()).await;
+        assert!(read.is_empty());
+        let meta = store.head("empty/object").await.unwrap().unwrap();
+        assert_eq!(meta.size, 0);
+    }
+
+    pub async fn list_with_and_without_prefix(store: &dyn BackupStore) {
+        for key in ["a/b/1", "a/b/2", "a/c/3", "ab/4", "d"] {
+            store.put(key, chunked_body(vec![1, 2, 3])).await.unwrap();
+        }
+
+        assert_eq!(
+            list_keys(store, "").await,
+            vec!["a/b/1", "a/b/2", "a/c/3", "ab/4", "d"],
+            "empty prefix lists everything in key order"
+        );
+        assert_eq!(list_keys(store, "a").await, vec!["a/b/1", "a/b/2", "a/c/3"]);
+        assert_eq!(list_keys(store, "a/b").await, vec!["a/b/1", "a/b/2"]);
+        assert_eq!(
+            list_keys(store, "a/b/").await,
+            vec!["a/b/1", "a/b/2"],
+            "a trailing slash means the same prefix"
+        );
+        assert_eq!(
+            list_keys(store, "d").await,
+            vec!["d"],
+            "a prefix equal to a key matches that key"
+        );
+        assert!(
+            list_keys(store, "a/b/1/x").await.is_empty(),
+            "prefix under a leaf matches nothing"
+        );
+        assert!(
+            list_keys(store, "nope").await.is_empty(),
+            "unmatched prefix lists nothing"
+        );
+    }
+
+    pub async fn prefix_matching_is_component_aligned(store: &dyn BackupStore) {
+        store.put("pre/fix", chunked_body(vec![1])).await.unwrap();
+        store
+            .put("pre/fixture", chunked_body(vec![2]))
+            .await
+            .unwrap();
+        assert_eq!(
+            list_keys(store, "pre/fix").await,
+            vec!["pre/fix"],
+            "string-prefix sibling must not match"
+        );
+        assert_eq!(
+            store.delete_prefix("pre/fix").await.unwrap(),
+            1,
+            "delete_prefix uses the same matching rule"
+        );
+        assert_eq!(list_keys(store, "pre").await, vec!["pre/fixture"]);
+    }
+
+    pub async fn delete_prefix_removes_and_counts(store: &dyn BackupStore) {
+        for key in ["del/a/1", "del/a/2", "del/b/3", "keep/4"] {
+            store.put(key, chunked_body(vec![9])).await.unwrap();
+        }
+        assert_eq!(store.delete_prefix("del").await.unwrap(), 3);
+        assert!(list_keys(store, "del").await.is_empty());
+        assert_eq!(list_keys(store, "keep").await, vec!["keep/4"]);
+        assert_eq!(
+            store.delete_prefix("del").await.unwrap(),
+            0,
+            "a prefix that matches nothing deletes nothing"
+        );
+    }
+
+    pub async fn missing_object_is_not_found(store: &dyn BackupStore) {
+        let err = store.get("no/such/object").await.err().expect("get fails");
+        assert!(matches!(err, StoreError::NotFound), "{err}");
+        let head = store.head("no/such/object").await.expect("head succeeds");
+        assert!(head.is_none());
+    }
+
+    pub async fn invalid_keys_are_rejected(store: &dyn BackupStore) {
+        let bad_keys: &[&str] = &[
+            "",
+            "/",
+            "/abs",
+            "trail/",
+            "dou//ble",
+            ".",
+            "..",
+            "dot/./inner",
+            "dot/../escape",
+            "../up",
+            "up/..",
+            "back\\slash",
+            "seg/back\\slash",
+            "ctl\x00char",
+            "ctl/\x1f",
+        ];
+        for key in bad_keys {
+            let err = store
+                .put(key, chunked_body(vec![1]))
+                .await
+                .err()
+                .unwrap_or_else(|| panic!("put must reject {key:?}"));
+            assert!(
+                matches!(err, StoreError::InvalidKey(_)),
+                "put {key:?}: {err}"
+            );
+
+            let err = store
+                .get(key)
+                .await
+                .err()
+                .unwrap_or_else(|| panic!("get must reject {key:?}"));
+            assert!(
+                matches!(err, StoreError::InvalidKey(_)),
+                "get {key:?}: {err}"
+            );
+
+            let err = store
+                .head(key)
+                .await
+                .err()
+                .unwrap_or_else(|| panic!("head must reject {key:?}"));
+            assert!(
+                matches!(err, StoreError::InvalidKey(_)),
+                "head {key:?}: {err}"
+            );
+        }
+
+        let overlong = "a/".repeat(600);
+        let err = store
+            .put(overlong.trim_end_matches('/'), chunked_body(vec![1]))
+            .await
+            .expect_err("overlong key rejected");
+        assert!(matches!(err, StoreError::InvalidKey(_)), "{err}");
+
+        for bad_prefix in ["/", "dou//ble", "dot/../escape", "ctl\x02"] {
+            let err = store
+                .list(bad_prefix)
+                .try_collect::>()
+                .await
+                .err()
+                .unwrap_or_else(|| panic!("list must reject {bad_prefix:?}"));
+            assert!(matches!(err, StoreError::InvalidKey(_)), "{err}");
+            let err = store
+                .delete_prefix(bad_prefix)
+                .await
+                .err()
+                .unwrap_or_else(|| panic!("delete_prefix must reject {bad_prefix:?}"));
+            assert!(matches!(err, StoreError::InvalidKey(_)), "{err}");
+        }
+    }
+
+    /// A body larger than the part size: multipart on S3, plain streaming on
+    /// the filesystem. Content equality proves part order.
+    pub async fn body_larger_than_part_size(store: &dyn BackupStore) {
+        let len = usize::try_from(PART_SIZE).unwrap() * 2 + 4096;
+        let body = patterned_bytes(len);
+        store
+            .put("multi/part.bin", chunked_body(body.clone()))
+            .await
+            .expect("large put succeeds");
+        let meta = store.head("multi/part.bin").await.unwrap().unwrap();
+        assert_eq!(meta.size, len as u64);
+        let read = read_all(store.get("multi/part.bin").await.unwrap()).await;
+        assert_eq!(read.len(), body.len());
+        assert_eq!(read, body, "reassembled content must match exactly");
+    }
+
+    /// A put whose body fails midway must surface the error and leave nothing
+    /// under the key.
+    pub async fn failed_put_leaves_nothing(store: &dyn BackupStore) {
+        let good = patterned_bytes(usize::try_from(PART_SIZE).unwrap() + 4096);
+        let err = store
+            .put("aborted/upload.bin", failing_body(good))
+            .await
+            .expect_err("mid-body failure surfaces");
+        assert!(
+            matches!(&err, StoreError::Other(msg) if msg.contains("injected")),
+            "the injected error must propagate, got: {err}"
+        );
+        assert!(
+            store.head("aborted/upload.bin").await.unwrap().is_none(),
+            "a failed put must leave nothing under the key"
+        );
+        assert!(list_keys(store, "aborted").await.is_empty());
+    }
+}
+
+mod filesystem {
+    use super::*;
+    use extenddb_backup_store::FilesystemStore;
+
+    async fn fs_store() -> (tempfile::TempDir, FilesystemStore) {
+        let dir = tempfile::tempdir().expect("tempdir");
+        let store = FilesystemStore::open(dir.path()).await.expect("open");
+        (dir, store)
+    }
+
+    #[tokio::test]
+    async fn round_trip() {
+        let (_dir, store) = fs_store().await;
+        suite::round_trip(&store).await;
+    }
+
+    #[tokio::test]
+    async fn overwrite_replaces_content() {
+        let (_dir, store) = fs_store().await;
+        suite::overwrite_replaces_content(&store).await;
+    }
+
+    #[tokio::test]
+    async fn empty_body_round_trips() {
+        let (_dir, store) = fs_store().await;
+        suite::empty_body_round_trips(&store).await;
+    }
+
+    #[tokio::test]
+    async fn list_with_and_without_prefix() {
+        let (_dir, store) = fs_store().await;
+        suite::list_with_and_without_prefix(&store).await;
+    }
+
+    #[tokio::test]
+    async fn prefix_matching_is_component_aligned() {
+        let (_dir, store) = fs_store().await;
+        suite::prefix_matching_is_component_aligned(&store).await;
+    }
+
+    #[tokio::test]
+    async fn delete_prefix_removes_and_counts() {
+        let (_dir, store) = fs_store().await;
+        suite::delete_prefix_removes_and_counts(&store).await;
+    }
+
+    #[tokio::test]
+    async fn missing_object_is_not_found() {
+        let (_dir, store) = fs_store().await;
+        suite::missing_object_is_not_found(&store).await;
+    }
+
+    #[tokio::test]
+    async fn invalid_keys_are_rejected() {
+        let (_dir, store) = fs_store().await;
+        suite::invalid_keys_are_rejected(&store).await;
+    }
+
+    #[tokio::test]
+    async fn body_larger_than_part_size() {
+        let (_dir, store) = fs_store().await;
+        suite::body_larger_than_part_size(&store).await;
+    }
+
+    #[tokio::test]
+    async fn failed_put_leaves_nothing() {
+        let (_dir, store) = fs_store().await;
+        suite::failed_put_leaves_nothing(&store).await;
+        // The rename-into-place discipline: a failed put must also leave no
+        // temporary file behind anywhere under the root.
+        let (dir, store) = fs_store().await;
+        let good = patterned_bytes(64 * 1024);
+        let _ = store.put("aborted/again.bin", failing_body(good)).await;
+        let mut stack = vec![dir.path().to_path_buf()];
+        while let Some(current) = stack.pop() {
+            for entry in std::fs::read_dir(¤t).expect("read_dir") {
+                let entry = entry.expect("entry");
+                if entry.file_type().expect("file_type").is_dir() {
+                    stack.push(entry.path());
+                } else {
+                    panic!("stray file after failed put: {}", entry.path().display());
+                }
+            }
+        }
+    }
+
+    #[tokio::test]
+    async fn validate_probes_the_root() {
+        let (_dir, store) = fs_store().await;
+        store.validate().await.expect("writable root validates");
+    }
+
+    #[tokio::test]
+    async fn validate_rejects_an_unwritable_root() {
+        use std::os::unix::fs::PermissionsExt;
+        let dir = tempfile::tempdir().expect("tempdir");
+        let store = FilesystemStore::open(dir.path()).await.expect("open");
+        std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500))
+            .expect("chmod");
+        let result = store.validate().await;
+        std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700))
+            .expect("chmod back");
+        let err = result.expect_err("unwritable root fails validation");
+        assert!(matches!(err, StoreError::PermissionDenied(_)), "{err}");
+    }
+
+    #[tokio::test]
+    async fn open_refuses_a_missing_root() {
+        let dir = tempfile::tempdir().expect("tempdir");
+        let missing = dir.path().join("does-not-exist");
+        assert!(FilesystemStore::open(&missing).await.is_err());
+    }
+
+    #[tokio::test]
+    async fn symlinked_directory_escape_is_refused() {
+        let outside = tempfile::tempdir().expect("outside dir");
+        std::fs::write(outside.path().join("secret.txt"), b"outside data").unwrap();
+
+        let (dir, store) = fs_store().await;
+        std::os::unix::fs::symlink(outside.path(), dir.path().join("esc")).unwrap();
+
+        let err = store
+            .get("esc/secret.txt")
+            .await
+            .err()
+            .expect("get refused");
+        assert!(matches!(err, StoreError::PermissionDenied(_)), "{err}");
+
+        let err = store
+            .head("esc/secret.txt")
+            .await
+            .expect_err("head refused");
+        assert!(matches!(err, StoreError::PermissionDenied(_)), "{err}");
+
+        let err = store
+            .put("esc/new-file", chunked_body(vec![1]))
+            .await
+            .expect_err("put refused");
+        assert!(matches!(err, StoreError::PermissionDenied(_)), "{err}");
+        assert!(
+            !outside.path().join("new-file").exists(),
+            "nothing may be written outside the root"
+        );
+    }
+
+    #[tokio::test]
+    async fn symlinked_file_escape_is_refused() {
+        let outside = tempfile::tempdir().expect("outside dir");
+        let target = outside.path().join("secret.txt");
+        std::fs::write(&target, b"outside data").unwrap();
+
+        let (dir, store) = fs_store().await;
+        std::os::unix::fs::symlink(&target, dir.path().join("leak.json")).unwrap();
+
+        let err = store.get("leak.json").await.err().expect("get refused");
+        assert!(matches!(err, StoreError::PermissionDenied(_)), "{err}");
+
+        let err = store
+            .put("leak.json", chunked_body(vec![1]))
+            .await
+            .expect_err("put through an escaping symlink refused");
+        assert!(matches!(err, StoreError::PermissionDenied(_)), "{err}");
+        assert_eq!(
+            std::fs::read(&target).unwrap(),
+            b"outside data",
+            "the symlink target must be untouched"
+        );
+    }
+
+    /// An in-root directory symlink aliases one key onto another: a put
+    /// through the alias would land under the victim's prefix while list and
+    /// `delete_prefix` on the alias see nothing. The store refuses the alias
+    /// on every key-resolving operation, so all operations agree on what a
+    /// key names.
+    #[cfg(unix)]
+    #[tokio::test]
+    async fn in_root_directory_symlink_is_refused() {
+        let (dir, store) = fs_store().await;
+        store
+            .put("victim/sub/obj", chunked_body(vec![1]))
+            .await
+            .unwrap();
+        std::os::unix::fs::symlink(dir.path().join("victim"), dir.path().join("alias")).unwrap();
+
+        let err = store
+            .put("alias/sub/obj2", chunked_body(vec![2]))
+            .await
+            .expect_err("put through an in-root symlink refused");
+        assert!(matches!(err, StoreError::PermissionDenied(_)), "{err}");
+        assert!(
+            !dir.path().join("victim/sub/obj2").exists(),
+            "nothing may land under the aliased prefix"
+        );
+
+        let err = store
+            .get("alias/sub/obj")
+            .await
+            .err()
+            .expect("get through an in-root symlink refused");
+        assert!(matches!(err, StoreError::PermissionDenied(_)), "{err}");
+
+        let err = store
+            .head("alias/sub/obj")
+            .await
+            .expect_err("head through an in-root symlink refused");
+        assert!(matches!(err, StoreError::PermissionDenied(_)), "{err}");
+
+        assert!(
+            list_keys(&store, "alias").await.is_empty(),
+            "the aliased prefix lists nothing"
+        );
+
+        let deleted = store.delete_prefix("alias").await.unwrap();
+        assert_eq!(deleted, 0, "delete through the alias removes nothing");
+        assert_eq!(
+            read_all(store.get("victim/sub/obj").await.unwrap()).await,
+            vec![1],
+            "the object under its real key survives"
+        );
+    }
+
+    /// An in-root file symlink planted at a key would redirect get and head
+    /// to another key's content, and a put over it would resolve through the
+    /// link. All three are refused.
+    #[cfg(unix)]
+    #[tokio::test]
+    async fn in_root_file_symlink_is_refused() {
+        let (dir, store) = fs_store().await;
+        store
+            .put("victim/data", chunked_body(vec![7]))
+            .await
+            .unwrap();
+        std::os::unix::fs::symlink(dir.path().join("victim/data"), dir.path().join("planted"))
+            .unwrap();
+
+        let err = store.get("planted").await.err().expect("get refused");
+        assert!(matches!(err, StoreError::PermissionDenied(_)), "{err}");
+
+        let err = store.head("planted").await.expect_err("head refused");
+        assert!(matches!(err, StoreError::PermissionDenied(_)), "{err}");
+
+        let err = store
+            .put("planted", chunked_body(vec![8]))
+            .await
+            .expect_err("put over an in-root file symlink refused");
+        assert!(matches!(err, StoreError::PermissionDenied(_)), "{err}");
+        assert_eq!(
+            read_all(store.get("victim/data").await.unwrap()).await,
+            vec![7],
+            "the symlink target must be untouched"
+        );
+    }
+
+    #[tokio::test]
+    async fn symlinks_are_invisible_to_list() {
+        let outside = tempfile::tempdir().expect("outside dir");
+        std::fs::write(outside.path().join("secret.txt"), b"x").unwrap();
+
+        let (dir, store) = fs_store().await;
+        store
+            .put("real/object", chunked_body(vec![1]))
+            .await
+            .unwrap();
+        std::os::unix::fs::symlink(outside.path(), dir.path().join("esc")).unwrap();
+
+        assert_eq!(list_keys(&store, "").await, vec!["real/object"]);
+    }
+
+    /// A put through a planted directory symlink: `create_dir_all` follows
+    /// symlinks in existing components, so a naive implementation creates
+    /// parent directories outside the root before its escape check refuses
+    /// the put. The store must check the deepest existing ancestor before
+    /// creating anything.
+    #[tokio::test]
+    async fn multi_level_put_under_directory_symlink_creates_nothing_outside() {
+        let outside = tempfile::tempdir().expect("outside dir");
+        let (dir, store) = fs_store().await;
+        std::os::unix::fs::symlink(outside.path(), dir.path().join("esc")).unwrap();
+
+        let err = store
+            .put("esc/sub/deeper/file", chunked_body(vec![1]))
+            .await
+            .expect_err("put through an escaping symlink refused");
+        assert!(matches!(err, StoreError::PermissionDenied(_)), "{err}");
+        assert!(
+            !outside.path().join("sub").exists(),
+            "no directory may be created outside the root"
+        );
+        assert_eq!(
+            std::fs::read_dir(outside.path()).unwrap().count(),
+            0,
+            "the symlink target must be untouched"
+        );
+    }
+
+    /// Same shape with a file symlink at the parent position: the walk stops
+    /// at the symlink, which resolves outside the root, and nothing is
+    /// created or modified on the far side.
+    #[tokio::test]
+    async fn multi_level_put_under_file_symlink_creates_nothing_outside() {
+        let outside = tempfile::tempdir().expect("outside dir");
+        let target = outside.path().join("secret.txt");
+        std::fs::write(&target, b"outside data").unwrap();
+
+        let (dir, store) = fs_store().await;
+        std::os::unix::fs::symlink(&target, dir.path().join("leak")).unwrap();
+
+        let err = store
+            .put("leak/sub/file", chunked_body(vec![1]))
+            .await
+            .expect_err("put through an escaping file symlink refused");
+        assert!(matches!(err, StoreError::PermissionDenied(_)), "{err}");
+        assert_eq!(
+            std::fs::read(&target).unwrap(),
+            b"outside data",
+            "the symlink target must be untouched"
+        );
+        assert_eq!(
+            std::fs::read_dir(outside.path()).unwrap().count(),
+            1,
+            "nothing may be created outside the root"
+        );
+    }
+
+    /// An in-root regular file at an ancestor of the key is a collision, not
+    /// an escape: the put fails with an I/O error and creates nothing.
+    #[tokio::test]
+    async fn put_under_an_existing_object_is_refused() {
+        let (_dir, store) = fs_store().await;
+        store.put("plain", chunked_body(vec![1])).await.unwrap();
+        let err = store
+            .put("plain/sub/file", chunked_body(vec![2]))
+            .await
+            .expect_err("ancestor collision refused");
+        assert!(matches!(err, StoreError::Io { .. }), "{err}");
+        assert_eq!(read_all(store.get("plain").await.unwrap()).await, vec![1]);
+    }
+}
+
+mod s3 {
+    use super::*;
+    use extenddb_backup_store::{S3Store, S3StoreConfig};
+
+    const SKIP: &str =
+        "skipping: EXTENDDB_TEST_S3_ENDPOINT is not set; see crates/backup-store/README.md";
+
+    /// The test harness captures `eprintln!`, so a skipped S3 suite would be
+    /// invisible in default output. Writing to the raw stderr handle bypasses
+    /// capture and keeps the skip visible.
+    fn announce_skip() {
+        use std::io::Write;
+        let _ = writeln!(std::io::stderr(), "{SKIP}");
+    }
+
+    /// Build a store against the local endpoint, or `None` (with a printed
+    /// reason) when the environment does not provide one. Each test gets a
+    /// unique bucket prefix so runs never collide.
+    async fn store() -> Option {
+        let Ok(endpoint) = std::env::var("EXTENDDB_TEST_S3_ENDPOINT") else {
+            announce_skip();
+            return None;
+        };
+        let bucket = std::env::var("EXTENDDB_TEST_S3_BUCKET")
+            .unwrap_or_else(|_| "extenddb-backup-store-tests".to_owned());
+        let region =
+            std::env::var("EXTENDDB_TEST_S3_REGION").unwrap_or_else(|_| "us-east-1".to_owned());
+        let config = S3StoreConfig {
+            bucket: bucket.clone(),
+            prefix: format!("conformance-{}/", uuid::Uuid::new_v4()),
+            region: Some(region),
+            endpoint: Some(endpoint),
+            force_path_style: true,
+            upload_part_size_bytes: PART_SIZE,
+            max_concurrent_uploads: 4,
+        };
+        let store = S3Store::open(&config).await.expect("S3Store::open");
+        ensure_bucket(&config).await;
+        Some(store)
+    }
+
+    /// Create the test bucket when it does not exist yet, with a raw client
+    /// so the check stays independent of the store under test.
+    async fn ensure_bucket(config: &S3StoreConfig) {
+        let client = raw_client(config).await;
+        match client.create_bucket().bucket(&config.bucket).send().await {
+            Ok(_) => {}
+            Err(err) => {
+                let service = err.as_service_error();
+                let exists = service.is_some_and(|e| {
+                    e.is_bucket_already_owned_by_you() || e.is_bucket_already_exists()
+                });
+                assert!(exists, "create_bucket failed: {err}");
+            }
+        }
+    }
+
+    async fn raw_client(config: &S3StoreConfig) -> aws_sdk_s3::Client {
+        let mut loader = aws_config::defaults(aws_config::BehaviorVersion::latest());
+        if let Some(region) = &config.region {
+            loader = loader.region(aws_config::Region::new(region.clone()));
+        }
+        if let Some(endpoint) = &config.endpoint {
+            loader = loader.endpoint_url(endpoint.clone());
+        }
+        let shared = loader.load().await;
+        let builder = aws_sdk_s3::config::Builder::from(&shared).force_path_style(true);
+        aws_sdk_s3::Client::from_conf(builder.build())
+    }
+
+    #[tokio::test]
+    async fn round_trip() {
+        let Some(store) = store().await else { return };
+        suite::round_trip(&store).await;
+    }
+
+    #[tokio::test]
+    async fn overwrite_replaces_content() {
+        let Some(store) = store().await else { return };
+        suite::overwrite_replaces_content(&store).await;
+    }
+
+    #[tokio::test]
+    async fn empty_body_round_trips() {
+        let Some(store) = store().await else { return };
+        suite::empty_body_round_trips(&store).await;
+    }
+
+    #[tokio::test]
+    async fn list_with_and_without_prefix() {
+        let Some(store) = store().await else { return };
+        suite::list_with_and_without_prefix(&store).await;
+    }
+
+    #[tokio::test]
+    async fn prefix_matching_is_component_aligned() {
+        let Some(store) = store().await else { return };
+        suite::prefix_matching_is_component_aligned(&store).await;
+    }
+
+    #[tokio::test]
+    async fn delete_prefix_removes_and_counts() {
+        let Some(store) = store().await else { return };
+        suite::delete_prefix_removes_and_counts(&store).await;
+    }
+
+    #[tokio::test]
+    async fn missing_object_is_not_found() {
+        let Some(store) = store().await else { return };
+        suite::missing_object_is_not_found(&store).await;
+    }
+
+    #[tokio::test]
+    async fn invalid_keys_are_rejected() {
+        let Some(store) = store().await else { return };
+        suite::invalid_keys_are_rejected(&store).await;
+    }
+
+    #[tokio::test]
+    async fn body_larger_than_part_size() {
+        let Some(store) = store().await else { return };
+        suite::body_larger_than_part_size(&store).await;
+    }
+
+    #[tokio::test]
+    async fn failed_put_aborts_the_multipart_upload() {
+        let Ok(endpoint) = std::env::var("EXTENDDB_TEST_S3_ENDPOINT") else {
+            announce_skip();
+            return;
+        };
+        let bucket = std::env::var("EXTENDDB_TEST_S3_BUCKET")
+            .unwrap_or_else(|_| "extenddb-backup-store-tests".to_owned());
+        let region =
+            std::env::var("EXTENDDB_TEST_S3_REGION").unwrap_or_else(|_| "us-east-1".to_owned());
+        let prefix = format!("conformance-{}/", uuid::Uuid::new_v4());
+        let config = S3StoreConfig {
+            bucket: bucket.clone(),
+            prefix: prefix.clone(),
+            region: Some(region),
+            endpoint: Some(endpoint),
+            force_path_style: true,
+            upload_part_size_bytes: PART_SIZE,
+            max_concurrent_uploads: 4,
+        };
+        let store = S3Store::open(&config).await.expect("S3Store::open");
+        ensure_bucket(&config).await;
+
+        suite::failed_put_leaves_nothing(&store).await;
+
+        // The store-level check proved no object landed; this proves the
+        // multipart upload itself was aborted rather than left in progress.
+        let client = raw_client(&config).await;
+        let uploads = client
+            .list_multipart_uploads()
+            .bucket(&bucket)
+            .prefix(&prefix)
+            .send()
+            .await
+            .expect("list_multipart_uploads");
+        assert!(
+            uploads.uploads().is_empty(),
+            "a failed put must abort its multipart upload: {:?}",
+            uploads.uploads()
+        );
+    }
+
+    /// An interceptor that fails `CompleteMultipartUpload`, so the abort path
+    /// after a complete-time failure is exercised against a real endpoint.
+    #[derive(Debug)]
+    struct FailCompleteMultipartUpload;
+
+    impl aws_sdk_s3::config::Intercept for FailCompleteMultipartUpload {
+        fn name(&self) -> &'static str {
+            "FailCompleteMultipartUpload"
+        }
+
+        fn read_before_execution(
+            &self,
+            context: &aws_sdk_s3::config::interceptors::BeforeSerializationInterceptorContextRef<
+                '_,
+            >,
+            _cfg: &mut aws_sdk_s3::config::ConfigBag,
+        ) -> Result<(), aws_sdk_s3::error::BoxError> {
+            let is_complete = context
+                .input()
+                .downcast_ref::()
+                .is_some();
+            if is_complete {
+                return Err("injected CompleteMultipartUpload failure".into());
+            }
+            Ok(())
+        }
+    }
+
+    #[tokio::test]
+    async fn failed_complete_aborts_the_multipart_upload() {
+        let Ok(endpoint) = std::env::var("EXTENDDB_TEST_S3_ENDPOINT") else {
+            announce_skip();
+            return;
+        };
+        let bucket = std::env::var("EXTENDDB_TEST_S3_BUCKET")
+            .unwrap_or_else(|_| "extenddb-backup-store-tests".to_owned());
+        let region =
+            std::env::var("EXTENDDB_TEST_S3_REGION").unwrap_or_else(|_| "us-east-1".to_owned());
+        let prefix = format!("conformance-{}/", uuid::Uuid::new_v4());
+        let config = S3StoreConfig {
+            bucket: bucket.clone(),
+            prefix: prefix.clone(),
+            region: Some(region),
+            endpoint: Some(endpoint),
+            force_path_style: true,
+            upload_part_size_bytes: PART_SIZE,
+            max_concurrent_uploads: 4,
+        };
+        let store = S3Store::open_with_interceptor(&config, FailCompleteMultipartUpload)
+            .await
+            .expect("S3Store::open_with_interceptor");
+        ensure_bucket(&config).await;
+
+        let body = patterned_bytes(usize::try_from(PART_SIZE).unwrap() * 2 + 4096);
+        let err = store
+            .put("complete/fails.bin", chunked_body(body))
+            .await
+            .expect_err("injected complete failure surfaces");
+        assert!(
+            err.to_string().contains("injected") || matches!(err, StoreError::Transport { .. }),
+            "the injected failure must propagate: {err}"
+        );
+        assert!(
+            store.head("complete/fails.bin").await.unwrap().is_none(),
+            "no object may exist after a failed complete"
+        );
+
+        let client = raw_client(&config).await;
+        let uploads = client
+            .list_multipart_uploads()
+            .bucket(&bucket)
+            .prefix(&prefix)
+            .send()
+            .await
+            .expect("list_multipart_uploads");
+        assert!(
+            uploads.uploads().is_empty(),
+            "a failed complete must abort its multipart upload: {:?}",
+            uploads.uploads()
+        );
+    }
+
+    #[tokio::test]
+    async fn validate_calls_head_bucket() {
+        let Some(store) = store().await else { return };
+        store.validate().await.expect("existing bucket validates");
+    }
+
+    #[tokio::test]
+    async fn validate_names_a_missing_bucket() {
+        let Ok(endpoint) = std::env::var("EXTENDDB_TEST_S3_ENDPOINT") else {
+            announce_skip();
+            return;
+        };
+        let region =
+            std::env::var("EXTENDDB_TEST_S3_REGION").unwrap_or_else(|_| "us-east-1".to_owned());
+        let missing = format!("extenddb-no-such-bucket-{}", uuid::Uuid::new_v4());
+        let config = S3StoreConfig {
+            bucket: missing.clone(),
+            prefix: String::new(),
+            region: Some(region),
+            endpoint: Some(endpoint),
+            force_path_style: true,
+            upload_part_size_bytes: PART_SIZE,
+            max_concurrent_uploads: 4,
+        };
+        let store = S3Store::open(&config).await.expect("S3Store::open");
+        let err = store.validate().await.expect_err("missing bucket fails");
+        assert!(
+            err.to_string().contains(&missing),
+            "the message must name the bucket: {err}"
+        );
+    }
+}