Skip to content

Commit 9076ef9

Browse files
committed
Merge remote-tracking branch 'origin/feat/backup-phase0a-keymap-manifest' into feat/backup-phase0a-dynamodb
2 parents bc57137 + f13cd1b commit 9076ef9

2 files changed

Lines changed: 125 additions & 15 deletions

File tree

internal/backup/manifest.go

Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -224,21 +224,8 @@ func ReadManifest(r io.Reader) (Manifest, error) {
224224
if err != nil {
225225
return Manifest{}, errors.Wrap(ErrInvalidManifest, err.Error())
226226
}
227-
// Phase 1: probe format_version with a relaxed shape that tolerates
228-
// arbitrary types on every other field.
229-
var probe struct {
230-
FormatVersion uint32 `json:"format_version"`
231-
}
232-
if err := json.Unmarshal(payload, &probe); err != nil {
233-
return Manifest{}, errors.Wrap(ErrInvalidManifest, err.Error())
234-
}
235-
if probe.FormatVersion == 0 {
236-
return Manifest{}, errors.Wrapf(ErrUnsupportedFormatVersion,
237-
"format_version is zero")
238-
}
239-
if probe.FormatVersion > CurrentFormatVersion {
240-
return Manifest{}, errors.Wrapf(ErrUnsupportedFormatVersion,
241-
"format_version %d > current %d (newer producer)", probe.FormatVersion, CurrentFormatVersion)
227+
if err := probeManifestFormatVersion(payload); err != nil {
228+
return Manifest{}, err
242229
}
243230
// Phase 2: strict struct decode on a known-supported version.
244231
var m Manifest
@@ -277,6 +264,48 @@ func ReadManifest(r io.Reader) (Manifest, error) {
277264
return m, nil
278265
}
279266

267+
// probeManifestFormatVersion runs the relaxed-shape format_version
268+
// gate that ReadManifest applies before the strict struct decode.
269+
// Splitting it into its own function keeps ReadManifest under the
270+
// project's cyclomatic-complexity ceiling. The contract:
271+
//
272+
// - missing or null `format_version` -> ErrInvalidManifest
273+
// (truncated/malformed file; Codex P2 round 8). Without this
274+
// branch json.Unmarshal would collapse absence to zero and the
275+
// version gate would misclassify as upgrade-required.
276+
// - `format_version` = 0 -> ErrUnsupportedFormatVersion (the
277+
// reserved sentinel for "no version assigned").
278+
// - `format_version` > CurrentFormatVersion ->
279+
// ErrUnsupportedFormatVersion (newer producer; upgrade-required).
280+
// - within range -> nil; the strict struct decode runs next.
281+
func probeManifestFormatVersion(payload []byte) error {
282+
var top map[string]json.RawMessage
283+
if err := json.Unmarshal(payload, &top); err != nil {
284+
return errors.Wrap(ErrInvalidManifest, err.Error())
285+
}
286+
rawFV, hasFV := top["format_version"]
287+
if !hasFV {
288+
return errors.Wrap(ErrInvalidManifest, "format_version missing")
289+
}
290+
if bytes.Equal(rawFV, jsonNullLiteral) {
291+
return errors.Wrap(ErrInvalidManifest, "format_version is null")
292+
}
293+
var probe struct {
294+
FormatVersion uint32 `json:"format_version"`
295+
}
296+
if err := json.Unmarshal(payload, &probe); err != nil {
297+
return errors.Wrap(ErrInvalidManifest, err.Error())
298+
}
299+
if probe.FormatVersion == 0 {
300+
return errors.Wrap(ErrUnsupportedFormatVersion, "format_version is zero")
301+
}
302+
if probe.FormatVersion > CurrentFormatVersion {
303+
return errors.Wrapf(ErrUnsupportedFormatVersion,
304+
"format_version %d > current %d (newer producer)", probe.FormatVersion, CurrentFormatVersion)
305+
}
306+
return nil
307+
}
308+
280309
// validateExclusionsFieldsPresent rejects manifests whose `exclusions`
281310
// section omits any of the required boolean flags. Go's
282311
// json.Unmarshal silently fills missing booleans with `false`, so a
@@ -339,6 +368,16 @@ func (m Manifest) validateRequiredFields() error {
339368
if m.FormatVersion == 0 {
340369
return errors.Wrap(ErrInvalidManifest, "format_version is zero")
341370
}
371+
// WriteManifest must refuse manifests advertising a version this
372+
// build cannot produce — without this gate, a caller mutating
373+
// `m.FormatVersion = CurrentFormatVersion + 1` would write a
374+
// manifest that ReadManifest in the same package then rejects as
375+
// ErrUnsupportedFormatVersion, producing self-incompatible
376+
// backup metadata. Codex P2 round 8.
377+
if m.FormatVersion > CurrentFormatVersion {
378+
return errors.Wrapf(ErrInvalidManifest,
379+
"format_version %d > current %d (this build cannot produce that)", m.FormatVersion, CurrentFormatVersion)
380+
}
342381
switch m.Phase {
343382
case PhasePhase0SnapshotDecode, PhasePhase1LivePinned:
344383
default:

internal/backup/manifest_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,77 @@ func TestReadManifest_RejectsZeroFormatVersion(t *testing.T) {
170170
}
171171
}
172172

173+
// TestReadManifest_RejectsMissingFormatVersion is the regression for
174+
// Codex P2 round 8: an absent `format_version` unmarshals into uint32
175+
// zero, which the version gate would otherwise misclassify as
176+
// ErrUnsupportedFormatVersion ("upgrade required"). A truncated /
177+
// malformed manifest that dropped the field belongs in the
178+
// ErrInvalidManifest branch instead.
179+
func TestReadManifest_RejectsMissingFormatVersion(t *testing.T) {
180+
t.Parallel()
181+
body := `{
182+
"phase": "phase0-snapshot-decode",
183+
"wall_time_iso": "2026-04-29T00:00:00Z",
184+
"adapters": {},
185+
"exclusions": {"include_incomplete_uploads":false,"include_orphans":false,"preserve_sqs_visibility":false,"include_sqs_side_records":false},
186+
"checksum_algorithm": "sha256",
187+
"checksum_format": "sha256sum",
188+
"encoded_filename_charset": "rfc3986-unreserved-plus-percent",
189+
"key_segment_max_bytes": 240,
190+
"s3_meta_suffix": ".elastickv-meta.json",
191+
"s3_collision_strategy": "leaf-data-suffix",
192+
"dynamodb_layout": "per-item"
193+
}`
194+
_, err := ReadManifest(strings.NewReader(body))
195+
if !errors.Is(err, ErrInvalidManifest) {
196+
t.Fatalf("err=%v want ErrInvalidManifest", err)
197+
}
198+
if errors.Is(err, ErrUnsupportedFormatVersion) {
199+
t.Fatalf("missing format_version must not surface as upgrade-required: %v", err)
200+
}
201+
}
202+
203+
// TestReadManifest_RejectsNullFormatVersion mirrors the missing-field
204+
// case for `"format_version": null`.
205+
func TestReadManifest_RejectsNullFormatVersion(t *testing.T) {
206+
t.Parallel()
207+
body := `{
208+
"format_version": null,
209+
"phase": "phase0-snapshot-decode",
210+
"wall_time_iso": "2026-04-29T00:00:00Z",
211+
"adapters": {},
212+
"exclusions": {"include_incomplete_uploads":false,"include_orphans":false,"preserve_sqs_visibility":false,"include_sqs_side_records":false},
213+
"checksum_algorithm": "sha256",
214+
"checksum_format": "sha256sum",
215+
"encoded_filename_charset": "rfc3986-unreserved-plus-percent",
216+
"key_segment_max_bytes": 240,
217+
"s3_meta_suffix": ".elastickv-meta.json",
218+
"s3_collision_strategy": "leaf-data-suffix",
219+
"dynamodb_layout": "per-item"
220+
}`
221+
_, err := ReadManifest(strings.NewReader(body))
222+
if !errors.Is(err, ErrInvalidManifest) {
223+
t.Fatalf("err=%v want ErrInvalidManifest", err)
224+
}
225+
}
226+
227+
// TestWriteManifest_RejectsFutureFormatVersion is the regression for
228+
// Codex P2 round 8: WriteManifest must refuse manifests advertising
229+
// a version this build cannot produce. Without this gate, a caller
230+
// mutating m.FormatVersion = CurrentFormatVersion + 1 writes a
231+
// manifest that the same package's ReadManifest then refuses,
232+
// producing self-incompatible backup metadata.
233+
func TestWriteManifest_RejectsFutureFormatVersion(t *testing.T) {
234+
t.Parallel()
235+
m := NewPhase0SnapshotManifest(time.Now())
236+
m.FormatVersion = CurrentFormatVersion + 1
237+
var buf bytes.Buffer
238+
err := WriteManifest(&buf, m)
239+
if !errors.Is(err, ErrInvalidManifest) {
240+
t.Fatalf("WriteManifest err=%v want ErrInvalidManifest for future format_version", err)
241+
}
242+
}
243+
173244
func TestReadManifest_AcceptsUnknownFieldsForSameMajorMinorEvolution(t *testing.T) {
174245
t.Parallel()
175246
// Same-major minor evolution: a newer producer adds an optional

0 commit comments

Comments
 (0)