Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: use url encoding type for list calls #14010

Merged
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1024,4 +1024,35 @@ describe('list API', () => {
});
});
});

describe.only.each([
pranavosu marked this conversation as resolved.
Show resolved Hide resolved
{
type: 'Prefix',
listFunction: (options?: any) =>
list(Amplify, { prefix: 'test/', options }),
},
{
type: 'Path',
listFunction: (options?: any) =>
list(Amplify, { path: 'test/', options }),
},
])('Encoding for List with $type', ({ listFunction }) => {
afterEach(() => {
mockListObject.mockClear();
});
it('should include encoding type', async () => {
mockListObjectsV2ApiWithPages(1);

await listFunction({
encodingType: 'url',
});
expect(listObjectsV2).toBeLastCalledWithConfigAndInput(
expect.any(Object),
expect.objectContaining({
Bucket: bucket,
EncodingType: 'url',
}),
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ const listObjectsV2ErrorCase403: ApiFunctionalTestCase<typeof listObjectsV2> = [
<Error>
<Code>NoSuchKey</Code>
<Message>The resource you requested does not exist</Message>
<Resource>/mybucket/myfoto.jpg</Resource>
<Resource>/mybucket/myfoto.jpg</Resource>
<RequestId>4442587FB7D0A2F9</RequestId>
</Error>`,
},
Expand Down Expand Up @@ -420,6 +420,173 @@ const listObjectsV2HappyCaseCustomEndpoint: ApiFunctionalTestCase<
}) as any,
];

const listObjectsV2HappyCaseWithEncoding: ApiFunctionalTestCase<
typeof listObjectsV2
> = [
'happy case',
'listObjectsV2 unicode values with encoding',
listObjectsV2,
{
...defaultConfig,
},
{
Bucket: 'bucket',
Prefix: 'Prefix',
EncodingType: 'url',
},
expect.any(Object),
{
status: 200,
headers: DEFAULT_RESPONSE_HEADERS,
body: `<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult
xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Name>bucket</Name>
<Prefix>some%20folder%20with%20%00%20unprintable%20unicode%2F</Prefix>
<Delimiter>bad%08key</Delimiter>
<StartAfter>bad%01key</StartAfter>
<KeyCount>6</KeyCount>
<MaxKeys>101</MaxKeys>
<EncodingType>url</EncodingType>
<IsTruncated>false</IsTruncated>
<Contents>
<Key>public/bad%3Cdiv%3Ekey</Key>
<LastModified>2024-11-05T18:13:11.000Z</LastModified>
<ETag>&quot;c0e066cc5238dd7937e464fe7572b71a&quot;</ETag>
<Size>5455</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>
<Contents>
<Key>bad%00key</Key>
<LastModified>2024-11-05T18:13:11.000Z</LastModified>
<ETag>&quot;c0e066cc5238dd7937e464fe7572b71a&quot;</ETag>
<Size>5455</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>
<Contents>
<Key>public/bad%7Fkey</Key>
<LastModified>2024-11-05T18:13:11.000Z</LastModified>
<ETag>&quot;c0e066cc5238dd7937e464fe7572b71a&quot;</ETag>
<Size>5455</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>
<CommonPrefixes>
<Prefix>public/some%20folder%20with%20spaces%2F</Prefix>
</CommonPrefixes>
<CommonPrefixes>
<Prefix>public/real%0A%0A%0A%0A%0A%0A%0A%0A%0Afunny%0A%0A%0A%0A%0A%0A%0A%0A%0Abiz%2F</Prefix>
</CommonPrefixes>
<CommonPrefixes>
<Prefix>public/some%20folder%20with%20%E3%81%8A%E3%81%AF%E3%82%88%E3%81%86%20multibyte%20unicode%2F</Prefix>
</CommonPrefixes>
</ListBucketResult>`,
},
expect.objectContaining({
CommonPrefixes: [
{
Prefix: 'public/some folder with spaces/',
},
{
Prefix: 'public/real\n\n\n\n\n\n\n\n\nfunny\n\n\n\n\n\n\n\n\nbiz/',
},
{
Prefix: 'public/some folder with おはよう multibyte unicode/',
},
],
Contents: [
{
Key: 'public/bad<div>key',
LastModified: new Date('2024-11-05T18:13:11.000Z'),
ETag: '"c0e066cc5238dd7937e464fe7572b71a"',
Size: 5455,
StorageClass: 'STANDARD',
},
{
Key: 'bad\x00key',
LastModified: new Date('2024-11-05T18:13:11.000Z'),
ETag: '"c0e066cc5238dd7937e464fe7572b71a"',
Size: 5455,
StorageClass: 'STANDARD',
},
{
Key: 'public/badkey',
LastModified: new Date('2024-11-05T18:13:11.000Z'),
ETag: '"c0e066cc5238dd7937e464fe7572b71a"',
Size: 5455,
StorageClass: 'STANDARD',
},
],
Prefix: 'some folder with \u0000 unprintable unicode/',
Delimiter: 'bad\x08key',
StartAfter: 'bad\x01key',
EncodingType: 'url',
Name: 'bucket',
}) as any,
];

const listObjectsV2ErrorCaseNoEncoding: ApiFunctionalTestCase<
typeof listObjectsV2
> = [
'error case',
'listObjectsV2 unicode values without encoding',
listObjectsV2,
{
...defaultConfig,
},
{
Bucket: 'bucket',
Prefix: 'Prefix',
EncodingType: undefined,
},
expect.any(Object),
{
status: 200,
headers: DEFAULT_RESPONSE_HEADERS,
body: `<?xml version="1.0" encoding="UTF-8"?>
<ListBucketResult
xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Name>badname</Name>
<Prefix>bad\x01key</Prefix>
<KeyCount>5</KeyCount>
<MaxKeys>101</MaxKeys>
<Delimiter>bad\x08key</Delimiter>
<IsTruncated>false</IsTruncated>
<StartAfter>おはよう multibyte unicode</StartAfter>
<Contents>
<Key>public/bad<div>key</Key>
<LastModified>2024-11-05T18:13:11.000Z</LastModified>
<ETag>&quot;c0e066cc5238dd7937e464fe7572b71a&quot;</ETag>
<Size>5455</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>
<Contents>
<Key>bad\x00key</Key>
<LastModified>2024-11-05T18:13:11.000Z</LastModified>
<ETag>&quot;c0e066cc5238dd7937e464fe7572b71a&quot;</ETag>
<Size>5455</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>
<Contents>
<Key>public/bad\x7fkey</Key>
<LastModified>2024-11-05T18:13:11.000Z</LastModified>
<ETag>&quot;c0e066cc5238dd7937e464fe7572b71a&quot;</ETag>
<Size>5455</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>
<CommonPrefixes>
<Prefix>public/some folder with spaces/</Prefix>
</CommonPrefixes>
<CommonPrefixes>
<Prefix>public/some folder with \x00 unprintable unicode/</Prefix>
</CommonPrefixes>
</ListBucketResult>`,
},
{
message: 'An unknown error has occurred.',
name: 'Unknown',
},
];

export default [
listObjectsV2HappyCaseTruncated,
listObjectsV2HappyCaseComplete,
Expand All @@ -428,4 +595,6 @@ export default [
listObjectsV2ErrorCaseMissingTruncated,
listObjectsV2ErrorCaseMissingToken,
listObjectsV2ErrorCase403,
listObjectsV2HappyCaseWithEncoding,
listObjectsV2ErrorCaseNoEncoding,
];
1 change: 1 addition & 0 deletions packages/storage/src/providers/s3/apis/internal/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export const list = async (
ContinuationToken: options?.listAll ? undefined : options?.nextToken,
Delimiter: getDelimiter(options),
ExpectedBucketOwner: options?.expectedBucketOwner,
EncodingType: options?.encodingType,
};
logger.debug(`listing items from "${listParams.Prefix}"`);

Expand Down
8 changes: 8 additions & 0 deletions packages/storage/src/providers/s3/types/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ interface CommonOptions {
* The expected owner of the target bucket.
*/
expectedBucketOwner?: string;

/**
* Specifies the encoding used for response elements that contain
* special characters. By setting this to 'url', special characters
* in object keys are URL-encoded in the response using the
* `application/x-www-form-urlencoded` encoding.
*/
encodingType?: 'url';
pranavosu marked this conversation as resolved.
Show resolved Hide resolved
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
s3TransferHandler,
} from '../utils';
import { IntegrityError } from '../../../../../errors/IntegrityError';
import { urlDecode } from '../../urlDecoder';

import type {
ListObjectsV2CommandInput,
Expand Down Expand Up @@ -94,10 +95,11 @@ const listObjectsV2Deserializer = async (
StartAfter: 'StartAfter',
});

const output = {
const output = decodeEncodedElements({
$metadata: parseMetadata(response),
...contents,
};
});

validateCorroboratingElements(output);

return output;
Expand Down Expand Up @@ -155,6 +157,48 @@ const validateCorroboratingElements = (response: ListObjectsV2Output) => {
}
};

/**
* Decodes URL-encoded elements in the S3 `ListObjectsV2Output` response when `EncodingType` is `'url'`.
* Applies to values for 'Delimiter', 'Prefix', 'StartAfter' and 'Key' in the response.
*/
const decodeEncodedElements = (
listOutput: ListObjectsV2Output,
): ListObjectsV2Output => {
if (listOutput.EncodingType !== 'url') {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be not url if we are the ones setting this in first place?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's highly unlikely, but I'd rather be safe.

pranavosu marked this conversation as resolved.
Show resolved Hide resolved
return listOutput;
}

const decodedListOutput = { ...listOutput };

// Decode top-level properties
(['Delimiter', 'Prefix', 'StartAfter'] as const).forEach(prop => {
const value = listOutput[prop];
if (typeof value === 'string') {
decodedListOutput[prop] = urlDecode(value);
}
});

// Decode 'Key' in each item of 'Contents', if it exists
if (listOutput.Contents) {
decodedListOutput.Contents = listOutput.Contents.map(content => ({
...content,
Key: content.Key ? urlDecode(content.Key) : content.Key,
}));
}

// Decode 'Prefix' in each item of 'CommonPrefixes', if it exists
if (listOutput.CommonPrefixes) {
decodedListOutput.CommonPrefixes = listOutput.CommonPrefixes.map(
content => ({
...content,
Prefix: content.Prefix ? urlDecode(content.Prefix) : content.Prefix,
}),
);
}

return decodedListOutput;
};

export const listObjectsV2 = composeServiceApi(
s3TransferHandler,
listObjectsV2Serializer,
Expand Down
1 change: 1 addition & 0 deletions packages/storage/src/providers/s3/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export { validateBucketOwnerID } from './validateBucketOwnerID';
export { validateStorageOperationInput } from './validateStorageOperationInput';
export { validateStorageOperationInputWithPrefix } from './validateStorageOperationInputWithPrefix';
export { isInputWithPath } from './isInputWithPath';
export { urlDecode } from './urlDecoder';
10 changes: 10 additions & 0 deletions packages/storage/src/providers/s3/utils/urlDecoder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Decodes a URL-encoded string by replacing '+' characters with spaces and applying `decodeURIComponent`.
* Reference:
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/decodeURIComponent#decoding_query_parameters_from_a_url
* @param {string} value - The URL-encoded string to decode.
* @returns {string} The decoded string.
*/
export const urlDecode = (value: string): string => {
return decodeURIComponent(value.replace(/\+/g, ' '));
};
Loading