Skip to content

Commit 844fd49

Browse files
committed
Merge remote-tracking branch 'origin/feat/backup-phase0a-keymap-manifest' into feat/backup-phase0a-s3
2 parents 2c44292 + 7364133 commit 844fd49

2 files changed

Lines changed: 81 additions & 16 deletions

File tree

internal/backup/manifest.go

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -132,19 +132,24 @@ type Exclusions struct {
132132
// Manifest is the on-disk MANIFEST.json structure. Field tags match the
133133
// spec in docs/design/2026_04_29_proposed_snapshot_logical_decoder.md.
134134
type Manifest struct {
135-
FormatVersion uint32 `json:"format_version"`
136-
Phase string `json:"phase"`
137-
ElastickvVersion string `json:"elastickv_version,omitempty"`
138-
ClusterID string `json:"cluster_id,omitempty"`
139-
SnapshotIndex uint64 `json:"snapshot_index,omitempty"`
140-
LastCommitTS uint64 `json:"last_commit_ts,omitempty"`
141-
WallTimeISO string `json:"wall_time_iso"`
142-
Source *Source `json:"source,omitempty"`
143-
Live *Live `json:"live,omitempty"`
144-
Adapters Adapters `json:"adapters"`
145-
Exclusions Exclusions `json:"exclusions"`
146-
ChecksumAlgorithm string `json:"checksum_algorithm"`
147-
ChecksumFormat string `json:"checksum_format"`
135+
FormatVersion uint32 `json:"format_version"`
136+
Phase string `json:"phase"`
137+
ElastickvVersion string `json:"elastickv_version,omitempty"`
138+
ClusterID string `json:"cluster_id,omitempty"`
139+
SnapshotIndex uint64 `json:"snapshot_index,omitempty"`
140+
LastCommitTS uint64 `json:"last_commit_ts,omitempty"`
141+
WallTimeISO string `json:"wall_time_iso"`
142+
Source *Source `json:"source,omitempty"`
143+
Live *Live `json:"live,omitempty"`
144+
// Adapters and Exclusions are pointer types so ReadManifest can
145+
// distinguish "section omitted entirely" (a corrupted or
146+
// truncated dump that should fail validation) from "section
147+
// present but populated with default values" (legitimate
148+
// scope-everything-excluded). Codex P2 #146 (round 3).
149+
Adapters *Adapters `json:"adapters"`
150+
Exclusions *Exclusions `json:"exclusions"`
151+
ChecksumAlgorithm string `json:"checksum_algorithm"`
152+
ChecksumFormat string `json:"checksum_format"`
148153

149154
EncodedFilenameCharset string `json:"encoded_filename_charset"`
150155
KeySegmentMaxBytes uint32 `json:"key_segment_max_bytes"`
@@ -163,12 +168,17 @@ var ErrInvalidManifest = errors.New("backup: manifest invalid")
163168

164169
// NewPhase0SnapshotManifest seeds a manifest with the Phase 0a defaults.
165170
// Callers fill in scope (Adapters), Source/wall time and exclusions before
166-
// passing it to WriteManifest.
171+
// passing it to WriteManifest. Adapters and Exclusions are seeded to
172+
// non-nil zero values so the resulting manifest passes the
173+
// "section-present" validation; callers populating individual scopes
174+
// reach in via the now-non-nil pointer.
167175
func NewPhase0SnapshotManifest(now time.Time) Manifest {
168176
return Manifest{
169177
FormatVersion: CurrentFormatVersion,
170178
Phase: PhasePhase0SnapshotDecode,
171179
WallTimeISO: now.UTC().Format(time.RFC3339Nano),
180+
Adapters: &Adapters{},
181+
Exclusions: &Exclusions{},
172182
ChecksumAlgorithm: ChecksumAlgorithmSHA256,
173183
ChecksumFormat: ChecksumFormatSha256sum,
174184
EncodedFilenameCharset: EncodedFilenameCharsetRFC3986,
@@ -266,6 +276,15 @@ func (m Manifest) validateRequiredFields() error {
266276
if _, err := time.Parse(time.RFC3339Nano, m.WallTimeISO); err != nil {
267277
return errors.Wrapf(ErrInvalidManifest, "wall_time_iso unparseable: %v", err)
268278
}
279+
// Adapters and Exclusions are required structural sections.
280+
// A manifest that omits either is treated as truncated/corrupted
281+
// (Codex P2 #146 round 3).
282+
if m.Adapters == nil {
283+
return errors.Wrap(ErrInvalidManifest, "adapters section missing")
284+
}
285+
if m.Exclusions == nil {
286+
return errors.Wrap(ErrInvalidManifest, "exclusions section missing")
287+
}
269288
return nil
270289
}
271290

internal/backup/manifest_test.go

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,13 @@ func TestManifest_Phase0RoundTrip(t *testing.T) {
1919
m.SnapshotIndex = 18432021
2020
m.LastCommitTS = 4517352099840000
2121
m.Source = &Source{FSMPath: "/data/fsm-snap/0000000000000064.fsm", FSMCRC32C: "deadbeef"}
22-
m.Adapters = Adapters{
22+
m.Adapters = &Adapters{
2323
DynamoDB: &Adapter{Tables: []string{"orders", "users"}},
2424
S3: &Adapter{Buckets: []string{"photos"}},
2525
Redis: &Adapter{Databases: []uint32{0}},
2626
SQS: &Adapter{Queues: []string{"orders-fifo.fifo"}},
2727
}
28-
m.Exclusions = Exclusions{} // all defaults
28+
m.Exclusions = &Exclusions{} // all defaults
2929

3030
var buf bytes.Buffer
3131
if err := WriteManifest(&buf, m); err != nil {
@@ -311,6 +311,52 @@ func TestAdaptersStruct_NilVsEmptyDistinguishedOnDisk(t *testing.T) {
311311
}
312312
}
313313

314+
func TestReadManifest_RejectsMissingAdapters(t *testing.T) {
315+
t.Parallel()
316+
// Adapters section omitted from the JSON entirely — Codex P2
317+
// #146 round 3. With Adapters as a pointer the omission decodes
318+
// as nil; validation must surface ErrInvalidManifest rather than
319+
// treat an empty zero-value section as valid.
320+
body := `{
321+
"format_version": 1,
322+
"phase": "phase0-snapshot-decode",
323+
"wall_time_iso": "2026-04-29T00:00:00Z",
324+
"exclusions": {"include_incomplete_uploads":false,"include_orphans":false,"preserve_sqs_visibility":false,"include_sqs_side_records":false},
325+
"checksum_algorithm": "sha256",
326+
"checksum_format": "sha256sum",
327+
"encoded_filename_charset": "rfc3986-unreserved-plus-percent",
328+
"key_segment_max_bytes": 240,
329+
"s3_meta_suffix": ".elastickv-meta.json",
330+
"s3_collision_strategy": "leaf-data-suffix",
331+
"dynamodb_layout": "per-item"
332+
}`
333+
_, err := ReadManifest(strings.NewReader(body))
334+
if !errors.Is(err, ErrInvalidManifest) {
335+
t.Fatalf("err=%v want ErrInvalidManifest for missing adapters", err)
336+
}
337+
}
338+
339+
func TestReadManifest_RejectsMissingExclusions(t *testing.T) {
340+
t.Parallel()
341+
body := `{
342+
"format_version": 1,
343+
"phase": "phase0-snapshot-decode",
344+
"wall_time_iso": "2026-04-29T00:00:00Z",
345+
"adapters": {},
346+
"checksum_algorithm": "sha256",
347+
"checksum_format": "sha256sum",
348+
"encoded_filename_charset": "rfc3986-unreserved-plus-percent",
349+
"key_segment_max_bytes": 240,
350+
"s3_meta_suffix": ".elastickv-meta.json",
351+
"s3_collision_strategy": "leaf-data-suffix",
352+
"dynamodb_layout": "per-item"
353+
}`
354+
_, err := ReadManifest(strings.NewReader(body))
355+
if !errors.Is(err, ErrInvalidManifest) {
356+
t.Fatalf("err=%v want ErrInvalidManifest for missing exclusions", err)
357+
}
358+
}
359+
314360
func TestWriteManifest_ProducesPrettyJSON(t *testing.T) {
315361
t.Parallel()
316362
m := NewPhase0SnapshotManifest(time.Now())

0 commit comments

Comments
 (0)