UNPKG

browserfs

Version:

A filesystem in your browser!

1,236 lines (1,145 loc) 41.5 kB
import file_system = require('../core/file_system'); import {ApiError, ErrorCode} from '../core/api_error'; import {default as Stats, FileType} from '../core/node_fs_stats'; import file = require('../core/file'); import file_flag = require('../core/file_flag'); import path = require('path'); import Inode = require('../generic/inode'); import preload_file = require('../generic/preload_file'); var ROOT_NODE_ID: string = "/"; /** * Generates a random ID. */ function GenerateRandomID(): string { // From http://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid-in-javascript return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); return v.toString(16); }); } /** * Helper function. Checks if 'e' is defined. If so, it triggers the callback * with 'e' and returns false. Otherwise, returns true. */ function noError(e: ApiError, cb: (e: ApiError) => void): boolean { if (e) { cb(e); return false; } return true; } /** * Helper function. Checks if 'e' is defined. If so, it aborts the transaction, * triggers the callback with 'e', and returns false. Otherwise, returns true. */ function noErrorTx(e: ApiError, tx: AsyncKeyValueRWTransaction, cb: (e: ApiError) => void): boolean { if (e) { tx.abort(() => { cb(e); }); return false; } return true; } /** * Represents a *synchronous* key-value store. */ export interface SyncKeyValueStore { /** * The name of the key-value store. */ name(): string; /** * Empties the key-value store completely. */ clear(): void; /** * Begins a new read-only transaction. */ beginTransaction(type: "readonly"): SyncKeyValueROTransaction; /** * Begins a new read-write transaction. */ beginTransaction(type: "readwrite"): SyncKeyValueRWTransaction; beginTransaction(type: string): SyncKeyValueROTransaction; } /** * A read-only transaction for a synchronous key value store. */ export interface SyncKeyValueROTransaction { /** * Retrieves the data at the given key. Throws an ApiError if an error occurs * or if the key does not exist. * @param key The key to look under for data. * @return The data stored under the key, or undefined if not present. */ get(key: string): NodeBuffer; } /** * A read-write transaction for a synchronous key value store. */ export interface SyncKeyValueRWTransaction extends SyncKeyValueROTransaction { /** * Adds the data to the store under the given key. * @param key The key to add the data under. * @param data The data to add to the store. * @param overwrite If 'true', overwrite any existing data. If 'false', * avoids storing the data if the key exists. * @return True if storage succeeded, false otherwise. */ put(key: string, data: NodeBuffer, overwrite: boolean): boolean; /** * Deletes the data at the given key. * @param key The key to delete from the store. */ del(key: string): void; /** * Commits the transaction. */ commit(): void; /** * Aborts and rolls back the transaction. */ abort(): void; } /** * An interface for simple synchronous key-value stores that don't have special * support for transactions and such. */ export interface SimpleSyncStore { get(key: string): NodeBuffer; put(key: string, data: NodeBuffer, overwrite: boolean): boolean; del(key: string): void; } /** * A simple RW transaction for simple synchronous key-value stores. */ export class SimpleSyncRWTransaction implements SyncKeyValueRWTransaction { constructor(private store: SimpleSyncStore) { } /** * Stores data in the keys we modify prior to modifying them. * Allows us to roll back commits. */ private originalData: { [key: string]: NodeBuffer } = {}; /** * List of keys modified in this transaction, if any. */ private modifiedKeys: string[] = []; /** * Stashes given key value pair into `originalData` if it doesn't already * exist. Allows us to stash values the program is requesting anyway to * prevent needless `get` requests if the program modifies the data later * on during the transaction. */ private stashOldValue(key: string, value: NodeBuffer) { // Keep only the earliest value in the transaction. if (!this.originalData.hasOwnProperty(key)) { this.originalData[key] = value } } /** * Marks the given key as modified, and stashes its value if it has not been * stashed already. */ private markModified(key: string) { if (this.modifiedKeys.indexOf(key) === -1) { this.modifiedKeys.push(key); if (!this.originalData.hasOwnProperty(key)) { this.originalData[key] = this.store.get(key); } } } public get(key: string): NodeBuffer { var val = this.store.get(key); this.stashOldValue(key, val); return val; } public put(key: string, data: NodeBuffer, overwrite: boolean): boolean { this.markModified(key); return this.store.put(key, data, overwrite); } public del(key: string): void { this.markModified(key); this.store.del(key); } public commit(): void {/* NOP */} public abort(): void { // Rollback old values. var i: number, key: string, value: NodeBuffer; for (i = 0; i < this.modifiedKeys.length; i++) { key = this.modifiedKeys[i]; value = this.originalData[key]; if (value === null) { // Key didn't exist. this.store.del(key); } else { // Key existed. Store old value. this.store.put(key, value, true); } } } } export interface SyncKeyValueFileSystemOptions { /** * The actual key-value store to read from/write to. */ store: SyncKeyValueStore; /** * Should the file system support properties (mtime/atime/ctime/chmod/etc)? * Enabling this slightly increases the storage space per file, and adds * atime updates every time a file is accessed, mtime updates every time * a file is modified, and permission checks on every operation. * * Defaults to *false*. */ //supportProps?: boolean; /** * Should the file system support links? */ //supportLinks?: boolean; } export class SyncKeyValueFile extends preload_file.PreloadFile<SyncKeyValueFileSystem> implements file.File { constructor(_fs: SyncKeyValueFileSystem, _path: string, _flag: file_flag.FileFlag, _stat: Stats, contents?: NodeBuffer) { super(_fs, _path, _flag, _stat, contents); } public syncSync(): void { if (this.isDirty()) { this._fs._syncSync(this.getPath(), this.getBuffer(), this.getStats()); this.resetDirty(); } } public closeSync(): void { this.syncSync(); } } /** * A "Synchronous key-value file system". Stores data to/retrieves data from an * underlying key-value store. * * We use a unique ID for each node in the file system. The root node has a * fixed ID. * @todo Introduce Node ID caching. * @todo Check modes. */ export class SyncKeyValueFileSystem extends file_system.SynchronousFileSystem { private store: SyncKeyValueStore; constructor(options: SyncKeyValueFileSystemOptions) { super(); this.store = options.store; // INVARIANT: Ensure that the root exists. this.makeRootDirectory(); } public static isAvailable(): boolean { return true; } public getName(): string { return this.store.name(); } public isReadOnly(): boolean { return false; } public supportsSymlinks(): boolean { return false; } public supportsProps(): boolean { return false; } public supportsSynch(): boolean { return true; } /** * Checks if the root directory exists. Creates it if it doesn't. */ private makeRootDirectory() { var tx = this.store.beginTransaction('readwrite'); if (tx.get(ROOT_NODE_ID) === undefined) { // Create new inode. var currTime = (new Date()).getTime(), // Mode 0666 dirInode = new Inode(GenerateRandomID(), 4096, 511 | FileType.DIRECTORY, currTime, currTime, currTime); // If the root doesn't exist, the first random ID shouldn't exist, // either. tx.put(dirInode.id, new Buffer("{}"), false); tx.put(ROOT_NODE_ID, dirInode.toBuffer(), false); tx.commit(); } } /** * Helper function for findINode. * @param parent The parent directory of the file we are attempting to find. * @param filename The filename of the inode we are attempting to find, minus * the parent. * @return string The ID of the file's inode in the file system. */ private _findINode(tx: SyncKeyValueROTransaction, parent: string, filename: string): string { var read_directory = (inode: Inode): string => { // Get the root's directory listing. var dirList = this.getDirListing(tx, parent, inode); // Get the file's ID. if (dirList[filename]) { return dirList[filename]; } else { throw ApiError.ENOENT(path.resolve(parent, filename)); } }; if (parent === '/') { if (filename === '') { // BASE CASE #1: Return the root's ID. return ROOT_NODE_ID; } else { // BASE CASE #2: Find the item in the root ndoe. return read_directory(this.getINode(tx, parent, ROOT_NODE_ID)); } } else { return read_directory(this.getINode(tx, parent + path.sep + filename, this._findINode(tx, path.dirname(parent), path.basename(parent)))); } } /** * Finds the Inode of the given path. * @param p The path to look up. * @return The Inode of the path p. * @todo memoize/cache */ private findINode(tx: SyncKeyValueROTransaction, p: string): Inode { return this.getINode(tx, p, this._findINode(tx, path.dirname(p), path.basename(p))); } /** * Given the ID of a node, retrieves the corresponding Inode. * @param tx The transaction to use. * @param p The corresponding path to the file (used for error messages). * @param id The ID to look up. */ private getINode(tx: SyncKeyValueROTransaction, p: string, id: string): Inode { var inode = tx.get(id); if (inode === undefined) { throw ApiError.ENOENT(p); } return Inode.fromBuffer(inode); } /** * Given the Inode of a directory, retrieves the corresponding directory * listing. */ private getDirListing(tx: SyncKeyValueROTransaction, p: string, inode: Inode): { [fileName: string]: string } { if (!inode.isDirectory()) { throw ApiError.ENOTDIR(p); } var data = tx.get(inode.id); if (data === undefined) { throw ApiError.ENOENT(p); } return JSON.parse(data.toString()); } /** * Creates a new node under a random ID. Retries 5 times before giving up in * the exceedingly unlikely chance that we try to reuse a random GUID. * @return The GUID that the data was stored under. */ private addNewNode(tx: SyncKeyValueRWTransaction, data: NodeBuffer): string { var retries = 0, currId: string; while (retries < 5) { try { currId = GenerateRandomID(); tx.put(currId, data, false); return currId; } catch (e) { // Ignore and reroll. } } throw new ApiError(ErrorCode.EIO, 'Unable to commit data to key-value store.'); } /** * Commits a new file (well, a FILE or a DIRECTORY) to the file system with * the given mode. * Note: This will commit the transaction. * @param p The path to the new file. * @param type The type of the new file. * @param mode The mode to create the new file with. * @param data The data to store at the file's data node. * @return The Inode for the new file. */ private commitNewFile(tx: SyncKeyValueRWTransaction, p: string, type: FileType, mode: number, data: NodeBuffer): Inode { var parentDir = path.dirname(p), fname = path.basename(p), parentNode = this.findINode(tx, parentDir), dirListing = this.getDirListing(tx, parentDir, parentNode), currTime = (new Date()).getTime(); // Invariant: The root always exists. // If we don't check this prior to taking steps below, we will create a // file with name '' in root should p == '/'. if (p === '/') { throw ApiError.EEXIST(p); } // Check if file already exists. if (dirListing[fname]) { throw ApiError.EEXIST(p); } try { // Commit data. var dataId = this.addNewNode(tx, data), fileNode = new Inode(dataId, data.length, mode | type, currTime, currTime, currTime), // Commit file node. fileNodeId = this.addNewNode(tx, fileNode.toBuffer()); // Update and commit parent directory listing. dirListing[fname] = fileNodeId; tx.put(parentNode.id, new Buffer(JSON.stringify(dirListing)), true); } catch (e) { tx.abort(); throw e; } tx.commit(); return fileNode; } /** * Delete all contents stored in the file system. */ public empty(): void { this.store.clear(); // INVARIANT: Root always exists. this.makeRootDirectory(); } public renameSync(oldPath: string, newPath: string): void { var tx = this.store.beginTransaction('readwrite'), oldParent = path.dirname(oldPath), oldName = path.basename(oldPath), newParent = path.dirname(newPath), newName = path.basename(newPath), // Remove oldPath from parent's directory listing. oldDirNode = this.findINode(tx, oldParent), oldDirList = this.getDirListing(tx, oldParent, oldDirNode); if (!oldDirList[oldName]) { throw ApiError.ENOENT(oldPath); } var nodeId: string = oldDirList[oldName]; delete oldDirList[oldName]; // Invariant: Can't move a folder inside itself. // This funny little hack ensures that the check passes only if oldPath // is a subpath of newParent. We append '/' to avoid matching folders that // are a substring of the bottom-most folder in the path. if ((newParent + '/').indexOf(oldPath + '/') === 0) { throw new ApiError(ErrorCode.EBUSY, oldParent); } // Add newPath to parent's directory listing. var newDirNode: Inode, newDirList: typeof oldDirList; if (newParent === oldParent) { // Prevent us from re-grabbing the same directory listing, which still // contains oldName. newDirNode = oldDirNode; newDirList = oldDirList; } else { newDirNode = this.findINode(tx, newParent); newDirList = this.getDirListing(tx, newParent, newDirNode); } if (newDirList[newName]) { // If it's a file, delete it. var newNameNode = this.getINode(tx, newPath, newDirList[newName]); if (newNameNode.isFile()) { try { tx.del(newNameNode.id); tx.del(newDirList[newName]); } catch (e) { tx.abort(); throw e; } } else { // If it's a directory, throw a permissions error. throw ApiError.EPERM(newPath); } } newDirList[newName] = nodeId; // Commit the two changed directory listings. try { tx.put(oldDirNode.id, new Buffer(JSON.stringify(oldDirList)), true); tx.put(newDirNode.id, new Buffer(JSON.stringify(newDirList)), true); } catch (e) { tx.abort(); throw e; } tx.commit(); } public statSync(p: string, isLstat: boolean): Stats { // Get the inode to the item, convert it into a Stats object. return this.findINode(this.store.beginTransaction('readonly'), p).toStats(); } public createFileSync(p: string, flag: file_flag.FileFlag, mode: number): file.File { var tx = this.store.beginTransaction('readwrite'), data = new Buffer(0), newFile = this.commitNewFile(tx, p, FileType.FILE, mode, data); // Open the file. return new SyncKeyValueFile(this, p, flag, newFile.toStats(), data); } public openFileSync(p: string, flag: file_flag.FileFlag): file.File { var tx = this.store.beginTransaction('readonly'), node = this.findINode(tx, p), data = tx.get(node.id); if (data === undefined) { throw ApiError.ENOENT(p); } return new SyncKeyValueFile(this, p, flag, node.toStats(), data); } /** * Remove all traces of the given path from the file system. * @param p The path to remove from the file system. * @param isDir Does the path belong to a directory, or a file? * @todo Update mtime. */ private removeEntry(p: string, isDir: boolean): void { var tx = this.store.beginTransaction('readwrite'), parent: string = path.dirname(p), parentNode = this.findINode(tx, parent), parentListing = this.getDirListing(tx, parent, parentNode), fileName: string = path.basename(p); if (!parentListing[fileName]) { throw ApiError.ENOENT(p); } // Remove from directory listing of parent. var fileNodeId = parentListing[fileName]; delete parentListing[fileName]; // Get file inode. var fileNode = this.getINode(tx, p, fileNodeId); if (!isDir && fileNode.isDirectory()) { throw ApiError.EISDIR(p); } else if (isDir && !fileNode.isDirectory()) { throw ApiError.ENOTDIR(p); } try { // Delete data. tx.del(fileNode.id); // Delete node. tx.del(fileNodeId); // Update directory listing. tx.put(parentNode.id, new Buffer(JSON.stringify(parentListing)), true); } catch (e) { tx.abort(); throw e; } // Success. tx.commit(); } public unlinkSync(p: string): void { this.removeEntry(p, false); } public rmdirSync(p: string): void { // Check first if directory is empty. if (this.readdirSync(p).length > 0) { throw ApiError.ENOTEMPTY(p); } else { this.removeEntry(p, true); } } public mkdirSync(p: string, mode: number): void { var tx = this.store.beginTransaction('readwrite'), data = new Buffer('{}'); this.commitNewFile(tx, p, FileType.DIRECTORY, mode, data); } public readdirSync(p: string): string[]{ var tx = this.store.beginTransaction('readonly'); return Object.keys(this.getDirListing(tx, p, this.findINode(tx, p))); } public _syncSync(p: string, data: NodeBuffer, stats: Stats): void { // @todo Ensure mtime updates properly, and use that to determine if a data // update is required. var tx = this.store.beginTransaction('readwrite'), // We use the _findInode helper because we actually need the INode id. fileInodeId = this._findINode(tx, path.dirname(p), path.basename(p)), fileInode = this.getINode(tx, p, fileInodeId), inodeChanged = fileInode.update(stats); try { // Sync data. tx.put(fileInode.id, data, true); // Sync metadata. if (inodeChanged) { tx.put(fileInodeId, fileInode.toBuffer(), true); } } catch (e) { tx.abort(); throw e; } tx.commit(); } } /** * Represents an *asynchronous* key-value store. */ export interface AsyncKeyValueStore { /** * The name of the key-value store. */ name(): string; /** * Empties the key-value store completely. */ clear(cb: (e?: ApiError) => void): void; /** * Begins a read-write transaction. */ beginTransaction(type: 'readwrite'): AsyncKeyValueRWTransaction; /** * Begins a read-only transaction. */ beginTransaction(type: 'readonly'): AsyncKeyValueROTransaction; beginTransaction(type: string): AsyncKeyValueROTransaction; } /** * Represents an asynchronous read-only transaction. */ export interface AsyncKeyValueROTransaction { /** * Retrieves the data at the given key. * @param key The key to look under for data. */ get(key: string, cb: (e: ApiError, data?: NodeBuffer) => void): void; } /** * Represents an asynchronous read-write transaction. */ export interface AsyncKeyValueRWTransaction extends AsyncKeyValueROTransaction { /** * Adds the data to the store under the given key. Overwrites any existing * data. * @param key The key to add the data under. * @param data The data to add to the store. * @param overwrite If 'true', overwrite any existing data. If 'false', * avoids writing the data if the key exists. * @param cb Triggered with an error and whether or not the value was * committed. */ put(key: string, data: NodeBuffer, overwrite: boolean, cb: (e: ApiError, committed?: boolean) => void): void; /** * Deletes the data at the given key. * @param key The key to delete from the store. */ del(key: string, cb: (e?: ApiError) => void): void; /** * Commits the transaction. */ commit(cb: (e?: ApiError) => void): void; /** * Aborts and rolls back the transaction. */ abort(cb: (e?: ApiError) => void): void; } export class AsyncKeyValueFile extends preload_file.PreloadFile<AsyncKeyValueFileSystem> implements file.File { constructor(_fs: AsyncKeyValueFileSystem, _path: string, _flag: file_flag.FileFlag, _stat: Stats, contents?: NodeBuffer) { super(_fs, _path, _flag, _stat, contents); } public sync(cb: (e?: ApiError) => void): void { if (this.isDirty()) { this._fs._sync(this.getPath(), this.getBuffer(), this.getStats(), (e?: ApiError) => { if (!e) { this.resetDirty(); } cb(e); }); } else { cb(); } } public close(cb: (e?: ApiError) => void): void { this.sync(cb); } } /** * An "Asynchronous key-value file system". Stores data to/retrieves data from * an underlying asynchronous key-value store. */ export class AsyncKeyValueFileSystem extends file_system.BaseFileSystem { private store: AsyncKeyValueStore; /** * Initializes the file system. Typically called by subclasses' async * constructors. */ public init(store: AsyncKeyValueStore, cb: (e?: ApiError) => void) { this.store = store; // INVARIANT: Ensure that the root exists. this.makeRootDirectory(cb); } public static isAvailable(): boolean { return true; } public getName(): string { return this.store.name(); } public isReadOnly(): boolean { return false; } public supportsSymlinks(): boolean { return false; } public supportsProps(): boolean { return false; } public supportsSynch(): boolean { return false; } /** * Checks if the root directory exists. Creates it if it doesn't. */ private makeRootDirectory(cb: (e?: ApiError) => void) { var tx = this.store.beginTransaction('readwrite'); tx.get(ROOT_NODE_ID, (e: ApiError, data?: NodeBuffer) => { if (e || data === undefined) { // Create new inode. var currTime = (new Date()).getTime(), // Mode 0666 dirInode = new Inode(GenerateRandomID(), 4096, 511 | FileType.DIRECTORY, currTime, currTime, currTime); // If the root doesn't exist, the first random ID shouldn't exist, // either. tx.put(dirInode.id, new Buffer("{}"), false, (e?: ApiError) => { if (noErrorTx(e, tx, cb)) { tx.put(ROOT_NODE_ID, dirInode.toBuffer(), false, (e?: ApiError) => { if (e) { tx.abort(() => { cb(e); }); } else { tx.commit(cb); } }); } }); } else { // We're good. tx.commit(cb); } }); } /** * Helper function for findINode. * @param parent The parent directory of the file we are attempting to find. * @param filename The filename of the inode we are attempting to find, minus * the parent. * @param cb Passed an error or the ID of the file's inode in the file system. */ private _findINode(tx: AsyncKeyValueROTransaction, parent: string, filename: string, cb: (e: ApiError, id?: string) => void): void { var handle_directory_listings = (e: ApiError, inode?: Inode, dirList?: {[name: string]: string}): void => { if (e) { cb(e) } else if (dirList[filename]) { cb(null, dirList[filename]); } else { cb(ApiError.ENOENT(path.resolve(parent, filename))); } }; if (parent === '/') { if (filename === '') { // BASE CASE #1: Return the root's ID. cb(null, ROOT_NODE_ID); } else { // BASE CASE #2: Find the item in the root node. this.getINode(tx, parent, ROOT_NODE_ID, (e: ApiError, inode?: Inode): void => { if (noError(e, cb)) { this.getDirListing(tx, parent, inode, (e: ApiError, dirList?: {[name: string]: string}): void => { // handle_directory_listings will handle e for us. handle_directory_listings(e, inode, dirList); }); } }); } } else { // Get the parent directory's INode, and find the file in its directory // listing. this.findINodeAndDirListing(tx, parent, handle_directory_listings); } } /** * Finds the Inode of the given path. * @param p The path to look up. * @param cb Passed an error or the Inode of the path p. * @todo memoize/cache */ private findINode(tx: AsyncKeyValueROTransaction, p: string, cb: (e: ApiError, inode?: Inode) => void): void { this._findINode(tx, path.dirname(p), path.basename(p), (e: ApiError, id?: string): void => { if (noError(e, cb)) { this.getINode(tx, p, id, cb); } }); } /** * Given the ID of a node, retrieves the corresponding Inode. * @param tx The transaction to use. * @param p The corresponding path to the file (used for error messages). * @param id The ID to look up. * @param cb Passed an error or the inode under the given id. */ private getINode(tx: AsyncKeyValueROTransaction, p: string, id: string, cb: (e: ApiError, inode?: Inode) => void): void { tx.get(id, (e: ApiError, data?: NodeBuffer): void => { if (noError(e, cb)) { if (data === undefined) { cb(ApiError.ENOENT(p)); } else { cb(null, Inode.fromBuffer(data)); } } }); } /** * Given the Inode of a directory, retrieves the corresponding directory * listing. */ private getDirListing(tx: AsyncKeyValueROTransaction, p: string, inode: Inode, cb: (e: ApiError, listing?: { [fileName: string]: string }) => void): void { if (!inode.isDirectory()) { cb(ApiError.ENOTDIR(p)); } else { tx.get(inode.id, (e: ApiError, data?: NodeBuffer): void => { if (noError(e, cb)) { try { cb(null, JSON.parse(data.toString())); } catch (e) { // Occurs when data is undefined, or corresponds to something other // than a directory listing. The latter should never occur unless // the file system is corrupted. cb(ApiError.ENOENT(p)); } } }); } } /** * Given a path to a directory, retrieves the corresponding INode and * directory listing. */ private findINodeAndDirListing(tx: AsyncKeyValueROTransaction, p: string, cb: (e: ApiError, inode?: Inode, listing?: { [fileName: string]: string }) => void): void { this.findINode(tx, p, (e: ApiError, inode?: Inode): void => { if (noError(e, cb)) { this.getDirListing(tx, p, inode, (e, listing?) => { if (noError(e, cb)) { cb(null, inode, listing); } }); } }); } /** * Adds a new node under a random ID. Retries 5 times before giving up in * the exceedingly unlikely chance that we try to reuse a random GUID. * @param cb Passed an error or the GUID that the data was stored under. */ private addNewNode(tx: AsyncKeyValueRWTransaction, data: NodeBuffer, cb: (e: ApiError, guid?: string) => void): void { var retries = 0, currId: string, reroll = () => { if (++retries === 5) { // Max retries hit. Return with an error. cb(new ApiError(ErrorCode.EIO, 'Unable to commit data to key-value store.')); } else { // Try again. currId = GenerateRandomID(); tx.put(currId, data, false, (e: ApiError, committed?: boolean) => { if (e || !committed) { reroll(); } else { // Successfully stored under 'currId'. cb(null, currId); } }); } }; reroll(); } /** * Commits a new file (well, a FILE or a DIRECTORY) to the file system with * the given mode. * Note: This will commit the transaction. * @param p The path to the new file. * @param type The type of the new file. * @param mode The mode to create the new file with. * @param data The data to store at the file's data node. * @param cb Passed an error or the Inode for the new file. */ private commitNewFile(tx: AsyncKeyValueRWTransaction, p: string, type: FileType, mode: number, data: NodeBuffer, cb: (e: ApiError, inode?: Inode) => void): void { var parentDir = path.dirname(p), fname = path.basename(p), currTime = (new Date()).getTime(); // Invariant: The root always exists. // If we don't check this prior to taking steps below, we will create a // file with name '' in root should p == '/'. if (p === '/') { return cb(ApiError.EEXIST(p)); } // Let's build a pyramid of code! // Step 1: Get the parent directory's inode and directory listing this.findINodeAndDirListing(tx, parentDir, (e: ApiError, parentNode?: Inode, dirListing?: {[name: string]: string}): void => { if (noErrorTx(e, tx, cb)) { if (dirListing[fname]) { // File already exists. tx.abort(() => { cb(ApiError.EEXIST(p)); }); } else { // Step 2: Commit data to store. this.addNewNode(tx, data, (e: ApiError, dataId?: string): void => { if (noErrorTx(e, tx, cb)) { // Step 3: Commit the file's inode to the store. var fileInode = new Inode(dataId, data.length, mode | type, currTime, currTime, currTime); this.addNewNode(tx, fileInode.toBuffer(), (e: ApiError, fileInodeId?: string): void => { if (noErrorTx(e, tx, cb)) { // Step 4: Update parent directory's listing. dirListing[fname] = fileInodeId; tx.put(parentNode.id, new Buffer(JSON.stringify(dirListing)), true, (e: ApiError): void => { if (noErrorTx(e, tx, cb)) { // Step 5: Commit and return the new inode. tx.commit((e?: ApiError): void => { if (noErrorTx(e, tx, cb)) { cb(null, fileInode); } }); } }); } }); } }); } } }); } /** * Delete all contents stored in the file system. */ public empty(cb: (e?: ApiError) => void): void { this.store.clear((e?) => { if (noError(e, cb)) { // INVARIANT: Root always exists. this.makeRootDirectory(cb); } }); } public rename(oldPath: string, newPath: string, cb: (e?: ApiError) => void): void { var tx = this.store.beginTransaction('readwrite'), oldParent = path.dirname(oldPath), oldName = path.basename(oldPath), newParent = path.dirname(newPath), newName = path.basename(newPath), inodes: { [path: string]: Inode } = {}, lists: { [path: string]: { [file: string]: string } } = {}, errorOccurred: boolean = false; // Invariant: Can't move a folder inside itself. // This funny little hack ensures that the check passes only if oldPath // is a subpath of newParent. We append '/' to avoid matching folders that // are a substring of the bottom-most folder in the path. if ((newParent + '/').indexOf(oldPath + '/') === 0) { return cb(new ApiError(ErrorCode.EBUSY, oldParent)); } /** * Responsible for Phase 2 of the rename operation: Modifying and * committing the directory listings. Called once we have successfully * retrieved both the old and new parent's inodes and listings. */ var theOleSwitcharoo = (): void => { // Sanity check: Ensure both paths are present, and no error has occurred. if (errorOccurred || !lists.hasOwnProperty(oldParent) || !lists.hasOwnProperty(newParent)) { return; } var oldParentList = lists[oldParent], oldParentINode = inodes[oldParent], newParentList = lists[newParent], newParentINode = inodes[newParent]; // Delete file from old parent. if (!oldParentList[oldName]) { cb(ApiError.ENOENT(oldPath)); } else { var fileId = oldParentList[oldName]; delete oldParentList[oldName]; // Finishes off the renaming process by adding the file to the new // parent. var completeRename = () => { newParentList[newName] = fileId; // Commit old parent's list. tx.put(oldParentINode.id, new Buffer(JSON.stringify(oldParentList)), true, (e: ApiError) => { if (noErrorTx(e, tx, cb)) { if (oldParent === newParent) { // DONE! tx.commit(cb); } else { // Commit new parent's list. tx.put(newParentINode.id, new Buffer(JSON.stringify(newParentList)), true, (e: ApiError) => { if (noErrorTx(e, tx, cb)) { tx.commit(cb); } }); } } }); }; if (newParentList[newName]) { // 'newPath' already exists. Check if it's a file or a directory, and // act accordingly. this.getINode(tx, newPath, newParentList[newName], (e: ApiError, inode?: Inode) => { if (noErrorTx(e, tx, cb)) { if (inode.isFile()) { // Delete the file and continue. tx.del(inode.id, (e?: ApiError) => { if (noErrorTx(e, tx, cb)) { tx.del(newParentList[newName], (e?: ApiError) => { if (noErrorTx(e, tx, cb)) { completeRename(); } }); } }); } else { // Can't overwrite a directory using rename. tx.abort((e?) => { cb(ApiError.EPERM(newPath)); }); } } }); } else { completeRename(); } } }; /** * Grabs a path's inode and directory listing, and shoves it into the * inodes and lists hashes. */ var processInodeAndListings = (p: string): void => { this.findINodeAndDirListing(tx, p, (e: ApiError, node?: Inode, dirList?: {[name: string]: string}): void => { if (e) { if (!errorOccurred) { errorOccurred = true; tx.abort(() => { cb(e); }); } // If error has occurred already, just stop here. } else { inodes[p] = node; lists[p] = dirList; theOleSwitcharoo(); } }); }; processInodeAndListings(oldParent); if (oldParent !== newParent) { processInodeAndListings(newParent); } } public stat(p: string, isLstat: boolean, cb: (err: ApiError, stat?: Stats) => void): void { var tx = this.store.beginTransaction('readonly'); this.findINode(tx, p, (e: ApiError, inode?: Inode): void => { if (noError(e, cb)) { cb(null, inode.toStats()); } }); } public createFile(p: string, flag: file_flag.FileFlag, mode: number, cb: (e: ApiError, file?: file.File) => void): void { var tx = this.store.beginTransaction('readwrite'), data = new Buffer(0); this.commitNewFile(tx, p, FileType.FILE, mode, data, (e: ApiError, newFile?: Inode): void => { if (noError(e, cb)) { cb(null, new AsyncKeyValueFile(this, p, flag, newFile.toStats(), data)); } }); } public openFile(p: string, flag: file_flag.FileFlag, cb: (e: ApiError, file?: file.File) => void): void { var tx = this.store.beginTransaction('readonly'); // Step 1: Grab the file's inode. this.findINode(tx, p, (e: ApiError, inode?: Inode) => { if (noError(e, cb)) { // Step 2: Grab the file's data. tx.get(inode.id, (e: ApiError, data?: NodeBuffer): void => { if (noError(e, cb)) { if (data === undefined) { cb(ApiError.ENOENT(p)); } else { cb(null, new AsyncKeyValueFile(this, p, flag, inode.toStats(), data)); } } }); } }); } /** * Remove all traces of the given path from the file system. * @param p The path to remove from the file system. * @param isDir Does the path belong to a directory, or a file? * @todo Update mtime. */ private removeEntry(p: string, isDir: boolean, cb: (e?: ApiError) => void): void { var tx = this.store.beginTransaction('readwrite'), parent: string = path.dirname(p), fileName: string = path.basename(p); // Step 1: Get parent directory's node and directory listing. this.findINodeAndDirListing(tx, parent, (e: ApiError, parentNode?: Inode, parentListing?: {[name: string]: string}): void => { if (noErrorTx(e, tx, cb)) { if (!parentListing[fileName]) { tx.abort(() => { cb(ApiError.ENOENT(p)); }); } else { // Remove from directory listing of parent. var fileNodeId = parentListing[fileName]; delete parentListing[fileName]; // Step 2: Get file inode. this.getINode(tx, p, fileNodeId, (e: ApiError, fileNode?: Inode): void => { if (noErrorTx(e, tx, cb)) { if (!isDir && fileNode.isDirectory()) { tx.abort(() => { cb(ApiError.EISDIR(p)); }); } else if (isDir && !fileNode.isDirectory()) { tx.abort(() => { cb(ApiError.ENOTDIR(p)); }); } else { // Step 3: Delete data. tx.del(fileNode.id, (e?: ApiError): void => { if (noErrorTx(e, tx, cb)) { // Step 4: Delete node. tx.del(fileNodeId, (e?: ApiError): void => { if (noErrorTx(e, tx, cb)) { // Step 5: Update directory listing. tx.put(parentNode.id, new Buffer(JSON.stringify(parentListing)), true, (e: ApiError): void => { if (noErrorTx(e, tx, cb)) { tx.commit(cb); } }); } }); } }); } } }); } } }); } public unlink(p: string, cb: (e?: ApiError) => void): void { this.removeEntry(p, false, cb); } public rmdir(p: string, cb: (e?: ApiError) => void): void { // Check first if directory is empty. this.readdir(p, (err, files?) => { if (err) { cb(err); } else if (files.length > 0) { cb(ApiError.ENOTEMPTY(p)); } else { this.removeEntry(p, true, cb); } }); } public mkdir(p: string, mode: number, cb: (e?: ApiError) => void): void { var tx = this.store.beginTransaction('readwrite'), data = new Buffer('{}'); this.commitNewFile(tx, p, FileType.DIRECTORY, mode, data, cb); } public readdir(p: string, cb: (err: ApiError, files?: string[]) => void): void { var tx = this.store.beginTransaction('readonly'); this.findINode(tx, p, (e: ApiError, inode?: Inode) => { if (noError(e, cb)) { this.getDirListing(tx, p, inode, (e: ApiError, dirListing?: {[name: string]: string}) => { if (noError(e, cb)) { cb(null, Object.keys(dirListing)); } }); } }); } public _sync(p: string, data: NodeBuffer, stats: Stats, cb: (e?: ApiError) => void): void { // @todo Ensure mtime updates properly, and use that to determine if a data // update is required. var tx = this.store.beginTransaction('readwrite'); // Step 1: Get the file node's ID. this._findINode(tx, path.dirname(p), path.basename(p), (e: ApiError, fileInodeId?: string): void => { if (noErrorTx(e, tx, cb)) { // Step 2: Get the file inode. this.getINode(tx, p, fileInodeId, (e: ApiError, fileInode?: Inode): void => { if (noErrorTx(e, tx, cb)) { var inodeChanged: boolean = fileInode.update(stats); // Step 3: Sync the data. tx.put(fileInode.id, data, true, (e: ApiError): void => { if (noErrorTx(e, tx, cb)) { // Step 4: Sync the metadata (if it changed)! if (inodeChanged) { tx.put(fileInodeId, fileInode.toBuffer(), true, (e: ApiError): void => { if (noErrorTx(e, tx, cb)) { tx.commit(cb); } }); } else { // No need to sync metadata; return. tx.commit(cb); } } }); } }); } }); } }