604 lines
21 KiB
Go
604 lines
21 KiB
Go
package cmd
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/aws/aws-sdk-go-v2/aws"
|
||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
type fakeBackupObject struct {
|
||
body []byte
|
||
etag string
|
||
contentType string
|
||
cacheControl string
|
||
interruptedAttempts int
|
||
wrongLengthAttempts int
|
||
}
|
||
|
||
type fakeBackupS3 struct {
|
||
pages []*s3.ListObjectsV2Output
|
||
listInputs []*s3.ListObjectsV2Input
|
||
objects map[string]fakeBackupObject
|
||
getCalls map[string]int
|
||
getOrder []string
|
||
}
|
||
|
||
func (f *fakeBackupS3) ListObjectsV2(_ context.Context, input *s3.ListObjectsV2Input, _ ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) {
|
||
f.listInputs = append(f.listInputs, input)
|
||
index := len(f.listInputs) - 1
|
||
if index >= len(f.pages) {
|
||
return nil, fmt.Errorf("unexpected ListObjectsV2 call %d", index+1)
|
||
}
|
||
return f.pages[index], nil
|
||
}
|
||
|
||
func (f *fakeBackupS3) GetObject(_ context.Context, input *s3.GetObjectInput, _ ...func(*s3.Options)) (*s3.GetObjectOutput, error) {
|
||
key := aws.ToString(input.Key)
|
||
if f.getCalls == nil {
|
||
f.getCalls = make(map[string]int)
|
||
}
|
||
f.getCalls[key]++
|
||
f.getOrder = append(f.getOrder, key)
|
||
object, exists := f.objects[key]
|
||
if !exists {
|
||
return nil, fmt.Errorf("unexpected GetObject key %q", key)
|
||
}
|
||
|
||
var body io.ReadCloser
|
||
if f.getCalls[key] <= object.interruptedAttempts {
|
||
body = io.NopCloser(&interruptedReader{data: append([]byte(nil), object.body...)})
|
||
} else {
|
||
body = io.NopCloser(strings.NewReader(string(object.body)))
|
||
}
|
||
contentLength := int64(len(object.body))
|
||
if f.getCalls[key] <= object.wrongLengthAttempts {
|
||
contentLength++
|
||
}
|
||
return &s3.GetObjectOutput{
|
||
Body: body,
|
||
ContentLength: aws.Int64(contentLength),
|
||
ETag: aws.String(object.etag),
|
||
ContentType: aws.String(object.contentType),
|
||
CacheControl: aws.String(object.cacheControl),
|
||
}, nil
|
||
}
|
||
|
||
type interruptedReader struct {
|
||
data []byte
|
||
}
|
||
|
||
func (r *interruptedReader) Read(p []byte) (int, error) {
|
||
if len(r.data) == 0 {
|
||
return 0, io.ErrUnexpectedEOF
|
||
}
|
||
n := copy(p, r.data)
|
||
r.data = r.data[n:]
|
||
if len(r.data) == 0 {
|
||
return n, io.ErrUnexpectedEOF
|
||
}
|
||
return n, nil
|
||
}
|
||
|
||
func backupListPage(keys []string, truncated bool, nextToken string) *s3.ListObjectsV2Output {
|
||
objects := make([]types.Object, 0, len(keys))
|
||
for _, key := range keys {
|
||
objects = append(objects, types.Object{
|
||
Key: aws.String(key),
|
||
Size: aws.Int64(999999), // 故意不可信;manifest 必须记录实际下载字节数。
|
||
ETag: aws.String("list-etag-must-not-be-used"),
|
||
})
|
||
}
|
||
output := &s3.ListObjectsV2Output{Contents: objects, IsTruncated: aws.Bool(truncated)}
|
||
if nextToken != "" {
|
||
output.NextContinuationToken = aws.String(nextToken)
|
||
}
|
||
return output
|
||
}
|
||
|
||
func TestBackupImagesWithClientCoversAllKeysAndPages(t *testing.T) {
|
||
keys := []string{
|
||
"2026/11/04/root.webp",
|
||
"picture/2026/11/04/picture.webp",
|
||
"tamabox/2025/12/historical.jpg",
|
||
"static/site.css",
|
||
}
|
||
objects := make(map[string]fakeBackupObject, len(keys))
|
||
for index, key := range keys {
|
||
objects[key] = fakeBackupObject{
|
||
body: []byte(fmt.Sprintf("actual-body-%d", index)),
|
||
etag: fmt.Sprintf("get-etag-%d", index),
|
||
contentType: []string{"image/webp", "image/webp", "image/jpeg", "text/css"}[index],
|
||
cacheControl: fmt.Sprintf("public, max-age=%d", index+60),
|
||
}
|
||
}
|
||
client := &fakeBackupS3{
|
||
pages: []*s3.ListObjectsV2Output{
|
||
backupListPage(keys[:2], true, "page-2"),
|
||
backupListPage(keys[2:], false, ""),
|
||
},
|
||
objects: objects,
|
||
}
|
||
outDir := t.TempDir()
|
||
require.NoError(t, backupImagesWithClient(context.Background(), client, "configured-bucket", outDir, ""))
|
||
|
||
require.Len(t, client.listInputs, 2)
|
||
for index, input := range client.listInputs {
|
||
require.Equal(t, "configured-bucket", aws.ToString(input.Bucket))
|
||
require.Equal(t, "", aws.ToString(input.Prefix), "生产默认必须列举整个 bucket")
|
||
if index == 0 {
|
||
require.Nil(t, input.ContinuationToken)
|
||
} else {
|
||
require.Equal(t, "page-2", aws.ToString(input.ContinuationToken))
|
||
}
|
||
}
|
||
require.ElementsMatch(t, keys, client.getOrder)
|
||
|
||
manifestBytes, err := os.ReadFile(filepath.Join(outDir, "images-manifest.json"))
|
||
require.NoError(t, err)
|
||
var manifest []imageManifestEntry
|
||
require.NoError(t, json.Unmarshal(manifestBytes, &manifest))
|
||
require.Len(t, manifest, len(keys))
|
||
for index, entry := range manifest {
|
||
require.Equal(t, keys[index], entry.Key)
|
||
object := objects[entry.Key]
|
||
require.Equal(t, int64(len(object.body)), entry.Size)
|
||
digest := sha256.Sum256(object.body)
|
||
require.Equal(t, fmt.Sprintf("%x", digest), entry.SHA256)
|
||
require.Equal(t, object.etag, entry.ETag)
|
||
require.Equal(t, object.contentType, entry.ContentType)
|
||
require.Equal(t, object.cacheControl, entry.CacheControl)
|
||
|
||
path, err := localImagePath(filepath.Join(outDir, "images"), entry.Key)
|
||
require.NoError(t, err)
|
||
content, err := os.ReadFile(path)
|
||
require.NoError(t, err)
|
||
require.Equal(t, object.body, content)
|
||
}
|
||
}
|
||
|
||
func TestBackupImagesWithClientSupportsIsolatedPrefixAndEmptyManifest(t *testing.T) {
|
||
t.Run("prefix", func(t *testing.T) {
|
||
key := "tamabox-backup-test/unit/source/root.webp"
|
||
client := &fakeBackupS3{
|
||
pages: []*s3.ListObjectsV2Output{backupListPage([]string{key}, false, "")},
|
||
objects: map[string]fakeBackupObject{
|
||
key: {body: []byte("isolated"), contentType: "image/webp"},
|
||
},
|
||
}
|
||
require.NoError(t, backupImagesWithClient(context.Background(), client, "bucket", t.TempDir(), "tamabox-backup-test/unit/source/"))
|
||
require.Len(t, client.listInputs, 1)
|
||
require.Equal(t, "tamabox-backup-test/unit/source/", aws.ToString(client.listInputs[0].Prefix))
|
||
require.Equal(t, []string{key}, client.getOrder)
|
||
})
|
||
|
||
t.Run("empty array", func(t *testing.T) {
|
||
client := &fakeBackupS3{pages: []*s3.ListObjectsV2Output{backupListPage(nil, false, "")}}
|
||
outDir := t.TempDir()
|
||
require.NoError(t, backupImagesWithClient(context.Background(), client, "bucket", outDir, ""))
|
||
content, err := os.ReadFile(filepath.Join(outDir, "images-manifest.json"))
|
||
require.NoError(t, err)
|
||
require.Equal(t, "[]", strings.TrimSpace(string(content)))
|
||
})
|
||
}
|
||
|
||
func TestBackupRejectsPathAttacksBeforeAnyDownload(t *testing.T) {
|
||
tests := []struct {
|
||
name string
|
||
key *string
|
||
}{
|
||
{name: "nil key", key: nil},
|
||
{name: "empty key", key: aws.String("")},
|
||
{name: "absolute POSIX", key: aws.String("/outside")},
|
||
{name: "absolute Windows", key: aws.String("C:/outside")},
|
||
{name: "volume relative", key: aws.String("C:outside")},
|
||
{name: "parent", key: aws.String("../outside")},
|
||
{name: "nested parent", key: aws.String("picture/../outside")},
|
||
{name: "backslash", key: aws.String(`picture\outside`)},
|
||
{name: "NUL", key: aws.String("picture/\x00outside")},
|
||
{name: "empty segment", key: aws.String("picture//outside")},
|
||
{name: "leading dot segment", key: aws.String("./outside")},
|
||
{name: "inner dot segment", key: aws.String("picture/./outside")},
|
||
{name: "directory", key: aws.String("picture/")},
|
||
{name: "dot only", key: aws.String(".")},
|
||
}
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
page := backupListPage(nil, false, "")
|
||
page.Contents = []types.Object{{Key: tt.key}}
|
||
client := &fakeBackupS3{pages: []*s3.ListObjectsV2Output{page}}
|
||
err := backupImagesWithClient(context.Background(), client, "bucket", t.TempDir(), "")
|
||
require.Error(t, err)
|
||
require.Empty(t, client.getOrder, "非法 key 必须在任何下载前被拒绝")
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestBackupPreflightsAllPagesAndLocalCollisionsBeforeDownload(t *testing.T) {
|
||
t.Run("invalid key on later page", func(t *testing.T) {
|
||
client := &fakeBackupS3{
|
||
pages: []*s3.ListObjectsV2Output{
|
||
backupListPage([]string{"picture/valid.webp"}, true, "next"),
|
||
backupListPage([]string{"../escape"}, false, ""),
|
||
},
|
||
objects: map[string]fakeBackupObject{"picture/valid.webp": {body: []byte("valid")}},
|
||
}
|
||
require.Error(t, backupImagesWithClient(context.Background(), client, "bucket", t.TempDir(), ""))
|
||
require.Len(t, client.listInputs, 2)
|
||
require.Empty(t, client.getOrder)
|
||
})
|
||
|
||
for _, keys := range [][]string{{"a", "a/b"}, {"a/b", "a"}, {"same", "same"}} {
|
||
t.Run(fmt.Sprintf("collision %q", keys), func(t *testing.T) {
|
||
client := &fakeBackupS3{pages: []*s3.ListObjectsV2Output{backupListPage(keys, false, "")}}
|
||
require.Error(t, backupImagesWithClient(context.Background(), client, "bucket", t.TempDir(), ""))
|
||
require.Empty(t, client.getOrder)
|
||
})
|
||
}
|
||
|
||
t.Run("existing file blocks planned directory", func(t *testing.T) {
|
||
outDir := t.TempDir()
|
||
require.NoError(t, os.MkdirAll(filepath.Join(outDir, "images"), 0o700))
|
||
require.NoError(t, os.WriteFile(filepath.Join(outDir, "images", "a"), []byte("existing"), 0o600))
|
||
client := &fakeBackupS3{pages: []*s3.ListObjectsV2Output{backupListPage([]string{"a/b"}, false, "")}}
|
||
require.Error(t, backupImagesWithClient(context.Background(), client, "bucket", outDir, ""))
|
||
require.Empty(t, client.getOrder)
|
||
})
|
||
|
||
t.Run("existing directory blocks planned file", func(t *testing.T) {
|
||
outDir := t.TempDir()
|
||
require.NoError(t, os.MkdirAll(filepath.Join(outDir, "images", "a"), 0o700))
|
||
client := &fakeBackupS3{pages: []*s3.ListObjectsV2Output{backupListPage([]string{"a"}, false, "")}}
|
||
require.Error(t, backupImagesWithClient(context.Background(), client, "bucket", outDir, ""))
|
||
require.Empty(t, client.getOrder)
|
||
})
|
||
}
|
||
|
||
func TestBackupRetriesInterruptedStreamsAndLeavesNoPart(t *testing.T) {
|
||
t.Run("eventual success", func(t *testing.T) {
|
||
key := "picture/retry.webp"
|
||
body := []byte("complete-object-after-retries")
|
||
client := &fakeBackupS3{
|
||
pages: []*s3.ListObjectsV2Output{backupListPage([]string{key}, false, "")},
|
||
objects: map[string]fakeBackupObject{
|
||
key: {body: body, interruptedAttempts: 2, etag: "successful-get-etag"},
|
||
},
|
||
}
|
||
outDir := t.TempDir()
|
||
require.NoError(t, backupImagesWithClient(context.Background(), client, "bucket", outDir, ""))
|
||
require.Equal(t, 3, client.getCalls[key])
|
||
content, err := os.ReadFile(filepath.Join(outDir, "images", "picture", "retry.webp"))
|
||
require.NoError(t, err)
|
||
require.Equal(t, body, content)
|
||
requireNoPartFiles(t, filepath.Join(outDir, "images", "picture"))
|
||
})
|
||
|
||
t.Run("short body relative to GET content length", func(t *testing.T) {
|
||
key := "short-body.webp"
|
||
client := &fakeBackupS3{
|
||
pages: []*s3.ListObjectsV2Output{backupListPage([]string{key}, false, "")},
|
||
objects: map[string]fakeBackupObject{
|
||
key: {body: []byte("complete-on-second-attempt"), wrongLengthAttempts: 1},
|
||
},
|
||
}
|
||
outDir := t.TempDir()
|
||
require.NoError(t, backupImagesWithClient(context.Background(), client, "bucket", outDir, ""))
|
||
require.Equal(t, 2, client.getCalls[key])
|
||
requireNoPartFiles(t, filepath.Join(outDir, "images"))
|
||
})
|
||
|
||
t.Run("final failure", func(t *testing.T) {
|
||
key := "retry.txt"
|
||
client := &fakeBackupS3{
|
||
pages: []*s3.ListObjectsV2Output{backupListPage([]string{key}, false, "")},
|
||
objects: map[string]fakeBackupObject{
|
||
key: {body: []byte("never-complete"), interruptedAttempts: 3},
|
||
},
|
||
}
|
||
outDir := t.TempDir()
|
||
err := backupImagesWithClient(context.Background(), client, "bucket", outDir, "")
|
||
require.Error(t, err)
|
||
require.Contains(t, err.Error(), "after 3 attempts")
|
||
require.Equal(t, 3, client.getCalls[key])
|
||
require.NoFileExists(t, filepath.Join(outDir, "images", key))
|
||
requireNoPartFiles(t, filepath.Join(outDir, "images"))
|
||
require.NoFileExists(t, filepath.Join(outDir, "images-manifest.json"))
|
||
})
|
||
}
|
||
|
||
type capturedRestorePut struct {
|
||
key string
|
||
body []byte
|
||
contentType string
|
||
cacheControl string
|
||
size int64
|
||
}
|
||
|
||
type fakeRestoreStore struct {
|
||
existing map[string]bool
|
||
headErrors map[string]error
|
||
putFailures map[string]error
|
||
headCalls []string
|
||
puts []capturedRestorePut
|
||
}
|
||
|
||
func (f *fakeRestoreStore) HeadObject(_ context.Context, input *s3.HeadObjectInput, _ ...func(*s3.Options)) (*s3.HeadObjectOutput, error) {
|
||
key := aws.ToString(input.Key)
|
||
f.headCalls = append(f.headCalls, key)
|
||
if err := f.headErrors[key]; err != nil {
|
||
return nil, err
|
||
}
|
||
if f.existing[key] {
|
||
return &s3.HeadObjectOutput{}, nil
|
||
}
|
||
return nil, &types.NotFound{}
|
||
}
|
||
|
||
func (f *fakeRestoreStore) PutObject(_ context.Context, input *s3.PutObjectInput) error {
|
||
body, err := io.ReadAll(input.Body)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
key := aws.ToString(input.Key)
|
||
f.puts = append(f.puts, capturedRestorePut{
|
||
key: key,
|
||
body: body,
|
||
contentType: aws.ToString(input.ContentType),
|
||
cacheControl: aws.ToString(input.CacheControl),
|
||
size: aws.ToInt64(input.ContentLength),
|
||
})
|
||
return f.putFailures[key]
|
||
}
|
||
|
||
type restoreFixtureObject struct {
|
||
key string
|
||
body []byte
|
||
storedBody []byte
|
||
manifestSize *int64
|
||
manifestSHA string
|
||
contentType string
|
||
cacheControl string
|
||
}
|
||
|
||
func createRestoreFixture(t *testing.T, objects []restoreFixtureObject) string {
|
||
t.Helper()
|
||
dir := t.TempDir()
|
||
manifest := make([]imageManifestEntry, 0, len(objects))
|
||
for _, object := range objects {
|
||
storedBody := object.storedBody
|
||
if storedBody == nil {
|
||
storedBody = object.body
|
||
}
|
||
path := filepath.Join(dir, "images", filepath.FromSlash(object.key))
|
||
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700))
|
||
require.NoError(t, os.WriteFile(path, storedBody, 0o600))
|
||
|
||
size := int64(len(object.body))
|
||
if object.manifestSize != nil {
|
||
size = *object.manifestSize
|
||
}
|
||
digest := sha256.Sum256(object.body)
|
||
hash := fmt.Sprintf("%x", digest)
|
||
if object.manifestSHA != "" {
|
||
hash = object.manifestSHA
|
||
}
|
||
manifest = append(manifest, imageManifestEntry{
|
||
Key: object.key,
|
||
Size: size,
|
||
SHA256: hash,
|
||
ETag: "source-get-etag",
|
||
ContentType: object.contentType,
|
||
CacheControl: object.cacheControl,
|
||
})
|
||
}
|
||
data, err := json.MarshalIndent(manifest, "", " ")
|
||
require.NoError(t, err)
|
||
require.NoError(t, os.WriteFile(filepath.Join(dir, "images-manifest.json"), data, 0o600))
|
||
return dir
|
||
}
|
||
|
||
func TestRestoreImagesPreservesKeysContentAndMetadataWithTargetPrefix(t *testing.T) {
|
||
objects := []restoreFixtureObject{
|
||
{key: "2026/11/04/root.webp", body: []byte("root-body"), contentType: "image/webp", cacheControl: "public, max-age=60"},
|
||
{key: "picture/2026/11/04/pic.webp", body: []byte("picture-body"), contentType: "image/webp", cacheControl: "public, max-age=120"},
|
||
{key: "static/site.css", body: []byte("body{}"), contentType: "text/css", cacheControl: "no-cache"},
|
||
}
|
||
input := createRestoreFixture(t, objects)
|
||
store := &fakeRestoreStore{}
|
||
err := restoreImagesWithStore(context.Background(), store, "bucket", restoreImagesOptions{
|
||
Input: input,
|
||
Yes: true,
|
||
TargetPrefix: "isolated-restore", // 不带斜杠也应规范化为恰好一个路径分隔符。
|
||
})
|
||
require.NoError(t, err)
|
||
require.Len(t, store.headCalls, len(objects))
|
||
require.Len(t, store.puts, len(objects))
|
||
|
||
byKey := make(map[string]capturedRestorePut, len(store.puts))
|
||
for _, put := range store.puts {
|
||
byKey[put.key] = put
|
||
}
|
||
for _, object := range objects {
|
||
target := "isolated-restore/" + object.key
|
||
put := byKey[target]
|
||
require.Equal(t, object.body, put.body)
|
||
require.Equal(t, object.contentType, put.contentType)
|
||
require.Equal(t, object.cacheControl, put.cacheControl)
|
||
require.Equal(t, int64(len(object.body)), put.size)
|
||
}
|
||
}
|
||
|
||
func TestRestoreImagesDefaultRefusesOverwriteWithZeroPut(t *testing.T) {
|
||
objects := []restoreFixtureObject{
|
||
{key: "first.webp", body: []byte("first")},
|
||
{key: "second.webp", body: []byte("second")},
|
||
}
|
||
store := &fakeRestoreStore{existing: map[string]bool{"second.webp": true}}
|
||
err := restoreImagesWithStore(context.Background(), store, "bucket", restoreImagesOptions{
|
||
Input: createRestoreFixture(t, objects),
|
||
Yes: true,
|
||
})
|
||
require.Error(t, err)
|
||
require.Contains(t, err.Error(), "no objects were uploaded")
|
||
require.Empty(t, store.puts)
|
||
}
|
||
|
||
func TestRestoreImagesOverwriteAndDryRun(t *testing.T) {
|
||
t.Run("overwrite", func(t *testing.T) {
|
||
store := &fakeRestoreStore{existing: map[string]bool{"picture.webp": true}}
|
||
err := restoreImagesWithStore(context.Background(), store, "bucket", restoreImagesOptions{
|
||
Input: createRestoreFixture(t, []restoreFixtureObject{{key: "picture.webp", body: []byte("replacement")}}),
|
||
Yes: true,
|
||
Overwrite: true,
|
||
})
|
||
require.NoError(t, err)
|
||
require.Empty(t, store.headCalls, "显式 overwrite 不需要默认防覆盖 Head")
|
||
require.Len(t, store.puts, 1)
|
||
require.Equal(t, []byte("replacement"), store.puts[0].body)
|
||
})
|
||
|
||
t.Run("dry run without yes", func(t *testing.T) {
|
||
store := &fakeRestoreStore{}
|
||
err := restoreImagesWithStore(context.Background(), store, "bucket", restoreImagesOptions{
|
||
Input: createRestoreFixture(t, []restoreFixtureObject{{key: "picture.webp", body: []byte("dry-run")}}),
|
||
DryRun: true,
|
||
TargetPrefix: "drill/",
|
||
})
|
||
require.NoError(t, err)
|
||
require.Equal(t, []string{"drill/picture.webp"}, store.headCalls)
|
||
require.Empty(t, store.puts)
|
||
})
|
||
|
||
t.Run("actual write requires yes", func(t *testing.T) {
|
||
store := &fakeRestoreStore{}
|
||
err := restoreImagesWithStore(context.Background(), store, "bucket", restoreImagesOptions{
|
||
Input: createRestoreFixture(t, []restoreFixtureObject{{key: "picture.webp", body: []byte("write")}}),
|
||
})
|
||
require.Error(t, err)
|
||
require.Contains(t, err.Error(), "--yes")
|
||
require.Empty(t, store.headCalls)
|
||
require.Empty(t, store.puts)
|
||
})
|
||
}
|
||
|
||
func TestRestoreRejectsTamperedFilesBeforeAnyRemoteCall(t *testing.T) {
|
||
wrongSize := int64(999)
|
||
tests := []struct {
|
||
name string
|
||
second restoreFixtureObject
|
||
}{
|
||
{
|
||
name: "SHA256 tamper with unchanged size",
|
||
second: restoreFixtureObject{
|
||
key: "second.webp", body: []byte("second"), storedBody: []byte("SECOND"),
|
||
},
|
||
},
|
||
{
|
||
name: "size tamper",
|
||
second: restoreFixtureObject{
|
||
key: "second.webp", body: []byte("second"), manifestSize: &wrongSize,
|
||
},
|
||
},
|
||
{
|
||
name: "manifest hash tamper",
|
||
second: restoreFixtureObject{
|
||
key: "second.webp", body: []byte("second"), manifestSHA: strings.Repeat("0", 64),
|
||
},
|
||
},
|
||
}
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
store := &fakeRestoreStore{}
|
||
input := createRestoreFixture(t, []restoreFixtureObject{
|
||
{key: "first.webp", body: []byte("first")},
|
||
tt.second,
|
||
})
|
||
err := restoreImagesWithStore(context.Background(), store, "bucket", restoreImagesOptions{Input: input, Yes: true})
|
||
require.Error(t, err)
|
||
require.Empty(t, store.headCalls, "全部本地文件应在任何远端操作前完成校验")
|
||
require.Empty(t, store.puts, "首个有效文件也不能在后续文件校验失败前上传")
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestReadImageManifestRejectsInvalidStructure(t *testing.T) {
|
||
validHash := strings.Repeat("a", 64)
|
||
tests := []struct {
|
||
name string
|
||
json string
|
||
}{
|
||
{name: "null", json: `null`},
|
||
{name: "trailing JSON", json: `[] {}`},
|
||
{name: "trailing garbage", json: `[] garbage`},
|
||
{name: "duplicate key", json: fmt.Sprintf(`[{"key":"same","size":0,"sha256":%q},{"key":"same","size":0,"sha256":%q}]`, validHash, validHash)},
|
||
{name: "short hash", json: `[{"key":"image","size":1,"sha256":"abc"}]`},
|
||
{name: "non hex hash", json: fmt.Sprintf(`[{"key":"image","size":1,"sha256":%q}]`, strings.Repeat("z", 64))},
|
||
{name: "negative size", json: fmt.Sprintf(`[{"key":"image","size":-1,"sha256":%q}]`, validHash)},
|
||
{name: "path traversal", json: fmt.Sprintf(`[{"key":"../image","size":1,"sha256":%q}]`, validHash)},
|
||
}
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
path := filepath.Join(t.TempDir(), "images-manifest.json")
|
||
// 非数组用例直接保留原 JSON;其余用例同样应被解析层拒绝。
|
||
require.NoError(t, os.WriteFile(path, []byte(tt.json), 0o600))
|
||
_, err := readImageManifest(path)
|
||
require.Error(t, err)
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestRestoreRejectsSymlinkBeforeRemoteCalls(t *testing.T) {
|
||
input := createRestoreFixture(t, []restoreFixtureObject{{key: "picture.webp", body: []byte("content")}})
|
||
source := filepath.Join(input, "images", "picture.webp")
|
||
outside := filepath.Join(t.TempDir(), "outside.webp")
|
||
require.NoError(t, os.WriteFile(outside, []byte("content"), 0o600))
|
||
require.NoError(t, os.Remove(source))
|
||
if err := os.Symlink(outside, source); err != nil {
|
||
t.Skipf("当前平台无法创建 symlink:%v", err)
|
||
}
|
||
|
||
store := &fakeRestoreStore{}
|
||
err := restoreImagesWithStore(context.Background(), store, "bucket", restoreImagesOptions{Input: input, DryRun: true})
|
||
require.Error(t, err)
|
||
require.Empty(t, store.headCalls)
|
||
require.Empty(t, store.puts)
|
||
}
|
||
|
||
func TestRestoreReportsPartialFailureWithoutClaimingTransaction(t *testing.T) {
|
||
store := &fakeRestoreStore{putFailures: map[string]error{"second.webp": errors.New("injected put failure")}}
|
||
err := restoreImagesWithStore(context.Background(), store, "bucket", restoreImagesOptions{
|
||
Input: createRestoreFixture(t, []restoreFixtureObject{
|
||
{key: "first.webp", body: []byte("first")},
|
||
{key: "second.webp", body: []byte("second")},
|
||
}),
|
||
Yes: true,
|
||
})
|
||
require.Error(t, err)
|
||
require.Contains(t, err.Error(), "1 successful uploads")
|
||
require.Contains(t, err.Error(), "not transactional")
|
||
require.Len(t, store.puts, 2)
|
||
}
|
||
|
||
func TestRestoreRejectsUnsafeTargetPrefixBeforeRemoteCalls(t *testing.T) {
|
||
store := &fakeRestoreStore{}
|
||
err := restoreImagesWithStore(context.Background(), store, "bucket", restoreImagesOptions{
|
||
Input: createRestoreFixture(t, []restoreFixtureObject{{key: "picture.webp", body: []byte("content")}}),
|
||
Yes: true,
|
||
TargetPrefix: "../outside/",
|
||
})
|
||
require.Error(t, err)
|
||
require.Empty(t, store.headCalls)
|
||
require.Empty(t, store.puts)
|
||
}
|