@@ -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 :
0 commit comments