diff --git a/src/io/__tests__/amazonS3.spec.ts b/src/io/__tests__/amazonS3.spec.ts index 55e598c91..2a9702cfa 100644 --- a/src/io/__tests__/amazonS3.spec.ts +++ b/src/io/__tests__/amazonS3.spec.ts @@ -77,6 +77,33 @@ describe('amazonS3', () => { expect(init).toBeUndefined(); }); + it('percent-encodes reserved chars in keys but preserves slashes', async () => { + fetchSpy.mockResolvedValueOnce( + okResponse( + xmlListResponse([ + 'scan?1.dcm', + 'seg#1.dcm', + 'my scan.dcm', + 'a+b.dcm', + 'sub/dir/file.dcm', + ]) + ) + ); + + const urls: string[] = []; + await getObjectsFromS3('s3://bucket/prefix', (_name, url) => + urls.push(url) + ); + + expect(urls).toEqual([ + 'https://bucket.s3.amazonaws.com/scan%3F1.dcm', + 'https://bucket.s3.amazonaws.com/seg%231.dcm', + 'https://bucket.s3.amazonaws.com/my%20scan.dcm', + 'https://bucket.s3.amazonaws.com/a%2Bb.dcm', + 'https://bucket.s3.amazonaws.com/sub/dir/file.dcm', + ]); + }); + it('follows pagination via continuation token', async () => { fetchSpy .mockResolvedValueOnce( diff --git a/src/io/amazonS3.ts b/src/io/amazonS3.ts index ba2d735f8..a78c10774 100644 --- a/src/io/amazonS3.ts +++ b/src/io/amazonS3.ts @@ -10,8 +10,13 @@ export const isAmazonS3Uri = (uri: string) => export type ObjectAvailableCallback = (name: string, url: string) => void; +// Percent-encode each path segment so keys containing reserved URI chars +// (`?`, `#`, space, `+`, …) round-trip correctly. Slashes are preserved. +const encodeS3Key = (key: string) => + key.split('/').map(encodeURIComponent).join('/'); + const getObjectPublicUrl = (bucket: string, key: string) => - `https://${bucket}.s3.amazonaws.com/${key}`; + `https://${bucket}.s3.amazonaws.com/${encodeS3Key(key)}`; const buildListUrl = ( bucket: string,