From 89a0546e5d43a3373fc79217c9ec3628525d53ac Mon Sep 17 00:00:00 2001 From: Lee Hannigan Date: Thu, 10 Sep 2026 16:00:19 +0000 Subject: [PATCH 1/3] feat(backup): add backup store crate with filesystem and S3 implementations New workspace crate extenddb-backup-store: the BackupStore trait (put/get/head/list/delete_prefix/validate) behind which the engine will write portable backups, per docs/design/15-backup-restore.md section 2. Key space is shared by both stores: /-separated components, each non-empty, no dot or dot-dot, no backslashes, no control characters, 1024 bytes total. Prefix matching is component-aligned so a/b never matches a/bc, on S3 included via client-side filtering. FilesystemStore canonicalizes its root at construction and refuses any operation whose resolved target escapes the root. Because create_dir_all follows symlinks in existing path components, put resolves the deepest existing ancestor of its destination and checks it against the root BEFORE creating any directory, then creates the remaining components one at a time with create_dir, re-verifying each is a real directory and not a symlink. Writes go through a temporary sibling that is synced before the rename, and the parent directory is synced after it, so an Ok return means the object and its directory entry are on disk. Prefix deletion removes files and the directories the deletion emptied; temporary names are invisible to listings. Escape refusals name the offending path. S3Store builds on aws-sdk-s3 with credentials from the SDK default chain; supports bucket, prefix, region, endpoint override and path-style addressing for S3-compatible stores. Bodies above the configured part size (default 8 MiB, enforced floor 5 MiB) upload as multipart with bounded concurrency. Every failure after CreateMultipartUpload reaches the abort, a failed complete included, so no orphaned parts accrue storage. Listing paginates; prefix deletion batches DeleteObjects requests of 1000. BackupStoreConfig models the store subset of the [backup] section with deny_unknown_fields; open(config) constructs Arc. Server wiring is deliberately left to the configuration task. A shared test module exercises both stores: round trips, listing with and without prefix, prefix deletion counts, missing-object behavior, every rejected key shape, bodies larger than the part size, abort on a mid-body failure and on a complete-time failure injected through a client interceptor, and traversal and symlink escapes for the filesystem store, including multi-level keys under planted directory and file symlinks asserting nothing is created outside the root. S3 tests run against a local endpoint when EXTENDDB_TEST_S3_ENDPOINT is set and announce the skip on raw stderr otherwise; the crate README documents how to run them. aws-config and aws-sdk-s3 are pinned exactly to the newest versions whose minimum supported rustc matches the workspace rust-version of 1.88; later releases require a newer compiler and fail the 1.88.0 CI job. Their transitive aws-smithy-* crates are held in Cargo.lock for the same reason, and crc moves to 3.3.0 so sqlx and the checksum path share one crc major. Both license notices are regenerated for the crc version change and both checks pass. The workspace builds and the crate tests pass on the 1.88.0 toolchain and on stable, with the S3 half of the suite additionally run against a local MinIO on both toolchains. Signed-off-by: Lee Hannigan --- Cargo.lock | 635 ++++++++++++++++- Cargo.toml | 12 + SOFTWARE-LICENSE-NOTICES-DEV.html | 2 +- SOFTWARE-LICENSE-NOTICES.html | 2 +- crates/backup-store/Cargo.toml | 25 + crates/backup-store/README.md | 50 ++ crates/backup-store/src/config.rs | 271 ++++++++ crates/backup-store/src/error.rs | 60 ++ crates/backup-store/src/fs.rs | 525 ++++++++++++++ crates/backup-store/src/key.rs | 181 +++++ crates/backup-store/src/lib.rs | 137 ++++ crates/backup-store/src/s3.rs | 594 ++++++++++++++++ crates/backup-store/tests/conformance.rs | 845 +++++++++++++++++++++++ 13 files changed, 3314 insertions(+), 25 deletions(-) create mode 100644 crates/backup-store/Cargo.toml create mode 100644 crates/backup-store/README.md create mode 100644 crates/backup-store/src/config.rs create mode 100644 crates/backup-store/src/error.rs create mode 100644 crates/backup-store/src/fs.rs create mode 100644 crates/backup-store/src/key.rs create mode 100644 crates/backup-store/src/lib.rs create mode 100644 crates/backup-store/src/s3.rs create mode 100644 crates/backup-store/tests/conformance.rs diff --git a/Cargo.lock b/Cargo.lock index 9cf1a6d54..eff0714e0 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,24 @@ dependencies = [ "zeroize", ] +[[package]] +name = "extenddb-backup-store" +version = "0.1.11" +dependencies = [ + "aws-config", + "aws-sdk-s3", + "bytes", + "futures", + "serde", + "tempfile", + "thiserror", + "tokio", + "tokio-util", + "toml", + "tracing", + "uuid", +] + [[package]] name = "extenddb-cache" version = "0.1.11" @@ -1395,7 +1810,7 @@ checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" dependencies = [ "futures-core", "futures-sink", - "spin", + "spin 0.9.8", ] [[package]] @@ -1410,6 +1825,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 +2033,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.4.0", "indexmap", "slab", "tokio", @@ -1638,7 +2059,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 +2215,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 +2236,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 +2254,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -1811,8 +2265,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -1848,14 +2302,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 +2335,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 +2610,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 +2681,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 +3036,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 +3052,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 +3185,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 +3485,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 +3616,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 +3682,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 +3969,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 +4262,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 +4532,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 +4628,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 +4793,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 +4818,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 +4976,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 +5298,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 44ebd9fa0..3fe610455 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 e18c5fc35..198643aa2 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.html b/SOFTWARE-LICENSE-NOTICES.html
index 2e6a38e47..84e555335 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 000000000..44409daef
--- /dev/null
+++ b/crates/backup-store/Cargo.toml
@@ -0,0 +1,25 @@
+# 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 }
+
+[dev-dependencies]
+tempfile = "3"
+toml = { workspace = true }
diff --git a/crates/backup-store/README.md b/crates/backup-store/README.md
new file mode 100644
index 000000000..7a914c5af
--- /dev/null
+++ b/crates/backup-store/README.md
@@ -0,0 +1,50 @@
+# 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, refuses any
+  operation whose resolved target escapes the root (symlink defense), and
+  writes 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 000000000..bbf373534
--- /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 000000000..239290b80
--- /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 000000000..9d5d81a5a
--- /dev/null
+++ b/crates/backup-store/src/fs.rs
@@ -0,0 +1,525 @@
+// 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 re-resolves its target and refuses to act
+//! when the resolved path escapes the root, so a symlink planted inside the
+//! root cannot redirect reads or writes elsewhere. Writes go to a temporary
+//! sibling file and rename into place, so a crash never leaves a truncated
+//! file under its final name.
+
+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 })
+    }
+
+    /// Map a validated key to its path under the root. Key validation has
+    /// already refused `..`, empty components, and separators, so the join
+    /// cannot step outside the root without a symlink, which the resolve
+    /// checks catch.
+    fn key_path(&self, key: &str) -> PathBuf {
+        let mut path = self.root.clone();
+        for component in key.split('/') {
+            path.push(component);
+        }
+        path
+    }
+
+    /// Canonicalize `path` and refuse it when it resolves outside the root.
+    /// Missing paths surface as `NotFound`.
+    async fn resolve_within_root(&self, path: &Path) -> Result {
+        let canonical = tokio::fs::canonicalize(path).await.map_err(|e| {
+            if e.kind() == std::io::ErrorKind::NotFound {
+                StoreError::NotFound
+            } else {
+                StoreError::io(format!("resolve {}", path.display()), e)
+            }
+        })?;
+        if canonical.starts_with(&self.root) {
+            Ok(canonical)
+        } else {
+            Err(StoreError::PermissionDenied(format!(
+                "{} resolves outside the backup root",
+                path.display()
+            )))
+        }
+    }
+
+    /// 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, canonicalize it, and
+    /// refuse before creating anything when it resolves outside 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(_) => existing = candidate,
+                // NotADirectory: an existing ancestor is a file or a symlink
+                // to one, so the walk stops there and the checks below decide
+                // (an escaping symlink is refused, 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: if it resolves outside the root (a planted symlink
+        // anywhere in the chain), the put is refused with nothing written.
+        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);
+
+        // If the destination already exists, refuse when it resolves outside
+        // the root, so a put cannot be redirected through a planted symlink.
+        match tokio::fs::symlink_metadata(&final_path).await {
+            Ok(meta) if meta.is_symlink() => match self.resolve_within_root(&final_path).await {
+                Ok(_) => {}
+                Err(StoreError::NotFound) => {
+                    return Err(StoreError::PermissionDenied(format!(
+                        "{} is a symlink that does not resolve inside the backup root",
+                        final_path.display()
+                    )));
+                }
+                Err(e) => return Err(e),
+            },
+            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 = self.resolve_within_root(&self.key_path(key)).await?;
+        let meta = tokio::fs::metadata(&path)
+            .await
+            .map_err(|e| StoreError::io(format!("stat {}", path.display()), e))?;
+        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 path = match self.resolve_within_root(&self.key_path(key)).await {
+            Ok(path) => path,
+            Err(StoreError::NotFound) => return Ok(None),
+            Err(e) => return Err(e),
+        };
+        let meta = tokio::fs::metadata(&path)
+            .await
+            .map_err(|e| StoreError::io(format!("stat {}", path.display()), 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)
+    }
+}
+
+/// 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 000000000..a6726102e
--- /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 000000000..f4e8716fd
--- /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 000000000..0b6331394
--- /dev/null
+++ b/crates/backup-store/src/s3.rs
@@ -0,0 +1,594 @@
+// 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 test suite uses to make a chosen operation fail;
+    /// it is not part of the supported API surface.
+    #[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 000000000..7dd472126
--- /dev/null
+++ b/crates/backup-store/tests/conformance.rs
@@ -0,0 +1,845 @@
+// 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"
+        );
+    }
+
+    #[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}"
+        );
+    }
+}

