From d9837fb319868690d20fca18e114ab25b76c0a5b Mon Sep 17 00:00:00 2001 From: Feng Ruohang Date: Tue, 3 Nov 2026 22:32:55 +0800 Subject: [PATCH] fix(restore): retain legacy part-size compatibility Signed-off-by: Feng Ruohang --- cmd/erasure-object.go | 10 ++++-- cmd/restore-native-encoding_test.go | 55 ++++++++++++++++++++++++++++- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/cmd/erasure-object.go b/cmd/erasure-object.go index b8a610b7b..f3cd7e562 100644 --- a/cmd/erasure-object.go +++ b/cmd/erasure-object.go @@ -2728,8 +2728,14 @@ func (er erasureObjects) restoreTransitionedObject(ctx context.Context, bucket s // rehydrate the parts back on disk as per the original xl.meta prior to transition for _, partInfo := range oi.Parts { - // Stored and logical lengths differ for compressed/encrypted data. - hr, err := hash.NewReader(ctx, io.LimitReader(gr, partInfo.Size), partInfo.Size, "", "", partInfo.ActualSize) + // Old uncompressed parts may not record ActualSize. Encrypted parts + // derive it from the written ciphertext in PutObjectPart; compressed + // parts need their recorded logical length, including valid zero sizes. + actualSize := partInfo.Size + if oi.IsCompressed() { + actualSize = partInfo.ActualSize + } + hr, err := hash.NewReader(ctx, io.LimitReader(gr, partInfo.Size), partInfo.Size, "", "", actualSize) if err != nil { return setRestoreHeaderFn(oi, err) } diff --git a/cmd/restore-native-encoding_test.go b/cmd/restore-native-encoding_test.go index 1ee83a927..6a4fa3ba9 100644 --- a/cmd/restore-native-encoding_test.go +++ b/cmd/restore-native-encoding_test.go @@ -19,6 +19,7 @@ package cmd import ( "bytes" + "context" "fmt" "net/http" "net/http/httptest" @@ -102,6 +103,10 @@ func TestNativeRestoreEncodedObjects(t *testing.T) { if beforeGet.Code != http.StatusOK || !bytes.Equal(beforeGet.Body.Bytes(), want) { t.Fatalf("fixture GET: %d bytes=%d want=%d", beforeGet.Code, beforeGet.Body.Len(), len(want)) } + beforeETags := beforeGet.Header()[xhttp.ETag] + if len(beforeETags) != 1 || beforeETags[0] == "" { + t.Fatal("fixture GET must expose a nonempty ETag") + } beforeRaw := nativeRestoreSnapshot(t, r) topts := r.opts(before.VersionID) topts.MTime = before.ModTime @@ -176,7 +181,8 @@ func TestNativeRestoreEncodedObjects(t *testing.T) { if got.Code != http.StatusOK || !bytes.Equal(got.Body.Bytes(), want) { t.Errorf("restored GET: %d bytes=%d want=%d", got.Code, got.Body.Len(), len(want)) } - if got.Header().Get(xhttp.ETag) != beforeGet.Header().Get(xhttp.ETag) { + afterETags := got.Header()[xhttp.ETag] + if len(afterETags) != 1 || afterETags[0] == "" || afterETags[0] != beforeETags[0] { t.Error("restore changed client ETag") } for i, number := range partNumbers { @@ -193,3 +199,50 @@ func TestNativeRestoreEncodedObjects(t *testing.T) { } } } + +// XL metadata predating compression has no ActualSize. Conversion and healing +// preserve those zero values; a plain multipart restore must derive the size. +func TestNativeRestoreLegacyPartSizes(t *testing.T) { + r := newRestoreSafetyTest(t, 1, "") + oi, body := nativeRestoreSeed(t, r, 0, true, true) + for _, disk := range r.z.serverPools[0].getHashedSet(r.object).getDisks() { + fi, err := disk.ReadVersion(t.Context(), r.bucket, r.bucket, r.object, oi.VersionID, ReadOptions{}) + if err != nil { + t.Fatal(err) + } + for i := range fi.Parts { + fi.Parts[i].ActualSize = 0 + } + delete(fi.Metadata, ReservedMetadataPrefix+"actual-size") + if err := disk.WriteMetadata(t.Context(), "", r.bucket, r.object, fi); err != nil { + t.Fatal(err) + } + } + before, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(oi.VersionID)) + if err != nil { + t.Fatal(err) + } + for _, part := range before.Parts { + if part.ActualSize != 0 { + t.Fatal("legacy fixture was normalized") + } + } + _, got, err := r.read(oi.VersionID) + if err != nil || !bytes.Equal(got, body) { + t.Fatalf("legacy fixture read: %v", err) + } + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + if err := r.z.RestoreTransitionedObject(ctx, r.bucket, r.object, nativeRestoreOptions(r, oi.VersionID)); err != nil { + t.Fatal(err) + } + tier := globalTierConfigMgr.drivercache["WARM"].(*restoreTestTier) + reads := tier.gets.Load() + _, got, err = r.read(oi.VersionID) + if err != nil || !bytes.Equal(got, body) { + t.Fatalf("legacy restored read: %v", err) + } + if tier.gets.Load() != reads { + t.Error("legacy restored read used the remote tier") + } +}