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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ All notable changes to **stunt** are documented here. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Adapters

- **BREAKING (test double): aws-s3-style ETags are now real MD5.**
Single-object and part ETags are the MD5 hex of the verbatim bytes and
multipart objects use `md5(concat part-md5 binaries)-N`, like real S3.
Fixtures pinning the old 64-hex SHA-256 ETags must be regenerated; abort
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.

## [0.52.0] — 2026-08-24

The conformance campaign: every real API adapter now carries a real test
Expand Down
2 changes: 1 addition & 1 deletion CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2600,7 +2600,7 @@ behavior notes live in each adapter's README.

**Deviations** (5)

- ETags are SHA-256-based (real S3 uses MD5); multipart ETag is sha256(etags)-N
- 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)
- SigV4 canonical URI/query rebuilt from decoded values — duplicates indistinguishable
Expand Down
21 changes: 9 additions & 12 deletions adapters/aws-s3-style/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ The real S3 multipart upload protocol, stateful on the object store:
| Method | Route | Description |
|--------|-------|-------------|
| POST | `/{bucket}/{key}?uploads` | CreateMultipartUpload → XML `<UploadId>` |
| PUT | `/{bucket}/{key}?partNumber=N&uploadId=...` | UploadPart → `ETag` header (quoted SHA-256 of the part bytes) |
| PUT | `/{bucket}/{key}?partNumber=N&uploadId=...` | UploadPart → `ETag` header (quoted MD5 hex of the part bytes) |
| GET | `/{bucket}/{key}?uploadId=...` | ListParts XML (`max-parts` / `part-number-marker` paging) |
| POST | `/{bucket}/{key}?uploadId=...` | CompleteMultipartUpload (XML body listing the parts) |
| DELETE | `/{bucket}/{key}?uploadId=...` | AbortMultipartUpload → 204 |
Expand All @@ -83,10 +83,8 @@ Semantics enforced like the real service:
real paging (`max-parts`, default 1000; `part-number-marker` →
`<NextPartNumberMarker>`/`<IsTruncated>`).

