Skip to content

Commit ba33df8

Browse files
committed
backup: validate chunk completeness + reject empty slash segments (PR #718, round 4)
Two Codex P1 follow-ups. #729 -- chunk completeness check. assembleObjectBody filtered + sorted whatever chunk files happened to exist and wrote them, but never verified the manifest's declared chunk_count was actually present. A partial / racy / corrupted snapshot would silently emit a truncated body. Added verifyChunkCompleteness which counts chunks per (partNo, partVersion) and asserts the count matches manifest.parts[].chunk_count AND the highest chunkNo equals chunk_count-1. Mismatch surfaces as ErrS3IncompleteBlobChunks before any bytes are written. The declaredParts map's value type changed from struct{} to s3DeclaredPart{chunkCount} to carry the contract. #614 -- empty slash segments. safeJoinUnderRoot rejected `.` and `..` but allowed empty segments ("a//b", "a/", trailing "/"). filepath.Join collapses these, so distinct S3 keys would silently overwrite each other at finalize. The dot-segment guard now also rejects "" segments anywhere except the leading position (where a leading "/" produces an initial empty segment that filepath.Join handles safely under the already-tested absolute-path-confined-under-bucket behaviour). Tests: TestS3_IncompleteChunksRejected, TestS3_EmptySlashSegmentsRejected (covers a//b, a/, /a//, x/).
1 parent 844fd49 commit ba33df8

2 files changed

Lines changed: 143 additions & 14 deletions

File tree

internal/backup/s3.go

Lines changed: 94 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,57 @@ var (
4848
// ErrS3MetaSuffixCollision is returned when a user object key
4949
// collides with the reserved S3MetaSuffixReserved suffix.
5050
ErrS3MetaSuffixCollision = errors.New("backup: user S3 object key collides with reserved sidecar suffix")
51+
// ErrS3IncompleteBlobChunks is returned when a manifest declares
52+
// N chunks for some part but the snapshot did not contain all N.
53+
// Without this guard a partial / racy snapshot would silently
54+
// emit a truncated body. Codex P1 #729.
55+
ErrS3IncompleteBlobChunks = errors.New("backup: incomplete blob chunks for manifest-declared part")
5156
)
5257

58+
// verifyChunkCompleteness checks every (partNo, partVersion) entry in
59+
// declaredParts has the right number of chunkNo values present in
60+
// chunks. Chunks are expected at chunkNo in [0, chunk_count); a
61+
// missing index in that range surfaces as
62+
// ErrS3IncompleteBlobChunks rather than letting the assembler emit a
63+
// truncated body.
64+
//
65+
// declaredParts == nil means "no contract to verify" — used by tests
66+
// that pre-date the manifest-parts feature; production callers
67+
// always populate it via HandleObjectManifest.
68+
func verifyChunkCompleteness(chunks []s3ChunkKey, declaredParts map[s3PartKey]s3DeclaredPart) error {
69+
if declaredParts == nil {
70+
return nil
71+
}
72+
// Count present chunks per (partNo, partVersion).
73+
type observed struct {
74+
count uint64
75+
maxIndex uint64
76+
}
77+
got := make(map[s3PartKey]observed, len(declaredParts))
78+
for _, k := range chunks {
79+
pk := s3PartKey{partNo: k.partNo, partVersion: k.partVersion}
80+
o := got[pk]
81+
o.count++
82+
if k.chunkNo > o.maxIndex {
83+
o.maxIndex = k.chunkNo
84+
}
85+
got[pk] = o
86+
}
87+
for pk, want := range declaredParts {
88+
o := got[pk]
89+
// We accept o.count == want.chunkCount AND
90+
// o.maxIndex == chunkCount-1, because a snapshot with N
91+
// duplicates of chunkNo=0 would satisfy the count check
92+
// alone. Both the count and the highest index must match.
93+
if want.chunkCount > 0 && (o.count != want.chunkCount || o.maxIndex+1 != want.chunkCount) {
94+
return errors.Wrapf(ErrS3IncompleteBlobChunks,
95+
"partNo=%d partVersion=%d declared chunks=%d, observed count=%d maxIndex=%d",
96+
pk.partNo, pk.partVersion, want.chunkCount, o.count, o.maxIndex)
97+
}
98+
}
99+
return nil
100+
}
101+
53102
// S3Encoder emits per-bucket _bucket.json + assembled object bodies +
54103
// .elastickv-meta.json sidecars + KEYMAP.jsonl, per the Phase 0
55104
// design (docs/design/2026_04_29_proposed_snapshot_logical_decoder.md).
@@ -120,13 +169,13 @@ type s3ObjectState struct {
120169
// window) cannot be merged into the active body — Codex P1 #500,
121170
// Gemini HIGH #106/#476/#504.
122171
uploadID string
123-
// declaredParts is the set of (partNo, partVersion) tuples the
124-
// manifest claims belong to this object. When non-nil, the body
125-
// assembler restricts chunkPaths to entries matching this set —
126-
// Codex P1 #619. nil means "no filter" (used only in tests that
127-
// pre-date the manifest-parts feature; production callers always
128-
// receive a non-nil set via HandleObjectManifest).
129-
declaredParts map[s3PartKey]struct{}
172+
// declaredParts maps each manifest-declared (partNo, partVersion)
173+
// to the metadata the assembler needs to validate completeness
174+
// (chunk_count). When non-nil, the body assembler restricts
175+
// chunkPaths to entries matching the keys AND verifies every
176+
// chunk index in [0, chunk_count) is present — Codex P1 #619
177+
// (filter) + #729 (completeness). nil means "no filter".
178+
declaredParts map[s3PartKey]s3DeclaredPart
130179
// scratchDirCreated avoids the per-blob MkdirAll syscall flagged
131180
// by Gemini MEDIUM #285. The scratch directory for this object is
132181
// created exactly once on the first HandleBlob call.
@@ -146,14 +195,22 @@ type s3ChunkKey struct {
146195

147196
// s3PartKey is the manifest-declared part identifier: a (partNo,
148197
// partVersion) tuple. ChunkNo is excluded because the manifest's
149-
// per-part chunk_count + chunk_sizes drive how many chunks to expect
150-
// per part, but the manifest doesn't enumerate (chunkNo) tuples
151-
// directly.
198+
// per-part chunk_count drives how many chunks to expect per part;
199+
// that count is stored on the s3DeclaredPart value, not in the key.
152200
type s3PartKey struct {
153201
partNo uint64
154202
partVersion uint64
155203
}
156204

205+
// s3DeclaredPart captures what the manifest claims for a part: its
206+
// expected chunk_count. assembleObjectBody verifies that one chunk
207+
// per (partNo, partVersion, chunkNo) in [0, chunk_count) actually
208+
// arrived; a missing chunk surfaces as ErrS3IncompleteBlobChunks
209+
// rather than a silently-truncated body (Codex P1 #729).
210+
type s3DeclaredPart struct {
211+
chunkCount uint64
212+
}
213+
157214
// s3PublicBucket is the dump-format projection of s3BucketMeta.
158215
type s3PublicBucket struct {
159216
FormatVersion uint32 `json:"format_version"`
@@ -312,9 +369,11 @@ func (s *S3Encoder) HandleObjectManifest(key, value []byte) error {
312369
// behind by overwrite-then-async-cleanup must NOT be merged
313370
// into the body (Codex P1 #619).
314371
st.uploadID = live.UploadID
315-
st.declaredParts = make(map[s3PartKey]struct{}, len(live.Parts))
372+
st.declaredParts = make(map[s3PartKey]s3DeclaredPart, len(live.Parts))
316373
for _, p := range live.Parts {
317-
st.declaredParts[s3PartKey{partNo: p.PartNo, partVersion: p.PartVersion}] = struct{}{}
374+
st.declaredParts[s3PartKey{partNo: p.PartNo, partVersion: p.PartVersion}] = s3DeclaredPart{
375+
chunkCount: p.ChunkCount,
376+
}
318377
}
319378
st.chunkPaths = ensureChunkPaths(st.chunkPaths)
320379
return nil
@@ -608,11 +667,28 @@ func safeJoinUnderRoot(root, rel string) (string, error) {
608667
if strings.ContainsRune(rel, 0) {
609668
return "", errors.Wrapf(ErrS3MalformedKey, "object name contains NUL: %q", rel)
610669
}
611-
for _, seg := range strings.Split(rel, "/") {
670+
// Split on "/" and inspect every segment. S3 treats "a/", "a",
671+
// and "a//b" as three distinct keys, but filepath.Join collapses
672+
// them onto one filesystem path; without explicit rejection,
673+
// distinct user keys would silently overwrite each other at
674+
// finalize (Codex P1 #614).
675+
segs := strings.Split(rel, "/")
676+
for i, seg := range segs {
612677
switch seg {
613678
case ".", "..":
614679
return "", errors.Wrapf(ErrS3MalformedKey,
615680
"object name has dot segment %q (S3 treats it literally; rename in S3 first)", rel)
681+
case "":
682+
// A leading "/" produces an initial empty segment
683+
// (segs[0] == ""); filepath.Join handles that case
684+
// safely by stripping the prefix, matching the
685+
// already-tested "absolute path collapses under
686+
// bucket dir" behaviour. Reject empty segments
687+
// anywhere else (mid-path "//" or trailing "/").
688+
if i != 0 {
689+
return "", errors.Wrapf(ErrS3MalformedKey,
690+
"object name has empty path segment %q", rel)
691+
}
616692
}
617693
}
618694
cleanRoot := filepath.Clean(root)
@@ -724,6 +800,10 @@ func assembleObjectBody(outPath string, obj *s3ObjectState) error {
724800
// of truth; only its uploadID + declaredParts make it into the
725801
// assembled body.
726802
chunks := filterChunksForManifest(obj.chunkPaths, obj.uploadID, obj.declaredParts)
803+
if err := verifyChunkCompleteness(chunks, obj.declaredParts); err != nil {
804+
_ = tmp.Close()
805+
return err
806+
}
727807
for _, k := range chunks {
728808
path := obj.chunkPaths[k]
729809
if err := appendFile(tmp, path); err != nil {
@@ -751,7 +831,7 @@ func assembleObjectBody(outPath string, obj *s3ObjectState) error {
751831
// filter" — used by tests that pre-date these features. Production
752832
// callers always pass non-empty/non-nil values via
753833
// HandleObjectManifest.
754-
func filterChunksForManifest(m map[s3ChunkKey]string, manifestUploadID string, declaredParts map[s3PartKey]struct{}) []s3ChunkKey {
834+
func filterChunksForManifest(m map[s3ChunkKey]string, manifestUploadID string, declaredParts map[s3PartKey]s3DeclaredPart) []s3ChunkKey {
755835
keys := make([]s3ChunkKey, 0, len(m))
756836
for k := range m {
757837
if manifestUploadID != "" && k.uploadID != manifestUploadID {

internal/backup/s3_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,55 @@ func readBytesFile(t *testing.T, path string) []byte {
565565
return b
566566
}
567567

568+
func TestS3_IncompleteChunksRejected(t *testing.T) {
569+
t.Parallel()
570+
enc, _ := newS3Encoder(t)
571+
bucket := "b"
572+
gen := uint64(1)
573+
object := "obj"
574+
uploadID := "u"
575+
if err := enc.HandleBucketMeta(s3keys.BucketMetaKey(bucket), encodeS3BucketMetaValue(t, map[string]any{
576+
"bucket_name": bucket, "generation": gen,
577+
})); err != nil {
578+
t.Fatal(err)
579+
}
580+
// Manifest declares 3 chunks for partNo=1 but the snapshot only
581+
// has 2 (chunkNo=0 and chunkNo=2; chunkNo=1 missing). Codex P1
582+
// #729.
583+
if err := enc.HandleObjectManifest(s3keys.ObjectManifestKey(bucket, gen, object), encodeS3ManifestValue(t, map[string]any{
584+
"upload_id": uploadID, "size_bytes": 9, "parts": []map[string]any{
585+
{"part_no": 1, "size_bytes": 9, "chunk_count": 3},
586+
},
587+
})); err != nil {
588+
t.Fatal(err)
589+
}
590+
if err := enc.HandleBlob(s3keys.BlobKey(bucket, gen, object, uploadID, 1, 0), []byte("AAA")); err != nil {
591+
t.Fatal(err)
592+
}
593+
if err := enc.HandleBlob(s3keys.BlobKey(bucket, gen, object, uploadID, 1, 2), []byte("CCC")); err != nil {
594+
t.Fatal(err)
595+
}
596+
err := enc.Finalize()
597+
if !errors.Is(err, ErrS3IncompleteBlobChunks) {
598+
t.Fatalf("err=%v want ErrS3IncompleteBlobChunks for missing chunk", err)
599+
}
600+
}
601+
602+
func TestS3_EmptySlashSegmentsRejected(t *testing.T) {
603+
t.Parallel()
604+
cases := []string{"a//b", "a/", "/a//", "x/"}
605+
for _, key := range cases {
606+
t.Run(key, func(t *testing.T) {
607+
enc, _ := newS3Encoder(t)
608+
emitObject(t, enc, "b", 1, key, []byte("x"), "")
609+
err := enc.Finalize()
610+
if !errors.Is(err, ErrS3MalformedKey) {
611+
t.Fatalf("err=%v want ErrS3MalformedKey for key %q", err, key)
612+
}
613+
})
614+
}
615+
}
616+
568617
func TestS3_VersionedBlobAssembledByPartVersionOrder(t *testing.T) {
569618
t.Parallel()
570619
enc, root := newS3Encoder(t)

0 commit comments

Comments
 (0)