From 6c912a3d7183e152643e9e40bf47aa4d602a60ab Mon Sep 17 00:00:00 2001 From: kqjy Date: Mon, 9 Mar 2026 15:09:15 +0800 Subject: [PATCH] Add conditional GET/HEAD headers: If-Match, If-None-Match, If-Modified-Since, If-Unmodified-Since --- app/s3_api.py | 81 +++++++++++++++- app/version.py | 2 +- tests/test_conditional_headers.py | 156 ++++++++++++++++++++++++++++++ 3 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 tests/test_conditional_headers.py diff --git a/app/s3_api.py b/app/s3_api.py index 749e1e1..af54bed 100644 --- a/app/s3_api.py +++ b/app/s3_api.py @@ -1019,6 +1019,58 @@ def _method_not_allowed(allowed: list[str]) -> Response: return response +def _check_conditional_headers(etag: str, last_modified: float | None) -> Response | None: + from email.utils import parsedate_to_datetime + + if_match = request.headers.get("If-Match") + if if_match: + if if_match.strip() != "*": + match_etags = [e.strip().strip('"') for e in if_match.split(",")] + if etag not in match_etags: + return Response(status=412) + + if_unmodified = request.headers.get("If-Unmodified-Since") + if not if_match and if_unmodified and last_modified is not None: + try: + dt = parsedate_to_datetime(if_unmodified) + obj_dt = datetime.fromtimestamp(last_modified, timezone.utc) + if obj_dt > dt: + return Response(status=412) + except (TypeError, ValueError): + pass + + if_none_match = request.headers.get("If-None-Match") + if if_none_match: + if if_none_match.strip() == "*": + resp = Response(status=304) + resp.headers["ETag"] = f'"{etag}"' + if last_modified is not None: + resp.headers["Last-Modified"] = http_date(last_modified) + return resp + none_match_etags = [e.strip().strip('"') for e in if_none_match.split(",")] + if etag in none_match_etags: + resp = Response(status=304) + resp.headers["ETag"] = f'"{etag}"' + if last_modified is not None: + resp.headers["Last-Modified"] = http_date(last_modified) + return resp + + if_modified = request.headers.get("If-Modified-Since") + if not if_none_match and if_modified and last_modified is not None: + try: + dt = parsedate_to_datetime(if_modified) + obj_dt = datetime.fromtimestamp(last_modified, timezone.utc) + if obj_dt <= dt: + resp = Response(status=304) + resp.headers["ETag"] = f'"{etag}"' + resp.headers["Last-Modified"] = http_date(last_modified) + return resp + except (TypeError, ValueError): + pass + + return None + + def _apply_object_headers( response: Response, *, @@ -2897,7 +2949,24 @@ def object_handler(bucket_name: str, object_key: str): mimetype = metadata.get("__content_type__") or mimetypes.guess_type(object_key)[0] or "application/octet-stream" is_encrypted = "x-amz-server-side-encryption" in metadata - + + cond_etag = metadata.get("__etag__") + if not cond_etag and not is_encrypted: + try: + cond_etag = storage._compute_etag(path) + except OSError: + cond_etag = None + if cond_etag: + cond_mtime = float(metadata["__last_modified__"]) if "__last_modified__" in metadata else None + if cond_mtime is None: + try: + cond_mtime = path.stat().st_mtime + except OSError: + pass + cond_resp = _check_conditional_headers(cond_etag, cond_mtime) + if cond_resp: + return cond_resp + if request.method == "GET": range_header = request.headers.get("Range") @@ -3367,6 +3436,16 @@ def head_object(bucket_name: str, object_key: str) -> Response: metadata = _storage().get_object_metadata(bucket_name, object_key) etag = metadata.get("__etag__") or _storage()._compute_etag(path) + head_mtime = float(metadata["__last_modified__"]) if "__last_modified__" in metadata else None + if head_mtime is None: + try: + head_mtime = path.stat().st_mtime + except OSError: + pass + cond_resp = _check_conditional_headers(etag, head_mtime) + if cond_resp: + return cond_resp + cached_size = metadata.get("__size__") cached_mtime = metadata.get("__last_modified__") if cached_size is not None and cached_mtime is not None: diff --git a/app/version.py b/app/version.py index e049a33..b4679f1 100644 --- a/app/version.py +++ b/app/version.py @@ -1,6 +1,6 @@ from __future__ import annotations -APP_VERSION = "0.3.7" +APP_VERSION = "0.3.8" def get_version() -> str: diff --git a/tests/test_conditional_headers.py b/tests/test_conditional_headers.py new file mode 100644 index 0000000..dfbedcf --- /dev/null +++ b/tests/test_conditional_headers.py @@ -0,0 +1,156 @@ +import hashlib +import time + +import pytest + + +@pytest.fixture() +def bucket(client, signer): + headers = signer("PUT", "/cond-test") + client.put("/cond-test", headers=headers) + return "cond-test" + + +@pytest.fixture() +def uploaded(client, signer, bucket): + body = b"hello conditional" + etag = hashlib.md5(body).hexdigest() + headers = signer("PUT", f"/{bucket}/obj.txt", body=body) + resp = client.put(f"/{bucket}/obj.txt", headers=headers, data=body) + last_modified = resp.headers.get("Last-Modified") + return {"etag": etag, "last_modified": last_modified} + + +class TestIfMatch: + def test_get_matching_etag(self, client, signer, bucket, uploaded): + headers = signer("GET", f"/{bucket}/obj.txt", headers={"If-Match": f'"{uploaded["etag"]}"'}) + resp = client.get(f"/{bucket}/obj.txt", headers=headers) + assert resp.status_code == 200 + + def test_get_non_matching_etag(self, client, signer, bucket, uploaded): + headers = signer("GET", f"/{bucket}/obj.txt", headers={"If-Match": '"wrongetag"'}) + resp = client.get(f"/{bucket}/obj.txt", headers=headers) + assert resp.status_code == 412 + + def test_head_matching_etag(self, client, signer, bucket, uploaded): + headers = signer("HEAD", f"/{bucket}/obj.txt", headers={"If-Match": f'"{uploaded["etag"]}"'}) + resp = client.head(f"/{bucket}/obj.txt", headers=headers) + assert resp.status_code == 200 + + def test_head_non_matching_etag(self, client, signer, bucket, uploaded): + headers = signer("HEAD", f"/{bucket}/obj.txt", headers={"If-Match": '"wrongetag"'}) + resp = client.head(f"/{bucket}/obj.txt", headers=headers) + assert resp.status_code == 412 + + def test_wildcard_match(self, client, signer, bucket, uploaded): + headers = signer("GET", f"/{bucket}/obj.txt", headers={"If-Match": "*"}) + resp = client.get(f"/{bucket}/obj.txt", headers=headers) + assert resp.status_code == 200 + + def test_multiple_etags_one_matches(self, client, signer, bucket, uploaded): + etag_list = f'"bad1", "{uploaded["etag"]}", "bad2"' + headers = signer("GET", f"/{bucket}/obj.txt", headers={"If-Match": etag_list}) + resp = client.get(f"/{bucket}/obj.txt", headers=headers) + assert resp.status_code == 200 + + def test_multiple_etags_none_match(self, client, signer, bucket, uploaded): + headers = signer("GET", f"/{bucket}/obj.txt", headers={"If-Match": '"bad1", "bad2"'}) + resp = client.get(f"/{bucket}/obj.txt", headers=headers) + assert resp.status_code == 412 + + +class TestIfNoneMatch: + def test_get_matching_etag_returns_304(self, client, signer, bucket, uploaded): + headers = signer("GET", f"/{bucket}/obj.txt", headers={"If-None-Match": f'"{uploaded["etag"]}"'}) + resp = client.get(f"/{bucket}/obj.txt", headers=headers) + assert resp.status_code == 304 + assert uploaded["etag"] in resp.headers.get("ETag", "") + + def test_get_non_matching_etag_returns_200(self, client, signer, bucket, uploaded): + headers = signer("GET", f"/{bucket}/obj.txt", headers={"If-None-Match": '"wrongetag"'}) + resp = client.get(f"/{bucket}/obj.txt", headers=headers) + assert resp.status_code == 200 + + def test_head_matching_etag_returns_304(self, client, signer, bucket, uploaded): + headers = signer("HEAD", f"/{bucket}/obj.txt", headers={"If-None-Match": f'"{uploaded["etag"]}"'}) + resp = client.head(f"/{bucket}/obj.txt", headers=headers) + assert resp.status_code == 304 + + def test_head_non_matching_etag_returns_200(self, client, signer, bucket, uploaded): + headers = signer("HEAD", f"/{bucket}/obj.txt", headers={"If-None-Match": '"wrongetag"'}) + resp = client.head(f"/{bucket}/obj.txt", headers=headers) + assert resp.status_code == 200 + + def test_wildcard_returns_304(self, client, signer, bucket, uploaded): + headers = signer("GET", f"/{bucket}/obj.txt", headers={"If-None-Match": "*"}) + resp = client.get(f"/{bucket}/obj.txt", headers=headers) + assert resp.status_code == 304 + + +class TestIfModifiedSince: + def test_not_modified_returns_304(self, client, signer, bucket, uploaded): + headers = signer("GET", f"/{bucket}/obj.txt", headers={"If-Modified-Since": "Sun, 01 Jan 2034 00:00:00 GMT"}) + resp = client.get(f"/{bucket}/obj.txt", headers=headers) + assert resp.status_code == 304 + assert "ETag" in resp.headers + + def test_modified_returns_200(self, client, signer, bucket, uploaded): + headers = signer("GET", f"/{bucket}/obj.txt", headers={"If-Modified-Since": "Sun, 01 Jan 2000 00:00:00 GMT"}) + resp = client.get(f"/{bucket}/obj.txt", headers=headers) + assert resp.status_code == 200 + + def test_head_not_modified(self, client, signer, bucket, uploaded): + headers = signer("HEAD", f"/{bucket}/obj.txt", headers={"If-Modified-Since": "Sun, 01 Jan 2034 00:00:00 GMT"}) + resp = client.head(f"/{bucket}/obj.txt", headers=headers) + assert resp.status_code == 304 + + def test_if_none_match_takes_precedence(self, client, signer, bucket, uploaded): + headers = signer("GET", f"/{bucket}/obj.txt", headers={ + "If-None-Match": '"wrongetag"', + "If-Modified-Since": "Sun, 01 Jan 2034 00:00:00 GMT", + }) + resp = client.get(f"/{bucket}/obj.txt", headers=headers) + assert resp.status_code == 200 + + +class TestIfUnmodifiedSince: + def test_unmodified_returns_200(self, client, signer, bucket, uploaded): + headers = signer("GET", f"/{bucket}/obj.txt", headers={"If-Unmodified-Since": "Sun, 01 Jan 2034 00:00:00 GMT"}) + resp = client.get(f"/{bucket}/obj.txt", headers=headers) + assert resp.status_code == 200 + + def test_modified_returns_412(self, client, signer, bucket, uploaded): + headers = signer("GET", f"/{bucket}/obj.txt", headers={"If-Unmodified-Since": "Sun, 01 Jan 2000 00:00:00 GMT"}) + resp = client.get(f"/{bucket}/obj.txt", headers=headers) + assert resp.status_code == 412 + + def test_head_modified_returns_412(self, client, signer, bucket, uploaded): + headers = signer("HEAD", f"/{bucket}/obj.txt", headers={"If-Unmodified-Since": "Sun, 01 Jan 2000 00:00:00 GMT"}) + resp = client.head(f"/{bucket}/obj.txt", headers=headers) + assert resp.status_code == 412 + + def test_if_match_takes_precedence(self, client, signer, bucket, uploaded): + headers = signer("GET", f"/{bucket}/obj.txt", headers={ + "If-Match": f'"{uploaded["etag"]}"', + "If-Unmodified-Since": "Sun, 01 Jan 2000 00:00:00 GMT", + }) + resp = client.get(f"/{bucket}/obj.txt", headers=headers) + assert resp.status_code == 200 + + +class TestConditionalWithRange: + def test_if_match_with_range(self, client, signer, bucket, uploaded): + headers = signer("GET", f"/{bucket}/obj.txt", headers={ + "If-Match": f'"{uploaded["etag"]}"', + "Range": "bytes=0-4", + }) + resp = client.get(f"/{bucket}/obj.txt", headers=headers) + assert resp.status_code == 206 + + def test_if_match_fails_with_range(self, client, signer, bucket, uploaded): + headers = signer("GET", f"/{bucket}/obj.txt", headers={ + "If-Match": '"wrongetag"', + "Range": "bytes=0-4", + }) + resp = client.get(f"/{bucket}/obj.txt", headers=headers) + assert resp.status_code == 412