Skip to content

Commit

Permalink
lib, url: add a windows option to path parsing
Browse files Browse the repository at this point in the history
PR-URL: nodejs#52509
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Moshe Atlow <moshe@atlow.co.il>
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Zeyu "Alex" Yang <himself65@outlook.com>
Reviewed-By: LiviaMedeiros <livia@cirno.name>
  • Loading branch information
RedYetiDev authored and bmeck committed Jun 22, 2024
1 parent 5bdf980 commit de4c34d
Show file tree
Hide file tree
Showing 6 changed files with 267 additions and 216 deletions.
4 changes: 2 additions & 2 deletions doc/api/esm.md
Original file line number Diff line number Diff line change
Expand Up @@ -1175,7 +1175,7 @@ resolution for ESM specifiers is [commonjs-extension-resolution-loader][].
[`package.json`]: packages.md#nodejs-packagejson-field-definitions
[`path.dirname()`]: path.md#pathdirnamepath
[`process.dlopen`]: process.md#processdlopenmodule-filename-flags
[`url.fileURLToPath()`]: url.md#urlfileurltopathurl
[`url.fileURLToPath()`]: url.md#urlfileurltopathurl-options
[cjs-module-lexer]: https://github.com/nodejs/cjs-module-lexer/tree/1.2.2
[commonjs-extension-resolution-loader]: https://github.com/nodejs/loaders-test/tree/main/commonjs-extension-resolution-loader
[custom https loader]: module.md#import-from-https
Expand All @@ -1184,4 +1184,4 @@ resolution for ESM specifiers is [commonjs-extension-resolution-loader][].
[special scheme]: https://url.spec.whatwg.org/#special-scheme
[status code]: process.md#exit-codes
[the official standard format]: https://tc39.github.io/ecma262/#sec-modules
[url.pathToFileURL]: url.md#urlpathtofileurlpath
[url.pathToFileURL]: url.md#urlpathtofileurlpath-options
24 changes: 22 additions & 2 deletions doc/api/url.md
Original file line number Diff line number Diff line change
Expand Up @@ -1151,13 +1151,23 @@ console.log(url.domainToUnicode('xn--iñvalid.com'));
// Prints an empty string
```

### `url.fileURLToPath(url)`
### `url.fileURLToPath(url[, options])`

<!-- YAML
added: v10.12.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/52509
description: The `options` argument can now be used to
determine how to parse the `path` argument.
-->

* `url` {URL | string} The file URL string or URL object to convert to a path.
* `options` {Object}
* `windows` {boolean|undefined} `true` if the `path` should be
return as a windows filepath, `false` for posix, and
`undefined` for the system default.
**Default:** `undefined`.
* Returns: {string} The fully-resolved platform-specific Node.js file path.

This function ensures the correct decodings of percent-encoded characters as
Expand Down Expand Up @@ -1251,13 +1261,23 @@ console.log(url.format(myURL, { fragment: false, unicode: true, auth: false }));
// Prints 'https://測試/?abc'
```
### `url.pathToFileURL(path)`
### `url.pathToFileURL(path[, options])`
<!-- YAML
added: v10.12.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/52509
description: The `options` argument can now be used to
determine how to return the `path` value.
-->
* `path` {string} The path to convert to a File URL.
* `options` {Object}
* `windows` {boolean|undefined} `true` if the `path` should be
treated as a windows filepath, `false` for posix, and
`undefined` for the system default.
**Default:** `undefined`.
* Returns: {URL} The file URL object.
This function ensures that `path` is resolved absolutely, and that the URL
Expand Down
26 changes: 16 additions & 10 deletions lib/internal/url.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const {
getConstructorOf,
removeColors,
kEnumerableProperty,
kEmptyObject,
SideEffectFreeRegExpPrototypeSymbolReplace,
} = require('internal/util');

Expand Down Expand Up @@ -1480,14 +1481,15 @@ function getPathFromURLPosix(url) {
return decodeURIComponent(pathname);
}

