s3: url-encode ListMultipartUploads results for encoding-type=url (#11998)

* s3: url-encode ListMultipartUploads results for encoding-type=url

The response echoed EncodingType=url but returned raw upload keys, and
XML 1.0 cannot carry control characters, so the encoder replaced them
with U+FFFD. The listed key then matched no upload: AbortMultipartUpload
with it answered 404 NoSuchUpload, leaving such uploads uncleanable.

Encode the key-named elements AWS encodes for this API (Key, Prefix,
Delimiter, KeyMarker, NextKeyMarker) the way ListObjects encodes its
keys when encoding-type=url is requested. Upload ids stay opaque, and
responses without encoding-type=url are unchanged.

Fixes #11991

* s3: echo EncodingType only when encoding-type=url is honored

A request with an unrecognized encoding-type echoed the value back in
<EncodingType> while the keys stayed raw, matching neither AWS (which
omits the element unless url encoding applies) nor the ListObjects
handlers, which gate the echo on the parsed url flag.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: chrislusf <chrislusf@users.noreply.github.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-23 18:21:26 -07:00
1 parent 12d0537215
commit 3810fb1123
3 files changed
+103 -3

No files matched your search

+27 -1
View File
@@ -1400,7 +1400,6 @@ func (p *multipartUploadPage) result() *ListMultipartUploadsResult {
output := &ListMultipartUploadsResult{
Bucket: input.Bucket,
Delimiter: input.Delimiter,
EncodingType: input.EncodingType,
KeyMarker: input.KeyMarker,
MaxUploads: aws.Int64(int64(p.limit)),
Prefix: input.Prefix,
@@ -1425,9 +1424,36 @@ func (p *multipartUploadPage) result() *ListMultipartUploadsResult {
output.IsTruncated = aws.Bool(true)
output.NextKeyMarker, output.NextUploadIdMarker = last.Key, last.UploadId
}
if aws.StringValue(input.EncodingType) == s3.EncodingTypeUrl {
// AWS url-encodes these key-named elements when encoding-type=url is
// requested; without it XML 1.0 cannot carry the keys at all and the
// encoder replaces their characters with U+FFFD.
output.EncodingType = input.EncodingType
output.Delimiter = encodeMultipartUploadURLField(output.Delimiter)
output.Prefix = encodeMultipartUploadURLField(output.Prefix)
output.KeyMarker = encodeMultipartUploadURLField(output.KeyMarker)
output.NextKeyMarker = encodeMultipartUploadURLField(output.NextKeyMarker)
for _, upload := range output.Upload {
upload.Key = encodeMultipartUploadURLField(upload.Key)
}
}
return output
}
// encodeMultipartUploadURLField url-encodes key text the way ListObjects does:
// every "/"-separated segment is query-escaped and the separators are kept, so a
// client recovers the exact key with url.QueryUnescape.
func encodeMultipartUploadURLField(value *string) *string {
if value == nil {
return nil
}
segments := strings.Split(*value, "/")
for i, segment := range segments {
segments[i] = url.QueryEscape(segment)
}
return aws.String(strings.Join(segments, "/"))
}
type ListPartsResult struct {
XMLName xml.Name `xml:"http://s3.amazonaws.com/doc/2006-03-01/ ListPartsResult"`
@@ -0,0 +1,76 @@
package s3api
import (
"encoding/xml"
"net/url"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// With encoding-type=url the upload keys have to be url-encoded, as ListObjects
// does; otherwise XML 1.0 cannot carry a key with a control character, the
// encoder turns it into U+FFFD, and no client can recover the key to abort the
// upload.
func TestSelectMultipartUploadsEncodingTypeURL(t *testing.T) {
entries := []*filer_pb.Entry{
uploadEntry("1", "m\x01p b+.bin", 10),
uploadEntry("2", "plain/key.txt", 20),
}
input := listUploadsInput("m\x01", "m\x01", "", 1000)
input.EncodingType = aws.String(s3.EncodingTypeUrl)
out := selectMultipartUploads(input, entries)
require.Len(t, out.Upload, 1)
assert.Equal(t, "m%01p+b%2B.bin", *out.Upload[0].Key)
decoded, err := url.QueryUnescape(*out.Upload[0].Key)
require.NoError(t, err)
assert.Equal(t, "m\x01p b+.bin", decoded)
assert.Equal(t, "m%01", *out.Prefix)
assert.Equal(t, "m%01", *out.KeyMarker)
body, err := xml.Marshal(out)
require.NoError(t, err)
assert.NotContains(t, string(body), "\uFFFD")
assert.Contains(t, string(body), "<Key>m%01p+b%2B.bin</Key>")
assert.Contains(t, string(body), "<EncodingType>url</EncodingType>")
}
func TestSelectMultipartUploadsEncodingTypeURLNextKeyMarker(t *testing.T) {
entries := []*filer_pb.Entry{
uploadEntry("1", "a\x1fb", 10),
uploadEntry("2", "c", 20),
}
input := listUploadsInput("", "", "", 1)
input.EncodingType = aws.String(s3.EncodingTypeUrl)
out := selectMultipartUploads(input, entries)
require.True(t, *out.IsTruncated)
assert.Equal(t, "a%1Fb", *out.NextKeyMarker)
// The upload id is opaque, not a key name.
assert.Equal(t, "1", *out.NextUploadIdMarker)
body, err := xml.Marshal(out)
require.NoError(t, err)
assert.NotContains(t, string(body), "\uFFFD")
}
// Without encoding-type=url the response is unchanged, and the keys keep their
// raw form for clients that do not ask for encoding.
func TestSelectMultipartUploadsKeepsKeysRawWithoutEncodingType(t *testing.T) {
entries := []*filer_pb.Entry{
uploadEntry("1", "m\x01p b+.bin", 10),
uploadEntry("2", "plain/key.txt", 20),
}
out := selectMultipartUploads(listUploadsInput("", "", "", 1000), entries)
var got []string
for _, upload := range out.Upload {
got = append(got, *upload.Key)
}
assert.Equal(t, []string{"m\x01p b+.bin", "plain/key.txt"}, got)
}
@@ -290,8 +290,6 @@ func (s3a *S3ApiServer) ListMultipartUploadsHandler(w http.ResponseWriter, r *ht
return
}
// TODO handle encodingType
writeSuccessResponseXML(w, r, response)
}