342 lines
9.4 KiB
Go
342 lines
9.4 KiB
Go
package storage
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// FakeBackend provides a deterministic in-memory object store for tests.
|
|
type FakeBackend struct {
|
|
mu sync.RWMutex
|
|
|
|
Objects map[string]FakeObject
|
|
Uploads []FakeUploadCall
|
|
Downloads []FakeDownloadCall
|
|
Reads []FakeReadCall
|
|
|
|
ListErr error
|
|
DownloadErr error
|
|
UploadErr error
|
|
ExistsErr error
|
|
UploadHook func(FakeUploadCall) error
|
|
DownloadHook func(FakeDownloadCall) error
|
|
}
|
|
|
|
// FakeUploadCall captures one upload invocation in call order.
|
|
type FakeUploadCall struct {
|
|
LocalPath string
|
|
Key string
|
|
Options UploadOptions
|
|
}
|
|
|
|
// FakeDownloadCall captures one download invocation in call order.
|
|
type FakeDownloadCall struct {
|
|
Key string
|
|
LocalPath string
|
|
Bytes int64
|
|
}
|
|
|
|
// FakeReadCall captures one opened object in call order.
|
|
type FakeReadCall struct {
|
|
Key string
|
|
}
|
|
|
|
// FakeObject is a deterministic fake object-store record.
|
|
type FakeObject struct {
|
|
Key string
|
|
Data []byte
|
|
Metadata map[string]string
|
|
ETag string
|
|
LastModified *time.Time
|
|
}
|
|
|
|
// SeedObject inserts or replaces an object in the fake object store.
|
|
func (f *FakeBackend) SeedObject(obj FakeObject) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.seedObject(obj)
|
|
}
|
|
|
|
func (f *FakeBackend) seedObject(obj FakeObject) {
|
|
if f.Objects == nil {
|
|
f.Objects = map[string]FakeObject{}
|
|
}
|
|
key := normalizeObjectKey(obj.Key)
|
|
obj.Key = key
|
|
obj.Data = append([]byte(nil), obj.Data...)
|
|
obj.Metadata = copyMetadata(obj.Metadata)
|
|
if obj.ETag == "" {
|
|
obj.ETag = fakeObjectETag(obj.Data)
|
|
}
|
|
f.Objects[key] = obj
|
|
}
|
|
|
|
// List returns deterministic prefix-filtered objects.
|
|
func (f *FakeBackend) List(ctx context.Context, prefix string) ([]ObjectInfo, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
if f.ListErr != nil {
|
|
return nil, f.ListErr
|
|
}
|
|
|
|
f.mu.RLock()
|
|
defer f.mu.RUnlock()
|
|
normalizedPrefix := normalizeObjectKey(prefix)
|
|
keys := make([]string, 0, len(f.Objects))
|
|
for key := range f.Objects {
|
|
if strings.HasPrefix(key, normalizedPrefix) {
|
|
keys = append(keys, key)
|
|
}
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
out := make([]ObjectInfo, 0, len(keys))
|
|
for _, key := range keys {
|
|
obj := f.Objects[key]
|
|
out = append(out, ObjectInfo{
|
|
Key: obj.Key,
|
|
Size: int64(len(obj.Data)),
|
|
ETag: obj.ETag,
|
|
LastModified: obj.LastModified,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Read returns a stable object body and the generation observed with it.
|
|
func (f *FakeBackend) Read(ctx context.Context, key string) (ObjectInfo, io.ReadCloser, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return ObjectInfo{}, nil, err
|
|
}
|
|
if f.DownloadErr != nil {
|
|
return ObjectInfo{}, nil, f.DownloadErr
|
|
}
|
|
normalizedKey := normalizeObjectKey(key)
|
|
f.mu.RLock()
|
|
obj, ok := f.Objects[normalizedKey]
|
|
if ok {
|
|
obj.Data = append([]byte(nil), obj.Data...)
|
|
obj.Metadata = copyMetadata(obj.Metadata)
|
|
}
|
|
f.mu.RUnlock()
|
|
if !ok {
|
|
return ObjectInfo{}, nil, fmt.Errorf("read object %q: %w", normalizedKey, os.ErrNotExist)
|
|
}
|
|
f.mu.Lock()
|
|
f.Reads = append(f.Reads, FakeReadCall{Key: normalizedKey})
|
|
f.mu.Unlock()
|
|
return ObjectInfo{Key: obj.Key, Size: int64(len(obj.Data)), ETag: obj.ETag, LastModified: obj.LastModified}, io.NopCloser(bytes.NewReader(obj.Data)), nil
|
|
}
|
|
|
|
// DownloadTo writes one object to a caller-owned destination writer.
|
|
func (f *FakeBackend) DownloadTo(ctx context.Context, key string, destination io.Writer) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if f.DownloadErr != nil {
|
|
return f.DownloadErr
|
|
}
|
|
if destination == nil {
|
|
return fmt.Errorf("download object: destination writer is required")
|
|
}
|
|
if f.DownloadHook != nil {
|
|
if err := f.DownloadHook(FakeDownloadCall{Key: normalizeObjectKey(key)}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
_, source, err := f.Read(ctx, key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer source.Close()
|
|
count, err := io.Copy(destination, source)
|
|
if err != nil {
|
|
return fmt.Errorf("download object %q: write destination: %w", key, err)
|
|
}
|
|
f.mu.Lock()
|
|
f.Downloads = append(f.Downloads, FakeDownloadCall{Key: normalizeObjectKey(key), Bytes: count})
|
|
f.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// Download writes one object to a local path.
|
|
func (f *FakeBackend) Download(ctx context.Context, key, localPath string) error {
|
|
if strings.TrimSpace(localPath) == "" {
|
|
return fmt.Errorf("download object: local path is required")
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil {
|
|
return fmt.Errorf("download object %q: create parent directory: %w", key, err)
|
|
}
|
|
destination, err := os.Create(localPath)
|
|
if err != nil {
|
|
return fmt.Errorf("download object %q: create local file: %w", key, err)
|
|
}
|
|
defer destination.Close()
|
|
return f.DownloadTo(ctx, key, destination)
|
|
}
|
|
|
|
// Upload reads a local file and stores it under key.
|
|
func (f *FakeBackend) Upload(ctx context.Context, localPath, key string, opts UploadOptions) (ObjectInfo, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return ObjectInfo{}, err
|
|
}
|
|
if f.UploadErr != nil {
|
|
return ObjectInfo{}, f.UploadErr
|
|
}
|
|
if strings.TrimSpace(localPath) == "" {
|
|
return ObjectInfo{}, fmt.Errorf("upload object: local path is required")
|
|
}
|
|
if strings.TrimSpace(key) == "" {
|
|
return ObjectInfo{}, fmt.Errorf("upload object: key is required")
|
|
}
|
|
|
|
file, err := os.Open(localPath)
|
|
if err != nil {
|
|
return ObjectInfo{}, fmt.Errorf("upload object %q from %q: %w", key, localPath, err)
|
|
}
|
|
defer file.Close()
|
|
return f.uploadReader(ctx, file, key, opts, localPath)
|
|
}
|
|
|
|
// UploadReader stores content provided by a caller-owned reader.
|
|
func (f *FakeBackend) UploadReader(ctx context.Context, source io.Reader, key string, opts UploadOptions) (ObjectInfo, error) {
|
|
return f.uploadReader(ctx, source, key, opts, "reader")
|
|
}
|
|
|
|
func (f *FakeBackend) uploadReader(ctx context.Context, source io.Reader, key string, opts UploadOptions, localPath string) (ObjectInfo, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return ObjectInfo{}, err
|
|
}
|
|
if f.UploadErr != nil {
|
|
return ObjectInfo{}, f.UploadErr
|
|
}
|
|
if source == nil {
|
|
return ObjectInfo{}, fmt.Errorf("upload object: source is required")
|
|
}
|
|
if strings.TrimSpace(key) == "" {
|
|
return ObjectInfo{}, fmt.Errorf("upload object: key is required")
|
|
}
|
|
|
|
data, err := io.ReadAll(source)
|
|
if err != nil {
|
|
return ObjectInfo{}, fmt.Errorf("upload object %q from %q: %w", key, localPath, err)
|
|
}
|
|
|
|
normalizedKey := normalizeObjectKey(key)
|
|
call := FakeUploadCall{
|
|
LocalPath: localPath,
|
|
Key: normalizedKey,
|
|
Options: UploadOptions{
|
|
Metadata: copyMetadata(opts.Metadata),
|
|
ContentType: opts.ContentType,
|
|
},
|
|
}
|
|
f.mu.Lock()
|
|
f.Uploads = append(f.Uploads, call)
|
|
f.mu.Unlock()
|
|
if f.UploadHook != nil {
|
|
if err := f.UploadHook(call); err != nil {
|
|
return ObjectInfo{}, err
|
|
}
|
|
}
|
|
return f.storeUploadedObject(normalizedKey, data, opts), nil
|
|
}
|
|
|
|
// UploadConditional atomically checks and replaces one mutable object.
|
|
func (f *FakeBackend) UploadConditional(ctx context.Context, source io.Reader, key string, opts UploadOptions, condition WriteCondition) (ObjectInfo, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return ObjectInfo{}, err
|
|
}
|
|
if err := validateWriteCondition(condition); err != nil {
|
|
return ObjectInfo{}, err
|
|
}
|
|
if f.UploadErr != nil {
|
|
return ObjectInfo{}, f.UploadErr
|
|
}
|
|
if source == nil {
|
|
return ObjectInfo{}, fmt.Errorf("upload object: source is required")
|
|
}
|
|
normalizedKey := normalizeObjectKey(key)
|
|
if normalizedKey == "" {
|
|
return ObjectInfo{}, fmt.Errorf("upload object: key is required")
|
|
}
|
|
data, err := io.ReadAll(source)
|
|
if err != nil {
|
|
return ObjectInfo{}, fmt.Errorf("upload object %q: %w", normalizedKey, err)
|
|
}
|
|
call := FakeUploadCall{Key: normalizedKey, Options: UploadOptions{Metadata: copyMetadata(opts.Metadata), ContentType: opts.ContentType}}
|
|
f.mu.Lock()
|
|
f.Uploads = append(f.Uploads, call)
|
|
f.mu.Unlock()
|
|
if f.UploadHook != nil {
|
|
if err := f.UploadHook(call); err != nil {
|
|
return ObjectInfo{}, err
|
|
}
|
|
}
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
existing, found := f.Objects[normalizedKey]
|
|
if condition.RequireAbsent && found {
|
|
return ObjectInfo{}, ErrConditionNotMet
|
|
}
|
|
if expected := strings.TrimSpace(condition.MatchETag); expected != "" && (!found || existing.ETag != expected) {
|
|
return ObjectInfo{}, ErrConditionNotMet
|
|
}
|
|
return f.storeUploadedObjectLocked(normalizedKey, data, opts), nil
|
|
}
|
|
|
|
func (f *FakeBackend) storeUploadedObject(key string, data []byte, opts UploadOptions) ObjectInfo {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.storeUploadedObjectLocked(key, data, opts)
|
|
}
|
|
|
|
func (f *FakeBackend) storeUploadedObjectLocked(key string, data []byte, opts UploadOptions) ObjectInfo {
|
|
now := time.Now().UTC()
|
|
obj := FakeObject{Key: key, Data: append([]byte(nil), data...), Metadata: copyMetadata(opts.Metadata), LastModified: &now}
|
|
f.seedObject(obj)
|
|
return ObjectInfo{Key: key, Size: int64(len(data)), ETag: fakeObjectETag(data), LastModified: &now}
|
|
}
|
|
|
|
func fakeObjectETag(data []byte) string {
|
|
sum := sha256.Sum256(data)
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// Exists checks object presence.
|
|
func (f *FakeBackend) Exists(ctx context.Context, key string) (bool, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return false, err
|
|
}
|
|
if f.ExistsErr != nil {
|
|
return false, f.ExistsErr
|
|
}
|
|
f.mu.RLock()
|
|
_, ok := f.Objects[normalizeObjectKey(key)]
|
|
f.mu.RUnlock()
|
|
return ok, nil
|
|
}
|
|
|
|
func copyMetadata(in map[string]string) map[string]string {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(in))
|
|
for k, v := range in {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|