@@ -89,8 +89,14 @@ type S3Encoder struct {
8989}
9090
9191type s3BucketState struct {
92- name string
93- meta * s3PublicBucket
92+ name string
93+ meta * s3PublicBucket
94+ // activeGen is the bucket's current generation, captured from the
95+ // bucket-meta record. Used at flush time to suppress objects
96+ // belonging to older incarnations of the same bucket name (Codex
97+ // P2 #521). Zero means "no bucket meta seen yet"; in that state
98+ // every object flushes (the prior orphan-warning path covers it).
99+ activeGen uint64
94100 objects map [string ]* s3ObjectState // keyed by "object\x00generation"
95101 keymap * KeymapWriter
96102 keymapDir string
@@ -114,6 +120,13 @@ type s3ObjectState struct {
114120 // window) cannot be merged into the active body — Codex P1 #500,
115121 // Gemini HIGH #106/#476/#504.
116122 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 {}
117130 // scratchDirCreated avoids the per-blob MkdirAll syscall flagged
118131 // by Gemini MEDIUM #285. The scratch directory for this object is
119132 // created exactly once on the first HandleBlob call.
@@ -131,6 +144,16 @@ type s3ChunkKey struct {
131144 partVersion uint64
132145}
133146
147+ // s3PartKey is the manifest-declared part identifier: a (partNo,
148+ // 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.
152+ type s3PartKey struct {
153+ partNo uint64
154+ partVersion uint64
155+ }
156+
134157// s3PublicBucket is the dump-format projection of s3BucketMeta.
135158type s3PublicBucket struct {
136159 FormatVersion uint32 `json:"format_version"`
@@ -245,6 +268,7 @@ func (s *S3Encoder) HandleBucketMeta(key, value []byte) error {
245268 Region : live .Region ,
246269 ACL : live .Acl ,
247270 }
271+ st .activeGen = live .Generation
248272 return nil
249273}
250274
@@ -282,10 +306,16 @@ func (s *S3Encoder) HandleObjectManifest(key, value []byte) error {
282306 }
283307 // Capture the manifest's uploadID so assembleObjectBody can
284308 // filter blob chunks belonging to other (stale or in-flight)
285- // upload attempts. The live parts list is purely structural —
286- // the public sidecar has no need for it, but its uploadID is
287- // the load-bearing detail.
309+ // upload attempts. Also capture the manifest's declared
310+ // (partNo, partVersion) set so the assembler restricts itself
311+ // to canonically-declared parts — older partVersions left
312+ // behind by overwrite-then-async-cleanup must NOT be merged
313+ // into the body (Codex P1 #619).
288314 st .uploadID = live .UploadID
315+ st .declaredParts = make (map [s3PartKey ]struct {}, len (live .Parts ))
316+ for _ , p := range live .Parts {
317+ st .declaredParts [s3PartKey {partNo : p .PartNo , partVersion : p .PartVersion }] = struct {}{}
318+ }
289319 st .chunkPaths = ensureChunkPaths (st .chunkPaths )
290320 return nil
291321}
@@ -384,10 +414,16 @@ func (s *S3Encoder) flushBucket(b *s3BucketState) error {
384414 return err
385415 }
386416 }
387- for _ , obj := range b .objects {
388- if err := s .flushObject (b , bucketDir , obj ); err != nil {
389- return err
390- }
417+ staleCount , err := s .flushBucketObjects (b , bucketDir )
418+ if err != nil {
419+ return err
420+ }
421+ if staleCount > 0 {
422+ s .emitWarn ("s3_stale_generation_objects" ,
423+ "bucket" , b .name ,
424+ "active_generation" , b .activeGen ,
425+ "stale_count" , staleCount ,
426+ "hint" , "stale-gen objects excluded; restore would otherwise emit them under the new bucket" )
391427 }
392428 // closeJSONL errors must surface — they are the canonical "data
393429 // did not flush to disk" signal for a writable resource (Gemini
@@ -403,6 +439,37 @@ func (s *S3Encoder) flushBucket(b *s3BucketState) error {
403439 return nil
404440}
405441
442+ // flushBucketObjects walks the bucket's object set, routes stale-gen
443+ // objects to the orphan path (under --include-orphans) or drops them
444+ // with a warning counter, and flushes active-gen objects normally.
445+ // Split out of flushBucket to keep cyclomatic complexity within the
446+ // package cap.
447+ func (s * S3Encoder ) flushBucketObjects (b * s3BucketState , bucketDir string ) (int , error ) {
448+ stale := 0
449+ for _ , obj := range b .objects {
450+ // Suppress objects from older bucket incarnations: when a
451+ // bucket is deleted and recreated the generation bumps, but
452+ // snapshots taken mid-cleanup can still carry the previous
453+ // generation's manifests + chunks. Routing both to the same
454+ // natural path is non-deterministic last-write-wins (Codex
455+ // P2 #521). When a bucket-meta record is present, only its
456+ // active generation flushes.
457+ if b .activeGen != 0 && obj .generation != b .activeGen {
458+ stale ++
459+ if s .includeOrphans {
460+ if err := s .flushOrphanObject (b , bucketDir , obj ); err != nil {
461+ return stale , err
462+ }
463+ }
464+ continue
465+ }
466+ if err := s .flushObject (b , bucketDir , obj ); err != nil {
467+ return stale , err
468+ }
469+ }
470+ return stale , nil
471+ }
472+
406473// closeBucketKeymap closes the per-bucket KEYMAP.jsonl writer (if
407474// opened) and removes the file when no rename was recorded.
408475func closeBucketKeymap (b * s3BucketState ) error {
@@ -484,15 +551,42 @@ func (s *S3Encoder) flushOrphanObject(b *s3BucketState, bucketDir string, obj *s
484551
485552// safeJoinUnderRoot composes <root>/<rel> and asserts the result is
486553// still rooted under <root>. S3 object keys are user-controlled and
487- // can contain "..", absolute paths, or NUL bytes; without this guard
488- // a key like "../etc/passwd" would escape the dump tree and overwrite
489- // host files (Codex P1 #425).
554+ // can contain "..", absolute paths, NUL bytes, or "." segments;
555+ // without this guard a key like "../etc/passwd" would escape the
556+ // dump tree and overwrite host files (Codex P1 #425).
557+ //
558+ // We refuse keys whose path-segment components include "." or ".."
559+ // rather than filepath.Clean'ing them. S3 treats those bytes
560+ // literally — `aws s3 put-object` accepts a key like "a/../b" as
561+ // distinct from "b" — so collapsing them via filepath.Clean would
562+ // silently merge two distinct user keys into one output file
563+ // (Codex P2 #497). Operators with such keys must rename them in
564+ // S3, then re-take the dump; the spec's rename-collisions path
565+ // does not currently cover this.
566+ //
567+ // NUL bytes are also refused: POSIX cannot represent them in a
568+ // path component, and they have no legitimate meaning in S3 keys
569+ // transmitted over HTTP.
490570func safeJoinUnderRoot (root , rel string ) (string , error ) {
491571 if rel == "" {
492572 return "" , errors .Wrap (ErrS3MalformedKey , "empty object name" )
493573 }
574+ if strings .ContainsRune (rel , 0 ) {
575+ return "" , errors .Wrapf (ErrS3MalformedKey , "object name contains NUL: %q" , rel )
576+ }
577+ for _ , seg := range strings .Split (rel , "/" ) {
578+ switch seg {
579+ case "." , ".." :
580+ return "" , errors .Wrapf (ErrS3MalformedKey ,
581+ "object name has dot segment %q (S3 treats it literally; rename in S3 first)" , rel )
582+ }
583+ }
494584 cleanRoot := filepath .Clean (root )
495- joined := filepath .Clean (filepath .Join (cleanRoot , rel ))
585+ // Use filepath.Join here — its only behavioural change vs. raw
586+ // concatenation after the dot-segment guard above is normalising
587+ // a leading "/" off `rel` (which is what we want: absolute-path
588+ // keys collapse safely under bucketDir).
589+ joined := filepath .Join (cleanRoot , rel )
496590 rootSep := cleanRoot + string (filepath .Separator )
497591 if joined != cleanRoot && ! strings .HasPrefix (joined , rootSep ) {
498592 return "" , errors .Wrapf (ErrS3MalformedKey ,
@@ -579,14 +673,17 @@ func assembleObjectBody(outPath string, obj *s3ObjectState) error {
579673 _ = os .Remove (tmpPath )
580674 }
581675 }()
582- // Filter chunks by the manifest's uploadID. A snapshot taken
583- // during a delete/recreate or a retry-after-failed-CompleteUpload
584- // can legitimately contain blob chunks for multiple upload
585- // attempts under the same (bucket, generation, object). Mixing
586- // them produces corrupted bytes — Codex P1 #500 / Gemini HIGH
587- // #504. The manifest is the single source of truth; only its
588- // uploadID's chunks belong in the assembled body.
589- chunks := filterChunksForManifest (obj .chunkPaths , obj .uploadID )
676+ // Filter chunks by the manifest's uploadID AND its declared
677+ // (partNo, partVersion) set. A snapshot taken during
678+ // delete/recreate, retry-after-failed-CompleteUpload, or
679+ // part-overwrite-before-cleanup can legitimately contain blob
680+ // chunks for multiple upload attempts and/or multiple part
681+ // versions under the same (bucket, generation, object). Mixing
682+ // them produces corrupted bytes — Codex P1 #500 (uploadID),
683+ // Codex P1 #619 (partVersion). The manifest is the single source
684+ // of truth; only its uploadID + declaredParts make it into the
685+ // assembled body.
686+ chunks := filterChunksForManifest (obj .chunkPaths , obj .uploadID , obj .declaredParts )
590687 for _ , k := range chunks {
591688 path := obj .chunkPaths [k ]
592689 if err := appendFile (tmp , path ); err != nil {
@@ -606,16 +703,25 @@ func assembleObjectBody(outPath string, obj *s3ObjectState) error {
606703}
607704
608705// filterChunksForManifest returns the chunk keys belonging to
609- // manifestUploadID, sorted by (partNo, partVersion, chunkNo). An empty
610- // manifestUploadID matches every chunk — useful for tests that
611- // pre-date the uploadID feature, but production callers always have a
612- // non-empty uploadID via HandleObjectManifest.
613- func filterChunksForManifest (m map [s3ChunkKey ]string , manifestUploadID string ) []s3ChunkKey {
706+ // manifestUploadID AND whose (partNo, partVersion) appears in
707+ // declaredParts. Returned keys are sorted by (partNo, partVersion,
708+ // chunkNo) for byte-deterministic body assembly.
709+ //
710+ // An empty manifestUploadID and a nil declaredParts both mean "no
711+ // filter" — used by tests that pre-date these features. Production
712+ // callers always pass non-empty/non-nil values via
713+ // HandleObjectManifest.
714+ func filterChunksForManifest (m map [s3ChunkKey ]string , manifestUploadID string , declaredParts map [s3PartKey ]struct {}) []s3ChunkKey {
614715 keys := make ([]s3ChunkKey , 0 , len (m ))
615716 for k := range m {
616717 if manifestUploadID != "" && k .uploadID != manifestUploadID {
617718 continue
618719 }
720+ if declaredParts != nil {
721+ if _ , ok := declaredParts [s3PartKey {partNo : k .partNo , partVersion : k .partVersion }]; ! ok {
722+ continue
723+ }
724+ }
619725 keys = append (keys , k )
620726 }
621727 sort .SliceStable (keys , func (i , j int ) bool {
0 commit comments