diff --git a/lib/immutable-url.ts b/lib/immutable-url.ts index b753556..92c0a1f 100644 --- a/lib/immutable-url.ts +++ b/lib/immutable-url.ts @@ -531,7 +531,23 @@ export default class implements IURLExtended { this.slashes += '/'; } } - if (this.slashes.length >= 2) { + + const slashesLen = this.slashes.length; + let authorityIncluded = slashesLen >= 2; + // file: is a "special" scheme as per spec + if (this._protocol == 'file:') { + // ansolute path. decrement index so the slash is included in pathname + if (slashesLen == 1) { + authorityIncluded = false; + index--; + } else if (slashesLen > 2) { + // keep all slashes after file:// + index -= slashesLen - 2; + authorityIncluded = false; + } + // a file url with exactly two slashes denotes a file on a remote host: file://host/file + } + if (authorityIncluded) { // Two slashes: Authority is included index = this._extractHostname(index, end); } else { diff --git a/test/url-spec.test.ts b/test/url-spec.test.ts index 5b814b6..dbdf4fc 100644 --- a/test/url-spec.test.ts +++ b/test/url-spec.test.ts @@ -56,6 +56,7 @@ describe('URL Spec', () => { 'https://example.com/?q=+33%201', 'https://example.com/?q=+33+%201', 'https://[::1]/', + 'file:///test', ].forEach((urlString: string) => { it(urlString, () => { const expected = new URLSpec(urlString); @@ -234,4 +235,61 @@ describe('Divergence from URL Spec', () => { expect(encoded.href).toBe(expected.href); expect(encoded.origin).toBe(expected.origin); }); + + it('Does allow relative file paths', () => { + const urlString = 'file:test'; + const expected = new URLSpec(urlString); + const actual = new URL(urlString); + + expect(actual.hash).toBe(expected.hash); + expect(actual.password).toBe(expected.password); + expect(actual.protocol).toBe(expected.protocol); + expect(actual.search).toBe(expected.search); + expect(actual.username).toBe(expected.username); + expect(actual.host).toBe(expected.host); + expect(actual.hostname).toBe(expected.hostname); + + // relative pathnames are not allowed per spec and are interpreted as absolute paths in the spec + expect(actual.pathname).toBe('test'); + expect(expected.pathname).toBe('/test'); + }); + + it('Does allow absolute pathnames without authority', () => { + const urlString = 'file:/test'; + const expected = new URLSpec(urlString); + const actual = new URL(urlString); + + expect(actual.hash).toBe(expected.hash); + expect(actual.password).toBe(expected.password); + expect(actual.protocol).toBe(expected.protocol); + expect(actual.search).toBe(expected.search); + expect(actual.username).toBe(expected.username); + expect(actual.host).toBe(expected.host); + expect(actual.hostname).toBe(expected.hostname); + + // url reference implementation differs here + expect(actual.toString()).toBe('file:/test'); + expect(expected.toString()).toBe('file:///test'); + expect(actual.pathname).toBe('/test'); + expect(expected.pathname).toBe('/test'); + }); + + it('Does parse file URLs with authority but also creates an origin', () => { + const urlString = 'file://hostname/file'; + const expected = new URLSpec(urlString); + const actual = new URL(urlString); + + expect(actual.hash).toBe(expected.hash); + expect(actual.password).toBe(expected.password); + expect(expected.pathname).toBe(actual.pathname); + expect(actual.protocol).toBe(expected.protocol); + expect(actual.search).toBe(expected.search); + expect(actual.username).toBe(expected.username); + expect(actual.host).toBe(expected.host); + expect(actual.hostname).toBe(expected.hostname); + + // spec recommends origin null + expect(expected.origin).toBe('null'); + expect(actual.origin).toBe('file://hostname'); + }); });