browserfs
Version:
A filesystem in your browser!
1,236 lines (1,145 loc) • 41.5 kB
text/typescript
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);
}
}
});
}
});
}
});
}
}