From f7714eeff2df485155f0bd59f47a164c564db00f Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Wed, 29 Jul 2026 16:41:29 -0400 Subject: [PATCH] fix: negotiate HEAD content type on bucket root Follow-up to #25: HEAD /{bucket}/ returned application/xml unconditionally, while GET on the same URL serves the browse UI to clients that prefer HTML. Reuse _prefers_html so HEAD reports the content type GET would have sent, and set Vary: Accept since the response now varies on it. Co-Authored-By: Claude Fable 5 --- tests/test_file.py | 13 +++++++++++++ x2s3/app.py | 7 ++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/test_file.py b/tests/test_file.py index cb98eb0..5ef3e59 100644 --- a/tests/test_file.py +++ b/tests/test_file.py @@ -131,6 +131,19 @@ def test_head_root_index(app): assert get_response.headers['content-type'] == head_response.headers['content-type'] +def test_head_bucket_root_index(app): + # GET /{bucket}/ negotiates browse HTML vs XML listing; HEAD must match + with TestClient(app) as client: + for accept in ["text/html", "application/xml"]: + head_response = client.head("/local-files/", headers={"Accept": accept}) + assert head_response.status_code == 200 + assert head_response.headers['content-type'].startswith(accept) + assert head_response.headers['vary'] == "Accept" + get_response = client.get("/local-files/", headers={"Accept": accept}) + assert get_response.status_code == head_response.status_code + assert get_response.headers['content-type'] == head_response.headers['content-type'] + + def test_get_object(app): with TestClient(app) as client: response = client.get("/local-files/README.md") diff --git a/x2s3/app.py b/x2s3/app.py index 17812a5..e027eef 100644 --- a/x2s3/app.py +++ b/x2s3/app.py @@ -456,7 +456,12 @@ async def head_object(request: Request, path: str): # requires s3:ListBucket in real S3 if not target_config.browseable: return Response(status_code=403, media_type="application/xml") - return Response(status_code=200, media_type="application/xml") + # GET serves the browse UI or an XML listing here depending + # on Accept, so HEAD must report the matching content type + media_type = "text/html" if app.settings.ui and _prefers_html(request) \ + else "application/xml" + return Response(status_code=200, media_type=media_type, + headers={"Vary": "Accept"}) response = await client.head_object(target_path) if response.status_code == 404 and not target_config.browseable: