diff --git a/spindle/artifactstore/artifactstore.go b/spindle/artifactstore/artifactstore.go index 855a6c51c..dfedf2c4f 100644 --- a/spindle/artifactstore/artifactstore.go +++ b/spindle/artifactstore/artifactstore.go @@ -7,12 +7,11 @@ import ( "io" "os" "path/filepath" - "strings" - "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/s3" spindleconfig "tangled.org/core/spindle/config" + "tangled.org/core/spindle/storage" ) type Writer interface { @@ -30,154 +29,35 @@ type Store interface { Delete(ctx context.Context, ref string) error } -type DiskStore struct { - root string -} - -func NewDiskStore(root string) (*DiskStore, error) { - if root == "" { - return nil, fmt.Errorf("artifact disk directory is required") - } - return &DiskStore{root: filepath.Clean(root)}, nil -} - -func (s *DiskStore) Put(_ context.Context, ref string, r io.Reader) error { - path, err := s.resolve(ref) - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return fmt.Errorf("mkdir for artifact %q: %w", path, err) - } - tmpFile, err := os.CreateTemp(filepath.Dir(path), ".tmp-artifact-*") - if err != nil { - return fmt.Errorf("create temp artifact: %w", err) - } - tmpPath := tmpFile.Name() - defer func() { - _ = tmpFile.Close() - _ = os.Remove(tmpPath) - }() - if _, err := io.Copy(tmpFile, r); err != nil { - return fmt.Errorf("write artifact content: %w", err) - } - if err := tmpFile.Sync(); err != nil { - return fmt.Errorf("sync artifact file: %w", err) - } - if err := tmpFile.Close(); err != nil { - return fmt.Errorf("close artifact file: %w", err) - } - if err := os.Rename(tmpPath, path); err != nil { - return fmt.Errorf("rename artifact file to target: %w", err) - } - return nil -} - -func (s *DiskStore) Open(_ context.Context, ref string) (io.ReadCloser, error) { - path, err := s.resolve(ref) - if err != nil { - return nil, err - } - f, err := os.Open(path) - if err != nil { - return nil, fmt.Errorf("open disk artifact %q: %w", path, err) - } - return f, nil -} - -func (s *DiskStore) Delete(_ context.Context, ref string) error { - path, err := s.resolve(ref) - if err != nil { - return err - } - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("delete disk artifact %q: %w", path, err) - } - return nil -} - -func (s *DiskStore) resolve(ref string) (string, error) { - if ref == "" || filepath.IsAbs(ref) { - return "", fmt.Errorf("invalid disk artifact ref %q", ref) - } - path := filepath.Join(s.root, filepath.Clean(ref)) - rel, err := filepath.Rel(s.root, path) - // reject the store root - if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return "", fmt.Errorf("artifact ref %q escapes disk root %q", ref, s.root) - } - return path, nil -} - -type s3API interface { - PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) - GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error) - DeleteObject(ctx context.Context, params *s3.DeleteObjectInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) +type storageStore struct { + storage.Storage } -type S3Store struct { - client s3API - bucket string +func (s storageStore) Open(ctx context.Context, ref string) (io.ReadCloser, error) { + return s.Get(ctx, ref) } -func NewS3Store(client s3API, bucket string) (*S3Store, error) { - if client == nil { - return nil, fmt.Errorf("s3 client is required") - } - if bucket == "" { - return nil, fmt.Errorf("artifact S3 bucket is required") - } - return &S3Store{client: client, bucket: bucket}, nil +type diskStore struct { + storageStore + root string } -func (s *S3Store) Put(ctx context.Context, ref string, r io.Reader) error { - if err := validateObjectRef(ref); err != nil { - return err - } - _, err := s.client.PutObject(ctx, &s3.PutObjectInput{ - Bucket: aws.String(s.bucket), - Key: aws.String(ref), - Body: r, - }) +func newDiskStore(root string) (*diskStore, error) { + backend, err := storage.NewDisk(root) if err != nil { - return fmt.Errorf("s3 put object: %w", err) - } - return nil -} - -func (s *S3Store) Open(ctx context.Context, ref string) (io.ReadCloser, error) { - if err := validateObjectRef(ref); err != nil { return nil, err } - res, err := s.client.GetObject(ctx, &s3.GetObjectInput{ - Bucket: aws.String(s.bucket), - Key: aws.String(ref), - }) - if err != nil { - return nil, fmt.Errorf("s3 get object: %w", err) - } - return res.Body, nil -} - -func (s *S3Store) Delete(ctx context.Context, ref string) error { - if err := validateObjectRef(ref); err != nil { - return err - } - _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ - Bucket: aws.String(s.bucket), - Key: aws.String(ref), - }) - if err != nil { - return fmt.Errorf("s3 delete object: %w", err) - } - return nil + return &diskStore{ + storageStore: storageStore{Storage: backend}, + root: filepath.Clean(root), + }, nil } -func validateObjectRef(ref string) error { - if ref == "" || strings.HasPrefix(ref, "/") || strings.Contains(ref, "://") { - return fmt.Errorf("invalid artifact ref %q", ref) +func (s *diskStore) target(ref string) (string, error) { + if err := storage.ValidateKey(ref); err != nil { + return "", err } - return nil + return filepath.Join(s.root, filepath.FromSlash(ref)), nil } type Stores struct { @@ -192,7 +72,7 @@ func NewStores(cfg spindleconfig.ArtifactStores, diskFallback, legacyS3Bucket st diskDir = diskFallback } if diskDir != "" { - disk, err := NewDiskStore(diskDir) + disk, err := newDiskStore(diskDir) if err != nil { return nil, err } @@ -209,12 +89,12 @@ func NewStores(cfg spindleconfig.ArtifactStores, diskFallback, legacyS3Bucket st if err != nil { return nil, fmt.Errorf("load aws config: %w", err) } - s3Store, err := NewS3Store(s3.NewFromConfig(awsCfg), bucket) + backend, err := storage.NewS3(s3.NewFromConfig(awsCfg), bucket, "") if err != nil { return nil, err } stores.order = append(stores.order, "s3") - stores.stores["s3"] = s3Store + stores.stores["s3"] = storageStore{Storage: backend} } return stores, nil } @@ -244,8 +124,8 @@ func (s *Stores) PutFile(ctx context.Context, ref, sourcePath string) []error { var errs []error for _, name := range s.order { store := s.stores[name] - if disk, ok := store.(*DiskStore); ok { - target, err := disk.resolve(ref) + if disk, ok := store.(*diskStore); ok { + target, err := disk.target(ref) if err == nil { source, sourceErr := filepath.Abs(sourcePath) targetAbs, targetErr := filepath.Abs(target) diff --git a/spindle/artifactstore/artifactstore_test.go b/spindle/artifactstore/artifactstore_test.go index 57b58690e..604cffdd2 100644 --- a/spindle/artifactstore/artifactstore_test.go +++ b/spindle/artifactstore/artifactstore_test.go @@ -1,182 +1,130 @@ package artifactstore import ( - "bytes" "context" + "errors" "io" "os" + "path/filepath" "strings" - "sync" "testing" - "github.com/aws/aws-sdk-go-v2/service/s3" + spindleconfig "tangled.org/core/spindle/config" ) -type mockS3Client struct { - mu sync.Mutex - store map[string][]byte +type memoryStore struct { + objects map[string][]byte + putErr error + openErr error } -func newMockS3Client() *mockS3Client { - return &mockS3Client{ - store: make(map[string][]byte), +func (s *memoryStore) Put(_ context.Context, ref string, r io.Reader) error { + if s.putErr != nil { + return s.putErr } -} - -func (m *mockS3Client) PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) { - m.mu.Lock() - defer m.mu.Unlock() - b, err := io.ReadAll(params.Body) + data, err := io.ReadAll(r) if err != nil { - return nil, err + return err } - key := *params.Bucket + "/" + *params.Key - m.store[key] = b - return &s3.PutObjectOutput{}, nil -} - -func (m *mockS3Client) DeleteObject(ctx context.Context, params *s3.DeleteObjectInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) { - m.mu.Lock() - defer m.mu.Unlock() - key := *params.Bucket + "/" + *params.Key - delete(m.store, key) - return &s3.DeleteObjectOutput{}, nil + s.objects[ref] = data + return nil } -func (m *mockS3Client) GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error) { - m.mu.Lock() - defer m.mu.Unlock() - key := *params.Bucket + "/" + *params.Key - data, ok := m.store[key] +func (s *memoryStore) Open(_ context.Context, ref string) (io.ReadCloser, error) { + if s.openErr != nil { + return nil, s.openErr + } + data, ok := s.objects[ref] if !ok { return nil, os.ErrNotExist } - return &s3.GetObjectOutput{ - Body: io.NopCloser(bytes.NewReader(data)), - }, nil + return io.NopCloser(strings.NewReader(string(data))), nil +} + +func (s *memoryStore) Delete(_ context.Context, ref string) error { + delete(s.objects, ref) + return nil } -func TestDiskStore(t *testing.T) { - tempDir := t.TempDir() - store, err := NewDiskStore(tempDir) +func TestStoresDiskRoundTrip(t *testing.T) { + root := t.TempDir() + stores, err := NewStores(spindleconfig.ArtifactStores{ + Disk: spindleconfig.ArtifactStoreDisk{Dir: root}, + }, "", "") if err != nil { t.Fatal(err) } - ctx := context.Background() - ref := "logs/test.log" - content := "hello world log content" - - if err := store.Put(ctx, ref, strings.NewReader(content)); err != nil { - t.Fatalf("Put failed: %v", err) + source := filepath.Join(t.TempDir(), "source.log") + if err := os.WriteFile(source, []byte("hello world log content"), 0o644); err != nil { + t.Fatal(err) + } + if errs := stores.PutFile(context.Background(), "logs/test.log", source); len(errs) != 0 { + t.Fatalf("PutFile errors = %v", errs) } - rc, err := store.Open(ctx, ref) + rc, err := stores.Open(context.Background(), "logs/test.log") if err != nil { - t.Fatalf("Open failed: %v", err) + t.Fatal(err) } defer rc.Close() - got, err := io.ReadAll(rc) if err != nil { - t.Fatalf("ReadAll failed: %v", err) + t.Fatal(err) } - if string(got) != content { - t.Fatalf("got content %q, want %q", string(got), content) + if string(got) != "hello world log content" { + t.Fatalf("content = %q", got) } } -func TestDiskStoreTraversalProtection(t *testing.T) { - tempDir := t.TempDir() - store, err := NewDiskStore(tempDir) +func TestStoresRejectTraversal(t *testing.T) { + stores, err := NewStores(spindleconfig.ArtifactStores{ + Disk: spindleconfig.ArtifactStoreDisk{Dir: t.TempDir()}, + }, "", "") if err != nil { t.Fatal(err) } - ctx := context.Background() - badRef := "../outside" - - err = store.Put(ctx, badRef, strings.NewReader("bad")) - if err == nil { - t.Fatal("expected error putting file outside diskDir, got nil") + source := filepath.Join(t.TempDir(), "source.log") + if err := os.WriteFile(source, []byte("bad"), 0o644); err != nil { + t.Fatal(err) } - - _, err = store.Open(ctx, badRef) - if err == nil { - t.Fatal("expected error opening file outside diskDir, got nil") + if errs := stores.PutFile(context.Background(), "../outside", source); len(errs) != 1 { + t.Fatalf("PutFile errors = %v, want one", errs) + } + if _, err := stores.Open(context.Background(), "../outside"); err == nil { + t.Fatal("Open with traversal ref succeeded") } } -func TestS3Store(t *testing.T) { - mock := newMockS3Client() - store, err := NewS3Store(mock, "mybucket") - if err != nil { - t.Fatal(err) +func TestStoresFanOutAndReadFallback(t *testing.T) { + firstErr := errors.New("first store unavailable") + first := &memoryStore{objects: make(map[string][]byte), putErr: firstErr, openErr: firstErr} + second := &memoryStore{objects: make(map[string][]byte)} + stores := &Stores{ + order: []string{"first", "second"}, + stores: map[string]Store{"first": first, "second": second}, } - ctx := context.Background() - ref := "logs/run1.log" - content := "s3 log payload" - - if err := store.Put(ctx, ref, strings.NewReader(content)); err != nil { - t.Fatalf("Put to S3 failed: %v", err) + source := filepath.Join(t.TempDir(), "source.log") + if err := os.WriteFile(source, []byte("fallback content"), 0o644); err != nil { + t.Fatal(err) + } + errs := stores.PutFile(context.Background(), "logs/test.log", source) + if len(errs) != 1 || !errors.Is(errs[0], firstErr) { + t.Fatalf("PutFile errors = %v", errs) } - rc, err := store.Open(ctx, ref) + rc, err := stores.Open(context.Background(), "logs/test.log") if err != nil { - t.Fatalf("Open from S3 failed: %v", err) + t.Fatal(err) } defer rc.Close() - got, err := io.ReadAll(rc) if err != nil { - t.Fatalf("ReadAll failed: %v", err) + t.Fatal(err) } - if string(got) != content { - t.Fatalf("got content %q, want %q", string(got), content) + if string(got) != "fallback content" { + t.Fatalf("content = %q", got) } } - -func TestStoreDelete(t *testing.T) { - ctx := context.Background() - - t.Run("disk", func(t *testing.T) { - store, err := NewDiskStore(t.TempDir()) - if err != nil { - t.Fatal(err) - } - if err := store.Put(ctx, "logs/l1.log", strings.NewReader("data")); err != nil { - t.Fatal(err) - } - if err := store.Delete(ctx, "logs/l1.log"); err != nil { - t.Fatal(err) - } - if _, err := store.Open(ctx, "logs/l1.log"); err == nil { - t.Fatal("expected open to fail after delete") - } - // deleting a missing ref is fine - if err := store.Delete(ctx, "logs/l1.log"); err != nil { - t.Fatal(err) - } - }) - - t.Run("s3", func(t *testing.T) { - mock := newMockS3Client() - store, err := NewS3Store(mock, "bucket") - if err != nil { - t.Fatal(err) - } - if err := store.Put(ctx, "logs/l1.log", strings.NewReader("data")); err != nil { - t.Fatal(err) - } - if err := store.Delete(ctx, "logs/l1.log"); err != nil { - t.Fatal(err) - } - if _, err := store.Open(ctx, "logs/l1.log"); err == nil { - t.Fatal("expected open to fail after delete") - } - if err := store.Delete(ctx, "logs/l1.log"); err != nil { - t.Fatal(err) - } - }) -} diff --git a/spindle/storage/disk.go b/spindle/storage/disk.go new file mode 100644 index 000000000..670a58b63 --- /dev/null +++ b/spindle/storage/disk.go @@ -0,0 +1,91 @@ +package storage + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" +) + +type Disk struct { + root string +} + +func NewDisk(root string) (*Disk, error) { + if root == "" { + return nil, fmt.Errorf("storage: disk backend requires a directory") + } + if err := os.MkdirAll(root, 0o755); err != nil { + return nil, fmt.Errorf("storage: create disk root: %w", err) + } + return &Disk{root: filepath.Clean(root)}, nil +} + +func (d *Disk) path(key string) (string, error) { + if err := ValidateKey(key); err != nil { + return "", err + } + return filepath.Join(d.root, filepath.FromSlash(key)), nil +} + +func (d *Disk) Get(_ context.Context, key string) (io.ReadCloser, error) { + p, err := d.path(key) + if err != nil { + return nil, err + } + f, err := os.Open(p) + if err != nil { + if os.IsNotExist(err) { + return nil, ErrNotExist + } + return nil, fmt.Errorf("storage: get %q: %w", key, err) + } + return f, nil +} + +func (d *Disk) Put(_ context.Context, key string, r io.Reader) error { + p, err := d.path(key) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return fmt.Errorf("storage: put %q: %w", key, err) + } + // dont let readers see a partial object + tmp, err := os.CreateTemp(filepath.Dir(p), ".tmp-*") + if err != nil { + return fmt.Errorf("storage: put %q: %w", key, err) + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + if _, err := io.Copy(tmp, r); err != nil { + _ = tmp.Close() + return fmt.Errorf("storage: put %q: %w", key, err) + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return fmt.Errorf("storage: put %q: %w", key, err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("storage: put %q: %w", key, err) + } + if err := os.Rename(tmpName, p); err != nil { + return fmt.Errorf("storage: put %q: %w", key, err) + } + return nil +} + +func (d *Disk) Delete(_ context.Context, key string) error { + p, err := d.path(key) + if err != nil { + return err + } + if err := os.Remove(p); err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("storage: delete %q: %w", key, err) + } + return nil +} diff --git a/spindle/storage/s3.go b/spindle/storage/s3.go new file mode 100644 index 000000000..ab28ba12b --- /dev/null +++ b/spindle/storage/s3.go @@ -0,0 +1,96 @@ +package storage + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" +) + +type S3Client interface { + PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) + GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error) + DeleteObject(ctx context.Context, params *s3.DeleteObjectInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) +} + +type S3 struct { + bucket string + prefix string + client S3Client +} + +func NewS3(client S3Client, bucket, prefix string) (*S3, error) { + if client == nil { + return nil, fmt.Errorf("storage: s3 backend requires a client") + } + if bucket == "" { + return nil, fmt.Errorf("storage: s3 backend requires a bucket") + } + return &S3{ + bucket: bucket, + prefix: strings.Trim(prefix, "/"), + client: client, + }, nil +} + +func (s *S3) fullKey(key string) (string, error) { + if err := ValidateKey(key); err != nil { + return "", err + } + if s.prefix == "" { + return key, nil + } + return s.prefix + "/" + key, nil +} + +func (s *S3) Get(ctx context.Context, key string) (io.ReadCloser, error) { + full, err := s.fullKey(key) + if err != nil { + return nil, err + } + res, err := s.client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: &s.bucket, + Key: &full, + }) + if err != nil { + var nsk *types.NoSuchKey + if errors.As(err, &nsk) { + return nil, ErrNotExist + } + return nil, fmt.Errorf("storage: get %q: %w", key, err) + } + return res.Body, nil +} + +func (s *S3) Put(ctx context.Context, key string, r io.Reader) error { + full, err := s.fullKey(key) + if err != nil { + return err + } + if _, err := s.client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: &s.bucket, + Key: &full, + Body: r, + }); err != nil { + return fmt.Errorf("storage: put %q: %w", key, err) + } + return nil +} + +func (s *S3) Delete(ctx context.Context, key string) error { + full, err := s.fullKey(key) + if err != nil { + return err + } + if _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: &s.bucket, + Key: &full, + }); err != nil { + return fmt.Errorf("storage: delete %q: %w", key, err) + } + return nil +} diff --git a/spindle/storage/storage.go b/spindle/storage/storage.go new file mode 100644 index 000000000..dcf8b9f2d --- /dev/null +++ b/spindle/storage/storage.go @@ -0,0 +1,32 @@ +package storage + +import ( + "context" + "errors" + "fmt" + "io" + "regexp" + "strings" +) + +var ErrNotExist = errors.New("storage: object does not exist") + +type Storage interface { + Get(ctx context.Context, key string) (io.ReadCloser, error) + Put(ctx context.Context, key string, r io.Reader) error + Delete(ctx context.Context, key string) error +} + +var keyRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/@%+=-]{0,511}$`) + +func ValidateKey(key string) error { + if !keyRe.MatchString(key) { + return fmt.Errorf("storage: invalid key %q", key) + } + for _, seg := range strings.Split(key, "/") { + if seg == "" || seg == "." || seg == ".." { + return fmt.Errorf("storage: invalid key %q", key) + } + } + return nil +} diff --git a/spindle/storage/storage_test.go b/spindle/storage/storage_test.go new file mode 100644 index 000000000..3a6b2036e --- /dev/null +++ b/spindle/storage/storage_test.go @@ -0,0 +1,170 @@ +package storage + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "sync" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" +) + +type mockS3Client struct { + mu sync.Mutex + objects map[string][]byte +} + +func (m *mockS3Client) PutObject(_ context.Context, params *s3.PutObjectInput, _ ...func(*s3.Options)) (*s3.PutObjectOutput, error) { + m.mu.Lock() + defer m.mu.Unlock() + data, err := io.ReadAll(params.Body) + if err != nil { + return nil, err + } + m.objects[*params.Bucket+"/"+*params.Key] = data + return &s3.PutObjectOutput{}, nil +} + +func (m *mockS3Client) GetObject(_ context.Context, params *s3.GetObjectInput, _ ...func(*s3.Options)) (*s3.GetObjectOutput, error) { + m.mu.Lock() + defer m.mu.Unlock() + data, ok := m.objects[*params.Bucket+"/"+*params.Key] + if !ok { + return nil, &types.NoSuchKey{} + } + return &s3.GetObjectOutput{Body: io.NopCloser(bytes.NewReader(data))}, nil +} + +func (m *mockS3Client) DeleteObject(_ context.Context, params *s3.DeleteObjectInput, _ ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.objects, *params.Bucket+"/"+*params.Key) + return &s3.DeleteObjectOutput{}, nil +} +func TestValidateKey(t *testing.T) { + valid := []string{ + "did:plc:xyz123/go-mod-v1", + "did:web:spindle.example.com/cache.tar", + "abc", + "a/b/c/d", + } + for _, k := range valid { + if err := ValidateKey(k); err != nil { + t.Errorf("ValidateKey(%q) = %v, want nil", k, err) + } + } + + invalid := []string{ + "", + "../escape", + "a/../../b", + "/leading", + "trailing/", + "double//slash", + "with space", + "with\\backslash", + "-leading-dash", + } + for _, k := range invalid { + if err := ValidateKey(k); err == nil { + t.Errorf("ValidateKey(%q) = nil, want error", k) + } + } +} + +func TestDiskRoundTrip(t *testing.T) { + ctx := context.Background() + d, err := NewDisk(t.TempDir()) + if err != nil { + t.Fatal(err) + } + + key := "did:plc:xyz/go-mod-v1" + if err := d.Put(ctx, key, strings.NewReader("archive-bytes")); err != nil { + t.Fatal(err) + } + + rc, err := d.Get(ctx, key) + if err != nil { + t.Fatal(err) + } + got, err := io.ReadAll(rc) + rc.Close() + if err != nil { + t.Fatal(err) + } + if string(got) != "archive-bytes" { + t.Fatalf("got %q, want %q", got, "archive-bytes") + } + + // overwrite + if err := d.Put(ctx, key, strings.NewReader("new-bytes")); err != nil { + t.Fatal(err) + } + rc, _ = d.Get(ctx, key) + got, _ = io.ReadAll(rc) + rc.Close() + if string(got) != "new-bytes" { + t.Fatalf("overwrite: got %q, want %q", got, "new-bytes") + } + + if err := d.Delete(ctx, key); err != nil { + t.Fatalf("delete: %v", err) + } + if _, err := d.Get(ctx, key); !errors.Is(err, ErrNotExist) { + t.Fatalf("get deleted: got %v, want ErrNotExist", err) + } + if err := d.Delete(ctx, key); err != nil { + t.Fatalf("delete missing: %v", err) + } +} + +func TestDiskRejectsTraversal(t *testing.T) { + ctx := context.Background() + d, err := NewDisk(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if err := d.Put(ctx, "../evil", strings.NewReader("x")); err == nil { + t.Fatal("put with traversal key succeeded") + } + if _, err := d.Get(ctx, "../evil"); err == nil { + t.Fatal("get with traversal key succeeded") + } +} + +func TestS3RoundTrip(t *testing.T) { + ctx := context.Background() + client := &mockS3Client{objects: make(map[string][]byte)} + store, err := NewS3(client, "bucket", "prefix") + if err != nil { + t.Fatal(err) + } + + if err := store.Put(ctx, "logs/test.log", strings.NewReader("artifact")); err != nil { + t.Fatal(err) + } + rc, err := store.Get(ctx, "logs/test.log") + if err != nil { + t.Fatal(err) + } + got, err := io.ReadAll(rc) + rc.Close() + if err != nil { + t.Fatal(err) + } + if string(got) != "artifact" { + t.Fatalf("content = %q", got) + } + + if err := store.Delete(ctx, "logs/test.log"); err != nil { + t.Fatal(err) + } + if _, err := store.Get(ctx, "logs/test.log"); !errors.Is(err, ErrNotExist) { + t.Fatalf("get deleted = %v, want ErrNotExist", err) + } +}