Documented deviations: part ETags and the assembled object's ETag are
SHA-256 based (`sha256(concat part etags)-N` for the multipart object, like
real S3's `md5(md5s)-N` shape), and the 5 MiB minimum part size is **not**
enforced so small chunks can be exercised in local tests.
Documented deviations: the 5 MiB minimum part size is **not** enforced so
small chunks can be exercised in local tests.

ListObjectsV2 also honors the real S3 list params:

Expand Down Expand Up @@ -190,10 +188,9 @@ unknown access key yields `InvalidAccessKeyId`; a stale `x-amz-date` yields

### Clock-derived response data

- **ETag** is content-derived: the quoted SHA-256 hex digest of the object's
verbatim bytes (real S3 uses the MD5 digest for non-multipart uploads; the
engine's crypto module has no MD5, so the stronger digest is used — a
documented deviation).
- **ETag** is content-derived: the quoted MD5 hex digest of the object's
verbatim bytes, as in real S3. Multipart objects use
`md5(concat part-md5 binaries)-N`.
- **Last-Modified** (GET/HEAD headers, RFC 1123) and `<LastModified>` (XML,
ISO 8601 with milliseconds) derive from the engine clock at upload time.
- Bucket `<CreationDate>` derives from the clock as well.
Expand Down Expand Up @@ -223,7 +220,7 @@ curl "http://localhost:PORT/mybucket?list-type=2"
# Paginated listing
curl "http://localhost:PORT/mybucket?list-type=2&max-keys=10&continuation-token=<NextContinuationToken>"

# Binary round-trip (bytes stored verbatim, ETag = quoted sha256 of the bytes)
# Binary round-trip (bytes stored verbatim, ETag = quoted MD5 of the bytes)
curl -X PUT "http://localhost:PORT/mybucket/photo.jpg" \
-H "Authorization: AWS4-HMAC-SHA256 ..." \
-H "Content-Type: image/jpeg" \
Expand All @@ -235,11 +232,11 @@ curl -X POST "http://localhost:PORT/mybucket/big.bin?uploads" -H "Authorization:
-H "x-amz-date: 20260120T000000Z"
# → <InitiateMultipartUploadResult>...<UploadId>mpu_1</UploadId></InitiateMultipartUploadResult>
curl -X PUT "http://localhost:PORT/mybucket/big.bin?partNumber=1&uploadId=mpu_1" \
-H "Authorization: ..." --data-binary @part1.bin # → ETag: "sha256-of-part1"
-H "Authorization: ..." --data-binary @part1.bin # → ETag: "md5-of-part1"
curl -X POST "http://localhost:PORT/mybucket/big.bin?uploadId=mpu_1" \
-H "Authorization: ..." \
-d '<CompleteMultipartUpload><Part><PartNumber>1</PartNumber><ETag>"..."</ETag></Part></CompleteMultipartUpload>'
# → <CompleteMultipartUploadResult>...<ETag>"sha256-of-etags-1"</ETag>...</CompleteMultipartUploadResult>
# → <CompleteMultipartUploadResult>...<ETag>"md5-of-part-md5s-1"</ETag>...</CompleteMultipartUploadResult>
```

## Error responses
Expand Down
51 changes: 38 additions & 13 deletions adapters/aws-s3-style/scripts/lib.star
Original file line number Diff line number Diff line change
Expand Up @@ -739,8 +739,9 @@ def _find_object(bucket, key):
# _upsert_object writes an object's content bytes (reusing the existing
# blob id when overwriting, so the blob store has one file per object) and
# refreshes its metadata doc. Returns nothing; the ETag is derived by the
# caller (it differs for simple vs multipart uploads).
def _upsert_object(bucket, key, raw, ct, etag):
# caller (it differs for simple vs multipart uploads). meta is the
# user-metadata map (x-amz-meta-*); {} when none.
def _upsert_object(bucket, key, raw, ct, etag, meta):
oc = store_collection("objects")
bid = ""
obj_id = ""
Expand All @@ -758,6 +759,7 @@ def _upsert_object(bucket, key, raw, ct, etag):
"bid": bid,
"contentType": ct,
"etag": etag,
"metadata": meta,
"lastModified": _unix_to_iso8601(now_unix),
"lastModifiedUnix": now_unix,
"size": len(raw),
Expand Down Expand Up @@ -787,10 +789,10 @@ def _upsert_object(bucket, key, raw, ct, etag):
# a non-ascending part list → 400 InvalidPartOrder.
# - Completion assembles the parts, in ascending part-number order,
# into the object; abort discards every part and creates nothing.
# - Documented deviations: part ETags are SHA-256 digests (the crypto
# module has no MD5), the multipart object ETag is
# sha256(concat part etags)-N, and the 5 MiB minimum part size is
# NOT enforced so small chunks can be exercised in tests.
# - Documented deviations: part ETags are MD5 digests (like real S3),
# the multipart object ETag is md5(concat part-md5 binaries)-N, and
# the 5 MiB minimum part size is NOT enforced so small chunks can be
# exercised in tests.

# Real S3 allows part numbers 1..10k (assembled to keep digit runs short).
_MPU_MAX_PART_NUMBER = 10 * 1000
Expand Down Expand Up @@ -871,7 +873,7 @@ def _mpu_create(req, bucket, key):

# _mpu_upload_part handles PUT /{bucket}/{key}?partNumber=N&uploadId=... —
# stores the part bytes (out-of-order and re-uploads both fine) and returns
# the part ETag (SHA-256 of the verbatim part bytes).
# the part ETag (MD5 of the verbatim part bytes, like real S3).
def _mpu_upload_part(req, bucket, key):
part_raw = _query_val(req, "partNumber")
upload_id = _query_val(req, "uploadId")
Expand All @@ -896,7 +898,7 @@ def _mpu_upload_part(req, bucket, key):
raw = req.get("raw_body", "")
if raw == None:
raw = ""
etag = crypto.sha256(raw)
etag = crypto.md5(raw)

# One blob per (upload, part number); re-uploading a part overwrites it.
bid = upload_id + "_p" + str(n)
Expand Down Expand Up @@ -973,6 +975,16 @@ def _strip_quotes(s):
out = out + s[i]
return out

# _strip_weak normalizes a Complete ETag for comparison: strips one W/
# prefix, removes quotes, lowercases (uppercase hex accepted, like real
# S3; Complete is strict otherwise — no whitespace trim).
def _strip_weak(s):
if s == None:
return ""
if _has_prefix(s, "W/"):
s = s[2:]
return _strip_quotes(s).lower()

# _mpu_parse_complete parses the CompleteMultipartUpload XML body into an
# ordered [(part_number, etag), ...] list, or None when malformed.
def _mpu_parse_complete(raw):
Expand Down Expand Up @@ -1037,32 +1049,45 @@ def _mpu_complete(req, bucket, key):
prev = n

# Every listed part must exist with a matching ETag (real S3: InvalidPart).
# Both sides are normalized (W/ prefix, quotes, case) before comparing;
# an empty request ETag never matches (always checked, no bypass).
for entry in listed:
n = entry[0]
etag_req = entry[1]
row = stored.get(n, None)
if row == None:
return _xml_error("InvalidPart", "One or more of the specified parts could not be found. The part may not have been uploaded, or the specified entity tag may not match the part's entity tag.", "/" + bucket + "/" + key, 400)
if etag_req != "" and etag_req != row.get("etag", ""):
if _strip_weak(etag_req) != _strip_weak(row.get("etag", "")):
return _xml_error("InvalidPart", "One or more of the specified parts could not be found. The part may not have been uploaded, or the specified entity tag may not match the part's entity tag.", "/" + bucket + "/" + key, 400)

# Assemble: concatenate the part blobs in ascending part-number order.
b = store_blob("s3-objects")
full = ""
concat_etags = ""
hexes = []
for entry in listed:
row = stored[entry[0]]
content = b.get(row.get("bid", ""))
if content == None:
content = ""
full = full + content
concat_etags = concat_etags + row.get("etag", "")
etag = crypto.sha256(concat_etags) + "-" + str(len(listed))
hexes.append(_strip_weak(row.get("etag", "")))
# Guarded pre-check (type first — never bare len() on a non-string,
# which would 500): any shape mismatch → 400 InvalidPart without
# calling the builtin. The builtin is total too (None on mismatch).
if len(hexes) == 0 or len(hexes) > 10000:
return _xml_error("InvalidPart", "One or more of the specified parts could not be found. The part may not have been uploaded, or the specified entity tag may not match the part's entity tag.", "/" + bucket + "/" + key, 400)
for h in hexes:
if type(h) != "string" or len(h) != 32 or not _is_hex(h):
return _xml_error("InvalidPart", "One or more of the specified parts could not be found. The part may not have been uploaded, or the specified entity tag may not match the part's entity tag.", "/" + bucket + "/" + key, 400)
digest = crypto.md5_hex_concat(hexes)
if digest == None:
return _xml_error("InvalidPart", "One or more of the specified parts could not be found. The part may not have been uploaded, or the specified entity tag may not match the part's entity tag.", "/" + bucket + "/" + key, 400)
etag = digest + "-" + str(len(hexes))

ct = upload.get("contentType", "application/octet-stream")
if ct == None or ct == "":
ct = "application/octet-stream"
_upsert_object(bucket, key, full, ct, etag)
_upsert_object(bucket, key, full, ct, etag, {})

_mpu_discard(upload_id)
store_collection("mpu_uploads").delete(upload_id)
Expand Down
14 changes: 7 additions & 7 deletions adapters/aws-s3-style/scripts/objects.star
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@
# scripts/lib.star. The POST multipart entry point lives in
# scripts/multipart.star.

# _etag derives the object ETag from the content itself: the SHA-256 hex
# digest of the raw body (real S3 uses the MD5 digest for non-multipart
# uploads; the crypto module has no MD5, so the stronger digest is used —
# documented deviation). Returned/stored unquoted; rendered quoted.
# _etag derives the object ETag from the content itself: the MD5 hex
# digest of the raw body, like real S3 for non-multipart uploads.
# (MD5 here is a compat checksum only, never auth/integrity.)
# Returned/stored unquoted; rendered quoted.
def _etag(raw):
return crypto.sha256(raw)
return crypto.md5(raw)

# _obj_last_modified_rfc1123 renders the stored upload time as an RFC
# 1123 Last-Modified header value (falls back to the current clock for
Expand Down Expand Up @@ -84,10 +84,10 @@ def on_put_object(req):
if ct == None:
ct = "application/octet-stream"

# Content-derived ETag (SHA-256 of the verbatim bytes); the write path
# Content-derived ETag (MD5 of the verbatim bytes); the write path
# (blob + metadata doc) is shared with CompleteMultipartUpload.
etag = _etag(raw)
_upsert_object(bucket, key, raw, ct, etag)
_upsert_object(bucket, key, raw, ct, etag, {})

return respond(200, "", {
"ETag": '"' + etag + '"',
Expand Down
2 changes: 1 addition & 1 deletion conformance/matrix.json
Original file line number Diff line number Diff line change
Expand Up @@ -9380,7 +9380,7 @@
"No ListMultipartUploads (GET /{bucket}?uploads)"
],
"deviations": [
"ETags are SHA-256-based (real S3 uses MD5); multipart ETag is sha256(etags)-N",
"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)",
"SigV4 canonical URI/query rebuilt from decoded values — duplicates indistinguishable",
Expand Down
2 changes: 1 addition & 1 deletion conformance/matrix.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ adapters:
- "No AssumeRoleWithSAML"
aws-s3-style:
deviations:
- "ETags are SHA-256-based (real S3 uses MD5); multipart ETag is sha256(etags)-N"
- "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)"
- "SigV4 canonical URI/query rebuilt from decoded values — duplicates indistinguishable"
Expand Down
29 changes: 22 additions & 7 deletions internal/engine/aws_s3_style_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"crypto/hmac"
"crypto/md5"
"crypto/sha256"
"encoding/hex"
"fmt"
Expand Down Expand Up @@ -37,6 +38,11 @@ func awsSHA256Hex(b []byte) string {
return hex.EncodeToString(sum[:])
}

func awsMD5Hex(b []byte) string {
sum := md5.Sum(b)
return hex.EncodeToString(sum[:])
}

func awsHMACSHA256(key, data []byte) []byte {
h := hmac.New(sha256.New, key)
h.Write(data)
Expand Down Expand Up @@ -247,12 +253,21 @@ func TestAwsS3StyleAdapter(t *testing.T) {
if status != 200 {
t.Fatalf("put object -> status %d, want 200", status)
}
// ETag is content-derived: the quoted SHA-256 hex digest of the bytes.
wantETag := `"` + awsSHA256Hex([]byte(uploadContent)) + `"`
// ETag is content-derived: the quoted MD5 hex digest of the bytes.
wantETag := `"` + awsMD5Hex([]byte(uploadContent)) + `"`
if etag != wantETag {
t.Fatalf("put object ETag = %q, want %q", etag, wantETag)
}

// md5("hello") = 5d41402abc4b2a76b9719d911017c592 (S3 compat checksum).
helloETag, status := s3PutETag(t, base+"/mybucket/hello.txt", []byte("hello"), now)
if status != 200 {
t.Fatalf("put hello -> status %d, want 200", status)
}
if helloETag != `"5d41402abc4b2a76b9719d911017c592"` {
t.Fatalf("put hello ETag = %q, want %q", helloETag, `"5d41402abc4b2a76b9719d911017c592"`)
}

// ===== ListObjectsV2 shows the uploaded object (STATEFUL) =====

body, status = s3Get(t, base+"/mybucket?list-type=2", now)
Expand Down Expand Up @@ -579,7 +594,7 @@ func s3Delete(t *testing.T, rawurl string, at time.Time) *http.Response {
//
// - POST ?uploads → 200 InitiateMultipartUploadResult with an UploadId
// - UploadPart (out of order: 3, then 1, then 2) → per-part ETags that
// equal the quoted SHA-256 of the part bytes
// equal the quoted MD5 of the part bytes
// - ListParts → parts in ascending order with max-parts /
// part-number-marker paging
// - CompleteMultipartUpload with a missing part → 400 InvalidPart
Expand Down Expand Up @@ -652,7 +667,7 @@ func TestAwsS3StyleMultipartUpload(t *testing.T) {
if st != 200 {
t.Fatalf("upload part %d -> %d", tc.n, st)
}
want := `"` + awsSHA256Hex(tc.data) + `"`
want := `"` + awsMD5Hex(tc.data) + `"`
if etag != want {
t.Fatalf("upload part %d ETag = %q, want %q", tc.n, etag, want)
}
Expand Down Expand Up @@ -705,7 +720,7 @@ func TestAwsS3StyleMultipartUpload(t *testing.T) {
}

// ===== Complete: wrong part ETag → 400 InvalidPart =====
wrongEtag := s3CompleteBody([][2]string{{"1", etags[1]}, {"2", strings.Repeat("0", 64)}, {"3", etags[3]}})
wrongEtag := s3CompleteBody([][2]string{{"1", etags[1]}, {"2", strings.Repeat("0", 32)}, {"3", etags[3]}})
body, status = s3Post(t, partURL, []byte(wrongEtag), now)
if status != 400 || !strings.Contains(body, "InvalidPart") {
t.Fatalf("complete with wrong etag -> %d %q, want 400 InvalidPart", status, body)
Expand Down Expand Up @@ -858,8 +873,8 @@ func TestAWSS3StyleBinaryRoundTrip(t *testing.T) {
if status != 200 {
t.Fatalf("put binary -> %d", status)
}
if etag != `"`+awsSHA256Hex(bin)+`"` {
t.Fatalf("binary ETag = %q, want quoted sha256 of the bytes", etag)
if etag != `"`+awsMD5Hex(bin)+`"` {
t.Fatalf("binary ETag = %q, want quoted md5 of the bytes", etag)
}

got, status := s3Get(t, base+"/mybucket/bin.dat", now)
Expand Down
Loading
Loading