|
|
@ -339,20 +339,41 @@ func (s3a *S3ApiServer) CopyObjectHandler(w http.ResponseWriter, r *http.Request |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
func pathToBucketAndObject(path string) (bucket, object string) { |
|
|
func pathToBucketAndObject(path string) (bucket, object string) { |
|
|
|
|
|
// Remove leading slash if present
|
|
|
path = strings.TrimPrefix(path, "/") |
|
|
path = strings.TrimPrefix(path, "/") |
|
|
|
|
|
|
|
|
|
|
|
// Split by first slash to separate bucket and object
|
|
|
parts := strings.SplitN(path, "/", 2) |
|
|
parts := strings.SplitN(path, "/", 2) |
|
|
if len(parts) == 2 { |
|
|
if len(parts) == 2 { |
|
|
return parts[0], "/" + parts[1] |
|
|
|
|
|
|
|
|
bucket = parts[0] |
|
|
|
|
|
object = "/" + parts[1] |
|
|
|
|
|
return bucket, object |
|
|
} |
|
|
} |
|
|
return parts[0], "/" |
|
|
|
|
|
|
|
|
// Only bucket provided, no object - this is invalid in copy operations
|
|
|
|
|
|
return parts[0], "" |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
func pathToBucketObjectAndVersion(path string) (bucket, object, versionId string) { |
|
|
func pathToBucketObjectAndVersion(path string) (bucket, object, versionId string) { |
|
|
// Parse versionId from query string if present
|
|
|
|
|
|
|
|
|
// Parse versionId from query string if present ONLY at path boundaries
|
|
|
// Format: /bucket/object?versionId=version-id
|
|
|
// Format: /bucket/object?versionId=version-id
|
|
|
|
|
|
// Must ensure we're not matching "?versionId=" that's part of the object name itself
|
|
|
|
|
|
|
|
|
|
|
|
// Look for ?versionId= that comes after the bucket/object path
|
|
|
|
|
|
// The key insight: a real query parameter must either be at the end or followed by &
|
|
|
if idx := strings.Index(path, "?versionId="); idx != -1 { |
|
|
if idx := strings.Index(path, "?versionId="); idx != -1 { |
|
|
versionId = path[idx+len("?versionId="):] // dynamically calculate length
|
|
|
|
|
|
path = path[:idx] |
|
|
|
|
|
|
|
|
// Extract everything after "?versionId="
|
|
|
|
|
|
afterMarker := path[idx+len("?versionId="):] |
|
|
|
|
|
// Check if this looks like a real query parameter (ends string or has &)
|
|
|
|
|
|
endIdx := strings.Index(afterMarker, "&") |
|
|
|
|
|
if endIdx == -1 { |
|
|
|
|
|
// versionId goes to end of string
|
|
|
|
|
|
versionId = afterMarker |
|
|
|
|
|
path = path[:idx] |
|
|
|
|
|
} else { |
|
|
|
|
|
// versionId ends at &
|
|
|
|
|
|
versionId = afterMarker[:endIdx] |
|
|
|
|
|
path = path[:idx] |
|
|
|
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
bucket, object = pathToBucketAndObject(path) |
|
|
bucket, object = pathToBucketAndObject(path) |
|
|
@ -370,15 +391,28 @@ func (s3a *S3ApiServer) CopyObjectPartHandler(w http.ResponseWriter, r *http.Req |
|
|
dstBucket, dstObject := s3_constants.GetBucketAndObject(r) |
|
|
dstBucket, dstObject := s3_constants.GetBucketAndObject(r) |
|
|
|
|
|
|
|
|
// Copy source path.
|
|
|
// Copy source path.
|
|
|
cpSrcPath, err := url.QueryUnescape(r.Header.Get("X-Amz-Copy-Source")) |
|
|
|
|
|
|
|
|
cpSrcPath := r.Header.Get("X-Amz-Copy-Source") |
|
|
|
|
|
|
|
|
|
|
|
// Try URL unescaping - AWS SDK sends URL-encoded copy sources
|
|
|
|
|
|
unescapedPath, err := url.QueryUnescape(cpSrcPath) |
|
|
if err != nil { |
|
|
if err != nil { |
|
|
// Save unescaped string as is.
|
|
|
|
|
|
cpSrcPath = r.Header.Get("X-Amz-Copy-Source") |
|
|
|
|
|
|
|
|
// If unescaping fails, log and use original
|
|
|
|
|
|
glog.V(2).Infof("CopyObjectPart: Failed to unescape copy source %q: %v, using as-is", cpSrcPath, err) |
|
|
|
|
|
} else { |
|
|
|
|
|
cpSrcPath = unescapedPath |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
glog.V(2).Infof("CopyObjectPart: Copy source header=%q, after unescape=%q", r.Header.Get("X-Amz-Copy-Source"), cpSrcPath) |
|
|
|
|
|
|
|
|
srcBucket, srcObject, srcVersionId := pathToBucketObjectAndVersion(cpSrcPath) |
|
|
srcBucket, srcObject, srcVersionId := pathToBucketObjectAndVersion(cpSrcPath) |
|
|
|
|
|
|
|
|
|
|
|
glog.V(2).Infof("CopyObjectPart: Parsed srcBucket=%q, srcObject=%q, srcVersionId=%q", srcBucket, srcObject, srcVersionId) |
|
|
|
|
|
|
|
|
// If source object is empty or bucket is empty, reply back invalid copy source.
|
|
|
// If source object is empty or bucket is empty, reply back invalid copy source.
|
|
|
|
|
|
// Note: srcObject can be "/" for root-level objects, but empty string means parsing failed
|
|
|
if srcObject == "" || srcBucket == "" { |
|
|
if srcObject == "" || srcBucket == "" { |
|
|
|
|
|
glog.Errorf("CopyObjectPart: Invalid copy source - srcBucket=%q, srcObject=%q (original header: %q)", |
|
|
|
|
|
srcBucket, srcObject, r.Header.Get("X-Amz-Copy-Source")) |
|
|
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidCopySource) |
|
|
s3err.WriteErrorResponse(w, r, s3err.ErrInvalidCopySource) |
|
|
return |
|
|
return |
|
|
} |
|
|
} |
|
|
|