Skip to content

Commit 938eef7

Browse files
committed
fix: backup compat
Signed-off-by: ferhat elmas <elmas.ferhat@gmail.com>
1 parent 4557b92 commit 938eef7

4 files changed

Lines changed: 124 additions & 9 deletions

File tree

src/storage/backend/s3/adapter.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
UploadPart,
3333
withOptionalVersion,
3434
} from './../adapter'
35+
import { encodeCopySource } from './copy-source'
3536

3637
const {
3738
storageS3UploadQueueSize,
@@ -52,13 +53,6 @@ export interface S3ClientOptions {
5253
requestTimeout?: number
5354
}
5455

55-
function encodeCopySource(bucket: string, key: string): string {
56-
return `${encodeURIComponent(bucket)}/${key
57-
.split('/')
58-
.map((pathToken) => encodeURIComponent(pathToken))
59-
.join('/')}`
60-
}
61-
6256
/**
6357
* S3Backend
6458
* Interacts with a s3-compatible file system with this S3Adapter

src/storage/backend/s3/backup.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
S3Client,
88
UploadPartCopyCommand,
99
} from '@aws-sdk/client-s3'
10+
import { encodeCopySource } from './copy-source'
1011

1112
const FIVE_GB = 5 * 1024 * 1024 * 1024
1213

@@ -69,7 +70,7 @@ export class ObjectBackup {
6970
const copyParams = {
7071
Bucket: destinationBucket,
7172
Key: destinationKey,
72-
CopySource: encodeURIComponent(`/${sourceBucket}/${sourceKey}`),
73+
CopySource: encodeCopySource(sourceBucket, sourceKey),
7374
}
7475

7576
const copyCommand = new CopyObjectCommand(copyParams)
@@ -157,7 +158,7 @@ export class ObjectBackup {
157158
Key: destinationKey,
158159
PartNumber: partNumber,
159160
UploadId: uploadId,
160-
CopySource: encodeURIComponent(`/${sourceBucket}/${sourceKey}`),
161+
CopySource: encodeCopySource(sourceBucket, sourceKey),
161162
CopySourceRange: `bytes=${start}-${end}`,
162163
})
163164

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export function encodeCopySource(bucket: string, key: string): string {
2+
return `${encodeURIComponent(bucket)}/${key
3+
.split('/')
4+
.map((pathToken) => encodeURIComponent(pathToken))
5+
.join('/')}`
6+
}

src/test/s3-backup.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
'use strict'
2+
3+
import {
4+
CompleteMultipartUploadCommand,
5+
CopyObjectCommand,
6+
CreateMultipartUploadCommand,
7+
S3Client,
8+
UploadPartCopyCommand,
9+
} from '@aws-sdk/client-s3'
10+
import { ObjectBackup } from '../storage/backend/s3/backup'
11+
12+
jest.mock('@aws-sdk/client-s3', () => {
13+
const originalModule = jest.requireActual('@aws-sdk/client-s3')
14+
return {
15+
...originalModule,
16+
S3Client: jest.fn().mockImplementation(() => ({
17+
send: jest.fn(),
18+
})),
19+
}
20+
})
21+
22+
const encodeCopySourceByPathToken = (bucket: string, key: string) =>
23+
`${encodeURIComponent(bucket)}/${key
24+
.split('/')
25+
.map((pathToken) => encodeURIComponent(pathToken))
26+
.join('/')}`
27+
28+
describe('ObjectBackup', () => {
29+
let mockSend: jest.Mock
30+
let client: S3Client
31+
32+
beforeEach(() => {
33+
jest.clearAllMocks()
34+
mockSend = jest.fn()
35+
;(S3Client as jest.Mock).mockImplementation(() => ({
36+
send: mockSend,
37+
}))
38+
client = new S3Client({}) as unknown as S3Client
39+
})
40+
41+
test('singleCopy preserves path separators for Unicode source keys', async () => {
42+
mockSend.mockResolvedValue({})
43+
44+
const sourceKey = 'folder one/일이삼/子目录/🙂?#%.png'
45+
const destinationKey = 'backup/folder/복사본.png'
46+
47+
const backup = new ObjectBackup(client, {
48+
sourceBucket: 'source-bucket',
49+
sourceKey,
50+
destinationBucket: 'backup-bucket',
51+
destinationKey,
52+
size: 1024,
53+
})
54+
55+
await backup.backup()
56+
57+
expect(mockSend).toHaveBeenCalledTimes(1)
58+
const command = mockSend.mock.calls[0][0] as CopyObjectCommand
59+
expect(command).toBeInstanceOf(CopyObjectCommand)
60+
expect(command.input.CopySource).toBe(encodeCopySourceByPathToken('source-bucket', sourceKey))
61+
expect(command.input.CopySource).toContain('source-bucket/folder%20one/')
62+
expect(command.input.CopySource).not.toContain('%2Fsource-bucket%2F')
63+
expect(command.input.CopySource).not.toContain('source-bucket%2F')
64+
})
65+
66+
test('multipartCopy preserves path separators for Unicode source keys', async () => {
67+
mockSend.mockImplementation((command: unknown) => {
68+
if (command instanceof CreateMultipartUploadCommand) {
69+
return Promise.resolve({ UploadId: 'upload-id' })
70+
}
71+
72+
if (command instanceof UploadPartCopyCommand) {
73+
return Promise.resolve({
74+
CopyPartResult: {
75+
ETag: `"etag-${command.input.PartNumber}"`,
76+
},
77+
})
78+
}
79+
80+
if (command instanceof CompleteMultipartUploadCommand) {
81+
return Promise.resolve({})
82+
}
83+
84+
return Promise.resolve({})
85+
})
86+
87+
const sourceKey = 'folder one/일이삼/子目录/🙂?#%.png'
88+
const destinationKey = 'backup/folder/복사본.png'
89+
const partSize = 5 * 1024 * 1024 * 1024
90+
const backup = new ObjectBackup(client, {
91+
sourceBucket: 'source-bucket',
92+
sourceKey,
93+
destinationBucket: 'backup-bucket',
94+
destinationKey,
95+
size: partSize + 1024,
96+
})
97+
98+
await backup.backup()
99+
100+
const uploadPartCommands = mockSend.mock.calls
101+
.map(([command]) => command)
102+
.filter(
103+
(command): command is UploadPartCopyCommand => command instanceof UploadPartCopyCommand
104+
)
105+
106+
expect(uploadPartCommands).toHaveLength(2)
107+
for (const command of uploadPartCommands) {
108+
expect(command.input.CopySource).toBe(encodeCopySourceByPathToken('source-bucket', sourceKey))
109+
expect(command.input.CopySource).toContain('source-bucket/folder%20one/')
110+
expect(command.input.CopySource).not.toContain('%2Fsource-bucket%2F')
111+
expect(command.input.CopySource).not.toContain('source-bucket%2F')
112+
}
113+
})
114+
})

0 commit comments

Comments
 (0)