Skip to content

Commit caf577d

Browse files
authored
fix(storage): omit unserializable properties when hash uploadDataOptions (#14151)
2 parents 3606482 + ced3b8f commit caf577d

File tree

4 files changed

+112
-12
lines changed

4 files changed

+112
-12
lines changed

packages/aws-amplify/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -515,7 +515,7 @@
515515
"name": "[Storage] uploadData (S3)",
516516
"path": "./dist/esm/storage/index.mjs",
517517
"import": "{ uploadData }",
518-
"limit": "22.87 kB"
518+
"limit": "22.95 kB"
519519
}
520520
]
521521
}

packages/storage/__tests__/providers/s3/apis/internal/uploadData/multipartHandlers.test.ts

Lines changed: 79 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,10 @@ const bucket = 'bucket';
4444
const region = 'region';
4545
const defaultKey = 'key';
4646
const defaultContentType = 'application/octet-stream';
47-
const defaultCacheKey =
48-
'Jz3O2w==_8388608_application/octet-stream_bucket_public_key';
47+
const emptyOptionHash = 'o6a/Qw=='; // crc32 for '{}'
48+
const defaultCacheKey = `${emptyOptionHash}_8388608_application/octet-stream_bucket_public_key`;
4949
const testPath = 'testPath/object';
50-
const testPathCacheKey = `Jz3O2w==_8388608_${defaultContentType}_${bucket}_custom_${testPath}`;
50+
const testPathCacheKey = `${emptyOptionHash}_8388608_${defaultContentType}_${bucket}_custom_${testPath}`;
5151

