Skip to content

Commit f4d7604

Browse files
frristclaude
andcommitted
feat(s3frontend): S3 surface — HEAD Range/Expires, ?partNumber, default checksum
- HEAD honors Range (206/Content-Range, 416 on unsatisfiable); carry the Expires header - GET/HEAD by ?partNumber=N (part byte-span + x-amz-mp-parts-count) - server-computed default CRC64NVME checksum when the client names none Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 199114c commit f4d7604

10 files changed

Lines changed: 436 additions & 34 deletions

File tree

bucket/cbor_gen.go

Lines changed: 122 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

bucket/manifest.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ type ObjectManifest struct {
3939
ContentLanguage string `cborgen:"cl"`
4040
CacheControl string `cborgen:"cc"`
4141

42+
// Expires is the HTTP `Expires` caching header (RFC 7234) carried through
43+
// PUT and replayed verbatim on GET/HEAD. Despite the name it is NOT object
44+
// lifecycle/TTL — it never deletes the object; it is a passthrough system
45+
// header like CacheControl. (Real S3 Lifecycle expiration is a separate,
46+
// unimplemented feature — see docs/architecture.md §12.)
47+
Expires string `cborgen:"ex"`
48+
4249
// Metadata is the user metadata map (the x-amz-meta-* headers, with
4350
// the prefix stripped and keys lower-cased by the S3 layer). Nil when
4451
// the object carries no user metadata.
@@ -60,6 +67,15 @@ type Body struct {
6067
MD5 []byte `cborgen:"m"`
6168
Blobs []BlobRef `cborgen:"bl"`
6269

70+
// PartSizes records the byte length of each multipart part, in upload order,
71+
// segmenting [0, Size) into the parts the client completed. It lets a GET/HEAD
72+
// with ?partNumber=N return part N's byte span and the x-amz-mp-parts-count
73+
// header (the Blobs list alone cannot, since a part may span several blobs or
74+
// share a blob boundary). Nil for a single-PUT object, which has no parts — a
75+
// ?partNumber=1 there addresses the whole object and omits the parts count.
76+
// The sum of PartSizes equals Size. See docs/architecture.md §7.2.
77+
PartSizes []int64 `cborgen:"ps"`
78+
6379
// IndexRoot is reserved (nullable) for a future UnixFS + sharded-dag-index
6480
// record that would make a multi-shard object reassemblable without Ingot
6581
// ("credible exit"). It is unused this iteration; the flat Blobs list is

docs/architecture.md

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,9 @@ AbortMultipartUpload
429429
```
430430
431431
A completed multipart object is the ordered union of its parts' blobs plus a manifest of byte ranges;
432-
parts are not restitched.
432+
parts are not restitched. The commit also records each part's byte length (`Body.PartSizes`), so a
433+
later `GET`/`HEAD ?partNumber=N` can resolve part `N` to its byte span and report
434+
`x-amz-mp-parts-count` without re-deriving boundaries from the blob list.
433435
434436
### 7.3 The session latch (the Abort/Complete race)
435437
@@ -750,9 +752,17 @@ The storage / delete / retrieval core is built and tested:
750752
- **Dedup + reference-counted delete** ([§5](#5-the-data-layer)–[§6](#6-the-forgechain-layer)) — `blob_refs`, overwrite-in-place, `DeleteObject`,
751753
`remove(digest)` at zero claims, `gc_candidates` (write-only).
752754
- **S3 correctness** ([§3](#3-the-s3-layer)) — conditional requests (`If-Match`/`If-None-Match`/`If-(Un)Modified-Since`,
753-
re-checked at commit), additional checksums (`x-amz-checksum-*` validate + echo), `DeleteObjects`,
755+
re-checked at commit), additional checksums (`x-amz-checksum-*` validate + echo, with a
756+
server-computed full-object `CRC64NVME` default when the client names none — S3's
757+
default-checksum-on-store), `DeleteObjects`,
754758
metadata-only `CopyObject`, multipart (`Create`/`UploadPart`/`Complete`/`Abort` with the
755-
single-winner latch and the `-N` ETag), zero-byte objects.
759+
single-winner latch and the `-N` ETag), zero-byte objects. Ranged reads return `206`/`Content-Range`
760+
on **both** `GetObject` and `HeadObject` (`416` on an unsatisfiable range); the system headers
761+
carried through PUT and replayed on GET/HEAD include `Content-Encoding`/`Disposition`/`Language`,
762+
`Cache-Control`, and the `Expires` caching header. GET/HEAD also accept `?partNumber=N`: a completed
763+
multipart object records each part's byte length on the manifest (`Body.PartSizes`), so part `N`
764+
resolves to its byte span (`206` + `Content-Range` + `x-amz-mp-parts-count`); a single-PUT object
765+
exposes the whole body as part 1 (no parts-count), and `N` past the part count is `416`.
756766
757767
### Deferred by deliberate scope decision
758768
@@ -774,6 +784,15 @@ These are intentional simplifications of the target topology, not bugs:
774784
are recorded in a local Postgres `blob_locations` table behind a `Locator` seam, rather than read
775785
back through the indexing-service ([§5](#5-the-data-layer), [§8](#8-retrieval-addressing-when-bodies-need-a-sharded-dag-index)). The indexer-backed `Locator` is an `indexer-ready` swap-in.
776786
787+
### Object lifecycle (not implemented)
788+
789+
The `Expires` HTTP header *is* implemented — it is a passthrough caching header (RFC 7234) stored on
790+
the manifest and replayed on GET/HEAD, not a lifecycle control. True S3 **Lifecycle** management
791+
(`PutBucketLifecycleConfiguration` expiration/transition rules that actually delete or tier objects
792+
after N days, surfaced via the `x-amz-expiration` response header) is a separate, unimplemented
793+
feature. It would be its own subsystem — a per-bucket rule store plus a background sweeper driving the
794+
reference index — and is out of scope for this iteration.
795+
777796
### Deferred as forge-mode glue (validated live in smelt, not the in-process harness)
778797
779798
The in-memory harness uses a no-op uploader and serves reads from the spool, so these forge-network

s3frontend/checksum.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@ type checksumSpec struct {
1919
// checksumFromInput derives the checksum to compute from a PutObject request.
2020
// An explicit x-amz-checksum-<alg> value takes precedence (and is validated);
2121
// otherwise x-amz-checksum-algorithm selects the algorithm to compute and echo.
22-
// Returns nil when the request carries no additional checksum.
22+
// When the request names no checksum at all, the server falls back to a
23+
// full-object CRC64NVME — S3's default-checksum-on-store behavior, which clients
24+
// (and the SDK) expect to read back even when they sent nothing. That fallback
25+
// is computed, not validated, since there is no client value. So this never
26+
// returns a nil spec: every stored object carries at least a CRC64NVME.
2327
func checksumFromInput(in s3response.PutObjectInput) (*checksumSpec, error) {
2428
switch {
2529
case in.ChecksumSHA256 != nil:
@@ -39,7 +43,8 @@ func checksumFromInput(in s3response.PutObjectInput) (*checksumSpec, error) {
3943
}
4044
return &checksumSpec{in.ChecksumAlgorithm, ht, ""}, nil
4145
default:
42-
return nil, nil
46+
// No client checksum named → server-computed full-object CRC64NVME.
47+
return &checksumSpec{types.ChecksumAlgorithmCrc64nvme, utils.HashTypeCRC64NVME, ""}, nil
4348
}
4449
}
4550

s3frontend/checksum_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package s3frontend
2+
3+
import (
4+
"testing"
5+
6+
"github.com/aws/aws-sdk-go-v2/service/s3/types"
7+
"github.com/versity/versitygw/s3api/utils"
8+
"github.com/versity/versitygw/s3response"
9+
)
10+
11+
// TestChecksumFromInput_DefaultsToCRC64NVME locks S3's default-checksum-on-store
12+
// behavior: a PutObject that names no checksum still computes (without
13+
// validating) a full-object CRC64NVME, while an explicitly named algorithm or
14+
// value takes precedence.
15+
func TestChecksumFromInput_DefaultsToCRC64NVME(t *testing.T) {
16+
sha256 := "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="
17+
algoOnly := types.ChecksumAlgorithmCrc32
18+
19+
cases := []struct {
20+
name string
21+
in s3response.PutObjectInput
22+
wantAlgo types.ChecksumAlgorithm
23+
wantHash utils.HashType
24+
wantExpected string
25+
}{
26+
{
27+
name: "no checksum → default crc64nvme (compute, no validation)",
28+
in: s3response.PutObjectInput{},
29+
wantAlgo: types.ChecksumAlgorithmCrc64nvme,
30+
wantHash: utils.HashTypeCRC64NVME,
31+
wantExpected: "",
32+
},
33+
{
34+
name: "explicit value wins over the default",
35+
in: s3response.PutObjectInput{ChecksumSHA256: &sha256},
36+
wantAlgo: types.ChecksumAlgorithmSha256,
37+
wantHash: utils.HashTypeSha256,
38+
wantExpected: sha256,
39+
},
40+
{
41+
name: "named algorithm (no value) wins over the default",
42+
in: s3response.PutObjectInput{ChecksumAlgorithm: algoOnly},
43+
wantAlgo: types.ChecksumAlgorithmCrc32,
44+
wantHash: utils.HashTypeCRC32,
45+
wantExpected: "",
46+
},
47+
}
48+
for _, tc := range cases {
49+
t.Run(tc.name, func(t *testing.T) {
50+
spec, err := checksumFromInput(tc.in)
51+
if err != nil {
52+
t.Fatalf("unexpected err: %v", err)
53+
}
54+
if spec == nil {
55+
t.Fatal("spec = nil, want non-nil (every object carries at least a CRC64NVME)")
56+
}
57+
if spec.algo != tc.wantAlgo || spec.hashType != tc.wantHash || spec.expected != tc.wantExpected {
58+
t.Fatalf("got {algo=%s hash=%v expected=%q}, want {algo=%s hash=%v expected=%q}",
59+
spec.algo, spec.hashType, spec.expected, tc.wantAlgo, tc.wantHash, tc.wantExpected)
60+
}
61+
})
62+
}
63+
}

s3frontend/copy.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,13 +85,15 @@ func (b *Backend) CopyObject(ctx context.Context, input s3response.CopyObjectInp
8585
dstMf.ContentDisposition = backend.GetStringFromPtr(input.ContentDisposition)
8686
dstMf.ContentLanguage = backend.GetStringFromPtr(input.ContentLanguage)
8787
dstMf.CacheControl = backend.GetStringFromPtr(input.CacheControl)
88+
dstMf.Expires = backend.GetStringFromPtr(input.Expires)
8889
dstMf.Metadata = input.Metadata
8990
} else {
9091
dstMf.ContentType = srcMf.ContentType
9192
dstMf.ContentEncoding = srcMf.ContentEncoding
9293
dstMf.ContentDisposition = srcMf.ContentDisposition
9394
dstMf.ContentLanguage = srcMf.ContentLanguage
9495
dstMf.CacheControl = srcMf.CacheControl
96+
dstMf.Expires = srcMf.Expires
9597
dstMf.Metadata = srcMf.Metadata
9698
}
9799

s3frontend/multipart.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ func (b *Backend) CompleteMultipartUpload(ctx context.Context, input *s3.Complet
158158
// Validate the requested parts (ascending, each matching a recorded part by
159159
// number + ETag) and assemble the ordered body + the multipart ETag.
160160
var blobs []msbucket.BlobRef
161+
var partSizes []int64
161162
var offset int64
162163
etagHasher := md5.New()
163164
prev := 0
@@ -178,6 +179,9 @@ func (b *Backend) CompleteMultipartUpload(ctx context.Context, input *s3.Complet
178179
return s3response.CompleteMultipartUploadResult{}, "", s3err.GetAPIError(s3err.ErrInvalidPart)
179180
}
180181
etagHasher.Write(sp.ETagMD5)
182+
// Record this part's byte span (it may span several blobs) so a later
183+
// GET/HEAD ?partNumber=N can address it (§7.2).
184+
partStart := offset
181185
for _, d := range sp.BlobDigests {
182186
in, err := b.intents.GetIntent(ctx, d)
183187
if err != nil {
@@ -186,6 +190,7 @@ func (b *Backend) CompleteMultipartUpload(ctx context.Context, input *s3.Complet
186190
blobs = append(blobs, msbucket.BlobRef{Digest: d, Offset: offset, Length: in.Size})
187191
offset += in.Size
188192
}
193+
partSizes = append(partSizes, offset-partStart)
189194
}
190195

191196
// Accept every part's blobs on Forge (no-op in the harness), then commit.
@@ -198,7 +203,7 @@ func (b *Backend) CompleteMultipartUpload(ctx context.Context, input *s3.Complet
198203
Key: key,
199204
ContentType: sess.ContentType,
200205
Created: time.Now().Unix(),
201-
Body: msbucket.Body{Size: offset, Blobs: blobs},
206+
Body: msbucket.Body{Size: offset, Blobs: blobs, PartSizes: partSizes},
202207
ETag: etag,
203208
Metadata: sess.Metadata,
204209
}

0 commit comments

Comments
 (0)