From 626adbf14c7726ad61887c290f9a34d7947dca84 Mon Sep 17 00:00:00 2001 From: Vlad Mocanu <1106868+vtmocanu@users.noreply.github.com> Date: Wed, 8 Apr 2026 22:01:34 +0300 Subject: [PATCH] fix(storage): strip Root prefix from S3 List() returned paths (#1413) --- backend/internal/storage/s3.go | 9 +++++- backend/internal/storage/s3_test.go | 44 +++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/backend/internal/storage/s3.go b/backend/internal/storage/s3.go index 4bb5f062..2ac68ea7 100644 --- a/backend/internal/storage/s3.go +++ b/backend/internal/storage/s3.go @@ -138,7 +138,7 @@ func (s *s3Storage) List(ctx context.Context, path string) ([]ObjectInfo, error) continue } objects = append(objects, ObjectInfo{ - Path: aws.ToString(obj.Key), + Path: s.pathFromKey(aws.ToString(obj.Key)), Size: aws.ToInt64(obj.Size), ModTime: aws.ToTime(obj.LastModified), }) @@ -147,6 +147,13 @@ func (s *s3Storage) List(ctx context.Context, path string) ([]ObjectInfo, error) return objects, nil } +func (s *s3Storage) pathFromKey(key string) string { + if s.prefix == "" { + return key + } + return strings.TrimPrefix(key, s.prefix+"/") +} + func (s *s3Storage) Walk(ctx context.Context, root string, fn func(ObjectInfo) error) error { objects, err := s.List(ctx, root) if err != nil { diff --git a/backend/internal/storage/s3_test.go b/backend/internal/storage/s3_test.go index d3d790ff..7f0ca455 100644 --- a/backend/internal/storage/s3_test.go +++ b/backend/internal/storage/s3_test.go @@ -35,6 +35,50 @@ func TestS3Helpers(t *testing.T) { } }) + t.Run("pathFromKey strips prefix to honor relative-path contract", func(t *testing.T) { + tests := []struct { + name string + prefix string + key string + expected string + }{ + {name: "no prefix returns key unchanged", prefix: "", key: "images/logo.png", expected: "images/logo.png"}, + {name: "no prefix empty key", prefix: "", key: "", expected: ""}, + {name: "prefix matches and is stripped", prefix: "data/uploads", key: "data/uploads/application-images/logo.svg", expected: "application-images/logo.svg"}, + {name: "single-segment prefix stripped", prefix: "root", key: "root/foo/bar.txt", expected: "foo/bar.txt"}, + {name: "prefix equal to key without trailing slash is unchanged", prefix: "root", key: "root", expected: "root"}, + {name: "key without expected prefix returned unchanged", prefix: "data/uploads", key: "other/path.txt", expected: "other/path.txt"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s := &s3Storage{ + bucket: "bucket", + prefix: tc.prefix, + } + assert.Equal(t, tc.expected, s.pathFromKey(tc.key)) + }) + } + }) + + t.Run("pathFromKey is the inverse of buildObjectKey for clean paths", func(t *testing.T) { + paths := []string{ + "images/logo.png", + "application-images/logo.svg", + "oidc-client-images/abc.png", + "deeply/nested/file.bin", + } + prefixes := []string{"", "root", "data/uploads"} + + for _, prefix := range prefixes { + for _, p := range paths { + s := &s3Storage{bucket: "bucket", prefix: prefix} + assert.Equal(t, p, s.pathFromKey(s.buildObjectKey(p)), + "round-trip failed for prefix=%q path=%q", prefix, p) + } + } + }) + t.Run("isS3NotFound detects expected errors", func(t *testing.T) { assert.True(t, isS3NotFound(&smithy.GenericAPIError{Code: "NoSuchKey"})) assert.True(t, isS3NotFound(&smithy.GenericAPIError{Code: "NotFound"}))