diff --git a/weed/s3api/filer_multipart.go b/weed/s3api/filer_multipart.go index 8dca4cedc..de6b35ae8 100644 --- a/weed/s3api/filer_multipart.go +++ b/weed/s3api/filer_multipart.go @@ -11,7 +11,7 @@ import ( "fmt" "math" "net/url" - "path/filepath" + "path" "slices" "sort" "strconv" @@ -552,8 +552,8 @@ func (s3a *S3ApiServer) completeMultipartUpload(r *http.Request, input *s3.Compl } func (s3a *S3ApiServer) getEntryNameAndDir(input *s3.CompleteMultipartUploadInput) (string, string) { - entryName := filepath.Base(*input.Key) - dirName := filepath.ToSlash(filepath.Dir(*input.Key)) + entryName := path.Base(*input.Key) + dirName := path.Dir(*input.Key) if dirName == "." { dirName = "" } diff --git a/weed/s3api/filer_multipart_test.go b/weed/s3api/filer_multipart_test.go index 7f75a40de..bf4fa3ca2 100644 --- a/weed/s3api/filer_multipart_test.go +++ b/weed/s3api/filer_multipart_test.go @@ -1,12 +1,13 @@ package s3api import ( + "testing" + "time" + "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/s3" "github.com/seaweedfs/seaweedfs/weed/s3api/s3err" "github.com/stretchr/testify/assert" - "testing" - "time" ) func TestInitiateMultipartUploadResult(t *testing.T) { @@ -74,3 +75,60 @@ func Test_parsePartNumber(t *testing.T) { }) } } + +func TestGetEntryNameAndDir(t *testing.T) { + s3a := &S3ApiServer{ + option: &S3ApiServerOption{ + BucketsPath: "/buckets", + }, + } + + tests := []struct { + name string + bucket string + key string + expectedName string + expectedDirEnd string // We check the suffix since dir includes BucketsPath + }{ + { + name: "simple file at root", + bucket: "test-bucket", + key: "/file.txt", + expectedName: "file.txt", + expectedDirEnd: "/buckets/test-bucket", + }, + { + name: "file in subdirectory", + bucket: "test-bucket", + key: "/folder/file.txt", + expectedName: "file.txt", + expectedDirEnd: "/buckets/test-bucket/folder", + }, + { + name: "file in nested subdirectory", + bucket: "test-bucket", + key: "/folder/subfolder/file.txt", + expectedName: "file.txt", + expectedDirEnd: "/buckets/test-bucket/folder/subfolder", + }, + { + name: "key without leading slash", + bucket: "test-bucket", + key: "folder/file.txt", + expectedName: "file.txt", + expectedDirEnd: "/buckets/test-bucket/folder", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input := &s3.CompleteMultipartUploadInput{ + Bucket: aws.String(tt.bucket), + Key: aws.String(tt.key), + } + entryName, dirName := s3a.getEntryNameAndDir(input) + assert.Equal(t, tt.expectedName, entryName, "entry name mismatch") + assert.Equal(t, tt.expectedDirEnd, dirName, "directory mismatch") + }) + } +} diff --git a/weed/s3api/s3_constants/header.go b/weed/s3api/s3_constants/header.go index b7e1be9e5..4b34f397e 100644 --- a/weed/s3api/s3_constants/header.go +++ b/weed/s3api/s3_constants/header.go @@ -146,7 +146,10 @@ func GetBucketAndObject(r *http.Request) (bucket, object string) { // NormalizeObjectKey ensures the object key has a leading slash and no duplicate slashes. // This normalizes keys from various sources (URL path, form values, etc.) to a consistent format. +// It also converts Windows-style backslashes to forward slashes for cross-platform compatibility. func NormalizeObjectKey(object string) string { + // Convert Windows-style backslashes to forward slashes + object = strings.ReplaceAll(object, "\\", "/") object = removeDuplicateSlashes(object) if !strings.HasPrefix(object, "/") { object = "/" + object diff --git a/weed/s3api/s3_constants/header_test.go b/weed/s3api/s3_constants/header_test.go new file mode 100644 index 000000000..b16cfc6a8 --- /dev/null +++ b/weed/s3api/s3_constants/header_test.go @@ -0,0 +1,132 @@ +package s3_constants + +import ( + "testing" +) + +func TestNormalizeObjectKey(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "simple key", + input: "file.txt", + expected: "/file.txt", + }, + { + name: "key with leading slash", + input: "/file.txt", + expected: "/file.txt", + }, + { + name: "key with directory", + input: "folder/file.txt", + expected: "/folder/file.txt", + }, + { + name: "key with leading slash and directory", + input: "/folder/file.txt", + expected: "/folder/file.txt", + }, + { + name: "key with duplicate slashes", + input: "folder//subfolder///file.txt", + expected: "/folder/subfolder/file.txt", + }, + { + name: "Windows backslash - simple", + input: "folder\\file.txt", + expected: "/folder/file.txt", + }, + { + name: "Windows backslash - nested", + input: "folder\\subfolder\\file.txt", + expected: "/folder/subfolder/file.txt", + }, + { + name: "Windows backslash - with leading slash", + input: "/folder\\subfolder\\file.txt", + expected: "/folder/subfolder/file.txt", + }, + { + name: "mixed slashes", + input: "folder\\subfolder/another\\file.txt", + expected: "/folder/subfolder/another/file.txt", + }, + { + name: "Windows full path style (edge case)", + input: "C:\\Users\\test\\file.txt", + expected: "/C:/Users/test/file.txt", + }, + { + name: "empty string", + input: "", + expected: "/", + }, + { + name: "just a slash", + input: "/", + expected: "/", + }, + { + name: "just a backslash", + input: "\\", + expected: "/", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := NormalizeObjectKey(tt.input) + if result != tt.expected { + t.Errorf("NormalizeObjectKey(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestRemoveDuplicateSlashes(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "no duplicates", + input: "/folder/file.txt", + expected: "/folder/file.txt", + }, + { + name: "double slash", + input: "/folder//file.txt", + expected: "/folder/file.txt", + }, + { + name: "triple slash", + input: "/folder///file.txt", + expected: "/folder/file.txt", + }, + { + name: "multiple duplicate locations", + input: "//folder//subfolder///file.txt", + expected: "/folder/subfolder/file.txt", + }, + { + name: "empty string", + input: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := removeDuplicateSlashes(tt.input) + if result != tt.expected { + t.Errorf("removeDuplicateSlashes(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + diff --git a/weed/s3api/s3api_object_handlers_put.go b/weed/s3api/s3api_object_handlers_put.go index 3da9047ac..7c73f4ce0 100644 --- a/weed/s3api/s3api_object_handlers_put.go +++ b/weed/s3api/s3api_object_handlers_put.go @@ -9,7 +9,7 @@ import ( "io" "net/http" "net/url" - "path/filepath" + "path" "strconv" "strings" "time" @@ -491,7 +491,7 @@ func (s3a *S3ApiServer) putToFiler(r *http.Request, filePath string, dataReader // Create entry entry := &filer_pb.Entry{ - Name: filepath.Base(filePath), + Name: path.Base(filePath), IsDirectory: false, Attributes: &filer_pb.FuseAttributes{ Crtime: now.Unix(), @@ -611,10 +611,10 @@ func (s3a *S3ApiServer) putToFiler(r *http.Request, filePath string, dataReader // Use context.Background() to ensure metadata save completes even if HTTP request is cancelled // This matches the chunk upload behavior and prevents orphaned chunks glog.V(3).Infof("putToFiler: About to create entry - dir=%s, name=%s, chunks=%d, extended keys=%d", - filepath.Dir(filePath), filepath.Base(filePath), len(entry.Chunks), len(entry.Extended)) + path.Dir(filePath), path.Base(filePath), len(entry.Chunks), len(entry.Extended)) createErr := s3a.WithFilerClient(false, func(client filer_pb.SeaweedFilerClient) error { req := &filer_pb.CreateEntryRequest{ - Directory: filepath.Dir(filePath), + Directory: path.Dir(filePath), Entry: entry, } glog.V(3).Infof("putToFiler: Calling CreateEntry for %s", filePath)