diff --git a/src/node/internal/internal_errors.ts b/src/node/internal/internal_errors.ts index 013d7ac854c..be1768e573a 100644 --- a/src/node/internal/internal_errors.ts +++ b/src/node/internal/internal_errors.ts @@ -550,6 +550,36 @@ export class ERR_ZLIB_INITIALIZATION_FAILED extends NodeError { } } +export class ERR_INVALID_URL extends NodeError { + input: string; + + constructor(url: string) { + super('ERR_INVALID_URL', 'Invalid URL'); + this.input = url; + } +} + +export class ERR_INVALID_URL_SCHEME extends NodeError { + constructor(scheme: string) { + super('ERR_INVALID_URL_SCHEME', `The URL must be of scheme ${scheme}`); + } +} + +export class ERR_INVALID_FILE_URL_HOST extends NodeError { + constructor(input: string) { + super( + 'ERR_INVALID_FILE_URL_HOST', + `File URL host must be "localhost" or empty on ${input}` + ); + } +} + +export class ERR_INVALID_FILE_URL_PATH extends NodeError { + constructor(input: string) { + super('ERR_INVALID_FILE_URL_PATH', `File URL path ${input}`); + } +} + export function aggregateTwoErrors(innerError: any, outerError: any) { if (innerError && outerError && innerError !== outerError) { if (Array.isArray(outerError.errors)) { diff --git a/src/node/internal/internal_path.ts b/src/node/internal/internal_path.ts index 8ed16bba359..3dc7e5905a0 100644 --- a/src/node/internal/internal_path.ts +++ b/src/node/internal/internal_path.ts @@ -1071,21 +1071,37 @@ const posix = { resolve(...args: string[]): string { let resolvedPath = ''; let resolvedAbsolute = false; + let slashCheck = false; - for (let i = args.length - 1; i >= -1 && !resolvedAbsolute; i--) { - const path = i >= 0 ? args[i] : '/'; - - validateString(path, 'path'); + for (let i = args.length - 1; i >= 0 && !resolvedAbsolute; i--) { + const path = args[i]; + validateString(path, `paths[${i}]`); // Skip empty entries if (path.length === 0) { continue; } + if ( + i === args.length - 1 && + isPosixPathSeparator(path.charCodeAt(path.length - 1)) + ) { + slashCheck = true; + } - resolvedPath = `${path}/${resolvedPath}`; + if (resolvedPath.length !== 0) { + resolvedPath = `${path}/${resolvedPath}`; + } else { + resolvedPath = path; + } resolvedAbsolute = path.charCodeAt(0) === CHAR_FORWARD_SLASH; } + if (!resolvedAbsolute) { + const cwd = '/'; + resolvedPath = `${cwd}/${resolvedPath}`; + resolvedAbsolute = cwd.charCodeAt(0) === CHAR_FORWARD_SLASH; + } + // At this point the path should be resolved to a full absolute path, but // handle relative paths to be safe (might happen when process.cwd() fails) @@ -1097,10 +1113,20 @@ const posix = { isPosixPathSeparator ); - if (resolvedAbsolute) { - return `/${resolvedPath}`; + if (!resolvedAbsolute) { + if (resolvedPath.length === 0) { + return '.'; + } + if (slashCheck) { + return `${resolvedPath}/`; + } + return resolvedPath; + } + + if (resolvedPath.length === 0 || resolvedPath === '/') { + return '/'; } - return resolvedPath.length > 0 ? resolvedPath : '.'; + return slashCheck ? `/${resolvedPath}/` : `/${resolvedPath}`; }, /** @@ -1173,11 +1199,41 @@ const posix = { if (from === to) return ''; - const fromStart = 1; - const fromEnd = from.length; + // Trim any leading slashes + let fromStart = 0; + while ( + fromStart < from.length && + from.charCodeAt(fromStart) === CHAR_FORWARD_SLASH + ) { + fromStart++; + } + // Trim trailing slashes + let fromEnd = from.length; + while ( + fromEnd - 1 > fromStart && + from.charCodeAt(fromEnd - 1) === CHAR_FORWARD_SLASH + ) { + fromEnd--; + } const fromLen = fromEnd - fromStart; - const toStart = 1; - const toLen = to.length - toStart; + + // Trim any leading slashes + let toStart = 0; + while ( + toStart < to.length && + to.charCodeAt(toStart) === CHAR_FORWARD_SLASH + ) { + toStart++; + } + // Trim trailing slashes + let toEnd = to.length; + while ( + toEnd - 1 > toStart && + to.charCodeAt(toEnd - 1) === CHAR_FORWARD_SLASH + ) { + toEnd--; + } + const toLen = toEnd - toStart; // Compare paths to find the longest common path from root const length = fromLen < toLen ? fromLen : toLen; diff --git a/src/node/internal/internal_url.ts b/src/node/internal/internal_url.ts new file mode 100644 index 00000000000..29bf6d81f69 --- /dev/null +++ b/src/node/internal/internal_url.ts @@ -0,0 +1,226 @@ +// Copyright (c) 2017-2022 Cloudflare, Inc. +// Licensed under the Apache 2.0 license found in the LICENSE file or at: +// https://opensource.org/licenses/Apache-2.0 +// Copyright Joyent and Node contributors. All rights reserved. MIT license. + +import { + ERR_INVALID_FILE_URL_HOST, + ERR_INVALID_FILE_URL_PATH, + ERR_INVALID_ARG_TYPE, + ERR_INVALID_ARG_VALUE, + ERR_INVALID_URL_SCHEME, +} from 'node-internal:internal_errors'; +import { default as urlUtil } from 'node-internal:url'; +import { CHAR_LOWERCASE_A, CHAR_LOWERCASE_Z } from 'node-internal:constants'; +import { + win32 as pathWin32, + posix as pathPosix, +} from 'node-internal:internal_path'; + +const FORWARD_SLASH = /\//g; + +// The following characters are percent-encoded when converting from file path +// to URL: +// - %: The percent character is the only character not encoded by the +// `pathname` setter. +// - \: Backslash is encoded on non-windows platforms since it's a valid +// character but the `pathname` setters replaces it by a forward slash. +// - LF: The newline character is stripped out by the `pathname` setter. +// (See whatwg/url#419) +// - CR: The carriage return character is also stripped out by the `pathname` +// setter. +// - TAB: The tab character is also stripped out by the `pathname` setter. +const percentRegEx = /%/g; +const backslashRegEx = /\\/g; +const newlineRegEx = /\n/g; +const carriageReturnRegEx = /\r/g; +const tabRegEx = /\t/g; +const questionRegex = /\?/g; +const hashRegex = /#/g; + +function encodePathChars( + filepath: string, + options?: { windows?: boolean | undefined } +): string { + const windows = options?.windows; + if (filepath.indexOf('%') !== -1) + filepath = filepath.replace(percentRegEx, '%25'); + // In posix, backslash is a valid character in paths: + if (!windows && filepath.indexOf('\\') !== -1) + filepath = filepath.replace(backslashRegEx, '%5C'); + if (filepath.indexOf('\n') !== -1) + filepath = filepath.replace(newlineRegEx, '%0A'); + if (filepath.indexOf('\r') !== -1) + filepath = filepath.replace(carriageReturnRegEx, '%0D'); + if (filepath.indexOf('\t') !== -1) + filepath = filepath.replace(tabRegEx, '%09'); + return filepath; +} + +/** + * Checks if a value has the shape of a WHATWG URL object. + * + * Using a symbol or instanceof would not be able to recognize URL objects + * coming from other implementations (e.g. in Electron), so instead we are + * checking some well known properties for a lack of a better test. + * + * We use `href` and `protocol` as they are the only properties that are + * easy to retrieve and calculate due to the lazy nature of the getters. + * + * We check for `auth` and `path` attribute to distinguish legacy url instance with + * WHATWG URL instance. + */ +/* eslint-disable */ +export function isURL(self?: any): self is URL { + return Boolean( + self?.href && + self.protocol && + self.auth === undefined && + self.path === undefined + ); +} +/* eslint-enable */ + +export function getPathFromURLPosix(url: URL): string { + if (url.hostname !== '') { + // Note: Difference between Node.js and Workerd. + // Node.js uses `process.platform` whereas workerd hard codes it to linux. + // This is done to avoid confusion regarding non-linux support and conformance. + throw new ERR_INVALID_FILE_URL_HOST('linux'); + } + const pathname = url.pathname; + for (let n = 0; n < pathname.length; n++) { + if (pathname[n] === '%') { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const third = pathname.codePointAt(n + 2)! | 0x20; + if (pathname[n + 1] === '2' && third === 102) { + throw new ERR_INVALID_FILE_URL_PATH( + 'must not include encoded / characters' + ); + } + } + } + return decodeURIComponent(pathname); +} + +export function getPathFromURLWin32(url: URL): string { + const hostname = url.hostname; + let pathname = url.pathname; + for (let n = 0; n < pathname.length; n++) { + if (pathname[n] === '%') { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + 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 new ERR_INVALID_FILE_URL_PATH( + 'must not include encoded \\ or / characters' + ); + } + } + } + pathname = pathname.replace(FORWARD_SLASH, '\\'); + pathname = decodeURIComponent(pathname); + if (hostname !== '') { + // If hostname is set, then we have a UNC path + // Pass the hostname through domainToUnicode just in case + // it is an IDN using punycode encoding. We do not need to worry + // about percent encoding because the URL parser will have + // already taken care of that for us. Note that this only + // causes IDNs with an appropriate `xn--` prefix to be decoded. + return `\\\\${urlUtil.domainToUnicode(hostname)}${pathname}`; + } + // Otherwise, it's a local path that requires a drive letter + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const letter = pathname.codePointAt(1)! | 0x20; + const sep = pathname.charAt(2); + if ( + letter < CHAR_LOWERCASE_A || + letter > CHAR_LOWERCASE_Z || // a..z A..Z + sep !== ':' + ) { + throw new ERR_INVALID_FILE_URL_PATH('must be absolute'); + } + return pathname.slice(1); +} + +export function fileURLToPath( + input: string | URL, + options?: { windows?: boolean } +): string { + const windows = options?.windows; + let path: URL; + if (typeof input === 'string') { + path = new URL(input); + } else if (!isURL(input)) { + throw new ERR_INVALID_ARG_TYPE('path', ['string', 'URL'], input); + } else { + path = input; + } + if (path.protocol !== 'file:') { + throw new ERR_INVALID_URL_SCHEME('file'); + } + return windows ? getPathFromURLWin32(path) : getPathFromURLPosix(path); +} + +export function pathToFileURL( + filepath: string, + options?: { windows?: boolean } +): URL { + const windows = options?.windows; + // IMPORTANT: Difference between Node.js and workerd. + // The following check does not exist in Node.js due to primordial usage. + if (typeof filepath !== 'string') { + throw new ERR_INVALID_ARG_TYPE('filepath', 'string', filepath); + } + if (windows && filepath.startsWith('\\\\')) { + const outURL = new URL('file://'); + // UNC path format: \\server\share\resource + // Handle extended UNC path and standard UNC path + // "\\?\UNC\" path prefix should be ignored. + // Ref: https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation + const isExtendedUNC = filepath.startsWith('\\\\?\\UNC\\'); + const prefixLength = isExtendedUNC ? 8 : 2; + const hostnameEndIndex = filepath.indexOf('\\', prefixLength); + if (hostnameEndIndex === -1) { + throw new ERR_INVALID_ARG_VALUE( + 'path', + filepath, + 'Missing UNC resource path' + ); + } + if (hostnameEndIndex === 2) { + throw new ERR_INVALID_ARG_VALUE('path', filepath, 'Empty UNC servername'); + } + const hostname = filepath.slice(prefixLength, hostnameEndIndex); + outURL.hostname = urlUtil.domainToASCII(hostname); + outURL.pathname = encodePathChars( + filepath.slice(hostnameEndIndex).replace(backslashRegEx, '/'), + { windows } + ); + return outURL; + } + let resolved = windows + ? pathWin32.resolve(filepath) + : pathPosix.resolve(filepath); + + // Call encodePathChars first to avoid encoding % again for ? and #. + resolved = encodePathChars(resolved, { windows }); + + // Question and hash character should be included in pathname. + // Therefore, encoding is required to eliminate parsing them in different states. + // This is done as an optimization to not creating a URL instance and + // later triggering pathname setter, which impacts performance + if (resolved.indexOf('?') !== -1) + resolved = resolved.replace(questionRegex, '%3F'); + if (resolved.indexOf('#') !== -1) + resolved = resolved.replace(hashRegex, '%23'); + return new URL(`file://${resolved}`); +} + +export function toPathIfFileURL(fileURLOrPath: URL | string): string { + if (!isURL(fileURLOrPath)) return fileURLOrPath; + return fileURLToPath(fileURLOrPath); +} diff --git a/src/node/url.ts b/src/node/url.ts index 80e2c9336fc..e2df17c9c48 100644 --- a/src/node/url.ts +++ b/src/node/url.ts @@ -3,6 +3,11 @@ // https://opensource.org/licenses/Apache-2.0 import { default as urlUtil } from 'node-internal:url'; import { ERR_MISSING_ARGS } from 'node-internal:internal_errors'; +import { + fileURLToPath, + pathToFileURL, + toPathIfFileURL, +} from 'node-internal:internal_url'; const { URL, URLSearchParams } = globalThis; @@ -22,6 +27,11 @@ export function domainToUnicode(domain?: unknown): string { return urlUtil.domainToUnicode(`${domain}`); } +export { + fileURLToPath, + pathToFileURL, + toPathIfFileURL, +} from 'node-internal:internal_url'; export { URL, URLSearchParams }; export default { @@ -29,4 +39,7 @@ export default { domainToUnicode, URL, URLSearchParams, + fileURLToPath, + pathToFileURL, + toPathIfFileURL, }; diff --git a/src/workerd/api/node/tests/path-test.js b/src/workerd/api/node/tests/path-test.js index a56e70fec76..fa6c9692ee6 100644 --- a/src/workerd/api/node/tests/path-test.js +++ b/src/workerd/api/node/tests/path-test.js @@ -100,17 +100,18 @@ export const test_path_zero_length_strings = { }, }; +// Ref: https://github.com/nodejs/node/blob/4d6d7d644be4f10f90e5c9c66563736112fffbff/test/parallel/test-path-resolve.js export const test_path_resolve = { test(ctrl, env, ctx) { const failures = []; const posixyCwd = '/'; const resolveTests = [ - [['/var/lib', '../', 'file/'], '/var/file'], - [['/var/lib', '/../', 'file/'], '/file'], + [['/var/lib', '../', 'file/'], '/var/file/'], + [['/var/lib', '/../', 'file/'], '/file/'], [['a/b/c/', '../../..'], posixyCwd], [['.'], posixyCwd], - [['/some/dir', '.', '/absolute/'], '/absolute'], + [['/some/dir', '.', '/absolute/'], '/absolute/'], [['/foo/tmp.3/', '../tmp.3/cycles/root.js'], '/foo/tmp.3/cycles/root.js'], ]; resolveTests.forEach(([test, expected]) => { @@ -124,6 +125,7 @@ export const test_path_resolve = { }, }; +// Ref: https://github.com/nodejs/node/blob/4d6d7d644be4f10f90e5c9c66563736112fffbff/test/parallel/test-path-relative.js export const test_path_relative = { test(ctrl, env, ctx) { const failures = []; diff --git a/src/workerd/api/node/tests/url-nodejs-test.js b/src/workerd/api/node/tests/url-nodejs-test.js index 3b94ae4a0b6..0397e7f4d79 100644 --- a/src/workerd/api/node/tests/url-nodejs-test.js +++ b/src/workerd/api/node/tests/url-nodejs-test.js @@ -23,12 +23,14 @@ // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE // USE OR OTHER DEALINGS IN THE SOFTWARE. -import { strictEqual, throws, ok as assert } from 'node:assert'; +import { strictEqual, throws, ok as assert, match } from 'node:assert'; import { domainToASCII, domainToUnicode, URL as URLImpl, URLSearchParams as URLSearchParamsImpl, + fileURLToPath, + pathToFileURL, } from 'node:url'; // Tests are taken from: @@ -85,3 +87,369 @@ export const getBuiltinModule = { strictEqual(bim, url.default); }, }; + +// Ref: https://github.com/nodejs/node/blob/ddfef05f118e77cdb80a1f5971f076a03d221cb1/test/parallel/test-url-fileurltopath.js +export const testFileURLToPath = { + async test() { + // invalid arguments + for (const arg of [null, undefined, 1, {}, true]) { + throws(() => fileURLToPath(arg), { + code: 'ERR_INVALID_ARG_TYPE', + }); + } + + // input must be a file URL + throws(() => fileURLToPath('https://a/b/c'), { + code: 'ERR_INVALID_URL_SCHEME', + }); + + { + // fileURLToPath with host + const withHost = new URL('file://host/a'); + + throws(() => fileURLToPath(withHost), { + code: 'ERR_INVALID_FILE_URL_HOST', + }); + } + + const windowsTestCases = [ + // Lowercase ascii alpha + { path: 'C:\\foo', fileURL: 'file:///C:/foo' }, + // Uppercase ascii alpha + { path: 'C:\\FOO', fileURL: 'file:///C:/FOO' }, + // dir + { path: 'C:\\dir\\foo', fileURL: 'file:///C:/dir/foo' }, + // trailing separator + { path: 'C:\\dir\\', fileURL: 'file:///C:/dir/' }, + // dot + { path: 'C:\\foo.mjs', fileURL: 'file:///C:/foo.mjs' }, + // space + { path: 'C:\\foo bar', fileURL: 'file:///C:/foo%20bar' }, + // question mark + { path: 'C:\\foo?bar', fileURL: 'file:///C:/foo%3Fbar' }, + // number sign + { path: 'C:\\foo#bar', fileURL: 'file:///C:/foo%23bar' }, + // ampersand + { path: 'C:\\foo&bar', fileURL: 'file:///C:/foo&bar' }, + // equals + { path: 'C:\\foo=bar', fileURL: 'file:///C:/foo=bar' }, + // colon + { path: 'C:\\foo:bar', fileURL: 'file:///C:/foo:bar' }, + // semicolon + { path: 'C:\\foo;bar', fileURL: 'file:///C:/foo;bar' }, + // percent + { path: 'C:\\foo%bar', fileURL: 'file:///C:/foo%25bar' }, + // backslash + { path: 'C:\\foo\\bar', fileURL: 'file:///C:/foo/bar' }, + // backspace + { path: 'C:\\foo\bbar', fileURL: 'file:///C:/foo%08bar' }, + // tab + { path: 'C:\\foo\tbar', fileURL: 'file:///C:/foo%09bar' }, + // newline + { path: 'C:\\foo\nbar', fileURL: 'file:///C:/foo%0Abar' }, + // carriage return + { path: 'C:\\foo\rbar', fileURL: 'file:///C:/foo%0Dbar' }, + // latin1 + { path: 'C:\\fóóbàr', fileURL: 'file:///C:/f%C3%B3%C3%B3b%C3%A0r' }, + // Euro sign (BMP code point) + { path: 'C:\\€', fileURL: 'file:///C:/%E2%82%AC' }, + // Rocket emoji (non-BMP code point) + { path: 'C:\\🚀', fileURL: 'file:///C:/%F0%9F%9A%80' }, + // UNC path (see https://docs.microsoft.com/en-us/archive/blogs/ie/file-uris-in-windows) + { + path: '\\\\nas\\My Docs\\File.doc', + fileURL: 'file://nas/My%20Docs/File.doc', + }, + ]; + + const posixTestCases = [ + // Lowercase ascii alpha + { path: '/foo', fileURL: 'file:///foo' }, + // Uppercase ascii alpha + { path: '/FOO', fileURL: 'file:///FOO' }, + // dir + { path: '/dir/foo', fileURL: 'file:///dir/foo' }, + // trailing separator + { path: '/dir/', fileURL: 'file:///dir/' }, + // dot + { path: '/foo.mjs', fileURL: 'file:///foo.mjs' }, + // space + { path: '/foo bar', fileURL: 'file:///foo%20bar' }, + // question mark + { path: '/foo?bar', fileURL: 'file:///foo%3Fbar' }, + // number sign + { path: '/foo#bar', fileURL: 'file:///foo%23bar' }, + // ampersand + { path: '/foo&bar', fileURL: 'file:///foo&bar' }, + // equals + { path: '/foo=bar', fileURL: 'file:///foo=bar' }, + // colon + { path: '/foo:bar', fileURL: 'file:///foo:bar' }, + // semicolon + { path: '/foo;bar', fileURL: 'file:///foo;bar' }, + // percent + { path: '/foo%bar', fileURL: 'file:///foo%25bar' }, + // backslash + { path: '/foo\\bar', fileURL: 'file:///foo%5Cbar' }, + // backspace + { path: '/foo\bbar', fileURL: 'file:///foo%08bar' }, + // tab + { path: '/foo\tbar', fileURL: 'file:///foo%09bar' }, + // newline + { path: '/foo\nbar', fileURL: 'file:///foo%0Abar' }, + // carriage return + { path: '/foo\rbar', fileURL: 'file:///foo%0Dbar' }, + // latin1 + { path: '/fóóbàr', fileURL: 'file:///f%C3%B3%C3%B3b%C3%A0r' }, + // Euro sign (BMP code point) + { path: '/€', fileURL: 'file:///%E2%82%AC' }, + // Rocket emoji (non-BMP code point) + { path: '/🚀', fileURL: 'file:///%F0%9F%9A%80' }, + ]; + + // fileURLToPath with windows path + for (const { path, fileURL } of windowsTestCases) { + const fromString = fileURLToPath(fileURL, { windows: true }); + strictEqual(fromString, path); + const fromURL = fileURLToPath(new URL(fileURL), { windows: true }); + strictEqual(fromURL, path); + } + + // fileURLToPath with posix path + for (const { path, fileURL } of posixTestCases) { + const fromString = fileURLToPath(fileURL, { windows: false }); + strictEqual(fromString, path); + const fromURL = fileURLToPath(new URL(fileURL), { windows: false }); + strictEqual(fromURL, path); + } + + { + // options is null + const whenNullActual = fileURLToPath( + new URL(posixTestCases[0].fileURL), + null + ); + strictEqual(whenNullActual, posixTestCases[0].path); + + // default test cases + for (const { path, fileURL } of posixTestCases) { + const fromString = fileURLToPath(fileURL); + strictEqual(fromString, path); + const fromURL = fileURLToPath(new URL(fileURL)); + strictEqual(fromURL, path); + } + } + }, +}; + +// Ref: https://github.com/nodejs/node/blob/ddfef05f118e77cdb80a1f5971f076a03d221cb1/test/parallel/test-url-pathtofileurl.js +export const testPathToFileURL = { + async test() { + { + const fileURL = pathToFileURL('test/').href; + assert(fileURL.startsWith('file:///')); + assert(fileURL.endsWith('/')); + } + + { + const fileURL = pathToFileURL('test\\').href; + assert(fileURL.startsWith('file:///')); + assert(fileURL.endsWith('%5C')); + } + + { + const fileURL = pathToFileURL('test/%').href; + assert(fileURL.includes('%25')); + } + + { + // UNC path: \\server\share\resource + // Missing server: + throws(() => pathToFileURL('\\\\\\no-server', { windows: true }), { + code: 'ERR_INVALID_ARG_VALUE', + }); + + // Missing share or resource: + throws(() => pathToFileURL('\\\\host', { windows: true }), { + code: 'ERR_INVALID_ARG_VALUE', + }); + // Regression test for direct String.prototype.startsWith call + throws( + () => + pathToFileURL( + ['\\\\', { [Symbol.toPrimitive]: () => 'blep\\blop' }], + { windows: true } + ), + { + code: 'ERR_INVALID_ARG_TYPE', + } + ); + throws(() => pathToFileURL(['\\\\', 'blep\\blop'], { windows: true }), { + code: 'ERR_INVALID_ARG_TYPE', + }); + throws( + () => + pathToFileURL( + { + [Symbol.toPrimitive]: () => '\\\\blep\\blop', + }, + { windows: true } + ), + { + code: 'ERR_INVALID_ARG_TYPE', + } + ); + } + + { + // UNC paths on posix are considered a single path that has backslashes: + const fileURL = pathToFileURL('\\\\nas\\share\\path.txt', { + windows: false, + }).href; + match(fileURL, /file:\/\/.+%5C%5Cnas%5Cshare%5Cpath\.txt$/); + } + + const windowsTestCases = [ + // Lowercase ascii alpha + { path: 'C:\\foo', expected: 'file:///C:/foo' }, + // Uppercase ascii alpha + { path: 'C:\\FOO', expected: 'file:///C:/FOO' }, + // dir + { path: 'C:\\dir\\foo', expected: 'file:///C:/dir/foo' }, + // trailing separator + { path: 'C:\\dir\\', expected: 'file:///C:/dir/' }, + // dot + { path: 'C:\\foo.mjs', expected: 'file:///C:/foo.mjs' }, + // space + { path: 'C:\\foo bar', expected: 'file:///C:/foo%20bar' }, + // question mark + { path: 'C:\\foo?bar', expected: 'file:///C:/foo%3Fbar' }, + // number sign + { path: 'C:\\foo#bar', expected: 'file:///C:/foo%23bar' }, + // ampersand + { path: 'C:\\foo&bar', expected: 'file:///C:/foo&bar' }, + // equals + { path: 'C:\\foo=bar', expected: 'file:///C:/foo=bar' }, + // colon + { path: 'C:\\foo:bar', expected: 'file:///C:/foo:bar' }, + // semicolon + { path: 'C:\\foo;bar', expected: 'file:///C:/foo;bar' }, + // percent + { path: 'C:\\foo%bar', expected: 'file:///C:/foo%25bar' }, + // backslash + { path: 'C:\\foo\\bar', expected: 'file:///C:/foo/bar' }, + // backspace + { path: 'C:\\foo\bbar', expected: 'file:///C:/foo%08bar' }, + // tab + { path: 'C:\\foo\tbar', expected: 'file:///C:/foo%09bar' }, + // newline + { path: 'C:\\foo\nbar', expected: 'file:///C:/foo%0Abar' }, + // carriage return + { path: 'C:\\foo\rbar', expected: 'file:///C:/foo%0Dbar' }, + // latin1 + { path: 'C:\\fóóbàr', expected: 'file:///C:/f%C3%B3%C3%B3b%C3%A0r' }, + // Euro sign (BMP code point) + { path: 'C:\\€', expected: 'file:///C:/%E2%82%AC' }, + // Rocket emoji (non-BMP code point) + { path: 'C:\\🚀', expected: 'file:///C:/%F0%9F%9A%80' }, + // Local extended path + { + path: '\\\\?\\C:\\path\\to\\file.txt', + expected: 'file:///C:/path/to/file.txt', + }, + // UNC path (see https://docs.microsoft.com/en-us/archive/blogs/ie/file-uris-in-windows) + { + path: '\\\\nas\\My Docs\\File.doc', + expected: 'file://nas/My%20Docs/File.doc', + }, + // Extended UNC path + { + path: '\\\\?\\UNC\\server\\share\\folder\\file.txt', + expected: 'file://server/share/folder/file.txt', + }, + ]; + const posixTestCases = [ + // Lowercase ascii alpha + { path: '/foo', expected: 'file:///foo' }, + // Uppercase ascii alpha + { path: '/FOO', expected: 'file:///FOO' }, + // dir + { path: '/dir/foo', expected: 'file:///dir/foo' }, + // trailing separator + { path: '/dir/', expected: 'file:///dir/' }, + // dot + { path: '/foo.mjs', expected: 'file:///foo.mjs' }, + // space + { path: '/foo bar', expected: 'file:///foo%20bar' }, + // question mark + { path: '/foo?bar', expected: 'file:///foo%3Fbar' }, + // number sign + { path: '/foo#bar', expected: 'file:///foo%23bar' }, + // ampersand + { path: '/foo&bar', expected: 'file:///foo&bar' }, + // equals + { path: '/foo=bar', expected: 'file:///foo=bar' }, + // colon + { path: '/foo:bar', expected: 'file:///foo:bar' }, + // semicolon + { path: '/foo;bar', expected: 'file:///foo;bar' }, + // percent + { path: '/foo%bar', expected: 'file:///foo%25bar' }, + // backslash + { path: '/foo\\bar', expected: 'file:///foo%5Cbar' }, + // backspace + { path: '/foo\bbar', expected: 'file:///foo%08bar' }, + // tab + { path: '/foo\tbar', expected: 'file:///foo%09bar' }, + // newline + { path: '/foo\nbar', expected: 'file:///foo%0Abar' }, + // carriage return + { path: '/foo\rbar', expected: 'file:///foo%0Dbar' }, + // latin1 + { path: '/fóóbàr', expected: 'file:///f%C3%B3%C3%B3b%C3%A0r' }, + // Euro sign (BMP code point) + { path: '/€', expected: 'file:///%E2%82%AC' }, + // Rocket emoji (non-BMP code point) + { path: '/🚀', expected: 'file:///%F0%9F%9A%80' }, + ]; + + for (const { path, expected } of windowsTestCases) { + const actual = pathToFileURL(path, { windows: true }).href; + strictEqual(actual, expected); + } + + for (const { path, expected } of posixTestCases) { + const actual = pathToFileURL(path, { windows: false }).href; + strictEqual(actual, expected); + } + + // Test for non-string parameter + { + for (const badPath of [ + undefined, + null, + true, + 42, + 42n, + Symbol('42'), + NaN, + {}, + [], + () => {}, + Promise.resolve('foo'), + new Date(), + new String('notPrimitive'), + { + toString() { + return 'amObject'; + }, + }, + { [Symbol.toPrimitive]: (hint) => 'amObject' }, + ]) { + throws(() => pathToFileURL(badPath), { + code: 'ERR_INVALID_ARG_TYPE', + }); + } + } + }, +}; diff --git a/src/workerd/api/node/tests/url-nodejs-test.wd-test b/src/workerd/api/node/tests/url-nodejs-test.wd-test index 92589f61e7b..accd796ff6d 100644 --- a/src/workerd/api/node/tests/url-nodejs-test.wd-test +++ b/src/workerd/api/node/tests/url-nodejs-test.wd-test @@ -7,8 +7,8 @@ const unitTests :Workerd.Config = ( modules = [ (name = "worker", esModule = embed "url-nodejs-test.js") ], - compatibilityDate = "2023-10-01", - compatibilityFlags = ["nodejs_compat_v2"], + compatibilityDate = "2024-10-11", + compatibilityFlags = ["nodejs_compat"], ) ), ],