function fileURLToPath(path) {
function fileURLToPath(path, options = kEmptyObject) {
const windows = options?.windows;
if (typeof path === 'string')
path = new URL(path);
else if (!isURL(path))
throw new ERR_INVALID_ARG_TYPE('path', ['string', 'URL'], path);
if (path.protocol !== 'file:')
throw new ERR_INVALID_URL_SCHEME('file');
return isWindows ? getPathFromURLWin32(path) : getPathFromURLPosix(path);
return (windows ?? isWindows) ? getPathFromURLWin32(path) : getPathFromURLPosix(path);
}

// The following characters are percent-encoded when converting from file path
Expand All @@ -1509,11 +1511,12 @@ const tabRegEx = /\t/g;
const questionRegex = /\?/g;
const hashRegex = /#/g;

function encodePathChars(filepath) {
function encodePathChars(filepath, options = kEmptyObject) {
const windows = options?.windows;
if (StringPrototypeIndexOf(filepath, '%') !== -1)
filepath = RegExpPrototypeSymbolReplace(percentRegEx, filepath, '%25');
// In posix, backslash is a valid character in paths:
if (!isWindows && StringPrototypeIndexOf(filepath, '\\') !== -1)
if (!(windows ?? isWindows) && StringPrototypeIndexOf(filepath, '\\') !== -1)
filepath = RegExpPrototypeSymbolReplace(backslashRegEx, filepath, '%5C');
if (StringPrototypeIndexOf(filepath, '\n') !== -1)
filepath = RegExpPrototypeSymbolReplace(newlineRegEx, filepath, '%0A');
Expand All @@ -1524,8 +1527,9 @@ function encodePathChars(filepath) {
return filepath;
}

function pathToFileURL(filepath) {
if (isWindows && StringPrototypeStartsWith(filepath, '\\\\')) {
function pathToFileURL(filepath, options = kEmptyObject) {
const windows = options?.windows;
if ((windows ?? isWindows) && StringPrototypeStartsWith(filepath, '\\\\')) {
const outURL = new URL('file://');
// UNC path format: \\server\share\resource
const hostnameEndIndex = StringPrototypeIndexOf(filepath, '\\', 2);
Expand All @@ -1546,20 +1550,22 @@ function pathToFileURL(filepath) {
const hostname = StringPrototypeSlice(filepath, 2, hostnameEndIndex);
outURL.hostname = domainToASCII(hostname);
outURL.pathname = encodePathChars(
RegExpPrototypeSymbolReplace(backslashRegEx, StringPrototypeSlice(filepath, hostnameEndIndex), '/'));
RegExpPrototypeSymbolReplace(backslashRegEx, StringPrototypeSlice(filepath, hostnameEndIndex), '/'),
{ windows },
);
return outURL;
}
let resolved = path.resolve(filepath);
let resolved = (windows ?? isWindows) ? path.win32.resolve(filepath) : path.posix.resolve(filepath);
// path.resolve strips trailing slashes so we must add them back
const filePathLast = StringPrototypeCharCodeAt(filepath,
filepath.length - 1);
if ((filePathLast === CHAR_FORWARD_SLASH ||
(isWindows && filePathLast === CHAR_BACKWARD_SLASH)) &&
((windows ?? isWindows) && filePathLast === CHAR_BACKWARD_SLASH)) &&
resolved[resolved.length - 1] !== path.sep)
resolved += '/';

// Call encodePathChars first to avoid encoding % again for ? and #.
resolved = encodePathChars(resolved);
resolved = encodePathChars(resolved, { windows });

// Question and hash character should be included in pathname.
// Therefore, encoding is required to eliminate parsing them in different states.
Expand Down
4 changes: 2 additions & 2 deletions lib/url.js
Original file line number Diff line number Diff line change
Expand Up @@ -1020,10 +1020,10 @@ Url.prototype.parseHost = function parseHost() {
// When used internally, we are not obligated to associate TypeError with
// this function, so non-strings can be rejected by underlying implementation.
// Public API has to validate input and throw appropriate error.
function pathToFileURL(path) {
function pathToFileURL(path, options) {
validateString(path, 'path');

return _pathToFileURL(path);
return _pathToFileURL(path, options);
}

module.exports = {
Expand Down
216 changes: 115 additions & 101 deletions test/parallel/test-url-fileurltopath.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,106 +49,120 @@ assert.throws(() => url.fileURLToPath('https://a/b/c'), {
}
}

{
let testCases;
if (isWindows) {
testCases = [
// 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' },
];
} else {
testCases = [
// 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' },
];
}
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' },
];

for (const { path, fileURL } of testCases) {
const fromString = url.fileURLToPath(fileURL);
assert.strictEqual(fromString, path);
const fromURL = url.fileURLToPath(new URL(fileURL));
assert.strictEqual(fromURL, path);
}
for (const { path, fileURL } of windowsTestCases) {
const fromString = url.fileURLToPath(fileURL, { windows: true });
assert.strictEqual(fromString, path);
const fromURL = url.fileURLToPath(new URL(fileURL), { windows: true });
assert.strictEqual(fromURL, path);
}

for (const { path, fileURL } of posixTestCases) {
const fromString = url.fileURLToPath(fileURL, { windows: false });
assert.strictEqual(fromString, path);
const fromURL = url.fileURLToPath(new URL(fileURL), { windows: false });
assert.strictEqual(fromURL, path);
}

const defaultTestCases = isWindows ? windowsTestCases : posixTestCases;

// Test when `options` is null
const whenNullActual = url.fileURLToPath(new URL(defaultTestCases[0].fileURL), null);
assert.strictEqual(whenNullActual, defaultTestCases[0].path);

for (const { path, fileURL } of defaultTestCases) {
const fromString = url.fileURLToPath(fileURL);
assert.strictEqual(fromString, path);
const fromURL = url.fileURLToPath(new URL(fileURL));
assert.strictEqual(fromURL, path);
}
Loading

0 comments on commit de4c34d

Please sign in to comment.