Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 4 additions & 2 deletions CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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

Expand Down
17 changes: 17 additions & 0 deletions adapters/aws-s3-style/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Condition>` and `<Key>`. `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
Expand Down Expand Up @@ -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 |
Expand Down
257 changes: 257 additions & 0 deletions adapters/aws-s3-style/scripts/lib.star
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<?xml version="1.0" encoding="UTF-8"?>\n'
xml = xml + "<Error><Code>PreconditionFailed</Code>"
xml = xml + "<Message>At least one of the pre-conditions you specified did not hold</Message>"
xml = xml + "<Condition>" + _xml_escape(condition) + "</Condition>"
xml = xml + "<Key>" + _xml_escape(key) + "</Key>"
xml = xml + "<RequestId>" + _xml_escape(rid) + "</RequestId></Error>"
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
18 changes: 18 additions & 0 deletions adapters/aws-s3-style/scripts/objects.star
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = ""
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions conformance/matrix.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
],
Expand Down
2 changes: 2 additions & 0 deletions conformance/matrix.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading