HTTP server that exposes stores, arrays, groups - #3732
Conversation
| PEP_723_REGEX: Final = r"(?m)^# /// (?P<type>[a-zA-Z0-9-]+)$\s(?P<content>(^#(| .*)$\s)+)^# ///$" | ||
|
|
||
| # This is the absolute path to the local Zarr installation. Moving this test to a different directory will break it. | ||
| ZARR_PROJECT_PATH = Path(".").absolute() |
There was a problem hiding this comment.
changes in this file are simplifications to our examples testing infrastructure. Instead of re-writing the script header, we just override the declared zarr dep in the invocation of uv run ...
| @@ -0,0 +1,178 @@ | |||
| """Utilities for determining the set of valid store keys for zarr nodes. | |||
There was a problem hiding this comment.
we eventually need to find a more natural place for this code. I'm not sure which module it should live in.
Didn't end up doing these things. We can add them later if people are interested. |
|
here's a demo of this functionality: |
|
Does this really need to belong in the core python library? Is there any advantage to experimenting with it in this repository? |
IMO yes.
|
|
and putting this in |
|
another important use case: in zarr-python today, if you create a custom store, there is currently no way in zarr python to expose that store as a writable endpoint to a zarr-aware client. This PR enables this functionality. |
|
Love this, as someone who regularly uses @manzt https://github.com/manzt/simple-zarr-server |
|
thanks @psobolewskiPhD, given your experience with other tools let me know if there are any features missing from this PR and I can add them. |
I don't think this isn't a good argument. many zarr datasets are too big to compute on; should we vendor cubed/dask too? That said, i don't plan on helping maintain it so ... no skin off my back hehe |
I think visualizing zarr data is far more basic than doing distributed compute on it. It's very common to use data visualization as a basic sanity check when reading or writing data. And the server is less than 500 lines of code. I have not checked but I suspect this is a bit smaller than dask or cubed. |
|
Very cool! Works quite nicely! then this works from my CLI: (napari readers use extensions) |
Good idea, I can add this |
|
I did run into one issues, maybe PBCAK, but when the remote file being served was zarr2, if I didn't specify zarr_version=2 in my local (client) zarr.open things didn't work -- i couldn't get the arrays from a group. I happened to know it was zarr2, but in principle that might not be the case? |
# Conflicts: # mkdocs.yml # pyproject.toml
starlette 1.3 deprecated using httpx with its TestClient, which the warnings-as-errors filter turns into a collection error; add httpx2 to the test dependency group. uvicorn 0.51 removed Server.install_signal_handlers and now skips signal-handler setup off the main thread natively, so drop the monkey-patch workaround. Assisted-by: ClaudeCode:claude-fable-5
What if we sidestep these concerns entirely by putting the server in a separate package, e.g. |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #3732 +/- ##
==========================================
+ Coverage 93.87% 93.91% +0.04%
==========================================
Files 91 91
Lines 12594 12625 +31
==========================================
+ Hits 11822 11857 +35
+ Misses 772 768 -4
🚀 New features to boost your workflow:
|
Assisted-by: ClaudeCode:claude-fable-5
|
my current inclination to close this out is to add a new python package called |
The moved code now uses only public zarr API: zarr.buffer.cpu replaces zarr.core.buffer.cpu, spec-defined key names are inlined, chunk-key encodings are duck-typed on their spec names, and the shard grid shape is computed locally from public Array attributes. Also mirrors the root repo's [tool.numpydoc_validation] override in the package's pyproject.toml, since numpydoc-validation resolves config from the nearest pyproject.toml and would otherwise apply its stricter default checks to the moved docstrings. Assisted-by: ClaudeCode:claude-fable-5
…t the core fix Assisted-by: ClaudeCode:claude-fable-5
The server now lives in packages/zarr-server (published as zarr-server). The feature never shipped in a zarr release, so there is no deprecation shim. The decode_chunk_key strictness fix stays in zarr core. Assisted-by: ClaudeCode:claude-fable-5
Assisted-by: ClaudeCode:claude-fable-5
…removal The package lock embeds the workspace-root zarr project's metadata; Task 4 removed the root server extra and httpx2 test dep after this lock was first generated. Assisted-by: ClaudeCode:claude-fable-5
store_app (and thus serve_store) passed the request path straight to the store without validation, since the is_valid_node_key gate only ran for node_app. Starlette percent-decodes path params, so a request like GET /..%2fsecret.txt arrived as literal ".." and LocalStore resolved it outside the store root, allowing arbitrary file read via GET and arbitrary file write via PUT. Closes this path-traversal vulnerability by rejecting any "." or ".." path segment in _handle_request before the store is touched, for both store_app and node_app. Assisted-by: ClaudeCode:claude-fable-5
… guard
The path-traversal guard in _handle_request only rejected "." and ".."
segments. A percent-encoded leading slash (e.g. "/%2fetc%2fhostname")
decodes to an absolute path param ("/etc/hostname"), whose split()
produces an empty leading segment with no "." or ".." segment, so the
guard let it through. LocalStore resolves an absolute key by discarding
its configured root, allowing arbitrary filesystem read/write outside
the store. Reject empty segments too, closing the bypass.
Assisted-by: ClaudeCode:claude-fable-5
…eat/experimental-server
Keeps the zarr-server release workflow's pinned action SHAs in sync with the dependabot bump that landed in zarr-metadata-release.yml via the merge. Assisted-by: ClaudeCode:claude-fable-5
…d keys The traversal guard in _handle_request split only on "/", so backslash- separated segments like "..\\..\\win.ini" and drive-qualified or UNC-rooted keys like "C:/Windows/win.ini" or "\\host\share\x" passed through untouched. On Windows, LocalStore joins keys onto its root via pathlib, which discards the root entirely for a drive-qualified or rooted key -- turning percent-encoded backslash paths into arbitrary file read/write. Fold backslashes to "/" before the segment check (mirroring zarr's own normalize_path) and add an ntpath.splitdrive check to catch drive letters and UNC prefixes that don't produce empty/"."/".." segments. Added TDD coverage that fails against the old guard (via a store double that raises if get/set is ever called) and passes against the fix. Assisted-by: ClaudeCode:claude-fable-5
The `/..%2f..%2fsecret.txt` case in test_encoded_traversal_variants_ return_404 climbed two levels from tmp_path/store_root to tmp_path/.., but the secret was written at tmp_path/secret.txt (one level up) -- so the case passed for the wrong reason (no such file, not "guard blocked it") even with the traversal guard deleted entirely. Nest the store root exactly `climb_depth` directories below tmp_path per case, so every case's ".." segments resolve to tmp_path/secret.txt. Verified by mutation: copied _serve.py to a scratch dir (never the repo), deleted the guard body, and ran TestPathTraversalProtection against it via PYTHONPATH. Before this fix, 12/13 traversal tests failed against the mutant (1 false pass -- this vacuous case). After this fix, 13/13 fail against the mutant, and all still pass against the real guard. Assisted-by: ClaudeCode:claude-fable-5
|
Sounds good to me! |
… forever shutdown() joined the server thread with no timeout, and uvicorn's graceful wait is itself unbounded: force_exit is only set by a signal handler uvicorn deliberately skips off the main thread. A client mid request could wedge __exit__ unrecoverably. Bound uvicorn's graceful wait with timeout_graceful_shutdown and fall back to force_exit if the thread outlives it, both driven by a new shutdown_timeout parameter on serve_store/serve_node. Assisted-by: ClaudeCode:claude-fable-5
_handle_request special-cased PUT and let every other verb fall through to the read path, so a server built with DELETE/POST/PATCH answered them with the key's contents and changed nothing. HTTPMethod advertised all of them. Narrow HTTPMethod to GET/PUT/HEAD and reject anything else when the app is built. Doing this before the first release avoids narrowing a published type later. Assisted-by: ClaudeCode:claude-fable-5
… in CI The README's round-trip example reads back through FsspecStore, which needs an HTTP-capable fsspec that zarr-server does not depend on, so a clean install failed on the PyPI landing page's headline snippet. Same for examples/serve.py under a plain interpreter, which needs httpx. check_changelogs.yml also never visited packages/zarr-server/changes, so a malformed fragment would have passed PR CI and failed at release time. Assisted-by: ClaudeCode:claude-fable-5
…nd-trip Reading a served array back with zarr.open_array(url) routes through FsspecStore -- zarr's only URL-string backend -- so the README's headline example needs an HTTP-capable fsspec that the package itself has no reason to depend on. Carry that in a docs group, mirroring the root project's group for running examples, and use it to test the round-trip in CI so the example cannot rot. Assisted-by: ClaudeCode:claude-fable-5
The package is HTTP-specific end to end -- Starlette/ASGI, byte-range headers, CORS, HTTP verbs -- so the unqualified name overclaimed its scope and squatted the generic name that a future transport (an S3-compatible or WebDAV frontend) would want. Renames the distribution, the zarr_http_server module, the package directory, the zarr_http_server-v* release tags, both workflows and their artifact and PyPI environment names. Nothing is published yet, so this costs nothing now and would be permanent after the first upload. Assisted-by: ClaudeCode:claude-fable-5
… open Starlette's Route treats a falsy `methods` as "match every method", and an empty set passes the unsupported-verb check trivially, so store_app(store, methods=set()) served GET, DELETE, POST, PATCH and TRACE alike and accepted PUT writes -- the opposite of what the caller asked for, and the same fail-open the method narrowing set out to close. Assisted-by: ClaudeCode:claude-fable-5
…nopenable children Three defects the adversarial re-review reproduced: BackgroundServer reported the requested port, so port=0 produced http://host:0 while the socket was bound elsewhere -- clients following the documented url reached an unrelated service. Group key validation opened children through zarr's synchronous API directly on the event loop, serializing every concurrent request behind it (20 concurrent requests: 10.5s, now 1.1s) and outlasting the shutdown timeout. Move it to a worker thread. Child lookup caught only KeyError, so a corrupt metadata document or a codec from an uninstalled plugin surfaced as 500 and made a whole subtree unservable during ordinary operation. Assisted-by: ClaudeCode:claude-fable-5
…al review Hostile keys the store cannot express (embedded NUL, over-long names) now answer 404 instead of surfacing ValueError/OSError as a 500. PUT bodies are capped at DEFAULT_MAX_BODY_SIZE and answer 413 past it; Store.set takes a whole Buffer, so a body cannot be streamed and one request would otherwise size the server's memory. 206 responses carry Content-Range, and a range beyond the end or an inverted one answers 416 rather than an empty 206. A 0-d v2 array's sole chunk, stored under "0", is now servable: the key decodes to a 1-tuple no 0-d grid could match, so it needs the array's dimensionality to disambiguate. Array and group metadata key sets are split, so a v2 group no longer claims .zarray as its own; bind failures report the likely cause instead of a bare timeout; and serve_* gain a background: bool overload. The two out-of-bounds chunk tests planted no data, so they passed for the wrong reason -- mutating the bounds check to return True left the suite green. They now plant data at the out-of-grid key, and that mutation fails 3 tests. Adds the missing coverage for this branch's one core change, and widens the workflow's path filter to src/zarr, since the package resolves zarr from the repo root and a core change can break it. Assisted-by: ClaudeCode:claude-fable-5
… the body cap while reading The round-3 review found the previous round's error handling was too broad and its body cap too narrow. except (ValueError, OSError) around store.get and store.set caught the whole errno family, so EACCES, EROFS and ENOSPC all answered 404. Under the v3 spec an absent chunk is an uninitialized one and a reader is right to substitute the array's fill value, so 404 asserts something about the store's contents: an unreadable chunk answered that way has a correct client silently materialize fill values over data that exists, and a failed write looks like a pointless one rather than a failure. Only ENAMETOOLONG and EINVAL now mean "this key names nothing"; everything else surfaces. NUL bytes are rejected in the guard instead, so the store's own errors always mean real I/O trouble. max_body_size only consulted Content-Length, which a chunked request does not send, so request.body() buffered the whole thing before the check: 256 MiB passed a 1 KiB cap at 572 MiB peak RSS. The body is now read incrementally and abandoned at the cap -- the same attack peaks at 59 MiB. Also: a range too wide to allocate answers 416 rather than raising MemoryError; the force_exit fallback join is bounded, so shutdown cannot outlast its timeout; DEFAULT_MAX_BODY_SIZE is importable, since it is the documented default of three public functions; and _make_starlette_app loses a parameter it never read. Adds the coverage the review found missing: shard-grid bounds (the mutant served an out-of-grid shard key against a green suite), wrong-arity chunk keys, the chunked body path, I/O failures as 5xx, and a parser-level assertion for inverted ranges, which the status code alone could not distinguish on a MemoryStore. The drive-letter check stays unconditional. It over-rejects a first-segment node name like a:b, which is legal on POSIX -- but ntpath treats any single character before a colon as a drive, so there is no safe subset, and the guard is a string gate in front of an arbitrary Store whose path semantics this package cannot know. Assisted-by: ClaudeCode:claude-fable-5

This PR adds an experimental http server in
experimental.serve. This server can expose stores over http. It can also expose arrays and groups over http. Exposing a store means exposing the entire key: value space of the store. Exposing an array means only exposing the metadata + chunks. Exposing a group means only exposing sub-groups and sub-arrays. See #3731 for more on this distinction.The server is an optional dependency implemented via starlette. It handles byte-range reads and other http methods. CORS headers and allowed methods can be configured. I'm considering handling prefix requests like
foo/barby returning a simple HTML document that lists the visible keys underfoo/bar/, for user-friendliness and to aid httpstore readers that use such responses for listing contents. I'd also like to implement convenience functions for kicking off a server from jupyter and a CLI.Opening as a draft while I work on this.