diff --git a/packages/react-native/Libraries/Blob/URL.js b/packages/react-native/Libraries/Blob/URL.js index 8386cc6d979d..09daa80a3c14 100644 --- a/packages/react-native/Libraries/Blob/URL.js +++ b/packages/react-native/Libraries/Blob/URL.js @@ -63,6 +63,66 @@ function validateBaseUrl(url: string) { ); } +function removeDotSegments(path: string): string { + const segments = path.split('/'); + const output: Array = []; + for (const segment of segments) { + if (segment === '..') { + if (output.length > 1) { + output.pop(); + } + } else if (segment !== '.') { + output.push(segment); + } + } + const last = segments[segments.length - 1]; + if (last === '.' || last === '..') { + output.push(''); + } + return output.join('/'); +} + +function resolveReference(url: string, base: string): string { + if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(url)) { + return url; + } + const baseMatch = base.match( + /^([a-zA-Z][a-zA-Z\d+\-.]*:)?(\/\/[^/?#]*)?([^?#]*)(\?[^#]*)?/, + ); + const refMatch = url.match(/^(\/\/[^/?#]*)?([^?#]*)(\?[^#]*)?(#.*)?$/); + if (baseMatch == null || refMatch == null) { + return url; + } + const scheme = baseMatch[1] ?? ''; + const baseAuthority = baseMatch[2]; + const basePath = baseMatch[3]; + const baseQuery = baseMatch[4] ?? ''; + const [, refAuthority, refPath, refQuery, refFragment] = refMatch; + + let authority = baseAuthority ?? ''; + let path; + let query = refQuery ?? ''; + if (refAuthority != null) { + authority = refAuthority; + path = removeDotSegments(refPath); + } else if (refPath === '') { + path = basePath; + query = refQuery ?? baseQuery; + } else if (refPath.startsWith('/')) { + path = removeDotSegments(refPath); + } else if (baseAuthority != null && basePath === '') { + path = removeDotSegments(`/${refPath}`); + } else { + path = removeDotSegments( + basePath.slice(0, basePath.lastIndexOf('/') + 1) + refPath, + ); + } + if (authority !== '' && path === '') { + path = '/'; + } + return `${scheme}${authority}${path}${query}${refFragment ?? ''}`; +} + export class URL { _url: string; _searchParamsInstance: ?URLSearchParams = null; @@ -81,7 +141,11 @@ export class URL { // $FlowFixMe[missing-local-annot] constructor(url: string, base?: string | URL) { let baseUrl = null; - if (base == null || base === '' || validateBaseUrl(url)) { + if ( + base == null || + base === '' || + (validateBaseUrl(url) && !url.startsWith('//')) + ) { this._url = url; if (this._url.includes('#')) { const split = this._url.split('#'); @@ -111,17 +175,7 @@ export class URL { } else { baseUrl = base.toString(); } - if (baseUrl.endsWith('/')) { - baseUrl = baseUrl.slice(0, baseUrl.length - 1); - } - let path = url; - if (!path.startsWith('/')) { - path = `/${path}`; - } - if (baseUrl.endsWith(path)) { - path = ''; - } - this._url = `${baseUrl}${path}`; + this._url = resolveReference(url, baseUrl); } } diff --git a/packages/react-native/Libraries/Blob/__tests__/URL-itest.js b/packages/react-native/Libraries/Blob/__tests__/URL-itest.js index a8366268df4f..865c6bc7d0c6 100644 --- a/packages/react-native/Libraries/Blob/__tests__/URL-itest.js +++ b/packages/react-native/Libraries/Blob/__tests__/URL-itest.js @@ -25,12 +25,11 @@ describe('URL', function () { expect(d.href).toBe('https://developer.mozilla.org/en-US/docs'); const f = new URL('/en-US/docs', d); expect(f.href).toBe('https://developer.mozilla.org/en-US/docs'); - // from original test suite, but requires complex implementation - // const g = new URL( - // '/en-US/docs', - // 'https://developer.mozilla.org/fr-FR/toto', - // ); - // expect(g.href).toBe('https://developer.mozilla.org/en-US/docs'); + const g = new URL( + '/en-US/docs', + 'https://developer.mozilla.org/fr-FR/toto', + ); + expect(g.href).toBe('https://developer.mozilla.org/en-US/docs'); const h = new URL('/en-US/docs', a); expect(h.href).toBe('https://developer.mozilla.org/en-US/docs'); const i = new URL('http://github.com', 'http://google.com'); @@ -161,6 +160,61 @@ describe('URL', function () { 'https://example.com/path/to/resource', ); }); + it('resolves a network-path reference with the scheme of the base URL', () => { + expect(new URL('//cdn.example.com/x.js', 'https://example.com/').href).toBe( + 'https://cdn.example.com/x.js', + ); + }); + + it('resolves the RFC 3986 reference examples against a base URL', () => { + const base = 'http://a.com/b/c/d;p?q'; + const cases = [ + ['g:h', 'g:h'], + ['g', 'http://a.com/b/c/g'], + ['./g', 'http://a.com/b/c/g'], + ['g/', 'http://a.com/b/c/g/'], + ['/g', 'http://a.com/g'], + ['//g', 'http://g/'], + ['?y', 'http://a.com/b/c/d;p?y'], + ['g?y', 'http://a.com/b/c/g?y'], + ['#s', 'http://a.com/b/c/d;p?q#s'], + ['g#s', 'http://a.com/b/c/g#s'], + ['g?y#s', 'http://a.com/b/c/g?y#s'], + [';x', 'http://a.com/b/c/;x'], + ['g;x', 'http://a.com/b/c/g;x'], + ['g;x?y#s', 'http://a.com/b/c/g;x?y#s'], + ['', 'http://a.com/b/c/d;p?q'], + ['.', 'http://a.com/b/c/'], + ['./', 'http://a.com/b/c/'], + ['..', 'http://a.com/b/'], + ['../', 'http://a.com/b/'], + ['../g', 'http://a.com/b/g'], + ['../..', 'http://a.com/'], + ['../../', 'http://a.com/'], + ['../../g', 'http://a.com/g'], + ['../../../g', 'http://a.com/g'], + ['../../../../g', 'http://a.com/g'], + ['/./g', 'http://a.com/g'], + ['/../g', 'http://a.com/g'], + ['g.', 'http://a.com/b/c/g.'], + ['.g', 'http://a.com/b/c/.g'], + ['g..', 'http://a.com/b/c/g..'], + ['..g', 'http://a.com/b/c/..g'], + ['./../g', 'http://a.com/b/g'], + ['./g/.', 'http://a.com/b/c/g/'], + ['g/./h', 'http://a.com/b/c/g/h'], + ['g/../h', 'http://a.com/b/c/h'], + ['g;x=1/./y', 'http://a.com/b/c/g;x=1/y'], + ['g;x=1/../y', 'http://a.com/b/c/y'], + ['g?y/./x', 'http://a.com/b/c/g?y/./x'], + ['g?y/../x', 'http://a.com/b/c/g?y/../x'], + ['g#s/./x', 'http://a.com/b/c/g#s/./x'], + ['g#s/../x', 'http://a.com/b/c/g#s/../x'], + ]; + for (const [reference, expected] of cases) { + expect(new URL(reference, base).href).toBe(expected); + } + }); }); describe('URLSearchParams.sort', () => {