Skip to content

Commit 0e15f02

Browse files
committed
s3keys: export ParseBlobKey for offline blob-key consumers
The Phase 0a logical-backup decoder (internal/backup) needs to route each !s3|blob| record to its assembled object body without holding a live cluster. Today the package only exports parsers for object manifests and upload parts; blob keys are constructable via BlobKey / VersionedBlobKey but not parseable. ParseBlobKey decodes the 6-component form (BlobKey output) and the 7-component form (VersionedBlobKey output with partVersion != 0) into all components: bucket, generation, object, uploadID, partNo, chunkNo, partVersion. Truncated keys, malformed segments, and trailers that aren't either zero bytes or exactly one u64 are rejected with ok=false. Implementation is split into parseBlobKeyHead (the 6-component head) and parseOptionalPartVersion (the trailer) so cyclomatic complexity stays under the package cap without a //nolint marker. Tests cover the un-versioned round-trip, versioned round-trip, partVersion=0 fallback to un-versioned shape (matching VersionedBlobKey's documented behaviour), rejection of non-blob keys (bucket meta, object manifest, upload part, junk), and rejection of trailing-garbage keys.
1 parent 63bfe8d commit 0e15f02

2 files changed

Lines changed: 152 additions & 0 deletions

File tree

internal/s3keys/keys.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,88 @@ func ObjectManifestScanStart(bucket string, generation uint64, objectPrefix stri
241241
return out
242242
}
243243