From 6487c161b5e5edb2bcbd70c85ef8d4dc7a2c9779 Mon Sep 17 00:00:00 2001
From: Lee Hannigan 
Date: Mon, 14 Sep 2026 12:01:27 +0000
Subject: [PATCH 2/3] fix(backup-store): refuse symlinks in key resolution and
 bound the symlink claim

An in-root directory symlink (root/a pointing at root/z) aliased one key
onto another: put("a/b/file") landed at z/b/file, list("a") was empty,
delete_prefix("a") returned 0 while get("a/b/file") kept serving bytes,
and a planted file symlink redirected get to another key's content. The
canonicalize check only refused targets outside the root, so redirection
inside the root passed, and keys are account-id-prefixed, so an alias
maps one account's writes into another account's prefix.

The filesystem store now resolves every 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: get and head walk the path without
following links, put refuses symlinked ancestors in prepare_parent and a
symlink at the destination, and list and delete_prefix already skip
symlinks, so all five operations agree on what a key names.

The module doc and README claimed a symlink planted inside the root
cannot redirect reads or writes. That claim is now bounded to what the
checks deliver: they defeat symlinks present when an operation starts
and do not defend against another local process mutating the tree while
an operation runs, because the kernel re-resolves the textual path
between a component check and the next path step. Closing that window
needs directory-handle resolution (openat2 with RESOLVE_BENEATH or an
openat walk with O_NOFOLLOW at every step), which the standard library
does not expose; it is documented in the module doc as a follow-up, and
the backup root must not be writable by less-trusted users.

Also: open_with_interceptor is gated behind a new test-util feature so
it is no longer part of the crate's default API surface, and the module
doc notes the two filesystem-specific key space limits (NAME_MAX on a
component, and the temporary-file name pattern hidden from listings).

Signed-off-by: Lee Hannigan 
---
 Cargo.lock                               |   1 +
 crates/backup-store/Cargo.toml           |   6 +
 crates/backup-store/README.md            |  13 ++-
 crates/backup-store/src/fs.rs            | 143 +++++++++++++----------
 crates/backup-store/src/s3.rs            |   6 +-
 crates/backup-store/tests/conformance.rs |  84 +++++++++++++
 6 files changed, 185 insertions(+), 68 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index eff0714e0..29312a4d5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1569,6 +1569,7 @@ dependencies = [
  "aws-config",
  "aws-sdk-s3",
  "bytes",
+ "extenddb-backup-store",
  "futures",
  "serde",
  "tempfile",
diff --git a/crates/backup-store/Cargo.toml b/crates/backup-store/Cargo.toml
index 44409daef..bb07873e3 100644
--- a/crates/backup-store/Cargo.toml
+++ b/crates/backup-store/Cargo.toml
@@ -20,6 +20,12 @@ 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
index 7a914c5af..89f31ea9b 100644
--- a/crates/backup-store/README.md
+++ b/crates/backup-store/README.md
@@ -10,10 +10,15 @@ filesystem implementation and an S3 implementation built on the official
 - 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, refuses any
-  operation whose resolved target escapes the root (symlink defense), and
-  writes through a temporary sibling file renamed into place so a crash never
-  leaves a truncated object under its final name.
+- 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,
diff --git a/crates/backup-store/src/fs.rs b/crates/backup-store/src/fs.rs
index 9d5d81a5a..dd29ca06f 100644
--- a/crates/backup-store/src/fs.rs
+++ b/crates/backup-store/src/fs.rs
@@ -4,11 +4,29 @@
 //! Filesystem-backed store.
 //!
 //! Keys map to paths under a root directory that is canonicalized at
-//! construction. Every operation re-resolves its target and refuses to act
-//! when the resolved path escapes the root, so a symlink planted inside the
-//! root cannot redirect reads or writes elsewhere. Writes go to a temporary
-//! sibling file and rename into place, so a crash never leaves a truncated
-//! file under its final name.
+//! 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};
 
@@ -55,36 +73,39 @@ impl FilesystemStore {
         Ok(Self { root: canonical })
     }
 
-    /// Map a validated key to its path under the root. Key validation has
-    /// already refused `..`, empty components, and separators, so the join
-    /// cannot step outside the root without a symlink, which the resolve
-    /// checks catch.
-    fn key_path(&self, key: &str) -> PathBuf {
+    /// 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);
-        }
-        path
-    }
-
-    /// Canonicalize `path` and refuse it when it resolves outside the root.
-    /// Missing paths surface as `NotFound`.
-    async fn resolve_within_root(&self, path: &Path) -> Result {
-        let canonical = tokio::fs::canonicalize(path).await.map_err(|e| {
-            if e.kind() == std::io::ErrorKind::NotFound {
-                StoreError::NotFound
-            } else {
-                StoreError::io(format!("resolve {}", path.display()), e)
+            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));
             }
-        })?;
-        if canonical.starts_with(&self.root) {
-            Ok(canonical)
-        } else {
-            Err(StoreError::PermissionDenied(format!(
-                "{} resolves outside the backup root",
-                path.display()
-            )))
+            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
@@ -93,22 +114,24 @@ impl FilesystemStore {
     /// `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, canonicalize it, and
-    /// refuse before creating anything when it resolves outside 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.
+    /// 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 or a symlink
-                // to one, so the walk stops there and the checks below decide
-                // (an escaping symlink is refused, an in-root file is an
-                // ancestor collision).
+                // 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 =>
@@ -123,8 +146,8 @@ impl FilesystemStore {
         }
 
         // The deepest existing ancestor is checked before anything is
-        // created: if it resolves outside the root (a planted symlink
-        // anywhere in the chain), the put is refused with nothing written.
+        // 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))?;
@@ -180,19 +203,12 @@ impl FilesystemStore {
         let parent = self.prepare_parent(parent_components).await?;
         let final_path = parent.join(file_name);
 
-        // If the destination already exists, refuse when it resolves outside
-        // the root, so a put cannot be redirected through a planted symlink.
+        // 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() => match self.resolve_within_root(&final_path).await {
-                Ok(_) => {}
-                Err(StoreError::NotFound) => {
-                    return Err(StoreError::PermissionDenied(format!(
-                        "{} is a symlink that does not resolve inside the backup root",
-                        final_path.display()
-                    )));
-                }
-                Err(e) => return Err(e),
-            },
+            Ok(meta) if meta.is_symlink() => {
+                return Err(symlink_refused(&final_path));
+            }
             Ok(_) | Err(_) => {}
         }
 
@@ -241,10 +257,7 @@ impl FilesystemStore {
 
     async fn get_impl(&self, key: &str) -> Result {
         validate_key(key)?;
-        let path = self.resolve_within_root(&self.key_path(key)).await?;
-        let meta = tokio::fs::metadata(&path)
-            .await
-            .map_err(|e| StoreError::io(format!("stat {}", path.display()), e))?;
+        let (path, meta) = self.resolve_no_follow(key).await?;
         if !meta.is_file() {
             return Err(StoreError::NotFound);
         }
@@ -259,14 +272,11 @@ impl FilesystemStore {
 
     async fn head_impl(&self, key: &str) -> Result, StoreError> {
         validate_key(key)?;
-        let path = match self.resolve_within_root(&self.key_path(key)).await {
-            Ok(path) => path,
+        let (_, meta) = match self.resolve_no_follow(key).await {
+            Ok(resolved) => resolved,
             Err(StoreError::NotFound) => return Ok(None),
             Err(e) => return Err(e),
         };
-        let meta = tokio::fs::metadata(&path)
-            .await
-            .map_err(|e| StoreError::io(format!("stat {}", path.display()), e))?;
         if !meta.is_file() {
             return Ok(None);
         }
@@ -369,6 +379,15 @@ fn map_permission(context: String, e: std::io::Error) -> StoreError {
     }
 }
 
+/// 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.
diff --git a/crates/backup-store/src/s3.rs b/crates/backup-store/src/s3.rs
index 0b6331394..4e686da35 100644
--- a/crates/backup-store/src/s3.rs
+++ b/crates/backup-store/src/s3.rs
@@ -52,8 +52,10 @@ impl S3Store {
     }
 
     /// Open with an additional client interceptor. This is the failure
-    /// injection seam the test suite uses to make a chosen operation fail;
-    /// it is not part of the supported API surface.
+    /// 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,
diff --git a/crates/backup-store/tests/conformance.rs b/crates/backup-store/tests/conformance.rs
index 7dd472126..7fc356902 100644
--- a/crates/backup-store/tests/conformance.rs
+++ b/crates/backup-store/tests/conformance.rs
@@ -477,6 +477,90 @@ mod filesystem {
         );
     }
 
+    /// 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");

From 6f8bf141f9bf47ab7c4139e4bf3c3f9b038caf32 Mon Sep 17 00:00:00 2001
From: Lee Hannigan 
Date: Mon, 14 Sep 2026 12:02:40 +0000
Subject: [PATCH 3/3] chore(licenses): regenerate notices after merging main

The MongoDB notices file predated the merge of main into this branch and
still recorded crc 3.4.0; the branch's lock holds crc 3.3.0 so sqlx and
the checksum path share one crc major. Regenerated with the pinned
cargo-about 0.9.0 for all three variants the Licenses workflow checks;
only the MongoDB file changed and all three checks pass.

Signed-off-by: Lee Hannigan 
---
 SOFTWARE-LICENSE-NOTICES-MONGODB.html | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/SOFTWARE-LICENSE-NOTICES-MONGODB.html b/SOFTWARE-LICENSE-NOTICES-MONGODB.html
index 57d9567ee..6ac652974 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