Skip to content

Commit 39e6e70

Browse files
committed
fix: copy source with query param
Signed-off-by: ferhat elmas <elmas.ferhat@gmail.com>
1 parent 26042b9 commit 39e6e70

3 files changed

Lines changed: 152 additions & 9 deletions

File tree

src/storage/protocols/s3/copy-source-parser.ts

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,38 @@
11
import { ERRORS } from '@internal/errors'
22

3+
const VERSION_ID_QUERY_DELIMITER = '?versionId='
4+
5+
function splitCopySourceVersion(copySource: string): {
6+
encodedPath: string
7+
sourceVersion?: string
8+
} {
9+
const versionQueryIdx = copySource.lastIndexOf(VERSION_ID_QUERY_DELIMITER)
10+
11+
if (versionQueryIdx === -1) {
12+
return {
13+
encodedPath: copySource,
14+
}
15+
}
16+
17+
const sourceVersion = copySource.slice(versionQueryIdx + VERSION_ID_QUERY_DELIMITER.length)
18+
if (!sourceVersion) {
19+
throw ERRORS.InvalidParameter('CopySource')
20+
}
21+
22+
return {
23+
encodedPath: copySource.slice(0, versionQueryIdx),
24+
sourceVersion,
25+
}
26+
}
27+
328
export function parseCopySource(copySource: string): {
429
bucketName: string
530
objectKey: string
631
sourceVersion?: string
732
} {
833
const normalizedCopySource = copySource.startsWith('/') ? copySource.slice(1) : copySource
9-
const [encodedPath, ...queryParts] = normalizedCopySource.split('?')
10-
const queryParams = queryParts.join('?')
34+
// Preserve raw '?' characters in partially encoded keys and only peel off a trailing versionId suffix.
35+
const { encodedPath, sourceVersion } = splitCopySourceVersion(normalizedCopySource)
1136

1237
let decodedPath = ''
1338
try {
@@ -16,6 +41,10 @@ export function parseCopySource(copySource: string): {
1641
throw ERRORS.InvalidParameter('CopySource')
1742
}
1843

44+
if (decodedPath.startsWith('/')) {
45+
decodedPath = decodedPath.slice(1)
46+
}
47+
1948
const separatorIdx = decodedPath.indexOf('/')
2049
if (separatorIdx <= 0) {
2150
throw ERRORS.MissingParameter('CopySource')
@@ -27,13 +56,6 @@ export function parseCopySource(copySource: string): {
2756
throw ERRORS.MissingParameter('CopySource')
2857
}
2958

30-
const searchParams = new URLSearchParams(queryParams)
31-
const sourceVersion = searchParams.get('versionId') || undefined
32-
33-
if (searchParams.has('versionId') && !sourceVersion) {
34-
throw ERRORS.InvalidParameter('CopySource')
35-
}
36-
3759
return {
3860
bucketName,
3961
objectKey,

src/test/s3-copy-source-parser.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@ import { ErrorCode, StorageBackendError } from '@internal/errors'
44
import { parseCopySource } from '../storage/protocols/s3/copy-source-parser'
55

66
describe('parseCopySource', () => {
7+
test('preserves raw question marks and hashes in partially encoded keys', () => {
8+
const objectKey = 'folder/일이삼?x=1#frag.png'
9+
const result = parseCopySource(`bucket/${encodeURI(objectKey)}`)
10+
11+
expect(result).toEqual({
12+
bucketName: 'bucket',
13+
objectKey,
14+
sourceVersion: undefined,
15+
})
16+
})
17+
718
test('preserves question marks in versionId query values', () => {
819
const result = parseCopySource(
920
'bucket/folder%20one/%EC%9D%BC%EC%9D%B4%EC%82%BC.png?versionId=v1?part=2'
@@ -28,6 +39,16 @@ describe('parseCopySource', () => {
2839
})
2940
})
3041

42+
test('accepts fully URL-encoded leading-slash CopySource values', () => {
43+
const result = parseCopySource(encodeURIComponent('/bucket/folder one/일이삼/🙂?#%.png'))
44+
45+
expect(result).toEqual({
46+
bucketName: 'bucket',
47+
objectKey: 'folder one/일이삼/🙂?#%.png',
48+
sourceVersion: undefined,
49+
})
50+
})
51+
3152
test('rejects an empty versionId query value', () => {
3253
expect(() => parseCopySource('bucket/key?versionId=')).toThrow(
3354
expect.objectContaining<Partial<StorageBackendError>>({

src/test/s3-protocol.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2703,6 +2703,68 @@ describe('S3 Protocol', () => {
27032703
expect(listedKeys).toEqual(expect.arrayContaining([sourceKey, destinationKey]))
27042704
})
27052705

2706+
it('can copy objects when partially encoded CopySource keeps raw ? and # in the key', async () => {
2707+
const bucketName = await createBucket(client)
2708+
const sourceKey = `copy-src-reserved-${randomUUID()}-일이삼?x=1#frag.png`
2709+
const destinationKey = `copy-dst-reserved-${randomUUID()}-🙂.jpg`
2710+
2711+
await client.send(
2712+
new PutObjectCommand({
2713+
Bucket: bucketName,
2714+
Key: sourceKey,
2715+
Body: Buffer.alloc(1024 * 128),
2716+
})
2717+
)
2718+
2719+
const copyObjectResp = await client.send(
2720+
new CopyObjectCommand({
2721+
Bucket: bucketName,
2722+
Key: destinationKey,
2723+
CopySource: encodeURI(`${bucketName}/${sourceKey}`),
2724+
})
2725+
)
2726+
expect(copyObjectResp.$metadata.httpStatusCode).toBe(200)
2727+
2728+
const listObjectsResp = await client.send(
2729+
new ListObjectsV2Command({
2730+
Bucket: bucketName,
2731+
})
2732+
)
2733+
const listedKeys = (listObjectsResp.Contents || []).map((item) => item.Key)
2734+
expect(listedKeys).toEqual(expect.arrayContaining([sourceKey, destinationKey]))
2735+
})
2736+
2737+
it('can copy objects using fully URL-encoded leading-slash CopySource', async () => {
2738+
const bucketName = await createBucket(client)
2739+
const sourceKey = getUnicodeObjectName()
2740+
const destinationKey = `copy-dst-leading-encoded-${randomUUID()}-🙂.jpg`
2741+
2742+
await client.send(
2743+
new PutObjectCommand({
2744+
Bucket: bucketName,
2745+
Key: sourceKey,
2746+
Body: Buffer.alloc(1024 * 128),
2747+
})
2748+
)
2749+
2750+
const copyObjectResp = await client.send(
2751+
new CopyObjectCommand({
2752+
Bucket: bucketName,
2753+
Key: destinationKey,
2754+
CopySource: encodeURIComponent(`/${bucketName}/${sourceKey}`),
2755+
})
2756+
)
2757+
expect(copyObjectResp.$metadata.httpStatusCode).toBe(200)
2758+
2759+
const listObjectsResp = await client.send(
2760+
new ListObjectsV2Command({
2761+
Bucket: bucketName,
2762+
})
2763+
)
2764+
const listedKeys = (listObjectsResp.Contents || []).map((item) => item.Key)
2765+
expect(listedKeys).toEqual(expect.arrayContaining([sourceKey, destinationKey]))
2766+
})
2767+
27062768
it('can upload part copy using Unicode keys in CopySource', async () => {
27072769
const bucketName = await createBucket(client)
27082770
const sourceKey = `copy-part-src-${randomUUID()}-일이삼-🙂.jpg`
@@ -2779,6 +2841,44 @@ describe('S3 Protocol', () => {
27792841
expect(listPartsResp.Parts?.length).toBe(1)
27802842
})
27812843

2844+
it('can upload part copy when partially encoded CopySource keeps raw ? and # in the key', async () => {
2845+
const bucketName = await createBucket(client)
2846+
const sourceKey = `copy-part-src-reserved-${randomUUID()}-일이삼?x=1#frag.png`
2847+
const destinationKey = `copy-part-dst-reserved-${randomUUID()}-🙂.jpg`
2848+
2849+
await uploadFile(client, bucketName, sourceKey, 8)
2850+
2851+
const createMultiPartUpload = new CreateMultipartUploadCommand({
2852+
Bucket: bucketName,
2853+
Key: destinationKey,
2854+
ContentType: 'image/jpg',
2855+
CacheControl: 'max-age=2000',
2856+
})
2857+
const createMultipartResp = await client.send(createMultiPartUpload)
2858+
expect(createMultipartResp.UploadId).toBeTruthy()
2859+
2860+
const uploadPartCopyResp = await client.send(
2861+
new UploadPartCopyCommand({
2862+
Bucket: bucketName,
2863+
Key: destinationKey,
2864+
UploadId: createMultipartResp.UploadId,
2865+
PartNumber: 1,
2866+
CopySource: encodeURI(`${bucketName}/${sourceKey}`),
2867+
CopySourceRange: 'bytes=0-4096',
2868+
})
2869+
)
2870+
expect(uploadPartCopyResp.CopyPartResult?.ETag).toBeTruthy()
2871+
2872+
const listPartsResp = await client.send(
2873+
new ListPartsCommand({
2874+
Bucket: bucketName,
2875+
Key: destinationKey,
2876+
UploadId: createMultipartResp.UploadId,
2877+
})
2878+
)
2879+
expect(listPartsResp.Parts?.length).toBe(1)
2880+
})
2881+
27822882
it('should not upload if the name contains invalid characters', async () => {
27832883
const bucketName = await createBucket(client)
27842884
const invalidObjectName = getInvalidObjectName()

0 commit comments

Comments
 (0)