diff --git a/src/js/node/url.ts b/src/js/node/url.ts index e39f66566d63..c7e543c20adf 100644 --- a/src/js/node/url.ts +++ b/src/js/node/url.ts @@ -893,30 +893,6 @@ Url.prototype.parseHost = function parseHost() { if (host) this.hostname = host; }; -// function fileURLToPath(...args) { -// // Since we use WTF::URL::fileSystemPath directly in Bun.fileURLToPath, we don't get invalid windows -// // path checking. We patch this in to `node:url` for compatibility. Note that -// // this behavior is missing from WATWG URL. -// if (process.platform === "win32") { -// var url: string; -// if ($isObject(args[0]) && args[0] instanceof Url) { -// url = (args[0] as { href: string }).href; -// } else if (typeof args[0] === "string") { -// url = args[0]; -// } else { -// throw $ERR_INVALID_ARG_TYPE("url", ["string", "URL"], args[0]); -// } - -// for (var i = 0; i < url.length; i++) { -// if (url.charCodeAt(i) === Char.PERCENT && (i + 1) < url.length) { -// switch (url.charCodeAt(i + 1)) { -// break; -// } -// } -// } -// return Bun.fileURLToPath.$call(args); -// } - /** * Add new characters as needed from * [here](https://github.com/nodejs/node/blob/main/lib/internal/constants.js). @@ -936,12 +912,167 @@ const enum Char { PERCENT = 37, // % LEFT_SQUARE_BRACKET = 91, // [ RIGHT_SQUARE_BRACKET = 93, // ] + LOWERCASE_A = 97, // a + LOWERCASE_Z = 122, // z // whitespace NO_BREAK_SPACE = 160, // \u00A0 ZERO_WIDTH_NOBREAK_SPACE = 65279, // \uFEFF } +const isWindows = process.platform === "win32"; +const FORWARD_SLASH = /\//g; + +function getPathFromURLWin32(url: URL): string { + const hostname = url.hostname; + let pathname = url.pathname; + for (let n = 0; n < pathname.length; n++) { + if (pathname.$charCodeAt(n) === Char.PERCENT) { + const third = pathname.codePointAt(n + 2)! | 0x20; + if ( + (pathname[n + 1] === "2" && third === 102) || // 2f 2F / + (pathname[n + 1] === "5" && third === 99) // 5c 5C \ + ) { + throw $ERR_INVALID_FILE_URL_PATH("File URL path must not include encoded \\ or / characters"); + } + } + } + pathname = pathname.replace(FORWARD_SLASH, "\\"); + pathname = decodeURIComponent(pathname); + if (hostname !== "") { + // UNC path: \\server\share\resource + return `\\\\${domainToUnicode(hostname)}${pathname}`; + } + // Otherwise, it's a local path that requires a drive letter + const letter = pathname.codePointAt(1)! | 0x20; + const sep = pathname.$charCodeAt(2); + if (letter < Char.LOWERCASE_A || letter > Char.LOWERCASE_Z || sep !== Char.COLON) { + throw $ERR_INVALID_FILE_URL_PATH("File URL path must be absolute"); + } + return pathname.slice(1); +} + +function getPathFromURLPosix(url: URL): string { + if (url.hostname !== "") { + throw $ERR_INVALID_FILE_URL_HOST(`File URL host must be "localhost" or empty on ${process.platform}`); + } + const pathname = url.pathname; + for (let n = 0; n < pathname.length; n++) { + if (pathname.$charCodeAt(n) === Char.PERCENT) { + const third = pathname.codePointAt(n + 2)! | 0x20; + if (pathname[n + 1] === "2" && third === 102) { + throw $ERR_INVALID_FILE_URL_PATH("File URL path must not include encoded / characters"); + } + } + } + return decodeURIComponent(pathname); +} + +function fileURLToPath(path: unknown, options?: { windows?: boolean }): string { + const windows = options?.windows ?? isWindows; + // Bun.fileURLToPath already implements the host-platform semantics; only + // take the platform-parameterized path when the caller overrides it. + if (!windows === !isWindows) { + return Bun.fileURLToPath(path as string | URL); + } + if (typeof path === "string") { + path = new URL(path); + } else if (!isURL(path)) { + throw $ERR_INVALID_ARG_TYPE("path", ["string", "URL"], path); + } + const url = path as URL; + if (url.protocol !== "file:") { + throw $ERR_INVALID_URL_SCHEME("The URL must be of scheme file"); + } + return windows ? getPathFromURLWin32(url) : getPathFromURLPosix(url); +} + +// RFC1738 defines the following chars as "unsafe" for URLs +// @see https://www.ietf.org/rfc/rfc1738.txt 2.2. URL Character Encoding Issues +// prettier-ignore +const urlPathEncodings: Record = { + __proto__: null!, + 0x00: "%00", // \0 + 0x09: "%09", // \t + 0x0a: "%0A", // \n + 0x0d: "%0D", // \r + 0x20: "%20", // (space) + 0x22: "%22", // " + 0x23: "%23", // # + 0x25: "%25", // % + 0x3f: "%3F", // ? + 0x5b: "%5B", // [ + 0x5c: "%5C", // \ + 0x5d: "%5D", // ] + 0x5e: "%5E", // ^ + 0x7c: "%7C", // | + 0x7e: "%7E", // ~ +}; + +function encodePathChars(filepath: string, windows: boolean): string { + let out = "file://"; + let last = 0; + for (let i = 0; i < filepath.length; i++) { + const code = filepath.$charCodeAt(i); + if (code > 0x7e) continue; + if (windows && code === Char.BACKWARD_SLASH) { + out += filepath.slice(last, i) + "/"; + last = i + 1; + continue; + } + const replacement = urlPathEncodings[code]; + if (replacement !== undefined) { + out += filepath.slice(last, i) + replacement; + last = i + 1; + } + } + if (last === 0) return out + filepath; + return out + filepath.slice(last); +} + +function pathToFileURL(filepath: unknown, options?: { windows?: boolean }): URL { + const windows = options?.windows ?? isWindows; + // Bun.pathToFileURL already implements the host-platform semantics; only + // take the platform-parameterized path when the caller overrides it. + if (!windows === !isWindows) { + return Bun.pathToFileURL(filepath as string); + } + validateString(filepath, "path"); + const path = require("node:path"); + const isUNC = windows && (filepath as string).startsWith("\\\\"); + let resolved: string = isUNC + ? (filepath as string) + : windows + ? path.win32.resolve(filepath) + : path.posix.resolve(filepath); + if (isUNC || (windows && resolved.startsWith("\\\\"))) { + // UNC path format: \\server\share\resource + // "\\?\UNC\" path prefix should be ignored. + // Ref: https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation + const isExtendedUNC = resolved.startsWith("\\\\?\\UNC\\"); + const prefixLength = isExtendedUNC ? 8 : 2; + const hostnameEndIndex = resolved.indexOf("\\", prefixLength); + if (hostnameEndIndex === -1) { + throw $ERR_INVALID_ARG_VALUE("path", resolved, "Missing UNC resource path"); + } + if (hostnameEndIndex === 2) { + throw $ERR_INVALID_ARG_VALUE("path", resolved, "Empty UNC servername"); + } + const hostname = resolved.slice(prefixLength, hostnameEndIndex); + const outURL = new URL(encodePathChars(resolved.slice(hostnameEndIndex), true)); + outURL.hostname = hostname; + return outURL; + } + const filePathLast = (filepath as string).$charCodeAt((filepath as string).length - 1); + if ( + (filePathLast === Char.FORWARD_SLASH || (windows && filePathLast === Char.BACKWARD_SLASH)) && + resolved.$charCodeAt(resolved.length - 1) !== (windows ? Char.BACKWARD_SLASH : Char.FORWARD_SLASH) + ) { + resolved += "/"; + } + return new URL(encodePathChars(resolved, windows)); +} + // Port of Node.js fileURLToPathBuffer // (https://github.com/nodejs/node/blob/v26.3.0/lib/internal/url.js#L1638). // Unlike fileURLToPath it never interprets percent-encodings as UTF-8: the @@ -997,7 +1128,7 @@ function fileURLToPathBuffer(path: unknown, options?: { windows?: boolean }): Bu if (url.protocol !== "file:") { throw $ERR_INVALID_URL_SCHEME("The URL must be of scheme file"); } - if (windows ?? process.platform === "win32") { + if (windows ?? isWindows) { let pathname = url.pathname.replaceAll("/", "\\"); const decoded = percentDecodeIntoBuffer(pathname); const hostname = url.hostname; @@ -1027,8 +1158,8 @@ export default { Url, URLSearchParams, URL, - pathToFileURL: Bun.pathToFileURL, - fileURLToPath: Bun.fileURLToPath, + pathToFileURL, + fileURLToPath, fileURLToPathBuffer, urlToHttpOptions, domainToASCII, diff --git a/test/js/node/url/url-fileurltopath.test.js b/test/js/node/url/url-fileurltopath.test.js index 7d668ff1967d..7e1b80ffa98c 100644 --- a/test/js/node/url/url-fileurltopath.test.js +++ b/test/js/node/url/url-fileurltopath.test.js @@ -151,4 +151,40 @@ describe("url.fileURLToPath", () => { assert.strictEqual(fromURL, path); } }); + + test("options.windows forces Windows or POSIX semantics regardless of host OS", () => { + // {windows: true} — Windows path semantics + assert.strictEqual(url.fileURLToPath("file:///C:/x", { windows: true }), "C:\\x"); + assert.strictEqual(url.fileURLToPath("file:///C:/foo/bar", { windows: true }), "C:\\foo\\bar"); + assert.strictEqual(url.fileURLToPath(new URL("file:///C:/foo%20bar"), { windows: true }), "C:\\foo bar"); + // UNC path from hostname + assert.strictEqual(url.fileURLToPath("file://host/s/x", { windows: true }), "\\\\host\\s\\x"); + assert.strictEqual( + url.fileURLToPath("file://nas/My%20Docs/File.doc", { windows: true }), + "\\\\nas\\My Docs\\File.doc", + ); + // file://localhost/... normalizes to empty hostname, so still a local drive path + assert.strictEqual(url.fileURLToPath("file://localhost/C:/x", { windows: true }), "C:\\x"); + // Encoded \\ or / rejected under Windows semantics + assert.throws(() => url.fileURLToPath("file:///C:/a%5Cb", { windows: true }), { + code: "ERR_INVALID_FILE_URL_PATH", + }); + assert.throws(() => url.fileURLToPath("file:///C:/a%2Fb", { windows: true }), { + code: "ERR_INVALID_FILE_URL_PATH", + }); + // No drive letter under Windows semantics + assert.throws(() => url.fileURLToPath("file:///foo", { windows: true }), { code: "ERR_INVALID_FILE_URL_PATH" }); + + // {windows: false} — POSIX path semantics + assert.strictEqual(url.fileURLToPath("file:///foo/bar", { windows: false }), "/foo/bar"); + assert.strictEqual(url.fileURLToPath("file:///C:/x", { windows: false }), "/C:/x"); + // Hostname rejected under POSIX semantics + assert.throws(() => url.fileURLToPath("file://host/a", { windows: false }), { code: "ERR_INVALID_FILE_URL_HOST" }); + // Encoded / rejected under POSIX semantics, encoded \ is allowed + assert.throws(() => url.fileURLToPath("file:///a%2Fb", { windows: false }), { code: "ERR_INVALID_FILE_URL_PATH" }); + assert.strictEqual(url.fileURLToPath("file:///a%5Cb", { windows: false }), "/a\\b"); + + // options.windows === undefined falls back to the host platform + assert.strictEqual(url.fileURLToPath("file:///C:/x", { windows: undefined }), isWindows ? "C:\\x" : "/C:/x"); + }); }); diff --git a/test/js/node/url/url-pathtofileurl.test.js b/test/js/node/url/url-pathtofileurl.test.js index 561cb3e3b8b9..4ec251028f5b 100644 --- a/test/js/node/url/url-pathtofileurl.test.js +++ b/test/js/node/url/url-pathtofileurl.test.js @@ -171,6 +171,45 @@ describe("url.pathToFileURL", () => { } }); + test("options.windows forces Windows or POSIX semantics regardless of host OS", () => { + // {windows: true} — Windows path semantics + assert.strictEqual(url.pathToFileURL("C:\\x", { windows: true }).href, "file:///C:/x"); + assert.strictEqual(url.pathToFileURL("C:\\dir\\foo", { windows: true }).href, "file:///C:/dir/foo"); + assert.strictEqual(url.pathToFileURL("C:\\dir\\", { windows: true }).href, "file:///C:/dir/"); + assert.strictEqual(url.pathToFileURL("C:\\foo bar", { windows: true }).href, "file:///C:/foo%20bar"); + assert.strictEqual(url.pathToFileURL("C:\\foo%bar", { windows: true }).href, "file:///C:/foo%25bar"); + assert.strictEqual(url.pathToFileURL("C:\\€", { windows: true }).href, "file:///C:/%E2%82%AC"); + // UNC paths + assert.strictEqual(url.pathToFileURL("\\\\srv\\s\\x", { windows: true }).href, "file://srv/s/x"); + assert.strictEqual( + url.pathToFileURL("\\\\nas\\My Docs\\File.doc", { windows: true }).href, + "file://nas/My%20Docs/File.doc", + ); + // Forward-slash UNC: path.win32.resolve normalizes it to \\srv\share\x, which + // must be re-checked for the UNC prefix after resolving. + assert.strictEqual(url.pathToFileURL("//srv/share/x", { windows: true }).href, "file://srv/share/x"); + if (!isWindows) { + // Exercises the override path here; on Windows these go through the native + // Bun.pathToFileURL, whose UNC handling is covered by test.todo("UNC paths") above. + assert.strictEqual(url.pathToFileURL("\\\\?\\UNC\\srv\\s\\x", { windows: true }).href, "file://srv/s/x"); + assert.throws(() => url.pathToFileURL("\\\\host", { windows: true }), { code: "ERR_INVALID_ARG_VALUE" }); + assert.throws(() => url.pathToFileURL("\\\\\\x", { windows: true }), { code: "ERR_INVALID_ARG_VALUE" }); + } + + // {windows: false} — POSIX path semantics + assert.strictEqual(url.pathToFileURL("/foo/bar", { windows: false }).href, "file:///foo/bar"); + assert.strictEqual(url.pathToFileURL("/foo%bar", { windows: false }).href, "file:///foo%25bar"); + // backslash is a regular char under POSIX, percent-encoded + assert.strictEqual(url.pathToFileURL("/a\\b", { windows: false }).href, "file:///a%5Cb"); + + // round-trip across platform semantics + assert.strictEqual( + url.fileURLToPath(url.pathToFileURL("C:\\a\\b", { windows: true }), { windows: true }), + "C:\\a\\b", + ); + assert.strictEqual(url.fileURLToPath(url.pathToFileURL("/a/b", { windows: false }), { windows: false }), "/a/b"); + }); + // TODO: Support throwing correct exception for non-string params. test.todo("non-string parameter", () => { for (const badPath of [