Skip to content

fix(storage): GCS download URLs for keys containing '%' return NoSuchKey - #5369

Merged
nicoloboschi merged 2 commits into
mainfrom
fix/signed-url-percent-keys
Oct 7, 2026
Merged

nicoloboschi merged 2 commits into
mainfrom
fix/signed-url-percent-keys

Conversation

@cdbartholomew

@cdbartholomew cdbartholomew commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Exporting a bank whose id contains a space, a dot, or non-ASCII characters (e.g. Express AI — Trial) completes, but downloading the archive from the returned download_url fails on GCS:

<Error><Code>NoSuchKey</Code><Message>The specified key does not exist.</Message>…

Why:

  • Since storage keys started percent-encoding their segments (key_segment), such a bank's files are stored under an object name that literally contains %. For example, …/banks/Express%20AI%20%E2%80%94%20Trial/exports/<uuid>/transfer.zip is the actual object name.
  • obstore's GCS signer puts the key into the URL path unescaped. GCS decodes the path, looks up Express AI — Trial/…, a different object, and returns 404.
  • obstore's own get and head escape correctly, which is why storing and reading through the backend work and only the presigned URL is broken.

The same applies to any presigned download on GCS, not only exports.

Fix

GCSFileStorage.get_download_url escapes % as %25 before signing:

  • The signed path decodes back to the literal object name.
  • The signature is computed over that same path, so it still validates.
  • Keys without % are signed exactly as before.

GCS only: obstore's S3 and Azure signers already escape %, so pre-escaping there would double-encode. Both are now pinned by the test below.

Testing

  • New tests/test_file_storage_signed_urls.py: signs for real, offline — obstore's signers with throwaway credentials, so no network, bucket or container is needed — and asserts the URL path decodes back to the stored object name for plain-bank, Express AI — Trial, team.notes and 100% done.
    • It runs for all three obstore backends (GCS, S3, Azure): the sibling nobody tested is the one that breaks, and the SeaweedFS suite that covered S3 is skipped in CI.
    • With the fix: 16/16 pass. Without it, the 3 encoded GCS cases fail. S3 and Azure pass either way, confirming they need no escaping.
    • test_every_obstore_backend_is_covered enumerates the storage package, so a fourth backend cannot land without signing coverage.
  • tests/test_file_storage_s3.py: the presigned-URL test previously only checked that the result was a URL string; it now downloads through the presigned URL against SeaweedFS for the same bank ids and asserts the bytes — the real-server proof behind the offline contract.
  • Against a real GCS bucket, for a stored archive under an encoded bank id:
    • the URL from current main → 404 NoSuchKey;
    • with this change → 200, full archive, valid zip.
  • Storage suites: test_bank_file_storage and test_file_storage_bytes_contract pass locally, plus ruff and ty check.

Storage keys percent-encode their segments (key_segment), so a bank id
with a space, a dot or non-ASCII is stored under an object name that
literally contains '%' ("Express%20AI" is the name). obstore's GCS
signer puts the key into the URL path unescaped; GCS decodes the path,
looks up "Express AI", and the download fails with NoSuchKey even though
the export (or any other stored file) is there.

GCSFileStorage.get_download_url now escapes '%' as '%25' before signing,
so the path decodes back to the literal object name. The S3 signer
already escapes '%' -- pre-escaping there would double-encode -- so the
fix is GCS-only.

Tests:
- test_file_storage_gcs_signing.py signs for real, offline, with a
  throwaway service-account key and checks that the URL path decodes to
  the stored object name for plain, spaced, dotted, non-ASCII and '%'
  bank ids. Without the fix the three encoded cases fail.
- test_file_storage_s3.py now downloads through the presigned URL
  against SeaweedFS for the same bank ids (it previously only checked
  the result was a URL string), pinning that S3 needs no escaping.
Code-review fixes on the presigned-URL fix:

- Sibling parity: the offline signing test covered GCS only, leaving Azure
  with no signing coverage at all (its evidence for S3 was the SeaweedFS
  suite, which CI skips). Renamed to test_file_storage_signed_urls.py and
  parametrised over GCS, S3 and Azure — all three sign offline with
  throwaway credentials, so CI now pins the contract for every backend.
  Confirms Azure needs no escaping, which the PR had left untested.
- Added test_every_obstore_backend_is_covered: enumerates the storage
  package and fails if a backend is not in the signed list, so the next
  backend cannot land unsigned.
- gcs.py comment now names Azure alongside S3 and points at the one test.
- Hoisted the key_segment import to module scope in the S3 test.
@nicoloboschi
nicoloboschi force-pushed the fix/signed-url-percent-keys branch from f08bd26 to 259b5b6 Compare October 7, 2026 07:36
@nicoloboschi
nicoloboschi merged commit 2a3c55a into main Oct 7, 2026
230 of 234 checks passed
@nicoloboschi
nicoloboschi deleted the fix/signed-url-percent-keys branch October 7, 2026 08:33
@nicoloboschi nicoloboschi mentioned this pull request Nov 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants