From f130d452bf3142e91682ecf5266f53528be3c761 Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Thu, 16 May 2024 20:56:54 +0700 Subject: [PATCH 01/11] fix: Fix path extraction from URL in Windows In this change, refactored the local `fileUrlToPath` and `ls` function on extracting the string path from given URL object. Utilizing the `url.fileURLToPath` to ensure it extracted correctly in Windows instead of extract directly from `pathname` property which add trailing slash ('/') before the drive letter. For instance: Output using `pathname` property: "/D:/a/b/c" Output using `url.fileURLToPath`: "D:\\a\\b\\c" Combined with path separator replacement: "D:/a/b/c" --- src/lsfnd.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/lsfnd.ts b/src/lsfnd.ts index b5395c2..994edd0 100644 --- a/src/lsfnd.ts +++ b/src/lsfnd.ts @@ -13,7 +13,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { isRegExp } from 'node:util'; -import { URL } from 'node:url'; +import { URL, fileURLToPath } from 'node:url'; import { lsTypes } from './lsTypes'; import type { StringPath, @@ -81,7 +81,9 @@ function fileUrlToPath(url: URL | StringPath): StringPath { || (typeof url === 'string' && !/^file:(\/\/?|\.\.?\/*)/.test(url))) { throw new URIError('Invalid URL file scheme'); } - return (url instanceof URL) ? url.pathname : url.replace(/^file:/, ''); + return (url instanceof URL) + ? fileURLToPath(url).replaceAll(/\\/g, '/') + : url.replace(/^file:/, ''); } /** @@ -311,7 +313,10 @@ export async function ls( if (dirpath.protocol !== 'file:') { throw new URIError(`Unsupported protocol: '${dirpath.protocol}'`); } - dirpath = dirpath.pathname; // Extract the path (without the protocol) + // We need to use `fileURLToPath` to ensure it converted to string path + // correctly on Windows platform, after that replace all Windows path separator ('\') + // with POSIX path separator ('/'). + dirpath = fileURLToPath(dirpath).replaceAll(/\\/g, '/'); } else if (typeof dirpath === 'string') { if (/^[a-zA-Z]+:/.test(dirpath)) { if (!dirpath.startsWith('file:')) { From a80db2d01cdf77e148813af6c06474b6aa702691 Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Mon, 20 May 2024 19:43:24 +0700 Subject: [PATCH 02/11] feat: Add regex and function for path detection Added two constant variables containing regular expression pattern to parse file URL and Windows path, and added a new function called `isWin32Path` to detects whether the given path is a Windows path. --- src/lsfnd.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/lsfnd.ts b/src/lsfnd.ts index 994edd0..7d4d17f 100644 --- a/src/lsfnd.ts +++ b/src/lsfnd.ts @@ -27,6 +27,22 @@ import type { type Unpack = A extends Array<(infer U)> ? U : A; +/** + * A regular expression pattern to parse the file URL path, + * following the WHATWG URL Standard. + * + * @see {@link https://url.spec.whatwg.org/ WHATWG URL Standard} + * @internal + */ +const FILE_URL_PATTERN: RegExp = /^file:\/\/\/?(?:[A-Za-z]:)?(?:\/[^\s\\]+)*(?:\/)?/; + +/** + * A regular expression pattern to parse and detect the Windows path. + * + * @internal + */ +const WIN32_PATH_PATTERN: RegExp = /^[A-Za-z]:?(?:\\|\/)(?:[^\\/:*?"<>|\r\n]+(?:\\|\/))*[^\\/:*?"<>|\r\n]*$/; + /** * An object containing all default values of {@link LsOptions `LsOptions`} type. * @@ -86,6 +102,22 @@ function fileUrlToPath(url: URL | StringPath): StringPath { : url.replace(/^file:/, ''); } +/** + * Checks if the given string path is a Windows path. + * + * Before checking, the given path will be normalized first. + * + * @param p - The string path to be checked for. + * @returns `true` if the given path is a Windows path, `false` otherwise. + * @see {@link WIN32_PATH_PATTERN} + * + * @internal + */ +function isWin32Path(p: StringPath): boolean { + p = path.normalize(p); + return !!p && WIN32_PATH_PATTERN.test(p); +} + /** * Checks if a provided type matches any of the allowed types. * From 5e8d8b4bbc33f3ef9a95d018b5e9452b6e0b66be Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Tue, 21 May 2024 15:59:20 +0700 Subject: [PATCH 03/11] feat: Add new function to resolve file URL path Added a new function to resolve file URL path to a file path. The given path should be a valid file URL following the WHATWG URL Standard. If the provided URL is 'file://' or 'file:///', it is replaced with the root directory path (in the current drive for Windows systems). Otherwise, the URL is parsed using the `fileURLToPath` function. --- src/lsfnd.ts | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/lsfnd.ts b/src/lsfnd.ts index 7d4d17f..cd0fc18 100644 --- a/src/lsfnd.ts +++ b/src/lsfnd.ts @@ -118,6 +118,64 @@ function isWin32Path(p: StringPath): boolean { return !!p && WIN32_PATH_PATTERN.test(p); } +/** + * Resolves a file URL to a file path. + * + * @param {StringPath} p + * The file URL to resolve. It should be a string representing + * a valid file URL following the **WHATWG URL Standard**. + * @returns {StringPath} + * The resolved file path. If the provided URL is valid, + * it returns the corresponding file path. + * @throws {URIError} + * If the provided file URL scheme is invalid. This can occur + * if the URL scheme is not recognized or if it does not conform + * to the expected format. + * + * @remarks + * This function is used to convert a file URL to a file path. It first checks + * if the provided URL matches the expected pattern for file URLs. If it does, + * it proceeds to resolve the URL to a file path. If the URL scheme is not recognized + * or is invalid, a `URIError` is thrown. + * + * If the provided URL is `'file://'` or `'file:///'`, it is replaced with the root directory + * path (in the current drive for Windows systems). Otherwise, the URL is parsed using the + * `fileURLToPath` function. + * + * If the operating system is not Windows and the provided URL contains a Windows-style path, + * or if the operating system is Windows and the URL does not start with 'file:', an error is + * thrown indicating an invalid file URL scheme. + * + * @example + * // POSIX Path + * const fooPath = resolveFileURL('file:///path/to/foo.txt'); + * console.log(filePath); // Output: '/path/to/foo.txt' + * + * @example + * // Windows Path + * const projectsPath = resolveFileURL('file:///G:/Projects'); + * console.log(projectsPath); // Output: 'G:\\Projects' + * + * @see {@link https://url.spec.whatwg.org/ WHATWG URL Standard} + * @internal + */ +function resolveFileURL(p: StringPath): StringPath { + if (FILE_URL_PATTERN.test(p)) { + // If and only if the given path is 'file://' or 'file:///' + // then replace the path to root directory (in current drive for Windows systems). + // When the specified above URL path being passed to `fileURLPath` function, + // it throws an error due to non-absolute URL path was given. + if (/^file:(?:\/\/\/?)$/.test(p)) p = '/'; + // Otherwise, parse the file URL path + else p = fileURLToPath(p); + } else if ((os.platform() !== 'win32' && isWin32Path(p)) + || (os.platform() === 'win32' + && !(isWin32Path(p) || p.startsWith('file:')))) { + throw new URIError('Invalid file URL scheme'); + } + return p; +} + /** * Checks if a provided type matches any of the allowed types. * From 879d1f666e2929ee1cee7a099d170d278224b437 Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Tue, 21 May 2024 16:33:14 +0700 Subject: [PATCH 04/11] fix(ls): Enhance Windows path resolution Fixed and enhanced the Windows path resolution on `ls` function. Where in Windows systems the function cannot resolve the absolute path, because the drive letter path are treated as file URL path and causing an error. --- src/lsfnd.ts | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/src/lsfnd.ts b/src/lsfnd.ts index cd0fc18..6b02a71 100644 --- a/src/lsfnd.ts +++ b/src/lsfnd.ts @@ -12,7 +12,6 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { isRegExp } from 'node:util'; import { URL, fileURLToPath } from 'node:url'; import { lsTypes } from './lsTypes'; import type { @@ -398,6 +397,10 @@ export async function ls( ): Promise { let absdirpath: StringPath, reldirpath: StringPath; + + if (!(dirpath instanceof URL) && typeof dirpath !== 'string') { + throw new TypeError('Unknown type, expected a string or a URL object'); + } if (dirpath instanceof URL) { if (dirpath.protocol !== 'file:') { @@ -407,18 +410,14 @@ export async function ls( // correctly on Windows platform, after that replace all Windows path separator ('\') // with POSIX path separator ('/'). dirpath = fileURLToPath(dirpath).replaceAll(/\\/g, '/'); - } else if (typeof dirpath === 'string') { - if (/^[a-zA-Z]+:/.test(dirpath)) { - if (!dirpath.startsWith('file:')) { - throw new URIError(`Unsupported protocol: '${dirpath.split(':')[0]}:'`); - } - dirpath = fileUrlToPath(dirpath); - } - } else { - throw new TypeError('Unknown type, expected a string or an URL object'); + } else if (typeof dirpath === 'string' && /^[a-zA-Z]+:/.test(dirpath)) { + dirpath = resolveFileURL(dirpath); } - if (isRegExp(options)) { + // Normalize the given path + dirpath = path.normalize(dirpath); + + if (options instanceof RegExp) { // Store the regex value of `options` to temporary variable for `match` option const temp: RegExp = new RegExp(options.source) || options; options = resolveOptions(null); // Use the default options @@ -432,13 +431,13 @@ export async function ls( } // Check and resolve the `rootDir` option - if (options.rootDir - && (options.rootDir instanceof URL - || (typeof options.rootDir === 'string' - && /^[a-zA-Z]+:/.test(options.rootDir)) - ) - ) { - options.rootDir = fileUrlToPath(options.rootDir); + if (options.rootDir instanceof URL) { + if (options.rootDir.protocol !== 'file:') { + throw new URIError(`Unsupported protocol: '${options.rootDir.protocol}'`); + } + options.rootDir = fileURLToPath(options.rootDir).replaceAll(/\\/g, '/'); + } else if (typeof dirpath === 'string' && /^[a-zA-Z]+:/.test(options.rootDir!)) { + options.rootDir = resolveFileURL(options.rootDir!); } // Resolve the absolute and relative of the dirpath argument From cd61ed80bfab6c922659ce41de59efa47720a2dd Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Tue, 21 May 2024 16:39:08 +0700 Subject: [PATCH 05/11] refactor: Deprecate a function and re-sort imports Deprecated the `fileUrlToPath` function and re-sorted the import statements. --- src/lsfnd.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/lsfnd.ts b/src/lsfnd.ts index 6b02a71..6d36f57 100644 --- a/src/lsfnd.ts +++ b/src/lsfnd.ts @@ -11,18 +11,19 @@ */ import * as fs from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; import { URL, fileURLToPath } from 'node:url'; -import { lsTypes } from './lsTypes'; import type { - StringPath, + DefaultLsOptions, LsEntries, - LsResult, LsOptions, + LsResult, + LsTypes, ResolvedLsOptions, - DefaultLsOptions, - LsTypes + StringPath } from '../types'; +import { lsTypes } from './lsTypes'; type Unpack = A extends Array<(infer U)> ? U : A; @@ -90,6 +91,7 @@ export const defaultLsOptions: DefaultLsOptions = { * @see {@link https://nodejs.org/api/url.html#urlfileurltopathurl url.fileURLToPath} * * @internal + * @deprecated */ function fileUrlToPath(url: URL | StringPath): StringPath { if ((url instanceof URL && url.protocol !== 'file:') From 55b45c690d75742d0356b3fb5bc611168f83d390 Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Tue, 21 May 2024 17:24:46 +0700 Subject: [PATCH 06/11] fix: Fix error while stating read-protected entry Fixed the error while attempting to stat an entry that are read-protected due to a permission error (EPERM) or access error (EACCES). If throws an error during stating, then it attempts to open the entry using `fs.opendir` , if throw an error with code ENOTDIR we can coclude that the entry is a regular file, otherwise, it is a directory. We also not forget to close the `Dir` instance to avoid unexpected behavior. In addition, we also sort the result entries exact before it being returned. --- src/lsfnd.ts | 50 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/src/lsfnd.ts b/src/lsfnd.ts index 6d36f57..329758b 100644 --- a/src/lsfnd.ts +++ b/src/lsfnd.ts @@ -466,19 +466,57 @@ export async function ls( result = await Promise.all( utf8Entries.map(async function (entry: StringPath): Promise<(StringPath | null)> { entry = path.join(absdirpath, entry); - const stats: fs.Stats = await fs.promises.stat(entry); - let resultType: boolean = false; + let stats: fs.Stats | null = null; + let resultType: boolean = false, + isDir: boolean = false, + isFile: boolean = false; + + // Try to retrieve the information of file system using `fs.stat` + try { + stats = await fs.promises.stat(entry); + } catch (e: unknown) { + // Attempt to open the entry using `fs.opendir` if the file system could not be + // accessed because of a permission error or maybe access error. The function + // is meant to be used with directories exclusively, which is helpful for + // determining if an entry is a directory or a regular file. We can conclude that + // the entry is a regular file if it throws an error. In this method, we can + // avoid an internal error that occurs when try to access a read-protected file system, + // such the "System Volume Information" directory on all Windows drives. + try { + // Notably, we do not want to use any synchronous functions and instead + // want the process to be asynchronous. + const dir = await fs.promises.opendir(entry); + isDir = true; // Detected as a directory + await dir.close(); + } catch (eDir: unknown) { + // If and only if the thrown error have a code "ENOTDIR", + // then it treats the entry as a regular file. Otherwise, throw the error. + if (eDir instanceof Error && ('code' in eDir && eDir.code === 'ENOTDIR')) + isFile = true; // Detected as a regular file + else throw eDir; + } + } switch (type) { case lsTypes.LS_D: case 'LS_D': - resultType = (!stats.isFile() && stats.isDirectory()); + resultType = ( + !(stats?.isFile() || isFile) + && (stats?.isDirectory() || isDir) + ); break; case lsTypes.LS_F: case 'LS_F': - resultType = (stats.isFile() && !stats.isDirectory()); + resultType = ( + (stats?.isFile() || isFile) + && !(stats?.isDirectory() || isDir) + ); break; - default: resultType = (stats.isFile() || stats.isDirectory()); + default: + resultType = ( + (stats?.isFile() || isFile) + || (stats?.isDirectory() || isDir) + ); } return (( @@ -515,7 +553,7 @@ export async function ls( // Encode back the entries to the specified encoding if (result && options?.encoding! !== 'utf8') result = encodeTo(result, 'utf8', options.encoding!); - return result; + return (!!result ? result.sort() : result); } /** From b0e025ca3fb6bbac4d632934fe9ad87f218b7d18 Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Tue, 21 May 2024 17:44:36 +0700 Subject: [PATCH 07/11] test(lib): Refactor the `TestError` class - Added a parameter to `TestError` constructor, it accepts an object. - In the `it` function, changed the error message and passed the cause error to the `cause` field in `opts` parameter. --- test/lib/simpletest.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/lib/simpletest.js b/test/lib/simpletest.js index c60f239..1b0eb1d 100644 --- a/test/lib/simpletest.js +++ b/test/lib/simpletest.js @@ -26,8 +26,8 @@ const assert = require('node:assert'); * A custom class representing the error thrown by {@link module:simpletest~it} function. */ class TestError extends Error { - constructor(message) { - super(message); // Important + constructor(message, opts) { + super(message, opts); // Important this.name = 'TestError'; } } @@ -47,7 +47,7 @@ async function it(desc, func, continueOnErr=false) { console.log(` \x1b[92m\u2714 \x1b[0m\x1b[2m${desc}\x1b[0m`); } catch (err) { console.error(` \x1b[91m\u2718 \x1b[0m${desc}\n`); - console.error(new TestError(err.message)); + console.error(new TestError('Test failed!', { cause: err })); !!continueOnErr || process.exit(1); // Force terminate the process } } From c4349f05cf50a701f96269fb3b7864c9f86855a3 Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Tue, 21 May 2024 17:46:29 +0700 Subject: [PATCH 08/11] fix(test): Fix POSIX and file URL path resolution --- test/lsfnd.spec.cjs | 6 +++--- test/lsfnd.spec.mjs | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/test/lsfnd.spec.cjs b/test/lsfnd.spec.cjs index 180c320..65afa57 100644 --- a/test/lsfnd.spec.cjs +++ b/test/lsfnd.spec.cjs @@ -9,7 +9,7 @@ const { ls, lsFiles, lsDirs } = require('..'); const { it, rejects, doesNotReject, deepEq } = require('./lib/simpletest'); const rootDir = path.resolve('..'); -const rootDirPosix = path.posix.resolve('..'); +const rootDirPosix = rootDir.replaceAll(path.sep, '/'); console.log(`\n\x1b[1m${path.basename(__filename)}:\x1b[0m`); @@ -38,7 +38,7 @@ it('list root directory using URL object', async () => { }, false); it('list root directory using file URL path', async () => { - await doesNotReject(ls('file:'.concat(rootDirPosix)), URIError); + await doesNotReject(ls(pathToFileURL(rootDirPosix)), URIError); }, false); it('test if the options argument allows explicit null value', async () => { @@ -63,7 +63,7 @@ it('throws an error if the given directory path not exist', async () => { }, false); it('throws a `URIError` if the given file URL path using unsupported protocol', - async () => await rejects(ls('http:'.concat(rootDirPosix)), URIError), + async () => await rejects(ls('http:///'.concat(rootDirPosix)), URIError), false ); diff --git a/test/lsfnd.spec.mjs b/test/lsfnd.spec.mjs index edb45d6..716a781 100644 --- a/test/lsfnd.spec.mjs +++ b/test/lsfnd.spec.mjs @@ -13,7 +13,7 @@ const { it, rejects, doesNotReject, deepEq } = test; // Resolve import from Com const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const rootDir = path.resolve('..'); -const rootDirPosix = path.posix.resolve('..'); +const rootDirPosix = rootDir.replaceAll(path.sep, '/'); console.log(`\n\x1b[1m${path.basename(__filename)}:\x1b[0m`); @@ -42,7 +42,7 @@ it('list root directory using URL object', async () => { }, false); it('list root directory using file URL path', async () => { - await doesNotReject(ls('file:'.concat(rootDirPosix)), URIError); + await doesNotReject(ls(pathToFileURL(rootDirPosix)), URIError); }, false); it('test if the options argument allows explicit null value', async () => { @@ -67,7 +67,7 @@ it('throws an error if the given directory path not exist', async () => { }, false); it('throws an URIError if the given file URL path using unsupported protocol', - async () => await rejects(ls('http:'.concat(rootDirPosix)), URIError), + async () => await rejects(ls('http:///'.concat(rootDirPosix)), URIError), false ); From 62bce1881744835405ae1144919f07f9d6eed618 Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Tue, 21 May 2024 17:50:40 +0700 Subject: [PATCH 09/11] chore(scripts): Add several new npm scripts - `prepublishOnly`: Builds the project with '--overwrite' argument. - `prepack`: Tests the project before packing the project (`npm pack`). --- package.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 2c0f096..79c52c9 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,9 @@ "build:docs": "typedoc --options typedoc.config.js", "test": "node test/lsfnd.spec.cjs && node test/lsfnd.spec.mjs", "test:cjs": "node test/lsfnd.spec.cjs", - "test:mjs": "node test/lsfnd.spec.mjs" + "test:mjs": "node test/lsfnd.spec.mjs", + "prepublishOnly": "ts-node scripts/build.ts --overwrite", + "prepack": "npm test" }, "repository": { "type": "git", From 10e4af3280fc4f33597a35f23b80abcb842f703b Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Tue, 21 May 2024 19:18:20 +0700 Subject: [PATCH 10/11] fix: Resolve unchecked path for non-Windows systems --- src/lsfnd.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lsfnd.ts b/src/lsfnd.ts index 329758b..bbf78e4 100644 --- a/src/lsfnd.ts +++ b/src/lsfnd.ts @@ -169,7 +169,8 @@ function resolveFileURL(p: StringPath): StringPath { if (/^file:(?:\/\/\/?)$/.test(p)) p = '/'; // Otherwise, parse the file URL path else p = fileURLToPath(p); - } else if ((os.platform() !== 'win32' && isWin32Path(p)) + } else if ((os.platform() !== 'win32' + && (isWin32Path(p) || !p.startsWith('file:'))) || (os.platform() === 'win32' && !(isWin32Path(p) || p.startsWith('file:')))) { throw new URIError('Invalid file URL scheme'); From f418f507faec010cd72c6123bdf18e084199b6e3 Mon Sep 17 00:00:00 2001 From: Ryuu Mitsuki Date: Tue, 21 May 2024 19:26:15 +0700 Subject: [PATCH 11/11] ci: Add Windows to `runs-on` field Now it runs on Windows to check the compatibility both on Ubuntu (Linux) and Windows systems. --- .github/workflows/test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cc1aee6..4ad78e2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,9 +7,10 @@ on: jobs: build: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }}-latest strategy: matrix: + os: [ Windows, Ubuntu ] node-ver: [16.x, 18.x, 20.x] steps: