diff options
author | Nayeem Rahman <nayeemrmn99@gmail.com> | 2020-05-04 19:32:54 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-05-04 14:32:54 -0400 |
commit | 8c509bd88517ebc92673d9da91e71a08868e830e (patch) | |
tree | 5c880d6bafb1b5f22d69b3f1cfc52bbd4ea63056 /cli/js | |
parent | 6c02b061ce157b9fc3d20f9bcace0bc6638290d3 (diff) |
feat(URL): Support drive letters for file URLs on Windows (#5074)
refactor: Parse URLs more sequentially. This makes it easier to change matching behaviour depending on the protocol.
fix: Fail when a host isn't given for certain protocols.
fix: Convert back-slashes info forward-slashes.
Diffstat (limited to 'cli/js')
-rw-r--r-- | cli/js/tests/url_test.ts | 56 | ||||
-rw-r--r-- | cli/js/web/url.ts | 192 |
2 files changed, 176 insertions, 72 deletions
diff --git a/cli/js/tests/url_test.ts b/cli/js/tests/url_test.ts index 6529bf055..5b403fb1c 100644 --- a/cli/js/tests/url_test.ts +++ b/cli/js/tests/url_test.ts @@ -132,6 +132,43 @@ unitTest(function urlSearchParamsReuse(): void { assert(sp === url.searchParams, "Search params should be reused."); }); +unitTest(function urlBackSlashes(): void { + const url = new URL( + "https:\\\\foo:bar@baz.qat:8000\\qux\\quux?foo=bar&baz=12#qat" + ); + assertEquals( + url.href, + "https://foo:bar@baz.qat:8000/qux/quux?foo=bar&baz=12#qat" + ); +}); + +unitTest(function urlRequireHost(): void { + assertEquals(new URL("file:///").href, "file:///"); + assertThrows(() => { + new URL("ftp:///"); + }); + assertThrows(() => { + new URL("http:///"); + }); + assertThrows(() => { + new URL("https:///"); + }); + assertThrows(() => { + new URL("ws:///"); + }); + assertThrows(() => { + new URL("wss:///"); + }); +}); + +unitTest(function urlDriveLetter() { + assertEquals( + new URL("file:///C:").href, + Deno.build.os == "windows" ? "file:///C:/" : "file:///C:" + ); + assertEquals(new URL("http://example.com/C:").href, "http://example.com/C:"); +}); + unitTest(function urlBaseURL(): void { const base = new URL( "https://foo:bar@baz.qat:8000/qux/quux?foo=bar&baz=12#qat" @@ -158,6 +195,25 @@ unitTest(function urlRelativeWithBase(): void { assertEquals(new URL("../b", "file:///a/a/a").href, "file:///a/b"); }); +unitTest(function urlDriveLetterBase() { + assertEquals( + new URL("/b", "file:///C:/a/b").href, + Deno.build.os == "windows" ? "file:///C:/b" : "file:///b" + ); + assertEquals( + new URL("D:", "file:///C:/a/b").href, + Deno.build.os == "windows" ? "file:///D:/" : "file:///C:/a/D:" + ); + assertEquals( + new URL("/D:", "file:///C:/a/b").href, + Deno.build.os == "windows" ? "file:///D:/" : "file:///D:" + ); + assertEquals( + new URL("D:/b", "file:///C:/a/b").href, + Deno.build.os == "windows" ? "file:///D:/b" : "file:///C:/a/D:/b" + ); +}); + unitTest(function emptyBasePath(): void { assertEquals(new URL("", "http://example.com").href, "http://example.com/"); }); diff --git a/cli/js/web/url.ts b/cli/js/web/url.ts index cdbba36d9..41f855914 100644 --- a/cli/js/web/url.ts +++ b/cli/js/web/url.ts @@ -1,7 +1,8 @@ // Copyright 2018-2020 the Deno authors. All rights reserved. MIT license. +import { build } from "../build.ts"; +import { getRandomValues } from "../ops/get_random_values.ts"; import { customInspect } from "./console.ts"; import { urls } from "./url_search_params.ts"; -import { getRandomValues } from "../ops/get_random_values.ts"; interface URLParts { protocol: string; @@ -14,32 +15,14 @@ interface URLParts { hash: string; } -const patterns = { - protocol: "(?:([a-z]+):)", - authority: "(?://([^/?#]*))", - path: "([^?#]*)", - query: "(\\?[^#]*)", - hash: "(#.*)", - - authentication: "(?:([^:]*)(?::([^@]*))?@)", - hostname: "([^:]+)", - port: "(?::(\\d+))", -}; - -const urlRegExp = new RegExp( - `^${patterns.protocol}?${patterns.authority}?${patterns.path}${patterns.query}?${patterns.hash}?` -); - -const authorityRegExp = new RegExp( - `^${patterns.authentication}?${patterns.hostname}${patterns.port}?$` -); - const searchParamsMethods: Array<keyof URLSearchParams> = [ "append", "delete", "set", ]; +const specialSchemes = ["ftp", "file", "http", "https", "ws", "wss"]; + // https://url.spec.whatwg.org/#special-scheme const schemePorts: { [key: string]: string } = { ftp: "21", @@ -51,27 +34,69 @@ const schemePorts: { [key: string]: string } = { }; const MAX_PORT = 2 ** 16 - 1; -function parse(url: string): URLParts | undefined { - const urlMatch = urlRegExp.exec(url); - if (urlMatch) { - const [, , authority] = urlMatch; - const authorityMatch = authority - ? authorityRegExp.exec(authority) - : [null, null, null, null, null]; - if (authorityMatch) { - return { - protocol: urlMatch[1] || "", - username: authorityMatch[1] || "", - password: authorityMatch[2] || "", - hostname: authorityMatch[3] || "", - port: authorityMatch[4] || "", - path: urlMatch[3] || "", - query: urlMatch[4] || "", - hash: urlMatch[5] || "", - }; - } +// Remove the part of the string that matches the pattern and return the +// remainder (RHS) as well as the first captured group of the matched substring +// (LHS). e.g. +// takePattern("https://deno.land:80", /^([a-z]+):[/]{2}/) +// = ["http", "deno.land:80"] +// takePattern("deno.land:80", /^([^:]+):) +// = ["deno.land", "80"] +function takePattern(string: string, pattern: RegExp): [string, string] { + let capture = ""; + const rest = string.replace(pattern, (_, capture_) => { + capture = capture_; + return ""; + }); + return [capture, rest]; +} + +function parse(url: string, isBase = true): URLParts | undefined { + const parts: Partial<URLParts> = {}; + let restUrl; + [parts.protocol, restUrl] = takePattern(url, /^([a-z]+):/); + if (isBase && parts.protocol == "") { + return undefined; } - return undefined; + if (parts.protocol == "file") { + parts.username = ""; + parts.password = ""; + [parts.hostname, restUrl] = takePattern(restUrl, /^[/\\]{2}([^/\\?#]*)/); + if (parts.hostname.includes(":")) { + return undefined; + } + parts.port = ""; + } else if (specialSchemes.includes(parts.protocol)) { + let restAuthority; + [restAuthority, restUrl] = takePattern( + restUrl, + /^[/\\]{2}[/\\]*([^/\\?#]+)/ + ); + if (isBase && restAuthority == "") { + return undefined; + } + let restAuthentication; + [restAuthentication, restAuthority] = takePattern(restAuthority, /^(.*)@/); + [parts.username, restAuthentication] = takePattern( + restAuthentication, + /^([^:]*)/ + ); + [parts.password] = takePattern(restAuthentication, /^:(.*)/); + [parts.hostname, restAuthority] = takePattern(restAuthority, /^([^:]+)/); + [parts.port] = takePattern(restAuthority, /^:(.*)/); + if (!isValidPort(parts.port)) { + return undefined; + } + } else { + parts.username = ""; + parts.password = ""; + parts.hostname = ""; + parts.port = ""; + } + [parts.path, restUrl] = takePattern(restUrl, /^([^?#]*)/); + parts.path = parts.path.replace(/\\/g, "/"); + [parts.query, restUrl] = takePattern(restUrl, /^(\?[^#]*)/); + [parts.hash] = takePattern(restUrl, /^(#.*)/); + return parts as URLParts; } // Based on https://github.com/kelektiv/node-uuid @@ -92,7 +117,12 @@ function isAbsolutePath(path: string): boolean { // Resolves `.`s and `..`s where possible. // Preserves repeating and trailing `/`s by design. -function normalizePath(path: string): string { +// On Windows, drive letter paths will be given a leading slash, and also a +// trailing slash if there are no other components e.g. "C:" -> "/C:/". +function normalizePath(path: string, isFilePath = false): string { + if (build.os == "windows" && isFilePath) { + path = path.replace(/^\/*([A-Za-z]:)(\/|$)/, "/$1/"); + } const isAbsolute = isAbsolutePath(path); path = path.replace(/^\//, ""); const pathSegments = path.split("/"); @@ -123,27 +153,54 @@ function normalizePath(path: string): string { } // Standard URL basing logic, applied to paths. -function resolvePathFromBase(path: string, basePath: string): string { - const normalizedPath = normalizePath(path); +function resolvePathFromBase( + path: string, + basePath: string, + isFilePath = false +): string { + let normalizedPath = normalizePath(path, isFilePath); + let normalizedBasePath = normalizePath(basePath, isFilePath); + + let driveLetterPrefix = ""; + if (build.os == "windows" && isFilePath) { + let driveLetter = ""; + let baseDriveLetter = ""; + [driveLetter, normalizedPath] = takePattern( + normalizedPath, + /^(\/[A-Za-z]:)(?=\/)/ + ); + [baseDriveLetter, normalizedBasePath] = takePattern( + normalizedBasePath, + /^(\/[A-Za-z]:)(?=\/)/ + ); + driveLetterPrefix = driveLetter || baseDriveLetter; + } + if (isAbsolutePath(normalizedPath)) { - return normalizedPath; + return `${driveLetterPrefix}${normalizedPath}`; } - const normalizedBasePath = normalizePath(basePath); if (!isAbsolutePath(normalizedBasePath)) { throw new TypeError("Base path must be absolute."); } // Special case. if (path == "") { - return normalizedBasePath; + return `${driveLetterPrefix}${normalizedBasePath}`; } // Remove everything after the last `/` in `normalizedBasePath`. const prefix = normalizedBasePath.replace(/[^\/]*$/, ""); - // If `normalizedPath` ends with `.` or `..`, add a trailing space. + // If `normalizedPath` ends with `.` or `..`, add a trailing slash. const suffix = normalizedPath.replace(/(?<=(^|\/)(\.|\.\.))$/, "/"); - return normalizePath(prefix + suffix); + return `${driveLetterPrefix}${normalizePath(prefix + suffix)}`; +} + +function isValidPort(value: string): boolean { + // https://url.spec.whatwg.org/#port-state + if (value === "") true; + const port = Number(value); + return Number.isInteger(port) && port >= 0 && port <= MAX_PORT; } /** @internal */ @@ -189,18 +246,6 @@ export class URLImpl implements URL { urls.set(searchParams, this); }; - #validatePort = (value: string): string | undefined => { - // https://url.spec.whatwg.org/#port-state - if (value === "") return value; - - const port = Number(value); - if (Number.isInteger(port) && port >= 0 && port <= MAX_PORT) { - return port.toString(); - } - - return undefined; - }; - get hash(): string { return parts.get(this)!.hash; } @@ -300,8 +345,10 @@ export class URLImpl implements URL { } set port(value: string) { - const port = this.#validatePort(value); - parts.get(this)!.port = port ?? this.port; + if (!isValidPort(value)) { + return; + } + parts.get(this)!.port = value.toString(); } get protocol(): string { @@ -360,22 +407,19 @@ export class URLImpl implements URL { let baseParts: URLParts | undefined; if (base) { baseParts = typeof base === "string" ? parse(base) : parts.get(base); - if (!baseParts || baseParts.protocol == "") { + if (baseParts == undefined) { throw new TypeError("Invalid base URL."); } } - const urlParts = typeof url === "string" ? parse(url) : parts.get(url); - if (!urlParts) { - throw new TypeError("Invalid URL."); - } - - const { port } = !urlParts.protocol && baseParts ? baseParts : urlParts; - if (this.#validatePort(port) === undefined) { + const urlParts = + typeof url === "string" ? parse(url, !baseParts) : parts.get(url); + if (urlParts == undefined) { throw new TypeError("Invalid URL."); } if (urlParts.protocol) { + urlParts.path = normalizePath(urlParts.path, urlParts.protocol == "file"); parts.set(this, urlParts); } else if (baseParts) { parts.set(this, { @@ -384,7 +428,11 @@ export class URLImpl implements URL { password: baseParts.password, hostname: baseParts.hostname, port: baseParts.port, - path: resolvePathFromBase(urlParts.path, baseParts.path || "/"), + path: resolvePathFromBase( + urlParts.path, + baseParts.path || "/", + baseParts.protocol == "file" + ), query: urlParts.query, hash: urlParts.hash, }); |