// Copyright (c) 2026 Feng Ruohang // // This file is part of Silo Object Storage stack // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package cmd import ( "bytes" "fmt" "net/http" "net/http/httptest" "net/url" "testing" "time" "github.com/minio/minio/internal/bucket/versioning" "github.com/minio/minio/internal/event" "github.com/minio/minio/internal/hash" xhttp "github.com/minio/minio/internal/http" "github.com/minio/minio/internal/kms" "github.com/minio/minio/internal/pubsub" "github.com/minio/mux" ) // Exercise the stored representation produced by real S3 uploads. Native // restore copies encoded bytes, but must retain logical sizes and identity. func TestNativeRestoreEncodedObjects(t *testing.T) { for _, pools := range []int{1, 2} { for _, encoding := range []string{"plain", "compressed", "ssec", "s3-compressed"} { for _, layout := range []string{"single", "multipart", "sparse-one-part"} { t.Run(fmt.Sprintf("pools=%d/%s/%s", pools, encoding, layout), func(t *testing.T) { r := newRestoreSafetyTest(t, pools, versioning.Enabled) initConfigSubsystem(t.Context(), r.z) r.object = "restore-encoded.txt" previousKMS, previousTLS := GlobalKMS, globalIsTLS t.Cleanup(func() { GlobalKMS, globalIsTLS = previousKMS, previousTLS }) globalIsTLS = true headers := make(map[string]string) readHeaders := make(map[string]string) if encoding == "compressed" || encoding == "s3-compressed" { enableAttributesPartsCompression(t) } if encoding == "ssec" { headers = ssecTestHeaders(0x53) readHeaders = ssecTestHeaders(0x53) } if encoding == "s3-compressed" { var err error GlobalKMS, err = kms.NewBuiltin("restore-test-key", bytes.Repeat([]byte{0x59}, 32)) if err != nil { t.Fatal(err) } headers[xhttp.AmzServerSideEncryption] = xhttp.AmzEncryptionAES } previousObjectAPI := newObjectLayerFn() t.Cleanup(func() { setObjectLayer(previousObjectAPI) }) router := initTestAPIEndPoints(r.z, []string{"NewMultipart", "PutObjectPart", "CompleteMultipart", "PutObject", "GetObject"}) bodies := [][]byte{bytes.Repeat([]byte("encoded-restore-"), compMinIndexSize/16+1)} partNumbers := []int{1} switch layout { case "multipart": bodies = append(bodies, []byte("last encoded part")) partNumbers = []int{1, 2} case "sparse-one-part": partNumbers = []int{5} } want := bytes.Join(bodies, nil) if layout == "single" { headers[xhttp.AmzChecksumCRC32] = mustChecksum(t, hash.ChecksumCRC32, want) putCopyChecksumSource(t, router, globalActiveCred, r.bucket, r.object, want, headers) } else { attributesPartsUpload(t, router, globalActiveCred, r.bucket, r.object, headers, bodies, partNumbers) } before, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, ObjectOptions{}) if err != nil { t.Fatal(err) } if before.IsCompressed() != (encoding == "compressed" || encoding == "s3-compressed") { t.Fatalf("unexpected compression state: %v", before.IsCompressed()) } if before.IsCompressed() && len(before.Parts[0].Index) == 0 { t.Fatal("fixture must have a compression index") } if layout == "single" && len(before.Checksum) == 0 { t.Fatal("fixture must have a stored checksum") } objectURL := getGetObjectURL("", r.bucket, r.object) + "?versionId=" + url.QueryEscape(before.VersionID) beforeGet := attributesPartsSignedRequest(t, router, globalActiveCred, http.MethodGet, objectURL, nil, readHeaders) 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)) } beforeRaw := nativeRestoreSnapshot(t, r) topts := r.opts(before.VersionID) topts.MTime = before.ModTime topts.Transition = TransitionOptions{Tier: "WARM", ETag: before.ETag} if err := r.z.TransitionObject(t.Context(), r.bucket, r.object, topts); err != nil { t.Fatal(err) } observed := nativeRestoreHTTPObserver{r.z, make(chan nativeRestoreHTTPResult, 1)} api := objectAPIHandlers{ObjectAPI: func() ObjectLayer { return observed }} events, done := make(chan event.Event, 1), make(chan struct{}) defer close(done) if err := globalHTTPListen.Subscribe(pubsub.MaskFromMaskable(event.ObjectRestoreCompleted), events, done, nil); err != nil { t.Fatal(err) } restoreXML := []byte(`1`) req, err := newTestSignedRequestV4(http.MethodPost, objectURL+"&restore", int64(len(restoreXML)), bytes.NewReader(restoreXML), globalActiveCred.AccessKey, globalActiveCred.SecretKey, nil) if err != nil { t.Fatal(err) } if err := req.ParseForm(); err != nil { t.Fatal(err) } req = mux.SetURLVars(req, map[string]string{"bucket": r.bucket, "object": r.object}) rec := httptest.NewRecorder() api.PostRestoreObjectHandler(rec, req) if rec.Code != http.StatusOK { t.Fatalf("restore POST: %d %s", rec.Code, rec.Body.String()) } select { case result := <-observed.done: if result.err != nil { t.Fatalf("background restore: %v", result.err) } case <-time.After(5 * time.Second): t.Fatal("restore did not finish") } select { case <-events: case <-time.After(5 * time.Second): t.Fatal("restore completion event missing") } after, err := r.z.GetObjectInfo(t.Context(), r.bucket, r.object, r.opts(before.VersionID)) if err != nil { t.Fatal(err) } if after.ETag != before.ETag || after.Size != before.Size || !after.ModTime.Equal(before.ModTime) { t.Error("restore changed content identity") } afterRaw := nativeRestoreSnapshot(t, r) for pool := range beforeRaw { if len(afterRaw[pool]) != len(beforeRaw[pool]) { t.Fatalf("pool %d version count changed", pool) } for i, fi := range beforeRaw[pool] { if !bytes.Equal(fi.Checksum, afterRaw[pool][i].Checksum) { t.Error("restore lost stored checksum") } } } if len(after.Parts) != len(before.Parts) { t.Fatal("restore changed part count") } for i, part := range after.Parts { old := before.Parts[i] if part.Number != old.Number || part.Size != old.Size || part.ActualSize != old.ActualSize || !bytes.Equal(part.Index, old.Index) { t.Errorf("part %d layout or compression index changed", i) } } tier := globalTierConfigMgr.drivercache["WARM"].(*restoreTestTier) reads := tier.gets.Load() got := attributesPartsSignedRequest(t, router, globalActiveCred, http.MethodGet, objectURL, nil, readHeaders) 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) { t.Error("restore changed client ETag") } for i, number := range partNumbers { part := attributesPartsSignedRequest(t, router, globalActiveCred, http.MethodGet, objectURL+fmt.Sprintf("&partNumber=%d", number), nil, readHeaders) if part.Code != http.StatusPartialContent || !bytes.Equal(part.Body.Bytes(), bodies[i]) { t.Errorf("restored part %d: %d bytes=%d want=%d", number, part.Code, part.Body.Len(), len(bodies[i])) } } if tier.gets.Load() != reads { t.Error("restored GET still used remote tier") } }) } } } }