diff --git a/src/fsa-to-node/FsaNodeFs.ts b/src/fsa-to-node/FsaNodeFs.ts index 06fd0fc14..67ab938a6 100644 --- a/src/fsa-to-node/FsaNodeFs.ts +++ b/src/fsa-to-node/FsaNodeFs.ts @@ -1,4 +1,8 @@ -import type {FsCallbackApi} from '../node/types'; +import { createPromisesApi } from '../node/promises'; +import { getMkdirOptions } from '../node/options'; +import {createError, modeToNumber, pathToFilename, validateCallback} from '../node/util'; +import {pathToLocation} from './util'; +import type {FsCallbackApi, FsPromisesApi} from '../node/types'; import type * as misc from '../node/types/misc'; import type * as opts from '../node/types/options'; import type * as fsa from '../fsa/types'; @@ -12,6 +16,8 @@ const notImplemented: ((...args: unknown[]) => unknown) = () => { * [`FileSystemDirectoryHandle` object](https://developer.mozilla.org/en-US/docs/Web/API/FileSystemDirectoryHandle). */ export class FsaNodeFs implements FsCallbackApi { + public readonly promises: FsPromisesApi = createPromisesApi(this); + public constructor (protected readonly root: fsa.IFileSystemDirectoryHandle) {} public readonly open: FsCallbackApi['open'] = (path: misc.PathLike, flags: misc.TFlags, a?: misc.TMode | misc.TCallback, b?: misc.TCallback | string) => { @@ -169,17 +175,43 @@ export class FsaNodeFs implements FsCallbackApi { throw new Error('Not implemented'); } - mkdir(path: misc.PathLike, callback: misc.TCallback); - mkdir( - path: misc.PathLike, - mode: misc.TMode | (opts.IMkdirOptions & { recursive?: false }), - callback: misc.TCallback, - ); - mkdir(path: misc.PathLike, mode: opts.IMkdirOptions & { recursive: true }, callback: misc.TCallback); - mkdir(path: misc.PathLike, mode: misc.TMode | opts.IMkdirOptions, callback: misc.TCallback); - mkdir(path: misc.PathLike, a: misc.TCallback | misc.TMode | opts.IMkdirOptions, b?: misc.TCallback | misc.TCallback) { - throw new Error('Not implemented'); - } + /** + * @param path Path from root to the new folder. + * @param create Whether to create the folders if they don't exist. + */ + private async getDir(path: string[], create: boolean): Promise { + let curr: fsa.IFileSystemDirectoryHandle = this.root; + const options: fsa.GetDirectoryHandleOptions = {create}; + for (const name of path) + curr = await curr.getDirectoryHandle(name, options); + return curr; + } + + public readonly mkdir: FsCallbackApi['mkdir'] = (path: misc.PathLike, a: misc.TCallback | misc.TMode | opts.IMkdirOptions, b?: misc.TCallback | misc.TCallback) => { + const opts: misc.TMode | opts.IMkdirOptions = getMkdirOptions(a); + const callback = validateCallback(typeof a === 'function' ? a : b!); + // const modeNum = modeToNumber(opts.mode, 0o777); + const filename = pathToFilename(path); + const [folder, name] = pathToLocation(filename); + // TODO: need to throw if directory already exists + this.getDir(folder, opts.recursive ?? false) + .then(dir => dir.getDirectoryHandle(name, {create: true})) + .then(() => callback(null), error => { + if (!error || typeof error !== 'object') { + callback(createError('', 'mkdir')); + return; + } + switch (error.name) { + case 'NotFoundError': { + const err = createError('ENOTDIR', 'mkdir', folder.join('/')); + callback(err); + } + default: { + callback(createError('', 'mkdir')); + } + } + }); + }; mkdirp(path: misc.PathLike, callback: misc.TCallback); mkdirp(path: misc.PathLike, mode: misc.TMode, callback: misc.TCallback); diff --git a/src/fsa-to-node/__tests__/FsaNodeFs.test.ts b/src/fsa-to-node/__tests__/FsaNodeFs.test.ts new file mode 100644 index 000000000..115db67f2 --- /dev/null +++ b/src/fsa-to-node/__tests__/FsaNodeFs.test.ts @@ -0,0 +1,49 @@ +import {IFsWithVolume, memfs} from '../..'; +import {nodeToFsa} from '../../node-to-fsa'; +import {FsaNodeFs} from '../FsaNodeFs'; + +const setup = () => { + const mfs = memfs({ mountpoint: null }) as IFsWithVolume; + const dir = nodeToFsa(mfs, '/mountpoint', {mode: 'readwrite'}); + const fs = new FsaNodeFs(dir); + return { fs, mfs, dir }; +}; + +describe('.mkdir()', () => { + test('can create a sub-folder', async () => { + const { fs, mfs } = setup(); + await new Promise((resolve, reject) => fs.mkdir('/test', (err) => { + if (err) return reject(err); + return resolve(); + })); + expect(mfs.statSync('/mountpoint/test').isDirectory()).toBe(true); + }); + + test('throws when creating sub-sub-folder', async () => { + const { fs } = setup(); + try { + await new Promise((resolve, reject) => fs.mkdir('/test/subtest', (err) => { + if (err) return reject(err); + return resolve(); + })); + throw new Error('Expected error'); + } catch (error) { + expect(error.code).toBe('ENOTDIR'); + } + }); + + test('can create sub-sub-folder with "recursive" flag', async () => { + const { fs, mfs } = setup(); + await new Promise((resolve, reject) => fs.mkdir('/test/subtest', {recursive: true}, (err) => { + if (err) return reject(err); + return resolve(); + })); + expect(mfs.statSync('/mountpoint/test/subtest').isDirectory()).toBe(true); + }); + + test('can create sub-sub-folder with "recursive" flag with Promises API', async () => { + const { fs, mfs } = setup(); + await fs.promises.mkdir('/test/subtest', {recursive: true}); + expect(mfs.statSync('/mountpoint/test/subtest').isDirectory()).toBe(true); + }); +}); diff --git a/src/fsa-to-node/util.ts b/src/fsa-to-node/util.ts index aa9688171..61f4e2d9a 100644 --- a/src/fsa-to-node/util.ts +++ b/src/fsa-to-node/util.ts @@ -1,4 +1,4 @@ -import {FsaToNodeConstants} from "./constants"; +import {FsaToNodeConstants} from "./constants";; import type {FsLocation} from "./types"; export const pathToLocation = (path: string): FsLocation => { diff --git a/src/node-to-fsa/NodeFileSystemHandle.ts b/src/node-to-fsa/NodeFileSystemHandle.ts index fae7fe2bf..95233d63b 100644 --- a/src/node-to-fsa/NodeFileSystemHandle.ts +++ b/src/node-to-fsa/NodeFileSystemHandle.ts @@ -1,5 +1,5 @@ import { NodePermissionStatus } from './NodePermissionStatus'; -import type { IFileSystemHandle, NodeFileSystemHandlePermissionDescriptor } from '../fsa/types'; +import type { IFileSystemHandle, FileSystemHandlePermissionDescriptor } from '../fsa/types'; /** * Represents a File System Access API file handle `FileSystemHandle` object, @@ -25,7 +25,7 @@ export abstract class NodeFileSystemHandle implements IFileSystemHandle { * @see https://developer.mozilla.org/en-US/docs/Web/API/FileSystemHandle/queryPermission */ public queryPermission( - fileSystemHandlePermissionDescriptor: NodeFileSystemHandlePermissionDescriptor, + fileSystemHandlePermissionDescriptor: FileSystemHandlePermissionDescriptor, ): NodePermissionStatus { throw new Error('Not implemented'); } @@ -41,7 +41,7 @@ export abstract class NodeFileSystemHandle implements IFileSystemHandle { * @see https://developer.mozilla.org/en-US/docs/Web/API/FileSystemHandle/requestPermission */ public requestPermission( - fileSystemHandlePermissionDescriptor: NodeFileSystemHandlePermissionDescriptor, + fileSystemHandlePermissionDescriptor: FileSystemHandlePermissionDescriptor, ): NodePermissionStatus { throw new Error('Not implemented'); } diff --git a/src/node/constants.ts b/src/node/constants.ts new file mode 100644 index 000000000..fb27d7ac0 --- /dev/null +++ b/src/node/constants.ts @@ -0,0 +1,24 @@ +// Default modes for opening files. +export const enum MODE { + FILE = 0o666, + DIR = 0o777, + DEFAULT = MODE.FILE, +} + +export const ERRSTR = { + PATH_STR: 'path must be a string or Buffer', + // FD: 'file descriptor must be a unsigned 32-bit integer', + FD: 'fd must be a file descriptor', + MODE_INT: 'mode must be an int', + CB: 'callback must be a function', + UID: 'uid must be an unsigned int', + GID: 'gid must be an unsigned int', + LEN: 'len must be an integer', + ATIME: 'atime must be an integer', + MTIME: 'mtime must be an integer', + PREFIX: 'filename prefix is required', + BUFFER: 'buffer must be an instance of Buffer or StaticBuffer', + OFFSET: 'offset must be an integer', + LENGTH: 'length must be an integer', + POSITION: 'position must be an integer', +}; diff --git a/src/node/options.ts b/src/node/options.ts new file mode 100644 index 000000000..34102c496 --- /dev/null +++ b/src/node/options.ts @@ -0,0 +1,12 @@ +import type * as opts from "./types/options"; +import { MODE } from './constants'; + +const mkdirDefaults: opts.IMkdirOptions = { + mode: MODE.DIR, + recursive: false, +}; + +export const getMkdirOptions = (options): opts.IMkdirOptions => { + if (typeof options === 'number') return Object.assign({}, mkdirDefaults, { mode: options }); + return Object.assign({}, mkdirDefaults, options); +}; diff --git a/src/node/promises.ts b/src/node/promises.ts index 8c1ee2bc7..14f1d33b6 100644 --- a/src/node/promises.ts +++ b/src/node/promises.ts @@ -4,10 +4,9 @@ import type * as opts from './types/options'; import type * as misc from './types/misc'; import type { FsCallbackApi, FsPromisesApi } from './types'; -export function createPromisesApi(vol: FsCallbackApi): null | FsPromisesApi { - if (typeof Promise === 'undefined') return null; +export function createPromisesApi(vol: FsCallbackApi): FsPromisesApi { return { - FileHandle, + FileHandle: FileHandle as any, access(path: misc.PathLike, mode?: number): Promise { return promisify(vol, 'access')(path, mode); diff --git a/src/node/util.ts b/src/node/util.ts index aff4473a4..4e45278bd 100644 --- a/src/node/util.ts +++ b/src/node/util.ts @@ -1,4 +1,7 @@ +import {ERRSTR} from './constants'; +import * as errors from '../internal/errors'; import type { FsCallbackApi } from './types'; +import type * as misc from "./types/misc"; export function promisify( fs: FsCallbackApi, @@ -13,3 +16,127 @@ export function promisify( }); }); } + +export function validateCallback(callback: T): misc.AssertCallback { + if (typeof callback !== 'function') throw TypeError(ERRSTR.CB); + return callback as misc.AssertCallback; +} + +function _modeToNumber(mode: misc.TMode | undefined, def?): number | undefined { + if (typeof mode === 'number') return mode; + if (typeof mode === 'string') return parseInt(mode, 8); + if (def) return modeToNumber(def); + return undefined; +} + +export function modeToNumber(mode: misc.TMode | undefined, def?): number { + const result = _modeToNumber(mode, def); + if (typeof result !== 'number' || isNaN(result)) throw new TypeError(ERRSTR.MODE_INT); + return result; +} + +export function nullCheck(path, callback?) { + if (('' + path).indexOf('\u0000') !== -1) { + const er = new Error('Path must be a string without null bytes'); + (er as any).code = 'ENOENT'; + if (typeof callback !== 'function') throw er; + process.nextTick(callback, er); + return false; + } + return true; +} + +function getPathFromURLPosix(url): string { + if (url.hostname !== '') { + throw new errors.TypeError('ERR_INVALID_FILE_URL_HOST', process.platform); + } + const pathname = url.pathname; + for (let n = 0; n < pathname.length; n++) { + if (pathname[n] === '%') { + const third = pathname.codePointAt(n + 2) | 0x20; + if (pathname[n + 1] === '2' && third === 102) { + throw new errors.TypeError('ERR_INVALID_FILE_URL_PATH', 'must not include encoded / characters'); + } + } + } + return decodeURIComponent(pathname); +} + +export function pathToFilename(path: misc.PathLike): string { + if (typeof path !== 'string' && !Buffer.isBuffer(path)) { + try { + if (!(path instanceof require('url').URL)) throw new TypeError(ERRSTR.PATH_STR); + } catch (err) { + throw new TypeError(ERRSTR.PATH_STR); + } + + path = getPathFromURLPosix(path); + } + + const pathString = String(path); + nullCheck(pathString); + // return slash(pathString); + return pathString; +} + +const ENOENT = 'ENOENT'; +const EBADF = 'EBADF'; +const EINVAL = 'EINVAL'; +const EPERM = 'EPERM'; +const EPROTO = 'EPROTO'; +const EEXIST = 'EEXIST'; +const ENOTDIR = 'ENOTDIR'; +const EMFILE = 'EMFILE'; +const EACCES = 'EACCES'; +const EISDIR = 'EISDIR'; +const ENOTEMPTY = 'ENOTEMPTY'; +const ENOSYS = 'ENOSYS'; +const ERR_FS_EISDIR = 'ERR_FS_EISDIR'; + +function formatError(errorCode: string, func = '', path = '', path2 = '') { + let pathFormatted = ''; + if (path) pathFormatted = ` '${path}'`; + if (path2) pathFormatted += ` -> '${path2}'`; + + switch (errorCode) { + case ENOENT: + return `ENOENT: no such file or directory, ${func}${pathFormatted}`; + case EBADF: + return `EBADF: bad file descriptor, ${func}${pathFormatted}`; + case EINVAL: + return `EINVAL: invalid argument, ${func}${pathFormatted}`; + case EPERM: + return `EPERM: operation not permitted, ${func}${pathFormatted}`; + case EPROTO: + return `EPROTO: protocol error, ${func}${pathFormatted}`; + case EEXIST: + return `EEXIST: file already exists, ${func}${pathFormatted}`; + case ENOTDIR: + return `ENOTDIR: not a directory, ${func}${pathFormatted}`; + case EISDIR: + return `EISDIR: illegal operation on a directory, ${func}${pathFormatted}`; + case EACCES: + return `EACCES: permission denied, ${func}${pathFormatted}`; + case ENOTEMPTY: + return `ENOTEMPTY: directory not empty, ${func}${pathFormatted}`; + case EMFILE: + return `EMFILE: too many open files, ${func}${pathFormatted}`; + case ENOSYS: + return `ENOSYS: function not implemented, ${func}${pathFormatted}`; + case ERR_FS_EISDIR: + return `[ERR_FS_EISDIR]: Path is a directory: ${func} returned EISDIR (is a directory) ${path}`; + default: + return `${errorCode}: error occurred, ${func}${pathFormatted}`; + } +} + +export function createError(errorCode: string, func = '', path = '', path2 = '', Constructor = Error) { + const error = new Constructor(formatError(errorCode, func, path, path2)); + (error as any).code = errorCode; + + if (path) { + (error as any).path = path; + } + + return error; +} diff --git a/src/volume.ts b/src/volume.ts index 2eaedc6e9..a02ba561a 100644 --- a/src/volume.ts +++ b/src/volume.ts @@ -11,8 +11,11 @@ import { constants } from './constants'; import { EventEmitter } from 'events'; import { TEncodingExtended, TDataOut, assertEncoding, strToEncoding, ENCODING_UTF8 } from './encoding'; import * as errors from './internal/errors'; -import util = require('util'); +import * as util from 'util'; import { createPromisesApi } from './node/promises'; +import { ERRSTR, MODE } from './node/constants'; +import { getMkdirOptions } from './node/options'; +import {validateCallback, modeToNumber, pathToFilename, nullCheck, createError} from './node/util'; import type { PathLike, symlink } from 'fs'; const resolveCrossPlatform = pathModule.resolve; @@ -53,13 +56,6 @@ export type TCallback = (error?: IError | null, data?: TData) => void; // ---------------------------------------- Constants -// Default modes for opening files. -const enum MODE { - FILE = 0o666, - DIR = 0o777, - DEFAULT = MODE.FILE, -} - const kMinPoolSpace = 128; // const kMaxLength = require('buffer').kMaxLength; @@ -67,23 +63,6 @@ const kMinPoolSpace = 128; // TODO: Use `internal/errors.js` in the future. -const ERRSTR = { - PATH_STR: 'path must be a string or Buffer', - // FD: 'file descriptor must be a unsigned 32-bit integer', - FD: 'fd must be a file descriptor', - MODE_INT: 'mode must be an int', - CB: 'callback must be a function', - UID: 'uid must be an unsigned int', - GID: 'gid must be an unsigned int', - LEN: 'len must be an integer', - ATIME: 'atime must be an integer', - MTIME: 'mtime must be an integer', - PREFIX: 'filename prefix is required', - BUFFER: 'buffer must be an instance of Buffer or StaticBuffer', - OFFSET: 'offset must be an integer', - LENGTH: 'length must be an integer', - POSITION: 'position must be an integer', -}; const ERRSTR_OPTS = tipeof => `Expected options to be either an object or a string, but got ${tipeof} instead`; // const ERRSTR_FLAG = flag => `Unknown file open flag: ${flag}`; @@ -101,54 +80,6 @@ const ENOTEMPTY = 'ENOTEMPTY'; const ENOSYS = 'ENOSYS'; const ERR_FS_EISDIR = 'ERR_FS_EISDIR'; -function formatError(errorCode: string, func = '', path = '', path2 = '') { - let pathFormatted = ''; - if (path) pathFormatted = ` '${path}'`; - if (path2) pathFormatted += ` -> '${path2}'`; - - switch (errorCode) { - case ENOENT: - return `ENOENT: no such file or directory, ${func}${pathFormatted}`; - case EBADF: - return `EBADF: bad file descriptor, ${func}${pathFormatted}`; - case EINVAL: - return `EINVAL: invalid argument, ${func}${pathFormatted}`; - case EPERM: - return `EPERM: operation not permitted, ${func}${pathFormatted}`; - case EPROTO: - return `EPROTO: protocol error, ${func}${pathFormatted}`; - case EEXIST: - return `EEXIST: file already exists, ${func}${pathFormatted}`; - case ENOTDIR: - return `ENOTDIR: not a directory, ${func}${pathFormatted}`; - case EISDIR: - return `EISDIR: illegal operation on a directory, ${func}${pathFormatted}`; - case EACCES: - return `EACCES: permission denied, ${func}${pathFormatted}`; - case ENOTEMPTY: - return `ENOTEMPTY: directory not empty, ${func}${pathFormatted}`; - case EMFILE: - return `EMFILE: too many open files, ${func}${pathFormatted}`; - case ENOSYS: - return `ENOSYS: function not implemented, ${func}${pathFormatted}`; - case ERR_FS_EISDIR: - return `[ERR_FS_EISDIR]: Path is a directory: ${func} returned EISDIR (is a directory) ${path}`; - default: - return `${errorCode}: error occurred, ${func}${pathFormatted}`; - } -} - -function createError(errorCode: string, func = '', path = '', path2 = '', Constructor = Error) { - const error = new Constructor(formatError(errorCode, func, path, path2)); - (error as any).code = errorCode; - - if (path) { - (error as any).path = path; - } - - return error; -} - // ---------------------------------------- Flags // List of file `flags` as defined by Node. @@ -230,13 +161,6 @@ function optsGenerator(defaults: TOpts): (opts) => TOpts { return options => getOptions(defaults, options); } -type AssertCallback = T extends () => void ? T : never; - -function validateCallback(callback: T): AssertCallback { - if (typeof callback !== 'function') throw TypeError(ERRSTR.CB); - return callback as AssertCallback; -} - function optsAndCbGenerator(getOpts): (options, callback?) => [TOpts, TCallback] { return (options, callback?) => typeof options === 'function' ? [getOpts(), options] : [getOpts(options), validateCallback(callback)]; @@ -332,14 +256,6 @@ export interface IMkdirOptions { mode?: TMode; recursive?: boolean; } -const mkdirDefaults: IMkdirOptions = { - mode: MODE.DIR, - recursive: false, -}; -const getMkdirOptions = (options): IMkdirOptions => { - if (typeof options === 'number') return Object.assign({}, mkdirDefaults, { mode: options }); - return Object.assign({}, mkdirDefaults, options); -}; // Options for `fs.rmdir` and `fs.rmdirSync` export interface IRmdirOptions { @@ -398,39 +314,6 @@ const getStatOptsAndCb: (options: any, callback?: TCallback) => [IStatOpt // ---------------------------------------- Utility functions -function getPathFromURLPosix(url): string { - if (url.hostname !== '') { - throw new errors.TypeError('ERR_INVALID_FILE_URL_HOST', process.platform); - } - const pathname = url.pathname; - for (let n = 0; n < pathname.length; n++) { - if (pathname[n] === '%') { - const third = pathname.codePointAt(n + 2) | 0x20; - if (pathname[n + 1] === '2' && third === 102) { - throw new errors.TypeError('ERR_INVALID_FILE_URL_PATH', 'must not include encoded / characters'); - } - } - } - return decodeURIComponent(pathname); -} - -export function pathToFilename(path: PathLike): string { - if (typeof path !== 'string' && !Buffer.isBuffer(path)) { - try { - if (!(path instanceof require('url').URL)) throw new TypeError(ERRSTR.PATH_STR); - } catch (err) { - throw new TypeError(ERRSTR.PATH_STR); - } - - path = getPathFromURLPosix(path); - } - - const pathString = String(path); - nullCheck(pathString); - // return slash(pathString); - return pathString; -} - type TResolve = (filename: string, base?: string) => string; let resolve: TResolve = (filename, base = process.cwd()) => resolveCrossPlatform(base, filename); if (isWin) { @@ -467,30 +350,6 @@ export function bufferToEncoding(buffer: Buffer, encoding?: TEncodingExtended): else return buffer.toString(encoding); } -function nullCheck(path, callback?) { - if (('' + path).indexOf('\u0000') !== -1) { - const er = new Error('Path must be a string without null bytes'); - (er as any).code = ENOENT; - if (typeof callback !== 'function') throw er; - process.nextTick(callback, er); - return false; - } - return true; -} - -function _modeToNumber(mode: TMode | undefined, def?): number | undefined { - if (typeof mode === 'number') return mode; - if (typeof mode === 'string') return parseInt(mode, 8); - if (def) return modeToNumber(def); - return undefined; -} - -function modeToNumber(mode: TMode | undefined, def?): number { - const result = _modeToNumber(mode, def); - if (typeof result !== 'number' || isNaN(result)) throw new TypeError(ERRSTR.MODE_INT); - return result; -} - function isFd(path): boolean { return path >>> 0 === path; }