fix(list): preserve keys with empty path segments (//) instead of 503 - #117
fix(list): preserve keys with empty path segments (//) instead of 503#117alukach wants to merge 3 commits into
Conversation
Listing a prefix that contained a single object whose key has an empty path segment (a `//`) failed the *entire* page with 503 ServiceUnavailable, making the whole prefix unlistable through the gateway. S3 serves such keys fine — they are legal opaque byte strings — but `object_store::path::Path` rejects them on parse and cannot even represent them, and the LIST path routed every backend key through it. Fix the LIST path to fetch one page from the backend via raw signed HTTP (the same mechanism multipart and batch delete already use) and parse the S3 XML ourselves, keeping keys as raw byte-preserving strings. Anything listable in S3 now stays listable through multistore. - api/list.rs: add `BackendListResult`/`BackendObject` (raw String keys), `parse_backend_list_xml` (lenient serde parse), and `build_backend_list_url` (maps prefix/start-after/marker into the backend key space, SigV4-canonical query encoding, does not forward `encoding-type`). Rewrite `collect_list_entries` and the XML builders to consume the lenient type; directory-marker detection now compares on the slash-trimmed raw key. - proxy.rs `handle_list`: sign + `send_raw` a GET against the backend list URL, parse leniently, build the response. Drops the object_store `PaginatedListStore` path (removed from the backend in a follow-up). - Tests: unit coverage for `//` round-trips through parse and XML, the backend URL mapping, and an end-to-end `handle_list` regression proving a stray `//` key returns 200 with the key intact. Refs #116 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
With LIST no longer routed through object_store (previous commit), the `ProxyBackend::create_paginated_store` method and everything supporting it are dead code. Remove them so backends no longer have to build an object_store list store they never use. - backend/mod.rs: remove the `create_paginated_store` trait method and the dead `StoreBuilder::build`; keep `build_signer` (still used for presigned URLs). - cf-workers: remove the impl and delete the `fetch_connector` module — `FetchConnector` existed only to inject an HTTP connector into the object_store list store, and nothing else used it. - examples (server, lambda): remove the impls. - proxy.rs: drop the trait method from the test mocks. - docs: update custom-backend, request-lifecycle, crate-layout, and multi-runtime to describe LIST via raw signed HTTP and drop the FetchConnector references. Refs #116 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Claude finished @alukach's task in 3m 16s —— View job Reviewing PR #117
✅ No blocking issues — safe to merge. Traced the whole flow:
One non-blocking observation: Simplify (ponytail)
💰 Estimated review cost: $1.28 · 3m15s · 34 turns |
|
📖 Docs preview deployed to https://multistore-docs-pr-117.development-seed.workers.dev
|
|
🚀 Latest commit deployed to https://multistore-proxy-pr-117.development-seed.workers.dev
|
The proxy end-to-end test already covers parse->build->XML with a // key, and the parser unit test covers the parse. With raw String keys, build_list_xml is a trivial passthrough, so the middle test added nothing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Code review follow-ups (out of scope for this PR)A code review of this branch found no Critical issues and no regressions — the
|
Fixes #116.
What I'm changing
Listing a prefix that contained a single object whose key has an empty path segment (a
//) returned503 ServiceUnavailablefor the entire page — one malformed-looking (but perfectly legal) key made the whole prefix unlistable through the gateway. S3 itself lists these keys fine; keys are opaque byte strings and//is legal.The root cause is that the LIST path routed every backend key through
object_store::path::Path, whose strict parser rejects empty segments and which cannot even represent a//key (Path::fromwould collapse it). So there was no lenient-parse escape hatch — the fix is to stop routing list responses throughobject_store::Pathat all.Guiding principle: if a key is listable in S3, it must be listable through multistore. Keys pass through byte-faithfully — nothing is skipped or normalized.
How I did it
api/list.rs— newBackendListResult/BackendObject(rawStringkeys),parse_backend_list_xml(lenient serde/quick-xml parse of the backend'sListBucketResult), andbuild_backend_list_url(mapsprefix/start-after/markerinto the backend key space, encodes query values with the SigV4-canonical set sospace → %20, and deliberately does not forwardencoding-typeso we don't double-encode).collect_list_entriesand the two XML builders now consume the lenient type; directory-marker detection compares on the slash-trimmed raw key instead of anobject_store::Path.proxy.rshandle_list— fetches one page from the backend via raw signed HTTP (sign_s3_request+send_raw, the same mechanism multipart and batch delete already use), parses the XML leniently, and builds the response. Pagination is still pushed to the backend via the continuation token.refactor(backend)(second commit) —ProxyBackend::create_paginated_storeand itsobject_store::PaginatedListStoreplumbing are now dead. Removed the trait method,StoreBuilder::build, all impls (cf-workers, server, lambda examples, test mocks), and the cf-workersfetch_connectormodule (it existed only to inject an HTTP connector into that list store). Presigned URLs are unaffected — they're still built offline via theobject_storesigner.Known limitation (out of scope)
GET/PUT/HEAD/DELETEof a//key still returns 400 (validate_keyrejects degenerate segments, andobject_store's presigned-URL signer can't represent such keys). This PR fixes the reported LIST 503 so the keys are at least visible; making them individually addressable is a larger, separate change to the object-operation signing path. Happy to file a follow-up if wanted.Test plan
cargo test -p multistore— 131 lib tests pass, including new coverage://round-trips throughparse_backend_list_xml, survives to the response XML, the backend-URL prefix mapping/encoding, and an end-to-endhandle_listregression proving a stray//key returns 200 with the key intact.cargo test(full native workspace) — all green.cargo clippy(native +--target wasm32-unknown-unknown) — clean.cargo check -p multistore-cf-workers --target wasm32-unknown-unknown— compiles.cargo fmt.🤖 Generated with Claude Code