Skip to content

fix(files): download files whose names are not ASCII - #2426

Merged
jaluma merged 2 commits into
zylon-ai:mainfrom
MaxFreedomPollard:fix/non-ascii-download-filename
Nov 10, 2026
Merged

jaluma merged 2 commits into
zylon-ai:mainfrom
MaxFreedomPollard:fix/non-ascii-download-filename

Conversation

@MaxFreedomPollard

Copy link
Copy Markdown
Contributor

Description

GET /v1/files/{file_id}/content writes the file name straight into Content-Disposition: attachment; filename="..." (private_gpt/server/files/files_router.py:307). Starlette encodes response header values as Latin-1 (Response.init_headers), which causes two problems:

  • If the name contains any character outside Latin-1, building the response raises UnicodeEncodeError and the download fails with a 500. That covers Chinese, Cyrillic, Greek and emoji, and also everyday punctuation: an en dash, a curly apostrophe, or the U+202F narrow no-break space that macOS puts before AM/PM in screenshot names.
  • A Latin-1 name such as résumé.csv goes out as raw Latin-1 bytes. RFC 6266 advises against non-ASCII characters in filename because clients decode them inconsistently, and Starlette's own TestClient cannot decode that header at all.

Uploading accepts these names, so the file is stored and listed normally. Only the download breaks. The /content endpoint serves the workbench UI's download links and inline image previews (sessionFileUrl in ui/index.html) and the Anthropic SDK's client.beta.files.download().

With this change, ASCII names keep the current header byte for byte. Any other name is sent as filename*=UTF-8''<percent-encoded name> (RFC 6266 / RFC 8187), which is the form Starlette's FileResponse uses and which current browsers read.

HEAD /v1/files/{file_id} copies the same name into its custom X-File-Filename header and fails the same way for names outside Latin-1. Custom headers have no standard encoding, so I left HEAD out of this PR. I can add an encoding for it if you have one in mind.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)

How Has This Been Tested?

  • Added new unit/integration tests
  • I stared at the code and made sure it makes sense

test_content_download_filename_header in tests/server/files/test_files_router.py uploads a file under three names, downloads each through /content, and checks the exact Content-Disposition value: data.csv (unchanged), résumé.csv and 报告.csv.

On main, with only the test file changed:

$ PGPT_HOME=$PWD PYTHONPATH=. python -m pytest "tests/server/files/test_files_router.py::test_content_download_filename_header" -q
FAILED tests/server/files/test_files_router.py::test_content_download_filename_header[latin-1]
FAILED tests/server/files/test_files_router.py::test_content_download_filename_header[non-latin-1]
2 failed, 1 passed

[latin-1] fails with UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 23 while the test client reads the header. [non-latin-1] fails with assert 500 == 200. The 500 comes from UnicodeEncodeError: 'latin-1' codec can't encode characters in position 22-23, raised in Starlette's Response.init_headers.

With the fix, the new cases pass:

$ PGPT_HOME=<tmp> PYTHONPATH=. python -m pytest tests/server/files/test_files_router.py -q
1 failed, 40 passed

The one failure, test_head_file_returns_metadata_headers, fails the same way without this change in my environment: the libmagic build I had to use detects the uploaded text file as application/octet-stream. It is unrelated to this PR.

$ ruff format . --check
929 files already formatted

$ ruff check private_gpt tests scripts
All checks passed!

$ ty check private_gpt scripts
All checks passed!

The full pytest tests run doesn't pass cleanly in my sandbox, which blocks NLTK data downloads among other things: 336 tests fail on main with only the test file changed, and 334 with this change. The difference is exactly the two new Content-Disposition cases, so nothing else changes. CI is the real check for the rest of the suite.

Test Configuration:

  • OS: macOS 26.7.1 (arm64)
  • Python: 3.11.17
  • uv: 0.13.5

Checklist:

  • My code follows the style guidelines of this project
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes (see above: the full suite has unrelated failures in my sandbox)
  • Any dependent changes have been merged and published in downstream modules
  • I ran make check; make test to ensure type checks and tests pass (make check passes; make test see above)

🤖 Generated with Claude Code

GET /v1/files/{file_id}/content wrote the stored file name straight into
`Content-Disposition: attachment; filename="..."`. Starlette encodes header
values as Latin-1, so a name with any character outside Latin-1 (CJK,
Cyrillic, emoji, an en dash, macOS's U+202F in screenshot names) raised
UnicodeEncodeError and the download failed with a 500, and a Latin-1 name
such as résumé.csv went out as raw Latin-1 bytes that clients decode
inconsistently.

Keep the header byte for byte for ASCII names and send any other name as
RFC 6266 `filename*=UTF-8''<percent-encoded>`, as Starlette's FileResponse
does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@github-actions

github-actions Bot commented Nov 7, 2026

Copy link
Copy Markdown
Contributor

Stale pull request

@github-actions github-actions Bot added the stale label Nov 7, 2026

@jaluma jaluma left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Thanks!

@jaluma
jaluma merged commit 5f8aca6 into zylon-ai:main Nov 10, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants