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 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 } // 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) } 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 }