From c018013751848887056439d058200c00ebe81667 Mon Sep 17 00:00:00 2001 From: Sergio Castillo Date: Tue, 3 Sep 2024 09:54:49 -0700 Subject: [PATCH 1/2] chore: durability checks for create & complete multipart --- packages/aws-amplify/package.json | 2 +- .../client/S3/utils/integrityHelpers.test.ts | 34 ---- .../s3Data/completeMultipartUpload.test.ts | 143 ++++++++++++++ .../s3Data/createMultipartUpload.test.ts | 92 +++++++++ .../client/utils/integrityHelpers.test.ts | 71 +++++++ .../{S3 => }/utils/retryDecider.test.ts | 4 +- .../utils/validateMultipartUploadXML.test.ts | 186 ++++++++++++++++++ .../client/s3data/completeMultipartUpload.ts | 16 +- .../client/s3data/createMultipartUpload.ts | 6 + .../s3/utils/client/s3data/listParts.ts | 18 +- .../utils/client/utils/deserializeHelpers.ts | 16 ++ .../providers/s3/utils/client/utils/index.ts | 1 + .../s3/utils/client/utils/integrityHelpers.ts | 52 +++++ .../s3/utils/validateMultipartUploadXML.ts | 36 ++++ 14 files changed, 620 insertions(+), 57 deletions(-) delete mode 100644 packages/storage/__tests__/providers/s3/utils/client/S3/utils/integrityHelpers.test.ts create mode 100644 packages/storage/__tests__/providers/s3/utils/client/s3Data/completeMultipartUpload.test.ts create mode 100644 packages/storage/__tests__/providers/s3/utils/client/s3Data/createMultipartUpload.test.ts create mode 100644 packages/storage/__tests__/providers/s3/utils/client/utils/integrityHelpers.test.ts rename packages/storage/__tests__/providers/s3/utils/client/{S3 => }/utils/retryDecider.test.ts (93%) create mode 100644 packages/storage/__tests__/providers/s3/utils/validateMultipartUploadXML.test.ts create mode 100644 packages/storage/src/providers/s3/utils/validateMultipartUploadXML.ts diff --git a/packages/aws-amplify/package.json b/packages/aws-amplify/package.json index f03666ed33d..41e506e36f0 100644 --- a/packages/aws-amplify/package.json +++ b/packages/aws-amplify/package.json @@ -497,7 +497,7 @@ "name": "[Storage] uploadData (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ uploadData }", - "limit": "21.83 kB" + "limit": "22.01 kB" } ] } diff --git a/packages/storage/__tests__/providers/s3/utils/client/S3/utils/integrityHelpers.test.ts b/packages/storage/__tests__/providers/s3/utils/client/S3/utils/integrityHelpers.test.ts deleted file mode 100644 index 3390f56090e..00000000000 --- a/packages/storage/__tests__/providers/s3/utils/client/S3/utils/integrityHelpers.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - bothNilOrEqual, - isNil, -} from '../../../../../../../src/providers/s3/utils/client/utils/integrityHelpers'; - -describe('isNil', () => { - it.each([ - ['undefined', undefined, true], - ['null', null, true], - ['object', {}, false], - ['string', 'string', false], - ['empty string', '', false], - ['false', false, false], - ])('should correctly evaluate %s', (_, input, expected) => { - expect(isNil(input)).toBe(expected); - }); -}); - -describe('bothNilorEqual', () => { - it.each([ - ['both undefined', undefined, undefined, true], - ['both null', null, null, true], - ['null and undefined', null, undefined, true], - ['both equal', 'mock', 'mock', true], - ['undefined and falsy', undefined, '', false], - ['truthy and null', 'mock', null, false], - ['different strings', 'mock-1', 'mock-2', false], - ])( - 'should correctly compare %s', - (_, original: any, output: any, expected) => { - expect(bothNilOrEqual(original, output)).toBe(expected); - }, - ); -}); diff --git a/packages/storage/__tests__/providers/s3/utils/client/s3Data/completeMultipartUpload.test.ts b/packages/storage/__tests__/providers/s3/utils/client/s3Data/completeMultipartUpload.test.ts new file mode 100644 index 00000000000..5036a9de6fb --- /dev/null +++ b/packages/storage/__tests__/providers/s3/utils/client/s3Data/completeMultipartUpload.test.ts @@ -0,0 +1,143 @@ +import { HttpResponse } from '@aws-amplify/core/internals/aws-client-utils'; + +import { s3TransferHandler } from '../../../../../../src/providers/s3/utils/client/runtime/s3TransferHandler/fetch'; +import { completeMultipartUpload } from '../../../../../../src/providers/s3/utils/client/s3data'; +import { validateObjectUrl } from '../../../../../../src/providers/s3/utils/validateObjectUrl'; +import { validateMultipartUploadXML } from '../../../../../../src/providers/s3/utils/validateMultipartUploadXML'; +import { + DEFAULT_RESPONSE_HEADERS, + defaultConfig, + expectedMetadata, +} from '../S3/cases/shared'; +import { IntegrityError } from '../../../../../../src/errors/IntegrityError'; + +jest.mock('../../../../../../src/providers/s3/utils/validateObjectUrl'); +jest.mock( + '../../../../../../src/providers/s3/utils/validateMultipartUploadXML', +); +jest.mock( + '../../../../../../src/providers/s3/utils/client/runtime/s3TransferHandler/fetch', +); + +const mockS3TransferHandler = s3TransferHandler as jest.Mock; +const mockBinaryResponse = ({ + status, + headers, + body, +}: { + status: number; + headers: Record; + body: string; +}): HttpResponse => { + const responseBody = { + json: async (): Promise => { + throw new Error( + 'Parsing response to JSON is not implemented. Please use response.text() instead.', + ); + }, + blob: async () => new Blob([body], { type: 'plain/text' }), + text: async () => body, + } as HttpResponse['body']; + + return { + statusCode: status, + headers, + body: responseBody, + } as any; +}; + +const completeMultipartUploadSuccessResponse = { + status: 200, + headers: { + ...DEFAULT_RESPONSE_HEADERS, + 'x-amz-version-id': 'versionId', + etag: 'etag', + }, + body: '', +}; + +describe('completeMultipartUploadSerializer', () => { + const mockValidateObjectUrl = jest.mocked(validateObjectUrl); + const mockValidateMultipartUploadXML = jest.mocked( + validateMultipartUploadXML, + ); + beforeEach(() => { + mockS3TransferHandler.mockReset(); + }); + + it('should pass when objectUrl and multipartUploadXML is durable', async () => { + expect.assertions(1); + mockS3TransferHandler.mockResolvedValue( + mockBinaryResponse(completeMultipartUploadSuccessResponse as any), + ); + const output = await completeMultipartUpload(defaultConfig, { + Bucket: 'bucket', + Key: 'key', + UploadId: 'uploadId', + MultipartUpload: { + Parts: [ + { + ETag: 'etag', + PartNumber: 1, + }, + ], + }, + }); + console.log(output); + expect(output).toEqual({ + $metadata: expect.objectContaining(expectedMetadata), + }); + }); + + it('should fail when objectUrl is NOT durable', async () => { + expect.assertions(1); + mockS3TransferHandler.mockResolvedValue( + mockBinaryResponse(completeMultipartUploadSuccessResponse as any), + ); + const integrityError = new IntegrityError(); + mockValidateObjectUrl.mockImplementationOnce(() => { + throw integrityError; + }); + expect( + completeMultipartUpload(defaultConfig, { + Bucket: 'bucket', + Key: 'key', + UploadId: 'uploadId', + MultipartUpload: { + Parts: [ + { + ETag: 'etag', + PartNumber: 1, + }, + ], + }, + }), + ).rejects.toThrow(integrityError); + }); + + it('should fail when multipartUploadXML is NOT durable', async () => { + expect.assertions(1); + mockS3TransferHandler.mockResolvedValue( + mockBinaryResponse(completeMultipartUploadSuccessResponse as any), + ); + const integrityError = new IntegrityError(); + mockValidateMultipartUploadXML.mockImplementationOnce(() => { + throw integrityError; + }); + expect( + completeMultipartUpload(defaultConfig, { + Bucket: 'bucket', + Key: 'key', + UploadId: 'uploadId', + MultipartUpload: { + Parts: [ + { + ETag: 'etag', + PartNumber: 1, + }, + ], + }, + }), + ).rejects.toThrow(integrityError); + }); +}); diff --git a/packages/storage/__tests__/providers/s3/utils/client/s3Data/createMultipartUpload.test.ts b/packages/storage/__tests__/providers/s3/utils/client/s3Data/createMultipartUpload.test.ts new file mode 100644 index 00000000000..e705c86cde4 --- /dev/null +++ b/packages/storage/__tests__/providers/s3/utils/client/s3Data/createMultipartUpload.test.ts @@ -0,0 +1,92 @@ +import { HttpResponse } from '@aws-amplify/core/internals/aws-client-utils'; + +import { s3TransferHandler } from '../../../../../../src/providers/s3/utils/client/runtime/s3TransferHandler/fetch'; +import { createMultipartUpload } from '../../../../../../src/providers/s3/utils/client/s3data'; +import { validateObjectUrl } from '../../../../../../src/providers/s3/utils/validateObjectUrl'; +import { + DEFAULT_RESPONSE_HEADERS, + defaultConfig, + expectedMetadata, +} from '../S3/cases/shared'; +import { IntegrityError } from '../../../../../../src/errors/IntegrityError'; + +jest.mock('../../../../../../src/providers/s3/utils/validateObjectUrl'); +jest.mock( + '../../../../../../src/providers/s3/utils/client/runtime/s3TransferHandler/fetch', +); + +const mockS3TransferHandler = s3TransferHandler as jest.Mock; +const mockBinaryResponse = ({ + status, + headers, + body, +}: { + status: number; + headers: Record; + body: string; +}): HttpResponse => { + const responseBody = { + json: async (): Promise => { + throw new Error( + 'Parsing response to JSON is not implemented. Please use response.text() instead.', + ); + }, + blob: async () => new Blob([body], { type: 'plain/text' }), + text: async () => body, + } as HttpResponse['body']; + + return { + statusCode: status, + headers, + body: responseBody, + } as any; +}; + +const createMultipartUploadSuccessResponse = { + status: 200, + headers: { + ...DEFAULT_RESPONSE_HEADERS, + 'x-amz-version-id': 'versionId', + etag: 'etag', + }, + body: '', +}; + +describe('createMultipartUploadSerializer', () => { + const mockIsValidObjectUrl = jest.mocked(validateObjectUrl); + beforeEach(() => { + mockS3TransferHandler.mockReset(); + }); + + it('should pass when objectUrl is durable', async () => { + expect.assertions(1); + mockS3TransferHandler.mockResolvedValue( + mockBinaryResponse(createMultipartUploadSuccessResponse as any), + ); + const output = await createMultipartUpload(defaultConfig, { + Bucket: 'bucket', + Key: 'key', + }); + console.log(output); + expect(output).toEqual({ + $metadata: expect.objectContaining(expectedMetadata), + }); + }); + + it('should fail when objectUrl is NOT durable', async () => { + expect.assertions(1); + mockS3TransferHandler.mockResolvedValue( + mockBinaryResponse(createMultipartUploadSuccessResponse as any), + ); + const integrityError = new IntegrityError(); + mockIsValidObjectUrl.mockImplementationOnce(() => { + throw integrityError; + }); + expect( + createMultipartUpload(defaultConfig, { + Bucket: 'bucket', + Key: 'key', + }), + ).rejects.toThrow(integrityError); + }); +}); diff --git a/packages/storage/__tests__/providers/s3/utils/client/utils/integrityHelpers.test.ts b/packages/storage/__tests__/providers/s3/utils/client/utils/integrityHelpers.test.ts new file mode 100644 index 00000000000..84cc8cf10c7 --- /dev/null +++ b/packages/storage/__tests__/providers/s3/utils/client/utils/integrityHelpers.test.ts @@ -0,0 +1,71 @@ +import { + bothNilOrEqual, + isEqual, + isNil, + isObject, +} from '../../../../../../src/providers/s3/utils/client/utils/integrityHelpers'; + +describe('isNil', () => { + it.each([ + ['undefined', undefined, true], + ['null', null, true], + ['object', {}, false], + ['string', 'string', false], + ['empty string', '', false], + ['false', false, false], + ])('should correctly evaluate %s', (_, input, expected) => { + expect(isNil(input)).toBe(expected); + }); +}); + +describe('bothNilorEqual', () => { + it.each([ + ['both undefined', undefined, undefined, true], + ['both null', null, null, true], + ['null and undefined', null, undefined, true], + ['both equal', 'mock', 'mock', true], + ['undefined and falsy', undefined, '', false], + ['truthy and null', 'mock', null, false], + ['different strings', 'mock-1', 'mock-2', false], + ])( + 'should correctly compare %s', + (_, original: any, output: any, expected) => { + expect(bothNilOrEqual(original, output)).toBe(expected); + }, + ); +}); + +describe('Integrity Helpers Tests', () => { + describe('isObjectLike', () => { + // Generate all test cases for isObjectLike function here + test.each([ + [{}, true], + [{ a: 1 }, true], + [[1, 2, 3], false], + [null, false], + [undefined, false], + ['', false], + [1, false], + ])('isObjectLike(%p) = %p', (value, expected) => { + expect(isObject(value)).toBe(expected); + }); + }); + + describe('isEqual', () => { + test.each([ + [1, 1, true], + [1, 2, false], + [1, '1', false], + ['1', '1', true], + ['1', '2', false], + [{ a: 1 }, { a: 1 }, true], + [{ a: 1 }, { a: 2 }, false], + [{ a: 1 }, { b: 1 }, false], + [[1, 2], [1, 2], true], + [[1, 2], [2, 1], false], + [[1, 2], [1, 2, 3], false], + ])('isEqual(%p, %p) = %p', (a, b, expected) => { + expect(isEqual(a, b)).toBe(expected); + }); + }); +}); diff --git a/packages/storage/__tests__/providers/s3/utils/client/S3/utils/retryDecider.test.ts b/packages/storage/__tests__/providers/s3/utils/client/utils/retryDecider.test.ts similarity index 93% rename from packages/storage/__tests__/providers/s3/utils/client/S3/utils/retryDecider.test.ts rename to packages/storage/__tests__/providers/s3/utils/client/utils/retryDecider.test.ts index 5e1801c07db..5a233bd76ec 100644 --- a/packages/storage/__tests__/providers/s3/utils/client/S3/utils/retryDecider.test.ts +++ b/packages/storage/__tests__/providers/s3/utils/client/utils/retryDecider.test.ts @@ -5,8 +5,8 @@ import { getRetryDecider as getDefaultRetryDecider, } from '@aws-amplify/core/internals/aws-client-utils'; -import { retryDecider } from '../../../../../../../src/providers/s3/utils/client/utils'; -import { parseXmlError } from '../../../../../../../src/providers/s3/utils/client/utils/parsePayload'; +import { retryDecider } from '../../../../../../src/providers/s3/utils/client/utils'; +import { parseXmlError } from '../../../../../../src/providers/s3/utils/client/utils/parsePayload'; jest.mock( '../../../../../../../src/providers/s3/utils/client/utils/parsePayload', diff --git a/packages/storage/__tests__/providers/s3/utils/validateMultipartUploadXML.test.ts b/packages/storage/__tests__/providers/s3/utils/validateMultipartUploadXML.test.ts new file mode 100644 index 00000000000..ae3c1cfa5b6 --- /dev/null +++ b/packages/storage/__tests__/providers/s3/utils/validateMultipartUploadXML.test.ts @@ -0,0 +1,186 @@ +import { IntegrityError } from '../../../../src/errors/IntegrityError'; +import { validateMultipartUploadXML } from '../../../../src/providers/s3/utils/validateMultipartUploadXML'; + +describe('validateMultipartUploadXML', () => { + test.each([ + { + description: 'should NOT throw an error 1 valid part', + xml: ` + + 1 + checksumValue + + `, + input: { + Parts: [{ PartNumber: 1, ChecksumCRC32: 'checksumValue' }], + }, + success: true, + }, + { + description: 'should NOT throw an error 2 valid parts', + xml: ` + + 1 + checksumValue + + + 2 + checksumValue + + `, + input: { + Parts: [ + { PartNumber: 1, ChecksumCRC32: 'checksumValue' }, + { PartNumber: 2, ChecksumCRC32: 'checksumValue' }, + ], + }, + success: true, + }, + { + description: 'should throw an error if the XML is not valid', + xml: '>InvalidXML/<', + input: {}, + success: false, + notIntegrityError: true, + }, + { + description: + 'should throw an integrity error if the XML does not contain Part', + xml: '', + input: {}, + success: false, + }, + { + description: + 'should throw an integrity error when we have more parts than sent', + xml: ` + + 1 + checksumValue + + + 2 + checksumValue + + `, + input: { + Parts: [{ PartNumber: 1, ChecksumCRC32: 'checksumValue' }], + }, + success: false, + }, + { + description: + 'should throw an integrity error when we have less parts than sent', + xml: ` + + 1 + checksumValue + + `, + input: { + Parts: [ + { PartNumber: 1, ChecksumCRC32: 'checksumValue' }, + { PartNumber: 2, ChecksumCRC32: 'checksumValue' }, + ], + }, + success: false, + }, + { + description: + 'should throw an integrity error with not matching PartNumber', + xml: ` + + 2 + notMatchingChecksum + + `, + input: { + Parts: [{ PartNumber: 1, ChecksumCRC32: 'checksumValue' }], + }, + success: false, + }, + { + description: 'should throw an integrity error with not matching ETag', + xml: ` + + 1 + notMatchingChecksum + + `, + input: { + Parts: [{ PartNumber: 1, ETag: 'checksumValue' }], + }, + success: false, + }, + { + description: + 'should throw an integrity error with not matching ChecksumCRC32', + xml: ` + + 1 + notMatchingChecksum + + `, + input: { + Parts: [{ PartNumber: 1, ChecksumCRC32: 'checksumValue' }], + }, + success: false, + }, + { + description: + 'should throw an integrity error with not matching ChecksumCRC32C', + xml: ` + + 1 + notMatchingChecksum + + `, + input: { + Parts: [{ PartNumber: 1, ChecksumCRC32C: 'checksumValue' }], + }, + success: false, + }, + { + description: + 'should throw an integrity error with not matching ChecksumSHA1', + xml: ` + + 1 + notMatchingChecksum + + `, + input: { + Parts: [{ PartNumber: 1, ChecksumSHA1: 'checksumValue' }], + }, + success: false, + }, + { + description: + 'should throw an integrity error with not matching ChecksumSHA256', + xml: ` + + 1 + notMatchingChecksum + + `, + input: { + Parts: [{ PartNumber: 1, ChecksumSHA256: 'checksumValue' }], + }, + success: false, + }, + ])(`$description`, ({ input, xml, success, notIntegrityError }) => { + if (success) { + expect(() => { + validateMultipartUploadXML(input, xml); + }).not.toThrow(); + } else if (notIntegrityError) { + expect(() => { + validateMultipartUploadXML(input, xml); + }).toThrow(); + } else { + expect(() => { + validateMultipartUploadXML(input, xml); + }).toThrow(IntegrityError); + } + }); +}); diff --git a/packages/storage/src/providers/s3/utils/client/s3data/completeMultipartUpload.ts b/packages/storage/src/providers/s3/utils/client/s3data/completeMultipartUpload.ts index d732044e953..ff813d3a326 100644 --- a/packages/storage/src/providers/s3/utils/client/s3data/completeMultipartUpload.ts +++ b/packages/storage/src/providers/s3/utils/client/s3data/completeMultipartUpload.ts @@ -26,14 +26,16 @@ import { serializePathnameObjectKey, validateS3RequiredParameter, } from '../utils'; +import { validateObjectUrl } from '../../validateObjectUrl'; +import { validateMultipartUploadXML } from '../../validateMultipartUploadXML'; +import { defaultConfig } from './base'; import type { CompleteMultipartUploadCommandInput, CompleteMultipartUploadCommandOutput, CompletedMultipartUpload, CompletedPart, } from './types'; -import { defaultConfig } from './base'; const INVALID_PARAMETER_ERROR_MSG = 'Invalid parameter for ComplteMultipartUpload API'; @@ -64,14 +66,20 @@ const completeMultipartUploadSerializer = async ( uploadId: input.UploadId, }).toString(); validateS3RequiredParameter(!!input.MultipartUpload, 'MultipartUpload'); + validateObjectUrl({ + bucketName: input.Bucket, + key: input.Key, + objectURL: url, + }); + + const xml = serializeCompletedMultipartUpload(input.MultipartUpload); + validateMultipartUploadXML(input.MultipartUpload, xml); return { method: 'POST', headers, url, - body: - '' + - serializeCompletedMultipartUpload(input.MultipartUpload), + body: '' + xml, }; }; diff --git a/packages/storage/src/providers/s3/utils/client/s3data/createMultipartUpload.ts b/packages/storage/src/providers/s3/utils/client/s3data/createMultipartUpload.ts index 45d755d2ef9..01a59ba8525 100644 --- a/packages/storage/src/providers/s3/utils/client/s3data/createMultipartUpload.ts +++ b/packages/storage/src/providers/s3/utils/client/s3data/createMultipartUpload.ts @@ -21,6 +21,7 @@ import { serializePathnameObjectKey, validateS3RequiredParameter, } from '../utils'; +import { validateObjectUrl } from '../../validateObjectUrl'; import type { CreateMultipartUploadCommandInput, @@ -53,6 +54,11 @@ const createMultipartUploadSerializer = async ( validateS3RequiredParameter(!!input.Key, 'Key'); url.pathname = serializePathnameObjectKey(url, input.Key); url.search = 'uploads'; + validateObjectUrl({ + bucketName: input.Bucket, + key: input.Key, + objectURL: url, + }); return { method: 'POST', diff --git a/packages/storage/src/providers/s3/utils/client/s3data/listParts.ts b/packages/storage/src/providers/s3/utils/client/s3data/listParts.ts index 392eff988d0..e7ffe5c58f2 100644 --- a/packages/storage/src/providers/s3/utils/client/s3data/listParts.ts +++ b/packages/storage/src/providers/s3/utils/client/s3data/listParts.ts @@ -15,7 +15,7 @@ import { composeServiceApi } from '@aws-amplify/core/internals/aws-client-utils/ import { buildStorageServiceError, - deserializeNumber, + deserializeCompletedPartList, emptyArrayGuard, map, parseXmlBody, @@ -25,11 +25,7 @@ import { validateS3RequiredParameter, } from '../utils'; -import type { - CompletedPart, - ListPartsCommandInput, - ListPartsCommandOutput, -} from './types'; +import type { ListPartsCommandInput, ListPartsCommandOutput } from './types'; import { defaultConfig } from './base'; export type ListPartsInput = Pick< @@ -85,16 +81,6 @@ const listPartsDeserializer = async ( } }; -const deserializeCompletedPartList = (input: any[]): CompletedPart[] => - input.map(item => - map(item, { - PartNumber: ['PartNumber', deserializeNumber], - ETag: 'ETag', - Size: ['Size', deserializeNumber], - ChecksumCRC32: 'ChecksumCRC32', - }), - ); - export const listParts = composeServiceApi( s3TransferHandler, listPartsSerializer, diff --git a/packages/storage/src/providers/s3/utils/client/utils/deserializeHelpers.ts b/packages/storage/src/providers/s3/utils/client/utils/deserializeHelpers.ts index 8681d714372..ea90d46939a 100644 --- a/packages/storage/src/providers/s3/utils/client/utils/deserializeHelpers.ts +++ b/packages/storage/src/providers/s3/utils/client/utils/deserializeHelpers.ts @@ -5,6 +5,7 @@ import { Headers } from '@aws-amplify/core/internals/aws-client-utils'; import { ServiceError } from '@aws-amplify/core/internals/utils'; import { StorageError } from '../../../../../errors/StorageError'; +import { CompletedPart } from '../s3data'; type PropertyNameWithStringValue = string; type PropertyNameWithSubsequentDeserializer = [string, (arg: any) => T]; @@ -202,3 +203,18 @@ export const buildStorageServiceError = ( return storageError; }; + +/** + * Internal-only method used for deserializing the parts of a multipart upload. + * + * @internal + */ +export const deserializeCompletedPartList = (input: any[]): CompletedPart[] => + input.map(item => + map(item, { + PartNumber: ['PartNumber', deserializeNumber], + ETag: 'ETag', + Size: ['Size', deserializeNumber], + ChecksumCRC32: 'ChecksumCRC32', + }), + ); diff --git a/packages/storage/src/providers/s3/utils/client/utils/index.ts b/packages/storage/src/providers/s3/utils/client/utils/index.ts index 0e18731576d..f25cc45ecb0 100644 --- a/packages/storage/src/providers/s3/utils/client/utils/index.ts +++ b/packages/storage/src/providers/s3/utils/client/utils/index.ts @@ -13,6 +13,7 @@ export { export { buildStorageServiceError, deserializeBoolean, + deserializeCompletedPartList, deserializeMetadata, deserializeNumber, deserializeTimestamp, diff --git a/packages/storage/src/providers/s3/utils/client/utils/integrityHelpers.ts b/packages/storage/src/providers/s3/utils/client/utils/integrityHelpers.ts index eaf4bdd66bb..783be7c810d 100644 --- a/packages/storage/src/providers/s3/utils/client/utils/integrityHelpers.ts +++ b/packages/storage/src/providers/s3/utils/client/utils/integrityHelpers.ts @@ -8,3 +8,55 @@ export const isNil = (value?: T) => { export const bothNilOrEqual = (original?: string, output?: string): boolean => { return (isNil(original) && isNil(output)) || original === output; }; + +/** + * This function is used to determine if a value is an object. + * It excludes arrays and null values. + * + * @param value + * @returns + */ +export const isObject = (value?: T) => { + return value != null && typeof value === 'object' && !Array.isArray(value); +}; + +/** + * This function is used to compare two objects and determine if they are equal. + * It handles nested objects and arrays as well. + * Array order is not taken into account. + * + * @param object + * @param other + * @returns + */ +export const isEqual = (object: T, other: T): boolean => { + if (Array.isArray(object) && !Array.isArray(other)) { + return false; + } + if (!Array.isArray(object) && Array.isArray(other)) { + return false; + } + if (Array.isArray(object) && Array.isArray(other)) { + return ( + object.length === other.length && + object.every((val, ix) => isEqual(val, other[ix])) + ); + } + if (!isObject(object) || !isObject(other)) { + return object === other; + } + + const objectKeys = Object.keys(object as any); + const otherKeys = Object.keys(other as any); + + if (objectKeys.length !== otherKeys.length) { + return false; + } + + return objectKeys.every(key => { + return ( + otherKeys.includes(key) && + isEqual(object[key as keyof T] as any, other[key as keyof T] as any) + ); + }); +}; diff --git a/packages/storage/src/providers/s3/utils/validateMultipartUploadXML.ts b/packages/storage/src/providers/s3/utils/validateMultipartUploadXML.ts new file mode 100644 index 00000000000..0295ab511fc --- /dev/null +++ b/packages/storage/src/providers/s3/utils/validateMultipartUploadXML.ts @@ -0,0 +1,36 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { IntegrityError } from '../../../errors/IntegrityError'; + +import { parser } from './client/runtime'; +import { CompletedMultipartUpload } from './client/s3data/types'; +import { + deserializeCompletedPartList, + emptyArrayGuard, + map, +} from './client/utils'; +import { isEqual } from './client/utils/integrityHelpers'; + +export function validateMultipartUploadXML( + input: CompletedMultipartUpload, + xml: string, +) { + if (!input.Parts) { + throw new IntegrityError(); + } + const parsedXML = parser.parse(xml); + const mappedCompletedMultipartUpload: CompletedMultipartUpload = map( + parsedXML, + { + Parts: [ + 'Part', + value => emptyArrayGuard(value, deserializeCompletedPartList), + ], + }, + ); + + if (!isEqual(input, mappedCompletedMultipartUpload)) { + throw new IntegrityError(); + } +} From 3f216c7624cadacb22db7b45135fa0b067df7bc5 Mon Sep 17 00:00:00 2001 From: Sergio Castillo Date: Tue, 17 Sep 2024 16:44:07 -0700 Subject: [PATCH 2/2] fix: parsePayload mock path --- .../providers/s3/utils/client/utils/retryDecider.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/storage/__tests__/providers/s3/utils/client/utils/retryDecider.test.ts b/packages/storage/__tests__/providers/s3/utils/client/utils/retryDecider.test.ts index 5a233bd76ec..018a8deb4e7 100644 --- a/packages/storage/__tests__/providers/s3/utils/client/utils/retryDecider.test.ts +++ b/packages/storage/__tests__/providers/s3/utils/client/utils/retryDecider.test.ts @@ -8,9 +8,7 @@ import { import { retryDecider } from '../../../../../../src/providers/s3/utils/client/utils'; import { parseXmlError } from '../../../../../../src/providers/s3/utils/client/utils/parsePayload'; -jest.mock( - '../../../../../../../src/providers/s3/utils/client/utils/parsePayload', -); +jest.mock('../../../../../../src/providers/s3/utils/client/utils/parsePayload'); jest.mock('@aws-amplify/core/internals/aws-client-utils'); const mockErrorParser = jest.mocked(parseXmlError);