diff --git a/CHANGELOG.md b/CHANGELOG.md index 36f08cb..5b05537 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ All notable changes to **stunt** are documented here. The format is based on and re-upload any in-flight multipart upload, since `CompleteMultipartUpload` rejects mixed old and new part ETags with `400 InvalidPart`. MD5 is a compat checksum here, never auth or integrity. +- **aws-s3-style: conditional requests.** `GET`/`HEAD` honor `If-Match`, + `If-None-Match`, `If-Modified-Since`, and `If-Unmodified-Since`, returning + `304` or `412`; `PUT`/`DELETE` honor the ETag conditions. ## [0.52.0] — 2026-08-24 diff --git a/CONFORMANCE.md b/CONFORMANCE.md index 283860c..573001d 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -40,7 +40,7 @@ Behavior columns come in two kinds: **verified** (an official SDK was driven aga | [avalara-style](adapters/avalara-style/) | Avalara AvaTax REST API `2` | 8 | VM | — | — | [5](#avalara-style) | [3](#avalara-style) | | [aws-cognito-style](adapters/aws-cognito-style/) | Amazon Cognito Identity Provider API `2016-04-18` | 7 | VM | — | — | [6](#aws-cognito-style) | [3](#aws-cognito-style) | | [aws-iam-sts-style](adapters/aws-iam-sts-style/) | AWS STS + IAM API `2011-06-15` | 2 | SDK | aws-sdk-go-v2 @ v1.43.7 | 2 | [3](#aws-iam-sts-style) | [3](#aws-iam-sts-style) | -| [aws-s3-style](adapters/aws-s3-style/) | Amazon S3 API `2006-03-01` | 8 | SDK | aws-sdk-go-v2 @ v1.43.7 | 6 | [5](#aws-s3-style) | [5](#aws-s3-style) | +| [aws-s3-style](adapters/aws-s3-style/) | Amazon S3 API `2006-03-01` | 8 | SDK | aws-sdk-go-v2 @ v1.43.7 | 6 | [5](#aws-s3-style) | [7](#aws-s3-style) | | [azure-devops-style](adapters/azure-devops-style/) | Azure DevOps REST API `7.1` | 17 | VM | — | — | [8](#azure-devops-style) | [6](#azure-devops-style) | | [azure-servicebus-style](adapters/azure-servicebus-style/) | Azure Service Bus + Storage `2024-01-01` | 18 | VM | — | — | [6](#azure-servicebus-style) | [3](#azure-servicebus-style) | | [azure-storage-style](adapters/azure-storage-style/) | Azure Storage Blob REST API `2024-08-04` | 9 | VM | — | — | [6](#azure-storage-style) | [3](#azure-storage-style) | @@ -2598,11 +2598,13 @@ behavior notes live in each adapter's README. - No browser form POST uploads (POST policy) - No ListMultipartUploads (GET /{bucket}?uploads) -**Deviations** (5) +**Deviations** (7) - ETags are MD5 hex (multipart MD5(binary-concat)-N) - Multipart 5 MiB minimum part size not enforced (small parts allowed) - DELETE of a missing bucket is an idempotent 204 (real S3: 404 NoSuchBucket) +- DELETE object against a missing bucket is 204 (real S3: 404 NoSuchBucket) +- GET/HEAD object against a missing bucket is 404 NoSuchKey (real S3: 404 NoSuchBucket) - SigV4 canonical URI/query rebuilt from decoded values — duplicates indistinguishable - No RFC 1123 Date fallback; canonical header whitespace collapsing not applied diff --git a/adapters/aws-s3-style/README.md b/adapters/aws-s3-style/README.md index f89eb92..202f3e3 100644 --- a/adapters/aws-s3-style/README.md +++ b/adapters/aws-s3-style/README.md @@ -107,6 +107,22 @@ all list filters are applied before pagination, as in real S3. All XML responses use the correct S3 namespace: `http://s3.amazonaws.com/doc/2006-03-01/`. +### Conditional requests + +`GET`/`HEAD` honor `If-Match`, `If-None-Match`, `If-Modified-Since`, and +`If-Unmodified-Since`, evaluated fail-closed in that order. A matching +`If-None-Match` or `If-Modified-Since` on a read is `304 Not Modified` with +an empty body; any other failed precondition is `412 PreconditionFailed` +XML carrying `` and ``. `PUT`/`DELETE` honor the ETag +conditions only, including `*`; timestamp conditions are ignored on writes. +`If-None-Match` suppresses `If-Modified-Since` and `If-Match` suppresses +`If-Unmodified-Since`, per RFC 7232. Malformed or empty validators are +ignored rather than rejected. + +Two documented deviations: weak validators (`W/"..."`) compare as strong, +and a `DELETE` or `GET`/`HEAD` against a missing bucket is `204`/`NoSuchKey` +where real S3 returns `NoSuchBucket`. + ## Auth — AWS Signature Version 4 (SigV4), verified for real Amazon S3 uses **AWS Signature Version 4** (SigV4) for authentication. This @@ -257,6 +273,7 @@ All errors use S3-shaped XML: | `InvalidPart` | 400 | CompleteMultipartUpload lists a part that was never uploaded, or whose ETag does not match | | `InvalidPartOrder` | 400 | CompleteMultipartUpload part list is not in ascending order | | `MalformedXML` | 400 | CompleteMultipartUpload body is not valid `CompleteMultipartUpload` XML | +| `PreconditionFailed` | 412 | An `If-Match`/`If-None-Match`/`If-Unmodified-Since`/`If-Modified-Since` condition did not hold | | `MethodNotAllowed` | 405 | POST to an object without `?uploads`/`?uploadId` | | `NoSuchBucket` | 404 | Bucket doesn't exist | | `NoSuchKey` | 404 | Object key doesn't exist | diff --git a/adapters/aws-s3-style/scripts/lib.star b/adapters/aws-s3-style/scripts/lib.star index fd8e5ce..6c92458 100644 --- a/adapters/aws-s3-style/scripts/lib.star +++ b/adapters/aws-s3-style/scripts/lib.star @@ -1194,3 +1194,260 @@ def _to_num(v): if type(v) == "int": return v return int(v) + +# ==================================================================== +# Conditional preconditions (If-Match / If-None-Match / IMS / IUS) +# ==================================================================== +# S3 compat: PUT/DELETE ETags-only; GET/HEAD full. Order +# If-Match -> If-Unmodified-Since -> If-None-Match -> If-Modified-Since. +# Empty/whitespace/malformed -> absent (ignored); remaining valid +# header still evaluates. 412 XML carries Condition+Key+RequestId; +# 304 is empty with ETag+Last-Modified+request-id only. + +# _collapse_ows trims ends and collapses interior SP/TAB runs to one SP. +# _strip handles ends (SP/TAB/\n/\r); interior \n/\r are preserved so +# date parsing still rejects lone-CR etc. +def _collapse_ows(s): + if s == None: + return "" + s = _strip(str(s)) + out = "" + prev_sp = False + for i in range(len(s)): + ch = s[i] + if ch == " " or ch == "\t": + if not prev_sp: + out = out + " " + prev_sp = True + else: + out = out + ch + prev_sp = False + return out + +# _parse_etag_list parses an If-Match/If-None-Match header value into a +# list of stripped ETags, or None when absent/malformed (to be ignored). +# Grammar: ","-split, OWS trim + interior collapse, strip one "W/" +# prefix (bare "W/" -> malformed -> None), strip one quote pair. +# '""' -> [""] (one empty ETag, no-match, not absent); "*" anywhere -> +# match-any (caller checks "*" in list); compare is case-sensitive. +def _parse_etag_list(raw): + if raw == None: + return None + raw = str(raw) + if _strip(raw) == "": + return None + parts = _split(raw, ",") + out = [] + for p in parts: + t = _collapse_ows(p) + if t == "": + continue + if t == "W/" or t == "W": + return None + if _has_prefix(t, "W/"): + t = t[2:] + t = _collapse_ows(t) + if t == "" or t == "W/" or t == "W": + return None + if len(t) >= 2 and t[0] == '"' and t[len(t) - 1] == '"': + t = t[1:len(t) - 1] + out.append(t) + if len(out) == 0: + return None + return out + +# _rfc1123_to_unix parses a lenient IMF-fixdate subset into Unix seconds, +# or None when unparseable (to be ignored, never 400). +# Accepts: "Day, DD Mon YYYY HH:MM:SS GMT|UTC|UT", single-digit day, +# multiple SP, SP/TAB OWS, month case-insensitive, weekday ignored. +# Rejects: missing comma, 2-digit year, numeric/missing/named TZ (EST), +# hour>23/min>59/sec>59, day 00, month overflow, Feb30/Apr31, +# Feb29 on non-leap years. +def _rfc1123_to_unix(s): + if s == None: + return None + s = _collapse_ows(str(s)) + if s == "": + return None + comma = _find_substr(s, ",") + if comma < 0: + return None + rest = _strip(s[comma + 1:]) + if rest == "": + return None + parts = _split(rest, " ") + if len(parts) != 5: + return None + day_s = parts[0] + mon_s = parts[1] + year_s = parts[2] + time_s = parts[3] + tz_s = parts[4] + if not _is_digits(day_s) or len(day_s) < 1 or len(day_s) > 2: + return None + d = _to_int(day_s) + if d < 1 or d > 31: + return None + mon_low = mon_s.lower() + m = 0 + if mon_low == "jan": + m = 1 + elif mon_low == "feb": + m = 2 + elif mon_low == "mar": + m = 3 + elif mon_low == "apr": + m = 4 + elif mon_low == "may": + m = 5 + elif mon_low == "jun": + m = 6 + elif mon_low == "jul": + m = 7 + elif mon_low == "aug": + m = 8 + elif mon_low == "sep": + m = 9 + elif mon_low == "oct": + m = 10 + elif mon_low == "nov": + m = 11 + elif mon_low == "dec": + m = 12 + else: + return None + if len(year_s) != 4 or not _is_digits(year_s): + return None + y = _to_int(year_s) + tparts = _split(time_s, ":") + if len(tparts) != 3: + return None + hs = tparts[0] + mis = tparts[1] + ses = tparts[2] + if len(hs) != 2 or len(mis) != 2 or len(ses) != 2: + return None + if not _is_digits(hs) or not _is_digits(mis) or not _is_digits(ses): + return None + h = _to_int(hs) + mi = _to_int(mis) + se = _to_int(ses) + if h > 23 or mi > 59 or se > 59: + return None + if tz_s != "GMT" and tz_s != "UTC" and tz_s != "UT": + return None + dim = 31 + if m == 2: + leap = (y % 4 == 0) and ((y % 100 != 0) or (y % 400 == 0)) + if leap: + dim = 29 + else: + dim = 28 + elif m == 4 or m == 6 or m == 9 or m == 11: + dim = 30 + if d > dim: + return None + return _days_from_civil(y, m, d) * 86400 + h * 3600 + mi * 60 + se + +# _precondition_failed returns the S3 412 PreconditionFailed XML error. +def _precondition_failed(condition, key): + rid = _req_id() + xml = '\n' + xml = xml + "PreconditionFailed" + xml = xml + "At least one of the pre-conditions you specified did not hold" + xml = xml + "" + _xml_escape(condition) + "" + xml = xml + "" + _xml_escape(key) + "" + xml = xml + "" + _xml_escape(rid) + "" + return respond(412, xml, {"Content-Type": "application/xml", "x-amz-request-id": rid}) + +# _not_modified returns the 304 response (empty, ETag+LM+req-id only). +def _not_modified(cur_etag, lm_unix, lm_valid): + rid = _req_id() + lm_str = "" + if lm_valid: + lm_str = _unix_to_rfc1123(lm_unix) + else: + lm_str = _unix_to_rfc1123(clock.now_unix()) + return respond(304, "", {"ETag": '"' + cur_etag + '"', "Last-Modified": lm_str, "x-amz-request-id": rid}) + +# _check_preconditions evaluates If-Match -> IUS -> If-None-Match -> IMS. +# op is "put", "delete" (ETags-only) or "get", "head" (full). +# obj is the stored doc or None. Returns None when the request may +# proceed, else a 412/304 response. Missing objects: PUT/DELETE with +# If-Match present -> 412, else pass; GET/HEAD missing -> pass (caller +# returns 404). Legacy docs (lastModifiedUnix 0/None/absent) ignore TS +# conds; ETag conds still evaluate. +def _check_preconditions(req, obj, bucket, key, op): + headers = req.get("headers") + if headers == None: + headers = {} + im_raw = headers.get("If-Match", None) + inm_raw = headers.get("If-None-Match", None) + ius_raw = headers.get("If-Unmodified-Since", None) + ims_raw = headers.get("If-Modified-Since", None) + im_list = None + if im_raw != None: + im_list = _parse_etag_list(im_raw) + inm_list = None + if inm_raw != None: + inm_list = _parse_etag_list(inm_raw) + ius_ts = None + if ius_raw != None and _strip(str(ius_raw)) != "": + ius_ts = _rfc1123_to_unix(ius_raw) + ims_ts = None + if ims_raw != None and _strip(str(ims_raw)) != "": + ims_ts = _rfc1123_to_unix(ims_raw) + is_write = (op == "put" or op == "delete") + exists = (obj != None) + if not exists: + if is_write: + if im_list != None: + return _precondition_failed("If-Match", key) + return None + return None + cur_etag = obj.get("etag", "") + if cur_etag == None: + cur_etag = "" + cur_etag = str(cur_etag) + lu = obj.get("lastModifiedUnix", 0) + if lu == None: + lu = 0 + lu = _to_num(lu) + lm_valid = False + lm_unix = 0 + if lu != 0: + lm_valid = True + lm_unix = lu + if im_list != None: + match = False + for v in im_list: + if v == "*": + match = True + break + if v == cur_etag: + match = True + break + if not match: + return _precondition_failed("If-Match", key) + if not is_write: + if ius_ts != None and lm_valid: + if lm_unix > ius_ts: + return _precondition_failed("If-Unmodified-Since", key) + if inm_list != None: + matched = False + for v in inm_list: + if v == "*": + matched = True + break + if v == cur_etag: + matched = True + break + if matched: + if is_write: + return _precondition_failed("If-None-Match", key) + return _not_modified(cur_etag, lm_unix, lm_valid) + if not is_write: + if ims_ts != None and lm_valid: + if lm_unix <= ims_ts: + return _not_modified(cur_etag, lm_unix, lm_valid) + return None diff --git a/adapters/aws-s3-style/scripts/objects.star b/adapters/aws-s3-style/scripts/objects.star index 43db970..6144d4b 100644 --- a/adapters/aws-s3-style/scripts/objects.star +++ b/adapters/aws-s3-style/scripts/objects.star @@ -70,6 +70,11 @@ def on_put_object(req): if bucket_doc == None: return _no_such_bucket_error(bucket) + obj = _find_object(bucket, key) + cond = _check_preconditions(req, obj, bucket, key, "put") + if cond != None: + return cond + # Content goes in the byte-exact blob store (filesystem-backed), keyed by # bucket/key; the collection holds metadata only. raw_body is the verbatim # request bytes, so binary uploads round-trip exactly — a parsed body map @@ -112,6 +117,10 @@ def on_get_object(req): if obj == None: return _no_such_key(bucket, key) + cond = _check_preconditions(req, obj, bucket, key, "get") + if cond != None: + return cond + content = store_blob("s3-objects").get(obj.get("bid", "")) if content == None: content = "" @@ -143,6 +152,10 @@ def on_head_object(req): if obj == None: return _no_such_key(bucket, key) + cond = _check_preconditions(req, obj, bucket, key, "head") + if cond != None: + return cond + ct = obj.get("contentType", "application/octet-stream") if ct == None: ct = "application/octet-stream" @@ -175,6 +188,11 @@ def on_delete_object(req): if _query_present(req, "uploadId"): return _mpu_abort(req, bucket, key) + obj = _find_object(bucket, key) + cond = _check_preconditions(req, obj, bucket, key, "delete") + if cond != None: + return cond + oc = store_collection("objects") obj_id = None bid = None diff --git a/conformance/matrix.json b/conformance/matrix.json index de608de..fe3ce90 100644 --- a/conformance/matrix.json +++ b/conformance/matrix.json @@ -9383,6 +9383,8 @@ "ETags are MD5 hex (multipart MD5(binary-concat)-N)", "Multipart 5 MiB minimum part size not enforced (small parts allowed)", "DELETE of a missing bucket is an idempotent 204 (real S3: 404 NoSuchBucket)", + "DELETE object against a missing bucket is 204 (real S3: 404 NoSuchBucket)", + "GET/HEAD object against a missing bucket is 404 NoSuchKey (real S3: 404 NoSuchBucket)", "SigV4 canonical URI/query rebuilt from decoded values — duplicates indistinguishable", "No RFC 1123 Date fallback; canonical header whitespace collapsing not applied" ], diff --git a/conformance/matrix.yaml b/conformance/matrix.yaml index f8c57f1..98e5f23 100644 --- a/conformance/matrix.yaml +++ b/conformance/matrix.yaml @@ -152,6 +152,8 @@ adapters: - "ETags are MD5 hex (multipart MD5(binary-concat)-N)" - "Multipart 5 MiB minimum part size not enforced (small parts allowed)" - "DELETE of a missing bucket is an idempotent 204 (real S3: 404 NoSuchBucket)" + - "DELETE object against a missing bucket is 204 (real S3: 404 NoSuchBucket)" + - "GET/HEAD object against a missing bucket is 404 NoSuchKey (real S3: 404 NoSuchBucket)" - "SigV4 canonical URI/query rebuilt from decoded values — duplicates indistinguishable" - "No RFC 1123 Date fallback; canonical header whitespace collapsing not applied" missing: diff --git a/internal/engine/aws_s3_style_test.go b/internal/engine/aws_s3_style_test.go index ca8047c..7fede88 100644 --- a/internal/engine/aws_s3_style_test.go +++ b/internal/engine/aws_s3_style_test.go @@ -891,3 +891,245 @@ func TestAWSS3StyleBinaryRoundTrip(t *testing.T) { t.Fatalf("HEAD Content-Length = %q, want %d", hdr.Header.Get("Content-Length"), len(bin)) } } + +// TestAWSS3Conditionals exercises S3 conditional preconditions: +// PUT/DELETE ETag-only, GET/HEAD full with timestamps, 412/304 codes. +func TestAWSS3Conditionals(t *testing.T) { + adapterDir, err := filepath.Abs(filepath.Join("..", "..", "adapters", "aws-s3-style")) + if err != nil { + t.Fatal(err) + } + stateDir := t.TempDir() + vc := clock.NewVirtualClock(time.Date(2026, 1, 20, 12, 0, 0, 0, time.UTC)) + m := &manifest.Manifest{ + Path: filepath.Join(stateDir, "stunt.yaml"), + Version: 1, + Network: manifest.Network{Mode: "port", BasePort: 0}, + Services: map[string]manifest.Service{ + "s3": {Adapter: adapterDir}, + }, + } + e, err := New(m, WithClock(vc)) + if err != nil { + t.Fatalf("engine.New: %v", err) + } + defer e.Close() + addrs, cancel, err := e.ServeForTest(context.Background()) + if err != nil { + t.Fatalf("ServeForTest: %v", err) + } + defer cancel() + time.Sleep(50 * time.Millisecond) + base := addrs["s3"] + now := vc.Now() + + doCond := func(method, rawurl string, body []byte, cond map[string]string) (string, int, http.Header) { + t.Helper() + req := s3SignedReq(t, method, rawurl, body, now, awsStyleAccessKey, awsStyleSecretKey) + for k, v := range cond { + req.Header.Set(k, v) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return string(b), resp.StatusCode, resp.Header + } + + if _, status := s3Put(t, base+"/condbucket", nil, now); status != 200 { + t.Fatalf("create bucket -> %d", status) + } + + // The ETag algorithm is covered by its own test; here the returned value + // is just the validator these preconditions are compared against. + etag, status := s3PutETag(t, base+"/condbucket/file.txt", []byte("hello"), now) + if status != 200 { + t.Fatalf("put file -> %d", status) + } + if !strings.HasPrefix(etag, `"`) || !strings.HasSuffix(etag, `"`) { + t.Fatalf("put file ETag = %q, want a quoted validator", etag) + } + wrongETag := `"00000000000000000000000000000000"` + + lastModStr := vc.Now().UTC().Format(http.TimeFormat) + futureStr := vc.Now().Add(48 * time.Hour).UTC().Format(http.TimeFormat) + pastStr := vc.Now().Add(-48 * time.Hour).UTC().Format(http.TimeFormat) + // single-digit future day + UTC/UT variants + TAB OWS + month-ci + singleDigitFuture := vc.Now().Add(15 * 24 * time.Hour).UTC().Format("Mon, 2 Jan 2006 15:04:05 GMT") + _ = singleDigitFuture + utcStr := vc.Now().UTC().Format("Mon, 02 Jan 2006 15:04:05 UTC") + utStr := vc.Now().UTC().Format("Mon, 02 Jan 2006 15:04:05 UT") + tabStr := "Tue,\t20 Jan 2026 12:00:00 GMT" + multiSPStr := "Tue, 20 Jan 2026 12:00:00 GMT" + monthCIStr := "Tue, 20 JAN 2026 12:00:00 GMT" + weekdayIgnoreFuture := "Frobnicate, " + vc.Now().Add(48*time.Hour).UTC().Format("02 Jan 2006 15:04:05 GMT") + feb30Str := "Mon, 30 Feb 2026 00:00:00 GMT" + feb29NonLeap := "Sun, 29 Feb 2026 12:00:00 GMT" + feb29LeapFuture := "Sat, 29 Feb 2028 12:00:00 GMT" + + // PUT If-None-Match:* on existing -> 412 + if body, st, _ := doCond("PUT", base+"/condbucket/file.txt", []byte("hello"), map[string]string{"If-None-Match": "*"}); st != 412 { + t.Fatalf("put conflict INM:* -> status %d, want 412; body %s", st, body) + } + // PUT If-Match wrong -> 412 + if body, st, _ := doCond("PUT", base+"/condbucket/file.txt", []byte("hello"), map[string]string{"If-Match": wrongETag}); st != 412 { + t.Fatalf("put If-Match wrong -> status %d, want 412; body %s", st, body) + } + // PUT missing + If-Match:* -> 412 + if body, st, _ := doCond("PUT", base+"/condbucket/missing.txt", []byte("x"), map[string]string{"If-Match": "*"}); st != 412 { + t.Fatalf("put missing If-Match:* -> status %d, want 412; body %s", st, body) + } + // PUT missing + INM:* -> 200 (create allowed) + if body, st, _ := doCond("PUT", base+"/condbucket/created.txt", []byte("x"), map[string]string{"If-None-Match": "*"}); st != 200 { + t.Fatalf("put missing INM:* -> status %d, want 200; body %s", st, body) + } + // DELETE missing no-cond -> 204 + if _, st, _ := doCond("DELETE", base+"/condbucket/nokey.txt", nil, nil); st != 204 { + t.Fatalf("delete missing no-cond -> status %d, want 204", st) + } + // DELETE missing + If-Match -> 412 + if body, st, _ := doCond("DELETE", base+"/condbucket/nokey2.txt", nil, map[string]string{"If-Match": "*"}); st != 412 { + t.Fatalf("delete missing If-Match -> status %d, want 412; body %s", st, body) + } + // DELETE existing wrong If-Match -> 412 (object must survive) + if body, st, _ := doCond("DELETE", base+"/condbucket/file.txt", nil, map[string]string{"If-Match": wrongETag}); st != 412 { + t.Fatalf("delete If-Match wrong -> status %d, want 412; body %s", st, body) + } + // GET If-None-Match:etag -> 304 empty + ETag/LM/req-id, no CT + body, st, hdr := doCond("GET", base+"/condbucket/file.txt", nil, map[string]string{"If-None-Match": etag}) + if st != 304 { + t.Fatalf("get INM etag -> status %d, want 304; body %s", st, body) + } + if body != "" { + t.Fatalf("get 304 body = %q, want empty", body) + } + if hdr.Get("ETag") != etag { + t.Fatalf("get 304 ETag = %q, want %q", hdr.Get("ETag"), etag) + } + if hdr.Get("Last-Modified") == "" { + t.Fatal("get 304 missing Last-Modified") + } + if hdr.Get("x-amz-request-id") == "" { + t.Fatal("get 304 missing x-amz-request-id") + } + if hdr.Get("Content-Type") != "" { + t.Fatalf("get 304 Content-Type = %q, want empty", hdr.Get("Content-Type")) + } + // GET If-None-Match:* on existing -> 304 + if _, st, _ := doCond("GET", base+"/condbucket/file.txt", nil, map[string]string{"If-None-Match": "*"}); st != 304 { + t.Fatalf("get INM:* -> status %d, want 304", st) + } + // GET If-Match wrong -> 412 PreconditionFailed + body, st, hdr = doCond("GET", base+"/condbucket/file.txt", nil, map[string]string{"If-Match": wrongETag}) + if st != 412 { + t.Fatalf("get If-Match wrong -> status %d, want 412; body %s", st, body) + } + if !strings.Contains(body, "PreconditionFailed") { + t.Fatalf("get 412 missing PreconditionFailed; body %s", body) + } + if !strings.Contains(body, "If-Match") { + t.Fatalf("get 412 missing Condition If-Match; body %s", body) + } + if hdr.Get("Content-Type") != "application/xml" { + t.Fatalf("get 412 CT = %q, want application/xml", hdr.Get("Content-Type")) + } + if hdr.Get("x-amz-request-id") == "" { + t.Fatal("get 412 missing x-amz-request-id") + } + // GET missing + If-Match -> 404 (not 412) + if body, st, _ := doCond("GET", base+"/condbucket/nokey3.txt", nil, map[string]string{"If-Match": "*"}); st != 404 { + t.Fatalf("get missing If-Match -> status %d, want 404; body %s", st, body) + } + // malformed ETag ignored -> 200 + if body, st, _ := doCond("GET", base+"/condbucket/file.txt", nil, map[string]string{"If-Match": "W/"}); st != 200 { + t.Fatalf("get malformed If-Match W/ -> status %d, want 200; body %s", st, body) + } + if body, st, _ := doCond("GET", base+"/condbucket/file.txt", nil, map[string]string{"If-Modified-Since": "not-a-date"}); st != 200 { + t.Fatalf("get malformed IMS -> status %d, want 200; body %s", st, body) + } + // combined If-Match fail + INM match -> 412 wins (not 304) + if body, st, _ := doCond("GET", base+"/condbucket/file.txt", nil, map[string]string{"If-Match": wrongETag, "If-None-Match": etag}); st != 412 { + t.Fatalf("get combined If-Match-fail+INM -> status %d, want 412; body %s", st, body) + } + // *-in-list match-any + if _, st, _ := doCond("GET", base+"/condbucket/file.txt", nil, map[string]string{"If-None-Match": `*, "abc"`}); st != 304 { + t.Fatalf("get INM *-in-list -> status %d, want 304", st) + } + // quoted-empty no-match -> 200 + if body, st, _ := doCond("GET", base+"/condbucket/file.txt", nil, map[string]string{"If-None-Match": `""`}); st != 200 { + t.Fatalf("get INM quoted-empty -> status %d, want 200; body %s", st, body) + } + // W/ weak -> strong match -> 200 for If-Match (pass) + weak := `W/` + etag + if body, st, _ := doCond("GET", base+"/condbucket/file.txt", nil, map[string]string{"If-Match": weak}); st != 200 { + t.Fatalf("get If-Match W/etag -> status %d, want 200; body %s", st, body) + } + // IMS equal -> 304, past -> 200, future -> 304 + if _, st, _ := doCond("GET", base+"/condbucket/file.txt", nil, map[string]string{"If-Modified-Since": lastModStr}); st != 304 { + t.Fatalf("get IMS equal -> status %d, want 304", st) + } + if body, st, _ := doCond("GET", base+"/condbucket/file.txt", nil, map[string]string{"If-Modified-Since": pastStr}); st != 200 { + t.Fatalf("get IMS past -> status %d, want 200; body %s", st, body) + } + if _, st, _ := doCond("GET", base+"/condbucket/file.txt", nil, map[string]string{"If-Modified-Since": futureStr}); st != 304 { + t.Fatalf("get IMS future -> status %d, want 304", st) + } + // IUS past -> 412, future -> 200, equal -> 200 + if body, st, _ := doCond("GET", base+"/condbucket/file.txt", nil, map[string]string{"If-Unmodified-Since": pastStr}); st != 412 { + t.Fatalf("get IUS past -> status %d, want 412; body %s", st, body) + } + if body, st, _ := doCond("GET", base+"/condbucket/file.txt", nil, map[string]string{"If-Unmodified-Since": futureStr}); st != 200 { + t.Fatalf("get IUS future -> status %d, want 200; body %s", st, body) + } + if body, st, _ := doCond("GET", base+"/condbucket/file.txt", nil, map[string]string{"If-Unmodified-Since": lastModStr}); st != 200 { + t.Fatalf("get IUS equal -> status %d, want 200; body %s", st, body) + } + // future/weekday-ignore/Feb30-ignore/legacy + if _, st, _ := doCond("GET", base+"/condbucket/file.txt", nil, map[string]string{"If-Modified-Since": weekdayIgnoreFuture}); st != 304 { + t.Fatalf("get IMS weekday-ignore future -> status %d, want 304", st) + } + if body, st, _ := doCond("GET", base+"/condbucket/file.txt", nil, map[string]string{"If-Modified-Since": feb30Str}); st != 200 { + t.Fatalf("get IMS Feb30 -> status %d, want 200; body %s", st, body) + } + if body, st, _ := doCond("GET", base+"/condbucket/file.txt", nil, map[string]string{"If-Modified-Since": feb29NonLeap}); st != 200 { + t.Fatalf("get IMS Feb29-nonleap -> status %d, want 200; body %s", st, body) + } + if _, st, _ := doCond("GET", base+"/condbucket/file.txt", nil, map[string]string{"If-Modified-Since": feb29LeapFuture}); st != 304 { + t.Fatalf("get IMS Feb29-leap-future -> status %d, want 304", st) + } + if body, st, _ := doCond("GET", base+"/condbucket/file.txt", nil, map[string]string{"If-Modified-Since": "0"}); st != 200 { + t.Fatalf("get IMS legacy 0 -> status %d, want 200; body %s", st, body) + } + if _, st, _ := doCond("GET", base+"/condbucket/file.txt", nil, map[string]string{"If-Modified-Since": utcStr}); st != 304 { + t.Fatalf("get IMS UTC -> status %d, want 304", st) + } + if _, st, _ := doCond("GET", base+"/condbucket/file.txt", nil, map[string]string{"If-Modified-Since": utStr}); st != 304 { + t.Fatalf("get IMS UT -> status %d, want 304", st) + } + if _, st, _ := doCond("GET", base+"/condbucket/file.txt", nil, map[string]string{"If-Modified-Since": tabStr}); st != 304 { + t.Fatalf("get IMS TAB -> status %d, want 304", st) + } + if _, st, _ := doCond("GET", base+"/condbucket/file.txt", nil, map[string]string{"If-Modified-Since": multiSPStr}); st != 304 { + t.Fatalf("get IMS multi-SP -> status %d, want 304", st) + } + if _, st, _ := doCond("GET", base+"/condbucket/file.txt", nil, map[string]string{"If-Modified-Since": monthCIStr}); st != 304 { + t.Fatalf("get IMS month-ci -> status %d, want 304", st) + } + // PUT timestamps ignored (ETags-only) + if body, st, _ := doCond("PUT", base+"/condbucket/file.txt", []byte("hello"), map[string]string{"If-Unmodified-Since": pastStr}); st != 200 { + t.Fatalf("put IUS past ignored -> status %d, want 200; body %s", st, body) + } + // HEAD mirrors GET + if _, st, _ := doCond("HEAD", base+"/condbucket/file.txt", nil, map[string]string{"If-None-Match": etag}); st != 304 { + t.Fatalf("head INM etag -> status %d, want 304", st) + } + if body, st, _ := doCond("HEAD", base+"/condbucket/file.txt", nil, map[string]string{"If-Match": wrongETag}); st != 412 { + t.Fatalf("head If-Match wrong -> status %d, want 412; body %s", st, body) + } + // non-object ignore: List with If-Match wrong still 200 + if body, st, _ := doCond("GET", base+"/condbucket?list-type=2", nil, map[string]string{"If-Match": wrongETag}); st != 200 { + t.Fatalf("list with If-Match -> status %d, want 200; body %s", st, body) + } +}