From 019622ce5054934e382059da3170bc83200dfd7d Mon Sep 17 00:00:00 2001 From: Jean Lauliac Date: Tue, 27 Feb 2018 10:30:07 -0800 Subject: [PATCH] metro: extract MemoryFS into its own package Reviewed By: mjesun Differential Revision: D7098381 fbshipit-source-id: 0ac9d71b7912c9d448468b64969d04a8cc9c853d --- packages/metro-memory-fs/package.json | 14 + .../__snapshots__/index-test.js.snap | 25 + .../src/__tests__/index-test.js | 274 +++++++ packages/metro-memory-fs/src/index.js | 735 ++++++++++++++++++ 4 files changed, 1048 insertions(+) create mode 100644 packages/metro-memory-fs/package.json create mode 100644 packages/metro-memory-fs/src/__tests__/__snapshots__/index-test.js.snap create mode 100644 packages/metro-memory-fs/src/__tests__/index-test.js create mode 100644 packages/metro-memory-fs/src/index.js diff --git a/packages/metro-memory-fs/package.json b/packages/metro-memory-fs/package.json new file mode 100644 index 00000000..6c404a3f --- /dev/null +++ b/packages/metro-memory-fs/package.json @@ -0,0 +1,14 @@ +{ + "version": "0.28.0", + "name": "metro-memory-fs", + "description": "A memory-based implementation of `fs` useful for automated tests", + "main": "src/index.js", + "repository": { + "type": "git", + "url": "git@github.com:facebook/metro.git" + }, + "scripts": { + "prepare-release": "test -d build && rm -rf src.real && mv src src.real && mv build src", + "cleanup-release": "test ! -e build && mv src build && mv src.real src" + } +} diff --git a/packages/metro-memory-fs/src/__tests__/__snapshots__/index-test.js.snap b/packages/metro-memory-fs/src/__tests__/__snapshots__/index-test.js.snap new file mode 100644 index 00000000..c035763f --- /dev/null +++ b/packages/metro-memory-fs/src/__tests__/__snapshots__/index-test.js.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`throws when finding a symlink loop 1`] = `"ELOOP: \`/foo.txt\`: too many levels of symbolic links"`; + +exports[`throws when trying to create symlink over existing file 1`] = `"EEXIST: \`/foo.txt\`: directory or file already exists"`; + +exports[`throws when trying to open too many files 1`] = `"EMFILE: \`/foo.txt\`: too many open files"`; + +exports[`throws when trying to read a directory entry 1`] = `"EISDIR: \`/glo\`: cannot read/write to a directory"`; + +exports[`throws when trying to read directory as file 1`] = `"EISDIR: \`/glo\`: cannot read/write to a directory"`; + +exports[`throws when trying to read file via inexistent directory 1`] = `"ENOENT: \`/glo/../foo.txt\`: no such file or directory"`; + +exports[`throws when trying to read file with trailing slash 1`] = `"ENOTDIR: \`/foo.txt/\`: not a directory"`; + +exports[`throws when trying to read inexistent file (async) 1`] = `"ENOENT: \`/foo.txt\`: no such file or directory"`; + +exports[`throws when trying to read inexistent file 1`] = `"ENOENT: \`/foo.txt\`: no such file or directory"`; + +exports[`throws when trying to write a directory entry 1`] = `"EISDIR: \`/glo\`: cannot read/write to a directory"`; + +exports[`throws when trying to write to a read-only file descriptor 1`] = `"EBADF: file descriptor cannot be written to"`; + +exports[`throws when trying to write to an inexistent file descriptor 1`] = `"EBADF: file descriptor is not open"`; diff --git a/packages/metro-memory-fs/src/__tests__/index-test.js b/packages/metro-memory-fs/src/__tests__/index-test.js new file mode 100644 index 00000000..6ab9153d --- /dev/null +++ b/packages/metro-memory-fs/src/__tests__/index-test.js @@ -0,0 +1,274 @@ +/** + * Copyright (c) 2016-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails oncall+js_foundation + * @flow + * @format + */ + +'use strict'; + +const MemoryFs = require('../index'); + +let fs; + +beforeEach(() => { + fs = new MemoryFs(); +}); + +it('can write then read a file', () => { + fs.writeFileSync('/foo.txt', 'test'); + expect(fs.readFileSync('/foo.txt', 'utf8')).toEqual('test'); +}); + +it('can write then read a file with options object', () => { + fs.writeFileSync('/foo.txt', 'test'); + expect(fs.readFileSync('/foo.txt', {encoding: 'utf8'})).toEqual('test'); +}); + +it('works without binding functions', () => { + const {writeFileSync, readFileSync} = fs; + writeFileSync('/foo.txt', 'test'); + expect(readFileSync('/foo.txt', 'utf8')).toEqual('test'); +}); + +it('can write then read a file (async)', done => { + fs.writeFile('/foo.txt', 'test', wrError => { + if (wrError) { + return done(wrError); + } + fs.readFile('/foo.txt', 'utf8', (rdError, str) => { + if (rdError) { + return done(rdError); + } + expect(str).toEqual('test'); + done(); + }); + }); +}); + +it('can write then read a file as buffer', () => { + fs.writeFileSync('/foo.txt', new Buffer([1, 2, 3, 4])); + expect(fs.readFileSync('/foo.txt')).toEqual(new Buffer([1, 2, 3, 4])); +}); + +it('can write a file with a stream', done => { + const st = fs.createWriteStream('/foo.txt'); + let opened = false, + closed = false; + st.on('open', () => (opened = true)); + st.on('close', () => (closed = true)); + st.write('test'); + st.write(' foo'); + st.end(() => { + expect(opened).toBe(true); + expect(closed).toBe(true); + expect(fs.readFileSync('/foo.txt', 'utf8')).toEqual('test foo'); + done(); + }); +}); + +it('can write a file with a stream, as buffer', done => { + const st = fs.createWriteStream('/foo.txt'); + let opened = false, + closed = false; + st.on('open', () => (opened = true)); + st.on('close', () => (closed = true)); + st.write(Buffer.from('test')); + st.write(Buffer.from(' foo')); + st.end(() => { + expect(opened).toBe(true); + expect(closed).toBe(true); + expect(fs.readFileSync('/foo.txt', 'utf8')).toEqual('test foo'); + done(); + }); +}); + +it('can write a file with a stream, with a starting position', done => { + fs.writeFileSync('/foo.txt', 'test bar'); + const st = fs.createWriteStream('/foo.txt', {start: 5, flags: 'r+'}); + let opened = false, + closed = false; + st.on('open', () => (opened = true)); + st.on('close', () => (closed = true)); + st.write('beep'); + st.end(() => { + expect(opened).toBe(true); + expect(closed).toBe(true); + expect(fs.readFileSync('/foo.txt', 'utf8')).toEqual('test beep'); + done(); + }); +}); + +it('truncates a file that already exist', () => { + fs.writeFileSync('/foo.txt', 'test'); + fs.writeFileSync('/foo.txt', 'hop'); + expect(fs.readFileSync('/foo.txt', 'utf8')).toEqual('hop'); +}); + +it('can write to an arbitrary position in a file', () => { + const fd = fs.openSync('/foo.txt', 'w'); + fs.writeSync(fd, 'test'); + fs.writeSync(fd, 'a', 1); + fs.writeSync(fd, 'e', 4); + fs.closeSync(fd); + expect(fs.readFileSync('/foo.txt', 'utf8')).toEqual('taste'); +}); + +it('can check a file exist', () => { + fs.writeFileSync('/foo.txt', 'test'); + expect(fs.existsSync('/foo.txt')).toBe(true); + expect(fs.existsSync('/bar.txt')).toBe(false); + expect(fs.existsSync('/glo/bar.txt')).toBe(false); +}); + +it('can write then read a file in a subdirectory', () => { + fs.mkdirSync('/glo'); + fs.writeFileSync('/glo/foo.txt', 'test'); + expect(fs.readFileSync('/glo/foo.txt', 'utf8')).toEqual('test'); +}); + +it('can write then read via a symlinked file', () => { + fs.symlinkSync('foo.txt', '/bar.txt'); + fs.writeFileSync('/bar.txt', 'test'); + expect(fs.readFileSync('/bar.txt', 'utf8')).toEqual('test'); + expect(fs.readFileSync('/foo.txt', 'utf8')).toEqual('test'); +}); + +it('can write then read via a symlinked file (absolute path)', () => { + fs.symlinkSync('/foo.txt', '/bar.txt'); + fs.writeFileSync('/bar.txt', 'test'); + expect(fs.readFileSync('/bar.txt', 'utf8')).toEqual('test'); + expect(fs.readFileSync('/foo.txt', 'utf8')).toEqual('test'); +}); + +it('can write then read a file in a symlinked directory', () => { + fs.mkdirSync('/glo'); + fs.symlinkSync('glo', '/baz'); + fs.writeFileSync('/baz/foo.txt', 'test'); + expect(fs.readFileSync('/baz/foo.txt', 'utf8')).toEqual('test'); + expect(fs.readFileSync('/glo/foo.txt', 'utf8')).toEqual('test'); +}); + +it('gives the real path for a symbolic link to a non-existent file', () => { + fs.symlinkSync('foo.txt', '/bar.txt'); + // This *is* expected to work even if the file doesn't actually exist. + expect(fs.realpathSync('/bar.txt')).toEqual('/foo.txt'); +}); + +it('gives the real path for a symbolic link to a file', () => { + fs.writeFileSync('/foo.txt', 'test'); + fs.symlinkSync('foo.txt', '/bar.txt'); + expect(fs.realpathSync('/bar.txt')).toEqual('/foo.txt'); +}); + +it('gives the real path via a symlinked directory', () => { + fs.mkdirSync('/glo'); + fs.symlinkSync('glo', '/baz'); + expect(fs.realpathSync('/baz/foo.txt')).toEqual('/glo/foo.txt'); +}); + +it('gives stat about a regular file', () => { + fs.writeFileSync('/foo.txt', 'test'); + const st = fs.statSync('/foo.txt'); + expect(st.isFile()).toBe(true); + expect(st.isDirectory()).toBe(false); + expect(st.isSymbolicLink()).toBe(false); + expect(st.size).toBe(4); +}); + +it('able to list files of a directory', () => { + fs.mkdirSync('/baz'); + fs.writeFileSync('/baz/foo.txt', 'test'); + fs.writeFileSync('/baz/bar.txt', 'boop'); + fs.symlinkSync('glo', '/baz/glo.txt'); + expect(fs.readdirSync('/baz')).toEqual(['foo.txt', 'bar.txt', 'glo.txt']); +}); + +it('throws when trying to read inexistent file', () => { + expectFsError('ENOENT', () => fs.readFileSync('/foo.txt')); +}); + +it('throws when trying to read file via inexistent directory', () => { + fs.writeFileSync('/foo.txt', 'test'); + // It is *not* expected to simplify the path before resolution. Because + // `glo` does not exist along the way, it is expected to fail. + expectFsError('ENOENT', () => fs.readFileSync('/glo/../foo.txt')); +}); + +it('throws when trying to create symlink over existing file', () => { + fs.writeFileSync('/foo.txt', 'test'); + expectFsError('EEXIST', () => fs.symlinkSync('bar', '/foo.txt')); +}); + +it('throws when trying to write a directory entry', () => { + fs.mkdirSync('/glo'); + expectFsError('EISDIR', () => fs.writeFileSync('/glo', 'test')); +}); + +it('throws when trying to read a directory entry', () => { + fs.mkdirSync('/glo'); + expectFsError('EISDIR', () => fs.readFileSync('/glo')); +}); + +it('throws when trying to read inexistent file (async)', done => { + fs.readFile('/foo.txt', error => { + if (error.code !== 'ENOENT') { + return done(error); + } + expect(error.message).toMatchSnapshot(); + done(); + }); +}); + +it('throws when trying to read directory as file', () => { + fs.mkdirSync('/glo'); + expectFsError('EISDIR', () => fs.readFileSync('/glo')); +}); + +it('throws when trying to read file with trailing slash', () => { + fs.writeFileSync('/foo.txt', 'test'); + expectFsError('ENOTDIR', () => fs.readFileSync('/foo.txt/')); +}); + +it('throws when finding a symlink loop', () => { + fs.symlinkSync('foo.txt', '/bar.txt'); + fs.symlinkSync('bar.txt', '/glo.txt'); + fs.symlinkSync('glo.txt', '/foo.txt'); + expectFsError('ELOOP', () => fs.readFileSync('/foo.txt')); +}); + +it('throws when trying to write to an inexistent file descriptor', () => { + expectFsError('EBADF', () => fs.writeSync(42, new Buffer([1]))); +}); + +it('throws when trying to write to a read-only file descriptor', () => { + fs.writeFileSync('/foo.txt', 'test'); + const fd = fs.openSync('/foo.txt', 'r'); + expectFsError('EBADF', () => fs.writeSync(fd, new Buffer([1]))); +}); + +it('throws when trying to open too many files', () => { + fs.writeFileSync('/foo.txt', 'test'); + expectFsError('EMFILE', () => { + for (let i = 0; i < 1000; ++i) { + fs.openSync('/foo.txt', 'r'); + } + }); +}); + +function expectFsError(code, handler) { + try { + handler(); + throw new Error('an error was expected but did not happen'); + } catch (error) { + if (error.code !== code) { + throw error; + } + expect(error.message).toMatchSnapshot(); + expect(typeof error.errno).toBe('number'); + } +} diff --git a/packages/metro-memory-fs/src/index.js b/packages/metro-memory-fs/src/index.js new file mode 100644 index 00000000..a3366cc3 --- /dev/null +++ b/packages/metro-memory-fs/src/index.js @@ -0,0 +1,735 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +'use strict'; + +// $FlowFixMe: not defined by Flow +const constants = require('constants'); +const path = require('path'); +const stream = require('stream'); + +type NodeBase = {| + id: number, +|}; + +type DirectoryNode = {| + ...NodeBase, + type: 'directory', + entries: Map, +|}; + +type FileNode = {| + ...NodeBase, + type: 'file', + content: Buffer, +|}; + +type SymbolicLinkNode = {| + ...NodeBase, + type: 'symbolicLink', + target: string, +|}; + +type EntityNode = DirectoryNode | FileNode | SymbolicLinkNode; + +type Encoding = + | 'ascii' + | 'base64' + | 'binary' + | 'hex' + | 'latin1' + | 'ucs2' + | 'utf16le' + | 'utf8'; + +type Resolution = {| + +basename: string, + +dirNode: DirectoryNode, + +node: ?EntityNode, + +realpath: string, +|}; + +type Descriptor = {| + +node: FileNode, + +readable: boolean, + +writable: boolean, + position: number, +|}; + +const FLAGS_SPECS: { + [string]: { + exclusive?: true, + mustExist?: true, + readable?: true, + truncate?: true, + writable?: true, + }, +} = { + r: {mustExist: true, readable: true}, + 'r+': {mustExist: true, readable: true, writable: true}, + 'rs+': {mustExist: true, readable: true, writable: true}, + w: {truncate: true, writable: true}, + wx: {exclusive: true, truncate: true, writable: true}, + 'w+': {readable: true, truncate: true, writable: true}, + 'wx+': {exclusive: true, readable: true, truncate: true, writable: true}, +}; + +const ASYNC_FUNC_NAMES = [ + 'close', + 'open', + 'read', + 'readdir', + 'readFile', + 'realpath', + 'write', + 'writeFile', +]; + +/** + * Simulates `fs` API in an isolated, memory-based filesystem. This is useful + * for testing systems that rely on `fs` without affecting the real filesystem. + * This is meant to be a drop-in replacement/mock for `fs`, so it mimics + * closely the behavior of file path resolution and file accesses. + */ +class MemoryFs { + _root: DirectoryNode; + _fds: Map; + _nextId: number; + + close: (fd: number, callback: (error: ?Error) => mixed) => void; + open: ( + filePath: string | Buffer, + flag: string | number, + mode?: number, + callback: (error: ?Error, fd: ?number) => mixed, + ) => void; + read: ( + fd: number, + buffer: Buffer, + offset: number, + length: number, + position: ?number, + callback: (?Error, ?number) => mixed, + ) => void; + readFile: ( + filePath: string | Buffer, + options?: + | { + encoding?: Encoding, + flag?: string, + } + | Encoding + | ((?Error, ?Buffer | string) => mixed), + callback?: (?Error, ?Buffer | string) => mixed, + ) => void; + realpath: ( + filePath: string | Buffer, + callback: (?Error, ?string) => mixed, + ) => void; + write: ( + fd: number, + bufferOrString: Buffer | string, + offsetOrPosition?: number | ((?Error, number) => mixed), + lengthOrEncoding?: number | string | ((?Error, number) => mixed), + position?: number | ((?Error, number) => mixed), + callback?: (?Error, number) => mixed, + ) => void; + writeFile: ( + filePath: string | Buffer, + data: Buffer | string, + options?: + | { + encoding?: ?Encoding, + mode?: ?number, + flag?: ?string, + } + | Encoding + | ((?Error) => mixed), + callback?: (?Error) => mixed, + ) => void; + + constructor() { + this.reset(); + ASYNC_FUNC_NAMES.forEach(funcName => { + const func = (this: $FlowFixMe)[`${funcName}Sync`]; + (this: $FlowFixMe)[funcName] = function(...args) { + const callback = args.pop(); + process.nextTick(() => { + let retval; + try { + retval = func.apply(null, args); + } catch (error) { + callback(error); + return; + } + callback(null, retval); + }); + }; + }); + } + + reset() { + this._nextId = 1; + this._root = this._makeDir(); + this._fds = new Map(); + } + + closeSync = (fd: number): void => { + this._fds.delete(fd); + }; + + openSync = ( + filePath: string | Buffer, + flags: string | number, + mode?: number, + ): number => { + if (typeof flags === 'number') { + throw new Error(`numeric flags not supported: ${flags}`); + } + return this._open(pathStr(filePath), flags, mode); + }; + + readSync = ( + fd: number, + buffer: Buffer, + offset: number, + length: number, + position: ?number, + ): number => { + const desc = this._fds.get(fd); + if (desc == null) { + throw makeError('EBADF', null, 'file descriptor is not open'); + } + if (!desc.readable) { + throw makeError('EBADF', null, 'file descriptor cannot be written to'); + } + if (position != null) { + desc.position = position; + } + const endPos = Math.min(desc.position + length, desc.node.content.length); + desc.node.content.copy(buffer, offset, desc.position, endPos); + const bytesRead = endPos - desc.position; + desc.position = endPos; + return bytesRead; + }; + + readdirSync = ( + filePath: string | Buffer, + options?: + | { + encoding?: Encoding, + } + | Encoding, + ): Array => { + let encoding; + if (typeof options === 'string') { + encoding = options; + } else if (options != null) { + ({encoding} = options); + } + filePath = pathStr(filePath); + const {node} = this._resolve(filePath); + if (node == null) { + throw makeError('ENOENT', filePath, 'no such file or directory'); + } + if (node.type !== 'directory') { + throw makeError('ENOTDIR', filePath, 'not a directory'); + } + return Array.from(node.entries.keys()).map(str => { + if (encoding === 'utf8') { + return str; + } + const buffer = Buffer.from(str); + if (encoding === 'buffer') { + return buffer; + } + return buffer.toString(encoding); + }); + }; + + readFileSync = ( + filePath: string | Buffer, + options?: + | { + encoding?: Encoding, + flag?: string, + } + | Encoding, + ): Buffer | string => { + let encoding, flag; + if (typeof options === 'string') { + encoding = options; + } else if (options != null) { + ({encoding, flag} = options); + } + const fd = this._open(pathStr(filePath), flag || 'r'); + const chunks = []; + try { + const buffer = new Buffer(1024); + let bytesRead; + do { + bytesRead = this.readSync(fd, buffer, 0, buffer.length, null); + if (bytesRead === 0) { + continue; + } + const chunk = new Buffer(bytesRead); + buffer.copy(chunk, 0, 0, bytesRead); + chunks.push(chunk); + } while (bytesRead > 0); + } finally { + this.closeSync(fd); + } + const result = Buffer.concat(chunks); + if (encoding == null) { + return result; + } + return result.toString(encoding); + }; + + realpathSync = (filePath: string | Buffer): string => { + return this._resolve(pathStr(filePath)).realpath; + }; + + writeSync = ( + fd: number, + bufferOrString: Buffer | string, + offsetOrPosition?: number, + lengthOrEncoding?: number | string, + position?: number, + ): number => { + let encoding, offset, length, buffer; + if (typeof bufferOrString === 'string') { + position = offsetOrPosition; + encoding = lengthOrEncoding; + buffer = (Buffer: $FlowFixMe).from( + bufferOrString, + (encoding: $FlowFixMe) || 'utf8', + ); + } else { + offset = offsetOrPosition; + if (lengthOrEncoding != null && typeof lengthOrEncoding !== 'number') { + throw new Error('invalid length'); + } + length = lengthOrEncoding; + buffer = bufferOrString; + } + if (offset == null) { + offset = 0; + } + if (length == null) { + length = buffer.length; + } + return this._write(fd, buffer, offset, length, position); + }; + + writeFileSync = ( + filePath: string | Buffer, + data: Buffer | string, + options?: + | { + encoding?: ?Encoding, + mode?: ?number, + flag?: ?string, + } + | Encoding, + ): void => { + let encoding, mode, flag; + if (typeof options === 'string') { + encoding = options; + } else if (options != null) { + ({encoding, mode, flag} = options); + } + if (encoding == null) { + encoding = 'utf8'; + } + if (typeof data === 'string') { + data = (Buffer: $FlowFixMe).from(data, encoding); + } + const fd = this._open(pathStr(filePath), flag || 'w', mode); + try { + this._write(fd, data, 0, data.length); + } finally { + this.closeSync(fd); + } + }; + + mkdirSync = (dirPath: string | Buffer, mode?: number): void => { + if (mode == null) { + mode = 0o777; + } + dirPath = pathStr(dirPath); + const {dirNode, node, basename} = this._resolve(dirPath); + if (node != null) { + throw makeError('EEXIST', dirPath, 'directory or file already exists'); + } + dirNode.entries.set(basename, this._makeDir()); + }; + + symlinkSync = ( + target: string | Buffer, + filePath: string | Buffer, + type?: string, + ) => { + if (type == null) { + type = 'file'; + } + if (type !== 'file') { + throw new Error('symlink type not supported'); + } + filePath = pathStr(filePath); + const {dirNode, node, basename} = this._resolve(filePath); + if (node != null) { + throw makeError('EEXIST', filePath, 'directory or file already exists'); + } + dirNode.entries.set(basename, { + type: 'symbolicLink', + id: this._getId(), + target: pathStr(target), + }); + }; + + existsSync = (filePath: string | Buffer): boolean => { + try { + const {node} = this._resolve(pathStr(filePath)); + return node != null; + } catch (error) { + if (error.code === 'ENOENT') { + return false; + } + throw error; + } + }; + + statSync = (filePath: string | Buffer) => { + filePath = pathStr(filePath); + const {node} = this._resolve(filePath); + if (node == null) { + throw makeError('ENOENT', filePath, 'no such file or directory'); + } + return new Stats(node); + }; + + createWriteStream = ( + filePath: string | Buffer, + options?: + | { + autoClose?: ?boolean, + encoding?: ?Encoding, + fd?: ?number, + flags?: ?string, + mode?: ?number, + start?: ?number, + } + | Encoding, + ) => { + let autoClose, fd, flags, mode, start; + if (typeof options !== 'string' && options != null) { + ({autoClose, fd, flags, mode, start} = options); + } + if (fd == null) { + fd = this._open(pathStr(filePath), flags || 'w', mode); + } + if (start != null) { + this._write(fd, new Buffer(0), 0, 0, start); + } + const ffd = fd; + const st = new stream.Writable({ + write: (buffer, encoding, callback) => { + try { + this._write(ffd, buffer, 0, buffer.length); + (st: any).bytesWritten += buffer.length; + } catch (error) { + callback(error); + return; + } + callback(); + }, + final: callback => { + try { + if (autoClose !== false) { + this.closeSync(ffd); + st.emit('close'); + } + } catch (error) { + callback(error); + return; + } + callback(); + }, + }); + (st: any).path = filePath; + (st: any).bytesWritten = 0; + process.nextTick(() => st.emit('open', ffd)); + return st; + }; + + _makeDir() { + return {type: 'directory', id: this._getId(), entries: new Map()}; + } + + _getId() { + return ++this._nextId; + } + + _open(filePath: string, flags: string, mode: ?number): number { + if (mode == null) { + mode = 0o666; + } + const spec = FLAGS_SPECS[flags]; + if (spec == null) { + throw new Error(`flags not supported: \`${flags}\``); + } + const {writable = false, readable = false} = spec; + const {exclusive, mustExist, truncate} = spec; + let {dirNode, node, basename} = this._resolve(filePath); + if (node == null) { + if (mustExist) { + throw makeError('ENOENT', filePath, 'no such file or directory'); + } + node = {type: 'file', id: this._getId(), content: new Buffer(0)}; + dirNode.entries.set(basename, node); + } else { + if (exclusive) { + throw makeError('EEXIST', filePath, 'directory or file already exists'); + } + if (node.type !== 'file') { + throw makeError('EISDIR', filePath, 'cannot read/write to a directory'); + } + if (truncate) { + node.content = new Buffer(0); + } + } + return this._getFd(filePath, {node, position: 0, writable, readable}); + } + + /** + * Implemented according with + * http://man7.org/linux/man-pages/man7/path_resolution.7.html + */ + _resolve(originalFilePath: string): Resolution { + let filePath = originalFilePath; + let drive = ''; + if (path === path.win32 && filePath.match(/^[a-zA-Z]:\\/)) { + drive = filePath.substring(0, 2); + filePath = filePath.substring(2); + } + if (filePath === '') { + throw makeError('ENOENT', originalFilePath, 'no such file or directory'); + } + if (filePath[0] === '/') { + filePath = filePath.substring(1); + } else { + filePath = path.join(process.cwd().substring(1), filePath); + } + const entNames = filePath.split(path.sep); + checkPathLength(entNames, originalFilePath); + const context = { + node: this._root, + nodePath: [['', this._root]], + entNames, + symlinkCount: 0, + }; + while (context.entNames.length > 0) { + const entName = context.entNames.shift(); + this._resolveEnt(context, originalFilePath, entName); + } + const {nodePath} = context; + return { + realpath: drive + nodePath.map(x => x[0]).join(path.sep), + dirNode: (nodePath[nodePath.length - 2][1]: $FlowFixMe), + node: context.node, + basename: (nodePath[nodePath.length - 1][0]: $FlowFixMe), + }; + } + + _resolveEnt(context, filePath, entName) { + const {node} = context; + if (node == null) { + throw makeError('ENOENT', filePath, 'no such file or directory'); + } + if (node.type !== 'directory') { + throw makeError('ENOTDIR', filePath, 'not a directory'); + } + const {entries} = node; + if (entName === '' || entName === '.') { + return; + } + if (entName === '..') { + const {nodePath} = context; + if (nodePath.length > 1) { + nodePath.pop(); + context.node = nodePath[nodePath.length - 1][1]; + } + return; + } + const childNode = entries.get(entName); + if (childNode == null || childNode.type !== 'symbolicLink') { + context.node = childNode; + context.nodePath.push([entName, childNode]); + return; + } + if (context.symlinkCount >= 10) { + throw makeError('ELOOP', filePath, 'too many levels of symbolic links'); + } + let {target} = childNode; + if (target[0] === '/') { + target = target.substring(1); + context.node = this._root; + context.nodePath = [['', context.node]]; + } + context.entNames = target.split(path.sep).concat(context.entNames); + checkPathLength(context.entNames, filePath); + ++context.symlinkCount; + } + + _write( + fd: number, + buffer: Buffer, + offset: number, + length: number, + position: ?number, + ): number { + const desc = this._fds.get(fd); + if (desc == null) { + throw makeError('EBADF', null, 'file descriptor is not open'); + } + if (!desc.writable) { + throw makeError('EBADF', null, 'file descriptor cannot be written to'); + } + if (position == null) { + position = desc.position; + } + const {node} = desc; + if (node.content.length < position + length) { + const newBuffer = new Buffer(position + length); + node.content.copy(newBuffer, 0, 0, node.content.length); + node.content = newBuffer; + } + buffer.copy(node.content, position, offset, offset + length); + desc.position = position + length; + return buffer.length; + } + + _getFd(filePath: string, desc: Descriptor): number { + let fd = 3; + while (this._fds.has(fd)) { + ++fd; + } + if (fd >= 256) { + throw makeError('EMFILE', filePath, 'too many open files'); + } + this._fds.set(fd, desc); + return fd; + } +} + +class Stats { + _type: string; + dev: number; + mode: number; + nlink: number; + uid: number; + gid: number; + rdev: number; + blksize: number; + ino: number; + size: number; + blocks: number; + atimeMs: number; + mtimeMs: number; + ctimeMs: number; + birthtimeMs: number; + atime: Date; + mtime: Date; + ctime: Date; + birthtime: Date; + + /** + * Don't keep a reference to the node as it may get mutated over time. + */ + constructor(node: EntityNode) { + this._type = node.type; + this.dev = 1; + this.mode = 0; + this.nlink = 1; + this.uid = 100; + this.gid = 100; + this.rdev = 0; + this.blksize = 1024; + this.ino = node.id; + this.size = + node.type === 'file' + ? node.content.length + : node.type === 'symbolicLink' ? node.target.length : 0; + this.blocks = Math.ceil(this.size / 512); + this.atimeMs = 1; + this.mtimeMs = 1; + this.ctimeMs = 1; + this.birthtimeMs = 1; + this.atime = new Date(this.atimeMs); + this.mtime = new Date(this.mtimeMs); + this.ctime = new Date(this.ctimeMs); + this.birthtime = new Date(this.birthtimeMs); + } + + isFile(): boolean { + return this._type === 'file'; + } + isDirectory(): boolean { + return this._type === 'directory'; + } + isBlockDevice(): boolean { + return false; + } + isCharacterDevice(): boolean { + return false; + } + isSymbolicLink(): boolean { + return this._type === 'symbolicLink'; + } + isFIFO(): boolean { + return false; + } + isSocket(): boolean { + return false; + } +} + +function checkPathLength(entNames, filePath) { + if (entNames.length > 32) { + throw makeError( + 'ENAMETOOLONG', + filePath, + 'file path too long (or one of the intermediate ' + + 'symbolic link resolutions)', + ); + } +} + +function pathStr(filePath: string | Buffer): string { + if (typeof filePath === 'string') { + return filePath; + } + return filePath.toString('utf8'); +} + +function makeError(code, filePath, message) { + const err: $FlowFixMe = new Error( + filePath != null + ? `${code}: \`${filePath}\`: ${message}` + : `${code}: ${message}`, + ); + err.code = code; + err.errno = constants[code]; + err.path = filePath; + return err; +} + +module.exports = MemoryFs;