UNPKG

browserfs

Version:

A filesystem in your browser!

964 lines (852 loc) 29.1 kB
import {FileSystem, BaseFileSystem} from '../core/file_system'; import {ApiError, ErrorCode} from '../core/api_error'; import {FileFlag, ActionType} from '../core/file_flag'; import util = require('../core/util'); import {File} from '../core/file'; import {default as Stats, FileType} from '../core/node_fs_stats'; import {PreloadFile} from '../generic/preload_file'; import LockedFS from '../generic/locked_fs'; import path = require('path'); const deletionLogPath = '/.deletedFiles.log'; /** * Given a read-only mode, makes it writable. */ function makeModeWritable(mode: number): number { return 0o222 | mode; } function getFlag(f: string): FileFlag { return FileFlag.getFileFlag(f); } /** * Overlays a RO file to make it writable. */ class OverlayFile extends PreloadFile<UnlockedOverlayFS> implements File { constructor(fs: UnlockedOverlayFS, path: string, flag: FileFlag, stats: Stats, data: Buffer) { super(fs, path, flag, stats, data); } public sync(cb: (e?: ApiError) => void): void { if (!this.isDirty()) { cb(null); return; } this._fs._syncAsync(this, (err: ApiError) => { this.resetDirty(); cb(err); }); } public syncSync(): void { if (this.isDirty()) { this._fs._syncSync(this); this.resetDirty(); } } public close(cb: (e?: ApiError) => void): void { this.sync(cb); } public closeSync(): void { this.syncSync(); } } /** * OverlayFS makes a read-only filesystem writable by storing writes on a second, * writable file system. Deletes are persisted via metadata stored on the writable * file system. */ export class UnlockedOverlayFS extends BaseFileSystem implements FileSystem { private _writable: FileSystem; private _readable: FileSystem; private _isInitialized: boolean = false; private _initializeCallbacks: ((e?: ApiError) => void)[] = []; private _deletedFiles: {[path: string]: boolean} = {}; private _deleteLog: string = ''; // If 'true', we have scheduled a delete log update. private _deleteLogUpdatePending: boolean = false; // If 'true', a delete log update is needed after the scheduled delete log // update finishes. private _deleteLogUpdateNeeded: boolean = false; // If there was an error updating the delete log... private _deleteLogError: ApiError = null; constructor(writable: FileSystem, readable: FileSystem) { super(); this._writable = writable; this._readable = readable; if (this._writable.isReadOnly()) { throw new ApiError(ErrorCode.EINVAL, "Writable file system must be writable."); } } private checkInitialized(): void { if (!this._isInitialized) { throw new ApiError(ErrorCode.EPERM, "OverlayFS is not initialized. Please initialize OverlayFS using its initialize() method before using it."); } else if (this._deleteLogError !== null) { const e = this._deleteLogError; this._deleteLogError = null; throw e; } } private checkInitAsync(cb: (e?: ApiError) => void): boolean { if (!this._isInitialized) { cb(new ApiError(ErrorCode.EPERM, "OverlayFS is not initialized. Please initialize OverlayFS using its initialize() method before using it.")); return false; } else if (this._deleteLogError !== null) { const e = this._deleteLogError; this._deleteLogError = null; cb(e); return false; } return true; } private checkPath(p: string): void { if (p === deletionLogPath) { throw ApiError.EPERM(p); } } private checkPathAsync(p: string, cb: (e?: ApiError) => void): boolean { if (p === deletionLogPath) { cb(ApiError.EPERM(p)); return true; } return false; } public getOverlayedFileSystems(): { readable: FileSystem; writable: FileSystem; } { return { readable: this._readable, writable: this._writable }; } private createParentDirectoriesAsync(p: string, cb: (err?: ApiError)=>void): void { let parent = path.dirname(p) let toCreate: string[] = []; let _this = this; this._writable.stat(parent, false, statDone); function statDone(err: ApiError, stat?: Stats): void { if (err) { toCreate.push(parent); parent = path.dirname(parent); _this._writable.stat(parent, false, statDone); } else { createParents(); } } function createParents(): void { if (!toCreate.length) { return cb(); } let dir = toCreate.pop(); _this._readable.stat(dir, false, (err: ApiError, stats?: Stats) => { // stop if we couldn't read the dir if (!stats) { return cb(); } _this._writable.mkdir(dir, stats.mode, (err?: ApiError) => { if (err) { return cb(err); } createParents(); }); }); } } /** * With the given path, create the needed parent directories on the writable storage * should they not exist. Use modes from the read-only storage. */ private createParentDirectories(p: string): void { var parent = path.dirname(p), toCreate: string[] = []; while (!this._writable.existsSync(parent)) { toCreate.push(parent); parent = path.dirname(parent); } toCreate = toCreate.reverse(); toCreate.forEach((p: string) => { this._writable.mkdirSync(p, this.statSync(p, false).mode); }); } public static isAvailable(): boolean { return true; } public _syncAsync(file: PreloadFile<UnlockedOverlayFS>, cb: (err: ApiError)=>void): void { this.createParentDirectoriesAsync(file.getPath(), (err?: ApiError) => { if (err) { return cb(err); } this._writable.writeFile(file.getPath(), file.getBuffer(), null, getFlag('w'), file.getStats().mode, cb); }); } public _syncSync(file: PreloadFile<UnlockedOverlayFS>): void { this.createParentDirectories(file.getPath()); this._writable.writeFileSync(file.getPath(), file.getBuffer(), null, getFlag('w'), file.getStats().mode); } public getName() { return "OverlayFS"; } /** * Called once to load up metadata stored on the writable file system. */ public initialize(cb: (err?: ApiError) => void): void { const callbackArray = this._initializeCallbacks; const end = (e?: ApiError): void => { this._isInitialized = !e; this._initializeCallbacks = []; callbackArray.forEach(((cb) => cb(e))); }; // if we're already initialized, immediately invoke the callback if (this._isInitialized) { return cb(); } callbackArray.push(cb); // The first call to initialize initializes, the rest wait for it to complete. if (callbackArray.length !== 1) { return; } // Read deletion log, process into metadata. this._writable.readFile(deletionLogPath, 'utf8', getFlag('r'), (err: ApiError, data?: string) => { if (err) { // ENOENT === Newly-instantiated file system, and thus empty log. if (err.errno !== ErrorCode.ENOENT) { return end(err); } } else { this._deleteLog = data; } this._reparseDeletionLog(); end(null); }); } public isReadOnly(): boolean { return false; } public supportsSynch(): boolean { return this._readable.supportsSynch() && this._writable.supportsSynch(); } public supportsLinks(): boolean { return false; } public supportsProps(): boolean { return this._readable.supportsProps() && this._writable.supportsProps(); } private deletePath(p: string): void { this._deletedFiles[p] = true; this.updateLog(`d${p}\n`); } private updateLog(addition: string) { this._deleteLog += addition; if (this._deleteLogUpdatePending) { this._deleteLogUpdateNeeded = true; } else { this._deleteLogUpdatePending = true; this._writable.writeFile(deletionLogPath, this._deleteLog, 'utf8', FileFlag.getFileFlag('w'), 0o644, (e) => { this._deleteLogUpdatePending = false; if (e) { this._deleteLogError = e; } else if (this._deleteLogUpdateNeeded) { this._deleteLogUpdateNeeded = false; this.updateLog(''); } }); } } public getDeletionLog(): string { return this._deleteLog; } private _reparseDeletionLog(): void { this._deletedFiles = {}; this._deleteLog.split('\n').forEach((path: string) => { // If the log entry begins w/ 'd', it's a deletion. this._deletedFiles[path.slice(1)] = path.slice(0, 1) === 'd'; }); } public restoreDeletionLog(log: string): void { this._deleteLog = log; this._reparseDeletionLog(); this.updateLog(''); } public rename(oldPath: string, newPath: string, cb: (err?: ApiError) => void): void { if (!this.checkInitAsync(cb) || this.checkPathAsync(oldPath, cb) || this.checkPathAsync(newPath, cb)) return; if (oldPath === deletionLogPath || newPath === deletionLogPath) { return cb(ApiError.EPERM('Cannot rename deletion log.')); } // nothing to do if paths match if (oldPath === newPath) { return cb(); } this.stat(oldPath, false, (oldErr: ApiError, oldStats?: Stats) => { if (oldErr) { return cb(oldErr); } return this.stat(newPath, false, (newErr: ApiError, newStats?: Stats) => { // precondition: both oldPath and newPath exist and are dirs. // decreases: |files| // Need to move *every file/folder* currently stored on // readable to its new location on writable. function copyDirContents(files: string[]): void { let file = files.shift(); if (!file) { return cb(); } let oldFile = path.resolve(oldPath, file); let newFile = path.resolve(newPath, file); // Recursion! Should work for any nested files / folders. this.rename(oldFile, newFile, (err?: ApiError) => { if (err) { return cb(err); } copyDirContents(files); }); } let mode = 0o777; // from linux's rename(2) manpage: oldpath can specify a // directory. In this case, newpath must either not exist, or // it must specify an empty directory. if (oldStats.isDirectory()) { if (newErr) { if (newErr.errno !== ErrorCode.ENOENT) { return cb(newErr); } return this._writable.exists(oldPath, (exists: boolean) => { // simple case - both old and new are on the writable layer if (exists) { return this._writable.rename(oldPath, newPath, cb); } this._writable.mkdir(newPath, mode, (mkdirErr?: ApiError) => { if (mkdirErr) { return cb(mkdirErr); } this._readable.readdir(oldPath, (err: ApiError, files?: string[]) => { if (err) { return cb(); } copyDirContents(files); }); }); }); } mode = newStats.mode; if (!newStats.isDirectory()) { return cb(ApiError.ENOTDIR(newPath)); } this.readdir(newPath, (readdirErr: ApiError, files?: string[]) => { if (files && files.length) { return cb(ApiError.ENOTEMPTY(newPath)); } this._readable.readdir(oldPath, (err: ApiError, files?: string[]) => { if (err) { return cb(); } copyDirContents(files); }); }); } if (newStats && newStats.isDirectory()) { return cb(ApiError.EISDIR(newPath)); } this.readFile(oldPath, null, getFlag('r'), (err: ApiError, data?: any) => { if (err) { return cb(err); } return this.writeFile(newPath, data, null, getFlag('w'), oldStats.mode, (err: ApiError) => { if (err) { return cb(err); } return this.unlink(oldPath, cb); }); }); }); }); } public renameSync(oldPath: string, newPath: string): void { this.checkInitialized(); this.checkPath(oldPath); this.checkPath(newPath); if (oldPath === deletionLogPath || newPath === deletionLogPath) { throw ApiError.EPERM('Cannot rename deletion log.'); } // Write newPath using oldPath's contents, delete oldPath. var oldStats = this.statSync(oldPath, false); if (oldStats.isDirectory()) { // Optimization: Don't bother moving if old === new. if (oldPath === newPath) { return; } var mode = 0o777; if (this.existsSync(newPath)) { var stats = this.statSync(newPath, false), mode = stats.mode; if (stats.isDirectory()) { if (this.readdirSync(newPath).length > 0) { throw ApiError.ENOTEMPTY(newPath); } } else { throw ApiError.ENOTDIR(newPath); } } // Take care of writable first. Move any files there, or create an empty directory // if it doesn't exist. if (this._writable.existsSync(oldPath)) { this._writable.renameSync(oldPath, newPath); } else if (!this._writable.existsSync(newPath)) { this._writable.mkdirSync(newPath, mode); } // Need to move *every file/folder* currently stored on readable to its new location // on writable. if (this._readable.existsSync(oldPath)) { this._readable.readdirSync(oldPath).forEach((name) => { // Recursion! Should work for any nested files / folders. this.renameSync(path.resolve(oldPath, name), path.resolve(newPath, name)); }); } } else { if (this.existsSync(newPath) && this.statSync(newPath, false).isDirectory()) { throw ApiError.EISDIR(newPath); } this.writeFileSync(newPath, this.readFileSync(oldPath, null, getFlag('r')), null, getFlag('w'), oldStats.mode); } if (oldPath !== newPath && this.existsSync(oldPath)) { this.unlinkSync(oldPath); } } public stat(p: string, isLstat: boolean, cb: (err: ApiError, stat?: Stats) => void): void { if (!this.checkInitAsync(cb)) return; this._writable.stat(p, isLstat, (err: ApiError, stat?: Stats) => { if (err && err.errno === ErrorCode.ENOENT) { if (this._deletedFiles[p]) { cb(ApiError.ENOENT(p)); } this._readable.stat(p, isLstat, (err: ApiError, stat?: Stats) => { if (stat) { // Make the oldStat's mode writable. Preserve the topmost // part of the mode, which specifies if it is a file or a // directory. stat = stat.clone(); stat.mode = makeModeWritable(stat.mode); } cb(err, stat); }); } else { cb(err, stat); } }); } public statSync(p: string, isLstat: boolean): Stats { this.checkInitialized(); try { return this._writable.statSync(p, isLstat); } catch (e) { if (this._deletedFiles[p]) { throw ApiError.ENOENT(p); } var oldStat = this._readable.statSync(p, isLstat).clone(); // Make the oldStat's mode writable. Preserve the topmost part of the // mode, which specifies if it is a file or a directory. oldStat.mode = makeModeWritable(oldStat.mode); return oldStat; } } public open(p: string, flag: FileFlag, mode: number, cb: (err: ApiError, fd?: File) => any): void { if (!this.checkInitAsync(cb) || this.checkPathAsync(p, cb)) return; this.stat(p, false, (err: ApiError, stats?: Stats) => { if (stats) { switch (flag.pathExistsAction()) { case ActionType.TRUNCATE_FILE: return this.createParentDirectoriesAsync(p, (err?: ApiError)=> { if (err) { return cb(err); } this._writable.open(p, flag, mode, cb); }); case ActionType.NOP: return this._writable.exists(p, (exists: boolean) => { if (exists) { this._writable.open(p, flag, mode, cb); } else { // at this point we know the stats object we got is from // the readable FS. stats = stats.clone(); stats.mode = mode; this._readable.readFile(p, null, getFlag('r'), (readFileErr: ApiError, data?: any) => { if (readFileErr) { return cb(readFileErr); } if (stats.size === -1) { stats.size = data.length; } let f = new OverlayFile(this, p, flag, stats, data); cb(null, f); }); } }); default: return cb(ApiError.EEXIST(p)); } } else { switch(flag.pathNotExistsAction()) { case ActionType.CREATE_FILE: return this.createParentDirectoriesAsync(p, (err?: ApiError) => { if (err) { return cb(err); } return this._writable.open(p, flag, mode, cb); }); default: return cb(ApiError.ENOENT(p)); } } }); } public openSync(p: string, flag: FileFlag, mode: number): File { this.checkInitialized(); this.checkPath(p); if (p === deletionLogPath) { throw ApiError.EPERM('Cannot open deletion log.'); } if (this.existsSync(p)) { switch (flag.pathExistsAction()) { case ActionType.TRUNCATE_FILE: this.createParentDirectories(p); return this._writable.openSync(p, flag, mode); case ActionType.NOP: if (this._writable.existsSync(p)) { return this._writable.openSync(p, flag, mode); } else { // Create an OverlayFile. var buf = this._readable.readFileSync(p, null, getFlag('r')); var stats = this._readable.statSync(p, false).clone(); stats.mode = mode; return new OverlayFile(this, p, flag, stats, buf); } default: throw ApiError.EEXIST(p); } } else { switch(flag.pathNotExistsAction()) { case ActionType.CREATE_FILE: this.createParentDirectories(p); return this._writable.openSync(p, flag, mode); default: throw ApiError.ENOENT(p); } } } public unlink(p: string, cb: (err: ApiError) => void): void { if (!this.checkInitAsync(cb) || this.checkPathAsync(p, cb)) return; this.exists(p, (exists: boolean) => { if (!exists) return cb(ApiError.ENOENT(p)); this._writable.exists(p, (writableExists: boolean) => { if (writableExists) { return this._writable.unlink(p, (err: ApiError) => { if (err) { return cb(err); } this.exists(p, (readableExists: boolean) => { if (readableExists) { this.deletePath(p); } cb(null); }); }); } else { // if this only exists on the readable FS, add it to the // delete map. this.deletePath(p); cb(null); } }); }); } public unlinkSync(p: string): void { this.checkInitialized(); this.checkPath(p); if (this.existsSync(p)) { if (this._writable.existsSync(p)) { this._writable.unlinkSync(p); } // if it still exists add to the delete log if (this.existsSync(p)) { this.deletePath(p); } } else { throw ApiError.ENOENT(p); } } public rmdir(p: string, cb: (err?: ApiError) => void): void { if (!this.checkInitAsync(cb)) return; let rmdirLower = (): void => { this.readdir(p, (err: ApiError, files: string[]): void => { if (err) { return cb(err); } if (files.length) { return cb(ApiError.ENOTEMPTY(p)); } this.deletePath(p); cb(null); }); }; this.exists(p, (exists: boolean) => { if (!exists) { return cb(ApiError.ENOENT(p)); } this._writable.exists(p, (writableExists: boolean) => { if (writableExists) { this._writable.rmdir(p, (err: ApiError) => { if (err) { return cb(err); } this._readable.exists(p, (readableExists: boolean) => { if (readableExists) { rmdirLower(); } else { cb(); } }); }); } else { rmdirLower(); } }); }); } public rmdirSync(p: string): void { this.checkInitialized(); if (this.existsSync(p)) { if (this._writable.existsSync(p)) { this._writable.rmdirSync(p); } if (this.existsSync(p)) { // Check if directory is empty. if (this.readdirSync(p).length > 0) { throw ApiError.ENOTEMPTY(p); } else { this.deletePath(p); } } } else { throw ApiError.ENOENT(p); } } public mkdir(p: string, mode: number, cb: (err: ApiError, stat?: Stats) => void): void { if (!this.checkInitAsync(cb)) return; this.exists(p, (exists: boolean) => { if (exists) { return cb(ApiError.EEXIST(p)); } // The below will throw should any of the parent directories // fail to exist on _writable. this.createParentDirectoriesAsync(p, (err: ApiError) => { if (err) { return cb(err); } this._writable.mkdir(p, mode, cb); }); }); } public mkdirSync(p: string, mode: number): void { this.checkInitialized(); if (this.existsSync(p)) { throw ApiError.EEXIST(p); } else { // The below will throw should any of the parent directories fail to exist // on _writable. this.createParentDirectories(p); this._writable.mkdirSync(p, mode); } } public readdir(p: string, cb: (error: ApiError, files?: string[]) => void): void { if (!this.checkInitAsync(cb)) return; this.stat(p, false, (err: ApiError, dirStats?: Stats) => { if (err) { return cb(err); } if (!dirStats.isDirectory()) { return cb(ApiError.ENOTDIR(p)); } this._writable.readdir(p, (err: ApiError, wFiles: string[]) => { if (err && err.code !== 'ENOENT') { return cb(err); } else if (err || !wFiles) { wFiles = []; } this._readable.readdir(p, (err: ApiError, rFiles: string[]) => { // if the directory doesn't exist on the lower FS set rFiles // here to simplify the following code. if (err || !rFiles) { rFiles = []; } // Readdir in both, check delete log on read-only file system's files, merge, return. let seenMap: {[name: string]: boolean} = {}; let filtered: string[] = wFiles.concat(rFiles.filter((fPath: string) => !this._deletedFiles[`${p}/${fPath}`] )).filter((fPath: string) => { // Remove duplicates. let result = !seenMap[fPath]; seenMap[fPath] = true; return result; }); cb(null, filtered); }); }); }); } public readdirSync(p: string): string[] { this.checkInitialized(); var dirStats = this.statSync(p, false); if (!dirStats.isDirectory()) { throw ApiError.ENOTDIR(p); } // Readdir in both, check delete log on RO file system's listing, merge, return. var contents: string[] = []; try { contents = contents.concat(this._writable.readdirSync(p)); } catch (e) { } try { contents = contents.concat(this._readable.readdirSync(p).filter((fPath: string) => !this._deletedFiles[`${p}/${fPath}`] )); } catch (e) { } var seenMap: {[name: string]: boolean} = {}; return contents.filter((fileP: string) => { var result = !seenMap[fileP]; seenMap[fileP] = true; return result; }); } public exists(p: string, cb: (exists: boolean) => void): void { // Cannot pass an error back to callback, so throw an exception instead // if not initialized. this.checkInitialized(); this._writable.exists(p, (existsWritable: boolean) => { if (existsWritable) { return cb(true); } this._readable.exists(p, (existsReadable: boolean) => { cb(existsReadable && this._deletedFiles[p] !== true); }); }); } public existsSync(p: string): boolean { this.checkInitialized(); return this._writable.existsSync(p) || (this._readable.existsSync(p) && this._deletedFiles[p] !== true); } public chmod(p: string, isLchmod: boolean, mode: number, cb: (error?: ApiError) => void): void { if (!this.checkInitAsync(cb)) return; this.operateOnWritableAsync(p, (err?: ApiError) => { if (err) { return cb(err); } else { this._writable.chmod(p, isLchmod, mode, cb); } }); } public chmodSync(p: string, isLchmod: boolean, mode: number): void { this.checkInitialized(); this.operateOnWritable(p, () => { this._writable.chmodSync(p, isLchmod, mode); }); } public chown(p: string, isLchmod: boolean, uid: number, gid: number, cb: (error?: ApiError) => void): void { if (!this.checkInitAsync(cb)) return; this.operateOnWritableAsync(p, (err?: ApiError) => { if (err) { return cb(err); } else { this._writable.chown(p, isLchmod, uid, gid, cb); } }); } public chownSync(p: string, isLchown: boolean, uid: number, gid: number): void { this.checkInitialized(); this.operateOnWritable(p, () => { this._writable.chownSync(p, isLchown, uid, gid); }); } public utimes(p: string, atime: Date, mtime: Date, cb: (error?: ApiError) => void): void { if (!this.checkInitAsync(cb)) return; this.operateOnWritableAsync(p, (err?: ApiError) => { if (err) { return cb(err); } else { this._writable.utimes(p, atime, mtime, cb); } }); } public utimesSync(p: string, atime: Date, mtime: Date): void { this.checkInitialized(); this.operateOnWritable(p, () => { this._writable.utimesSync(p, atime, mtime); }); } /** * Helper function: * - Ensures p is on writable before proceeding. Throws an error if it doesn't exist. * - Calls f to perform operation on writable. */ private operateOnWritable(p: string, f: () => void): void { if (this.existsSync(p)) { if (!this._writable.existsSync(p)) { // File is on readable storage. Copy to writable storage before // changing its mode. this.copyToWritable(p); } f(); } else { throw ApiError.ENOENT(p); } } private operateOnWritableAsync(p: string, cb: (error?: ApiError) => void): void { this.exists(p, (exists: boolean) => { if (!exists) { return cb(ApiError.ENOENT(p)); } this._writable.exists(p, (existsWritable: boolean) => { if (existsWritable) { cb(); } else { return this.copyToWritableAsync(p, cb); } }); }); } /** * Copy from readable to writable storage. * PRECONDITION: File does not exist on writable storage. */ private copyToWritable(p: string): void { var pStats = this.statSync(p, false); if (pStats.isDirectory()) { this._writable.mkdirSync(p, pStats.mode); } else { this.writeFileSync(p, this._readable.readFileSync(p, null, getFlag('r')), null, getFlag('w'), this.statSync(p, false).mode); } } private copyToWritableAsync(p: string, cb: (err?: ApiError) => void): void { this.stat(p, false, (err: ApiError, pStats?: Stats) => { if (err) { return cb(err); } if (pStats.isDirectory()) { return this._writable.mkdir(p, pStats.mode, cb); } // need to copy file. this._readable.readFile(p, null, getFlag('r'), (err: ApiError, data?: Buffer) => { if (err) { return cb(err); } this.writeFile(p, data, null, getFlag('w'), pStats.mode, cb); }); }); } } export default class OverlayFS extends LockedFS<UnlockedOverlayFS> { constructor(writable: FileSystem, readable: FileSystem) { super(new UnlockedOverlayFS(writable, readable)); } initialize(cb: (err?: ApiError) => void): void { super.initialize(cb); } static isAvailable(): boolean { return UnlockedOverlayFS.isAvailable(); } getOverlayedFileSystems(): { readable: FileSystem; writable: FileSystem; } { return super.getFSUnlocked().getOverlayedFileSystems(); } unwrap(): UnlockedOverlayFS { return super.getFSUnlocked(); } }