Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 158 additions & 27 deletions src/js/node/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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<number, string> = {
__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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1027,8 +1158,8 @@ export default {
Url,
URLSearchParams,
URL,
pathToFileURL: Bun.pathToFileURL,
fileURLToPath: Bun.fileURLToPath,
pathToFileURL,
fileURLToPath,
fileURLToPathBuffer,
urlToHttpOptions,
domainToASCII,
Expand Down
36 changes: 36 additions & 0 deletions test/js/node/url/url-fileurltopath.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
39 changes: 39 additions & 0 deletions test/js/node/url/url-pathtofileurl.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down
Loading