mirror of
https://github.com/versity/versitygw.git
synced 2026-09-19 06:32:10 +00:00
fix: CopyObject with URL-encoded special chars
CopyObject was failing with NoSuchKey when source keys contained special
characters like {} or spaces. The X-Amz-Copy-Source header is URL-encoded
by clients, but ParseCopySource wasn't decoding before filesystem access.
Added url.QueryUnescape() to properly decode bucket and object names,
fixing copy operations for keys with special characters.
Fixing this also uncovered an errors with azure blob url encoding with
similar special character handling. Added this fix in for the integration
tests to pass.
Fixes #1832
Fixes #1637
This commit is contained in:
@@ -25,6 +25,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -950,7 +951,7 @@ func (az *Azure) CopyObject(ctx context.Context, input s3response.CopyObjectInpu
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Join([]string{*input.Bucket, *input.Key}, "/") == *input.CopySource {
|
||||
if srcBucket == *input.Bucket && srcObj == *input.Key {
|
||||
if input.MetadataDirective != types.MetadataDirectiveReplace {
|
||||
return s3response.CopyObjectOutput{}, s3err.GetAPIError(s3err.ErrInvalidCopyDest)
|
||||
}
|
||||
@@ -1858,7 +1859,10 @@ func (az *Azure) getContainerURL(cntr string) string {
|
||||
}
|
||||
|
||||
func (az *Azure) getBlobURL(cntr, blb string) string {
|
||||
return fmt.Sprintf("%v/%v", az.getContainerURL(cntr), blb)
|
||||
// URL-encode the blob name to handle special characters like {, }, #, spaces, etc.
|
||||
// Use PathEscape to encode the blob name while preserving forward slashes
|
||||
encodedBlob := url.PathEscape(blb)
|
||||
return fmt.Sprintf("%v/%v", az.getContainerURL(cntr), encodedBlob)
|
||||
}
|
||||
|
||||
func (az *Azure) getBlobClient(cntr, blb string) (*blob.Client, error) {
|
||||
|
||||
@@ -247,6 +247,17 @@ func ParseCopySource(copySourceHeader string) (string, string, string, error) {
|
||||
return "", "", "", s3err.GetAPIError(s3err.ErrInvalidCopySourceBucket)
|
||||
}
|
||||
|
||||
var err error
|
||||
// URL-decode the bucket and object names to handle special characters
|
||||
srcBucket, err = url.QueryUnescape(srcBucket)
|
||||
if err != nil {
|
||||
return "", "", "", s3err.GetAPIError(s3err.ErrInvalidCopySourceEncoding)
|
||||
}
|
||||
srcObject, err = url.QueryUnescape(srcObject)
|
||||
if err != nil {
|
||||
return "", "", "", s3err.GetAPIError(s3err.ErrInvalidCopySourceEncoding)
|
||||
}
|
||||
|
||||
return srcBucket, srcObject, versionId, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// Copyright 2026 Versity Software
|
||||
// This file is licensed under the Apache License, Version 2.0
|
||||
// (the "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
package backend
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/versity/versitygw/s3err"
|
||||
)
|
||||
|
||||
func TestParseCopySource(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
copySourceHeader string
|
||||
wantBucket string
|
||||
wantObject string
|
||||
wantVersionId string
|
||||
wantErr bool
|
||||
wantErrCode s3err.ErrorCode
|
||||
}{
|
||||
{
|
||||
name: "simple path",
|
||||
copySourceHeader: "mybucket/myobject",
|
||||
wantBucket: "mybucket",
|
||||
wantObject: "myobject",
|
||||
wantVersionId: "",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "path with leading slash",
|
||||
copySourceHeader: "/mybucket/myobject",
|
||||
wantBucket: "mybucket",
|
||||
wantObject: "myobject",
|
||||
wantVersionId: "",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "path with versionId",
|
||||
copySourceHeader: "mybucket/myobject?versionId=abc123",
|
||||
wantBucket: "mybucket",
|
||||
wantObject: "myobject",
|
||||
wantVersionId: "abc123",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "URL-encoded curly braces",
|
||||
copySourceHeader: "mybucket/myfolder/%7Be14c392b-09ad-4188-85f4-b779af00fb88%7D/testfile",
|
||||
wantBucket: "mybucket",
|
||||
wantObject: "myfolder/{e14c392b-09ad-4188-85f4-b779af00fb88}/testfile",
|
||||
wantVersionId: "",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "URL-encoded space",
|
||||
copySourceHeader: "mybucket/my%20object",
|
||||
wantBucket: "mybucket",
|
||||
wantObject: "my object",
|
||||
wantVersionId: "",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "URL-encoded special chars",
|
||||
copySourceHeader: "mybucket/obj%23%24%25%26",
|
||||
wantBucket: "mybucket",
|
||||
wantObject: "obj#$%&",
|
||||
wantVersionId: "",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "URL-encoded path with versionId",
|
||||
copySourceHeader: "mybucket/my%20folder/my%20object?versionId=xyz789",
|
||||
wantBucket: "mybucket",
|
||||
wantObject: "my folder/my object",
|
||||
wantVersionId: "xyz789",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid URL encoding - incomplete escape",
|
||||
copySourceHeader: "mybucket/object%",
|
||||
wantBucket: "",
|
||||
wantObject: "",
|
||||
wantVersionId: "",
|
||||
wantErr: true,
|
||||
wantErrCode: s3err.ErrInvalidCopySourceEncoding,
|
||||
},
|
||||
{
|
||||
name: "invalid URL encoding - invalid hex",
|
||||
copySourceHeader: "mybucket/object%ZZ",
|
||||
wantBucket: "",
|
||||
wantObject: "",
|
||||
wantVersionId: "",
|
||||
wantErr: true,
|
||||
wantErrCode: s3err.ErrInvalidCopySourceEncoding,
|
||||
},
|
||||
{
|
||||
name: "missing object",
|
||||
copySourceHeader: "mybucket",
|
||||
wantBucket: "",
|
||||
wantObject: "",
|
||||
wantVersionId: "",
|
||||
wantErr: true,
|
||||
wantErrCode: s3err.ErrInvalidCopySourceBucket,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotBucket, gotObject, gotVersionId, err := ParseCopySource(tt.copySourceHeader)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("ParseCopySource() error = nil, wantErr %v", tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, s3err.GetAPIError(tt.wantErrCode)) {
|
||||
t.Errorf("ParseCopySource() error = %v, want error code %v", err, tt.wantErrCode)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("ParseCopySource() unexpected error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if gotBucket != tt.wantBucket {
|
||||
t.Errorf("ParseCopySource() gotBucket = %v, want %v", gotBucket, tt.wantBucket)
|
||||
}
|
||||
if gotObject != tt.wantObject {
|
||||
t.Errorf("ParseCopySource() gotObject = %v, want %v", gotObject, tt.wantObject)
|
||||
}
|
||||
if gotVersionId != tt.wantVersionId {
|
||||
t.Errorf("ParseCopySource() gotVersionId = %v, want %v", gotVersionId, tt.wantVersionId)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user