244+
// ParseBlobKey decodes a !s3|blob| key into its components. Both the
245+
// 6-component form produced by BlobKey and the 7-component form
246+
// produced by VersionedBlobKey (with partVersion != 0) are supported;
247+
// partVersion is reported as zero for the un-versioned form.
248+
//
249+
// Returns ok=false on any parse failure (truncated key, malformed
250+
// segment, junk trailer). Used by the offline backup decoder
251+
// (internal/backup) to route blob chunks to their assembled object,
252+
// and by future readers that need to walk the blob keyspace without
253+
// holding a live cluster.
254+
func ParseBlobKey(key []byte) (bucket string, generation uint64, object string, uploadID string, partNo uint64, chunkNo uint64, partVersion uint64, ok bool) {
255+
if !bytes.HasPrefix(key, blobPrefixBytes) {
256+
return "", 0, "", "", 0, 0, 0, false
257+
}
258+
parts, ok := parseBlobKeyHead(key)
259+
if !ok {
260+
return "", 0, "", "", 0, 0, 0, false
261+
}
262+
partVersion, ok = parseOptionalPartVersion(key, parts.next)
263+
if !ok {
264+
return "", 0, "", "", 0, 0, 0, false
265+
}
266+
return parts.bucket, parts.generation, parts.object, parts.uploadID, parts.partNo, parts.chunkNo, partVersion, true
267+
}
268+
269+
// parsedBlobHead is the 6-component head of a blob key. The optional
270+
// partVersion trailer is parsed separately so cyclomatic complexity
271+
// stays under the package cap.
272+
type parsedBlobHead struct {
273+
bucket string
274+
generation uint64
275+
object string
276+
uploadID string
277+
partNo uint64
278+
chunkNo uint64
279+
next int
280+
}
281+
282+
func parseBlobKeyHead(key []byte) (parsedBlobHead, bool) {
283+
var p parsedBlobHead
284+
bucketRaw, next, ok := decodeSegment(key, len(blobPrefixBytes))
285+
if !ok {
286+
return p, false
287+
}
288+
if p.generation, next, ok = readU64(key, next); !ok {
289+
return p, false
290+
}
291+
objectRaw, next, ok := decodeSegment(key, next)
292+
if !ok {
293+
return p, false
294+
}
295+
uploadIDRaw, next, ok := decodeSegment(key, next)
296+
if !ok {
297+
return p, false
298+
}
299+
if p.partNo, next, ok = readU64(key, next); !ok {
300+
return p, false
301+
}
302+
if p.chunkNo, next, ok = readU64(key, next); !ok {
303+
return p, false
304+
}
305+
p.bucket = string(bucketRaw)
306+
p.object = string(objectRaw)
307+
p.uploadID = string(uploadIDRaw)
308+
p.next = next
309+
return p, true
310+
}
311+
312+
func parseOptionalPartVersion(key []byte, offset int) (uint64, bool) {
313+
switch {
314+
case offset == len(key):
315+
return 0, true
316+
case len(key)-offset == u64Bytes:
317+
v, next, ok := readU64(key, offset)
318+
if !ok || next != len(key) {
319+
return 0, false
320+
}
321+
return v, true
322+
}
323+
return 0, false
324+
}
325+
244326
func ParseObjectManifestKey(key []byte) (bucket string, generation uint64, object string, ok bool) {
245327
if !bytes.HasPrefix(key, objectManifestPrefixBytes) {
246328
return "", 0, "", false

internal/s3keys/keys_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,76 @@ func TestParseUploadPartKey_ZeroBytesInSegments(t *testing.T) {
117117
require.Equal(t, uint64(3), partNo)
118118
}
119119

120+
func TestParseBlobKey_UnversionedRoundTrip(t *testing.T) {
121+
t.Parallel()
122+
123+
bucket := "photos"
124+
gen := uint64(7)
125+
object := "2026/04/img.jpg"
126+
uploadID := "u-abc"
127+
partNo := uint64(3)
128+
chunkNo := uint64(5)
129+
130+
key := BlobKey(bucket, gen, object, uploadID, partNo, chunkNo)
131+
gotBucket, gotGen, gotObject, gotUpload, gotPart, gotChunk, gotVersion, ok := ParseBlobKey(key)
132+
require.True(t, ok)
133+
require.Equal(t, bucket, gotBucket)
134+
require.Equal(t, gen, gotGen)
135+
require.Equal(t, object, gotObject)
136+
require.Equal(t, uploadID, gotUpload)
137+
require.Equal(t, partNo, gotPart)
138+
require.Equal(t, chunkNo, gotChunk)
139+
require.Equal(t, uint64(0), gotVersion, "unversioned blob key must report partVersion=0")
140+
}
141+
142+
func TestParseBlobKey_VersionedRoundTrip(t *testing.T) {
143+
t.Parallel()
144+
145+
key := VersionedBlobKey("b", 1, "o", "u", 2, 3, 9)
146+
_, _, _, _, gotPart, gotChunk, gotVersion, ok := ParseBlobKey(key)
147+
require.True(t, ok)
148+
require.Equal(t, uint64(2), gotPart)
149+
require.Equal(t, uint64(3), gotChunk)
150+
require.Equal(t, uint64(9), gotVersion)
151+
}
152+
153+
func TestParseBlobKey_VersionedZeroFallsBackToUnversioned(t *testing.T) {
154+
t.Parallel()
155+
156+
// VersionedBlobKey(partVersion=0) is documented to fall back to
157+
// the un-versioned shape; ParseBlobKey must agree.
158+
key := VersionedBlobKey("b", 1, "o", "u", 2, 3, 0)
159+
require.True(t, bytes.Equal(key, BlobKey("b", 1, "o", "u", 2, 3)))
160+
_, _, _, _, _, _, gotVersion, ok := ParseBlobKey(key)
161+
require.True(t, ok)
162+
require.Equal(t, uint64(0), gotVersion)
163+
}
164+
165+
func TestParseBlobKey_RejectsNonBlob(t *testing.T) {
166+
t.Parallel()
167+
168+
cases := [][]byte{
169+
BucketMetaKey("b"),
170+
ObjectManifestKey("b", 1, "o"),
171+
UploadPartKey("b", 1, "o", "u", 1),
172+
[]byte("not-a-key"),
173+
}
174+
for _, k := range cases {
175+
_, _, _, _, _, _, _, ok := ParseBlobKey(k)
176+
require.False(t, ok, "expected ParseBlobKey to reject %q", k)
177+
}
178+
}
179+
180+
func TestParseBlobKey_RejectsTrailingGarbage(t *testing.T) {
181+
t.Parallel()
182+
183+
key := BlobKey("b", 1, "o", "u", 2, 3)
184+
bad := append([]byte{}, key...)
185+
bad = append(bad, 0x00, 0x00, 0x00, 0x00) // 4 trailing bytes -- not 0 and not u64Bytes
186+
_, _, _, _, _, _, _, ok := ParseBlobKey(bad)
187+
require.False(t, ok)
188+
}
189+
120190
func TestParseUploadPartKey_RejectsNonPartKeys(t *testing.T) {
121191
t.Parallel()
122192

0 commit comments

Comments
 (0)