5252
const mockCreateMultipartUpload = jest.mocked(createMultipartUpload);
5353
const mockUploadPart = jest.mocked(uploadPart);
@@ -545,14 +545,14 @@ describe('getMultipartUploadHandlers with key', () => {
545545
describe('cache validation', () => {
546546
it.each([
547547
{
548-
name: 'wrong part count',
548+
name: 'mismatched part count between cached upload and actual upload',
549549
parts: [{ PartNumber: 1 }, { PartNumber: 2 }, { PartNumber: 3 }],
550550
},
551551
{
552-
name: 'wrong part numbers',
552+
name: 'mismatched part numbers between cached upload and actual upload',
553553
parts: [{ PartNumber: 1 }, { PartNumber: 1 }],
554554
},
555-
])('should throw with $name', async ({ parts }) => {
555+
])('should throw integrity error with $name', async ({ parts }) => {
556556
mockMultipartUploadSuccess();
557557

558558
const mockDefaultStorage = defaultStorage as jest.Mocked<
@@ -619,6 +619,42 @@ describe('getMultipartUploadHandlers with key', () => {
619619
expect(mockListParts).not.toHaveBeenCalled();
620620
});
621621

622+
it('should omit unserializable option properties when calculating the hash key', async () => {
623+
mockMultipartUploadSuccess();
624+
const serializableOptions = {
625+
useAccelerateEndpoint: true,
626+
bucket: { bucketName: 'bucket', region: 'us-west-2' },
627+
expectedBucketOwner: '123',
628+
contentDisposition: 'attachment',
629+
contentEncoding: 'deflate',
630+
contentType: 'text/html',
631+
customEndpoint: 'abc',
632+
metadata: {},
633+
preventOverwrite: true,
634+
checksumAlgorithm: 'crc-32' as const,
635+
};
636+
const size = 8 * MB;
637+
const { multipartUploadJob } = getMultipartUploadHandlers(
638+
{
639+
key: defaultKey,
640+
data: new ArrayBuffer(size),
641+
options: {
642+
...serializableOptions,
643+
// The following options will be omitted
644+
locationCredentialsProvider: jest.fn(),
645+
onProgress: jest.fn(),
646+
resumableUploadsCache: mockDefaultStorage,
647+
},
648+
},
649+
size,
650+
);
651+
await multipartUploadJob();
652+
expect(mockCalculateContentCRC32).toHaveBeenNthCalledWith(
653+
1,
654+
JSON.stringify(serializableOptions),
655+
);
656+
});
657+
622658
it('should send createMultipartUpload request if the upload task is not cached', async () => {
623659
mockMultipartUploadSuccess();
624660
const size = 8 * MB;
@@ -693,7 +729,7 @@ describe('getMultipartUploadHandlers with key', () => {
693729
expect(Object.keys(cacheValue)).toEqual([
694730
expect.stringMatching(
695731
// \d{13} is the file lastModified property of a file
696-
/someName_\d{13}_Jz3O2w==_8388608_application\/octet-stream_bucket_public_key/,
732+
new RegExp(`someName_\\d{13}_${defaultCacheKey}`),
697733
),
698734
]);
699735
});
@@ -1451,6 +1487,42 @@ describe('getMultipartUploadHandlers with path', () => {
14511487
expect(mockListParts).not.toHaveBeenCalled();
14521488
});
14531489

1490+
it('should omit unserializable option properties when calculating the hash key', async () => {
1491+
mockMultipartUploadSuccess();
1492+
const serializableOptions = {
1493+
useAccelerateEndpoint: true,
1494+
bucket: { bucketName: 'bucket', region: 'us-west-2' },
1495+
expectedBucketOwner: '123',
1496+
contentDisposition: 'attachment',
1497+
contentEncoding: 'deflate',
1498+
contentType: 'text/html',
1499+
customEndpoint: 'abc',
1500+
metadata: {},
1501+
preventOverwrite: true,
1502+
checksumAlgorithm: 'crc-32' as const,
1503+
};
1504+
const size = 8 * MB;
1505+
const { multipartUploadJob } = getMultipartUploadHandlers(
1506+
{
1507+
path: testPath,
1508+
data: new ArrayBuffer(size),
1509+
options: {
1510+
...serializableOptions,
1511+
// The following options will be omitted
1512+
locationCredentialsProvider: jest.fn(),
1513+
onProgress: jest.fn(),
1514+
resumableUploadsCache: mockDefaultStorage,
1515+
},
1516+
},
1517+
size,
1518+
);
1519+
await multipartUploadJob();
1520+
expect(mockCalculateContentCRC32).toHaveBeenNthCalledWith(
1521+
1,
1522+
JSON.stringify(serializableOptions),
1523+
);
1524+
});
1525+
14541526
it('should send createMultipartUpload request if the upload task is cached but outdated', async () => {
14551527
mockDefaultStorage.getItem.mockResolvedValue(
14561528
JSON.stringify({

packages/storage/src/providers/s3/apis/internal/uploadData/multipart/uploadCache.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { UPLOADS_STORAGE_KEY } from '../../../../utils/constants';
1010
import { ResolvedS3Config } from '../../../../types/options';
1111
import { Part, listParts } from '../../../../utils/client/s3data';
1212
import { logger } from '../../../../../../utils';
13+
// TODO: Remove this interface when we move to public advanced APIs.
14+
import { UploadDataInput as UploadDataWithPathInputWithAdvancedOptions } from '../../../../../../internals/types/inputs';
1315

1416
const ONE_HOUR = 1000 * 60 * 60;
1517

@@ -96,6 +98,28 @@ const listCachedUploadTasks = async (
9698
}
9799
};
98100

101+
/**
102+
* Serialize the uploadData API options to string so it can be hashed.
103+
*/
104+
export const serializeUploadOptions = (
105+
options: UploadDataWithPathInputWithAdvancedOptions['options'] & {
106+
resumableUploadsCache?: KeyValueStorageInterface;
107+
} = {},
108+
): string => {
109+
const unserializableOptionProperties: string[] = [
110+
'onProgress',
111+
'resumableUploadsCache', // Internally injected implementation not set by customers
112+
'locationCredentialsProvider', // Internally injected implementation not set by customers
113+
] satisfies (keyof typeof options)[];
114+
const serializableOptions = Object.fromEntries(
115+
Object.entries(options).filter(
116+
([key]) => !unserializableOptionProperties.includes(key),
117+
),
118+
);
119+
120+
return JSON.stringify(serializableOptions);
121+
};
122+
99123
interface UploadsCacheKeyOptions {
100124
size: number;
101125
contentType?: string;

packages/storage/src/providers/s3/apis/internal/uploadData/multipart/uploadHandlers.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,11 @@ import { StorageOperationOptionsInput } from '../../../../../../types/inputs';
4040
import { IntegrityError } from '../../../../../../errors/IntegrityError';
4141

4242
import { uploadPartExecutor } from './uploadPartExecutor';
43-
import { getUploadsCacheKey, removeCachedUpload } from './uploadCache';
43+
import {
44+
getUploadsCacheKey,
45+
removeCachedUpload,
46+
serializeUploadOptions,
47+
} from './uploadCache';
4448
import { getConcurrentUploadsProgressTracker } from './progressTracker';
4549
import { loadOrCreateMultipartUpload } from './initialUpload';
4650
import { getDataChunker } from './getDataChunker';
@@ -151,9 +155,9 @@ export const getMultipartUploadHandlers = (
151155
resolvedAccessLevel = resolveAccessLevel(accessLevel);
152156
}
153157

154-
const optionsHash = (
155-
await calculateContentCRC32(JSON.stringify(uploadDataOptions))
156-
).checksum;
158+
const { checksum: optionsHash } = await calculateContentCRC32(
159+
serializeUploadOptions(uploadDataOptions),
160+
);
157161

158162
if (!inProgressUpload) {
159163
const { uploadId, cachedParts, finalCrc32 } =

0 commit comments

Comments
 (0)