@zenfs/core
Version:
A filesystem, anywhere
402 lines (401 loc) • 13.9 kB
JavaScript
import { EventEmitter } from 'eventemitter3';
import { withErrno } from 'kerium';
import { debug, err, warn } from 'kerium/log';
import { canary } from 'utilium';
import { resolveMountConfig } from '../config.js';
import { FileSystem } from '../internal/filesystem.js';
import { isDirectory } from '../internal/inode.js';
import { dirname, join } from '../path.js';
const journalOperations = new Set(['delete']);
/** Because TS doesn't work right w/o it */
function isJournalOp(op) {
return journalOperations.has(op);
}
const maxOpLength = Math.max(...journalOperations.values().map(op => op.length));
const journalMagicString = '#journal@v0\n';
/**
* Tracks various operations for the CoW backend
* @category Internals
* @internal
*/
export class Journal extends EventEmitter {
entries = [];
toString() {
return journalMagicString + this.entries.map(entry => `${entry.op.padEnd(maxOpLength)} ${entry.path}`).join('\n');
}
/**
* Parse a journal from a string
*/
fromString(value) {
if (!value.startsWith(journalMagicString))
throw err(withErrno('EINVAL', 'Invalid journal contents, refusing to parse'));
for (const line of value.split('\n')) {
if (line.startsWith('#'))
continue; // ignore comments
const [op, path] = line.split(/\s+/);
if (!isJournalOp(op)) {
warn('Unknown operation in journal (skipping): ' + op);
continue;
}
this.entries.push({ op, path });
}
return this;
}
add(op, path) {
this.entries.push({ op, path });
this.emit('update', op, path);
this.emit(op, path);
}
has(op, path) {
const test = JSON.stringify({ op, path });
for (const entry of this.entries)
if (JSON.stringify(entry) === test)
return true;
return false;
}
isDeleted(path) {
let deleted = false;
for (const entry of this.entries) {
if (entry.path != path)
continue;
switch (entry.op) {
case 'delete':
deleted = true;
}
}
return deleted;
}
}
/**
* Using a readable file system as a base, writes are done to a writable file system.
* @internal
* @category Internals
*/
export class CopyOnWriteFS extends FileSystem {
readable;
writable;
journal;
async ready() {
await this.readable.ready();
await this.writable.ready();
}
readySync() {
this.readable.readySync();
this.writable.readySync();
}
constructor(
/** The file system that initially populates this file system. */
readable,
/** The file system to write modified files to. */
writable,
/** The journal to use for persisting deletions */
journal = new Journal()) {
super(0x62756c6c, readable.name);
this.readable = readable;
this.writable = writable;
this.journal = journal;
if (writable.attributes.has('no_write')) {
throw err(withErrno('EINVAL', 'Writable file system can not be written to'));
}
readable.attributes.set('no_write');
}
isDeleted(path) {
return this.journal.isDeleted(path);
}
/**
* @todo Consider trying to track information on the writable as well
*/
usage() {
return this.readable.usage();
}
async sync() {
await this.writable.sync();
}
syncSync() {
this.writable.syncSync();
}
async read(path, buffer, offset, end) {
return (await this.writable.exists(path))
? await this.writable.read(path, buffer, offset, end)
: await this.readable.read(path, buffer, offset, end);
}
readSync(path, buffer, offset, end) {
return this.writable.existsSync(path) ? this.writable.readSync(path, buffer, offset, end) : this.readable.readSync(path, buffer, offset, end);
}
async write(path, buffer, offset) {
await this.copyForWrite(path);
return await this.writable.write(path, buffer, offset);
}
writeSync(path, buffer, offset) {
this.copyForWriteSync(path);
return this.writable.writeSync(path, buffer, offset);
}
async rename(oldPath, newPath) {
await this.copyForWrite(oldPath);
try {
await this.writable.rename(oldPath, newPath);
}
catch {
if (this.isDeleted(oldPath))
throw withErrno('ENOENT');
}
}
renameSync(oldPath, newPath) {
this.copyForWriteSync(oldPath);
try {
this.writable.renameSync(oldPath, newPath);
}
catch {
if (this.isDeleted(oldPath))
throw withErrno('ENOENT');
}
}
async stat(path) {
try {
return await this.writable.stat(path);
}
catch {
if (this.isDeleted(path))
throw withErrno('ENOENT');
return await this.readable.stat(path);
}
}
statSync(path) {
try {
return this.writable.statSync(path);
}
catch {
if (this.isDeleted(path))
throw withErrno('ENOENT');
return this.readable.statSync(path);
}
}
async touch(path, metadata) {
await this.copyForWrite(path);
await this.writable.touch(path, metadata);
}
touchSync(path, metadata) {
this.copyForWriteSync(path);
this.writable.touchSync(path, metadata);
}
async createFile(path, options) {
await this.createParentDirectories(path);
return await this.writable.createFile(path, options);
}
createFileSync(path, options) {
this.createParentDirectoriesSync(path);
return this.writable.createFileSync(path, options);
}
async link(srcpath, dstpath) {
await this.copyForWrite(srcpath);
await this.writable.link(srcpath, dstpath);
}
linkSync(srcpath, dstpath) {
this.copyForWriteSync(srcpath);
this.writable.linkSync(srcpath, dstpath);
}
async unlink(path) {
if (!(await this.exists(path)))
throw withErrno('ENOENT');
if (await this.writable.exists(path)) {
await this.writable.unlink(path);
}
// if it still exists add to the delete log
if (await this.exists(path)) {
this.journal.add('delete', path);
}
}
unlinkSync(path) {
if (!this.existsSync(path))
throw withErrno('ENOENT');
if (this.writable.existsSync(path)) {
this.writable.unlinkSync(path);
}
// if it still exists add to the delete log
if (this.existsSync(path)) {
this.journal.add('delete', path);
}
}
async rmdir(path) {
if (!(await this.exists(path)))
throw withErrno('ENOENT');
if (await this.writable.exists(path)) {
await this.writable.rmdir(path);
}
if (!(await this.exists(path))) {
return;
}
// Check if directory is empty.
if ((await this.readdir(path)).length)
throw withErrno('ENOTEMPTY');
this.journal.add('delete', path);
}
rmdirSync(path) {
if (!this.existsSync(path))
throw withErrno('ENOENT');
if (this.writable.existsSync(path)) {
this.writable.rmdirSync(path);
}
if (!this.existsSync(path)) {
return;
}
// Check if directory is empty.
if (this.readdirSync(path).length)
throw withErrno('ENOTEMPTY');
this.journal.add('delete', path);
}
async mkdir(path, options) {
if (await this.exists(path))
throw withErrno('EEXIST');
await this.createParentDirectories(path);
return await this.writable.mkdir(path, options);
}
mkdirSync(path, options) {
if (this.existsSync(path))
throw withErrno('EEXIST');
this.createParentDirectoriesSync(path);
return this.writable.mkdirSync(path, options);
}
async readdir(path) {
if (this.isDeleted(path) || !(await this.exists(path)))
throw withErrno('ENOENT');
const entries = (await this.readable.exists(path)) ? await this.readable.readdir(path) : [];
if (await this.writable.exists(path))
for (const entry of await this.writable.readdir(path)) {
if (!entries.includes(entry))
entries.push(entry);
}
return entries.filter(entry => !this.isDeleted(join(path, entry)));
}
readdirSync(path) {
if (this.isDeleted(path) || !this.existsSync(path))
throw withErrno('ENOENT');
const entries = this.readable.existsSync(path) ? this.readable.readdirSync(path) : [];
if (this.writable.existsSync(path))
for (const entry of this.writable.readdirSync(path)) {
if (!entries.includes(entry))
entries.push(entry);
}
return entries.filter(entry => !this.isDeleted(join(path, entry)));
}
streamRead(path, options) {
return this.writable.existsSync(path) ? this.writable.streamRead(path, options) : this.readable.streamRead(path, options);
}
streamWrite(path, options) {
this.copyForWriteSync(path);
return this.writable.streamWrite(path, options);
}
/**
* Create the needed parent directories on the writable storage should they not exist.
* Use modes from the read-only storage.
*/
createParentDirectoriesSync(path) {
const toCreate = [];
const silence = canary(withErrno('EDEADLK'));
for (let parent = dirname(path); !this.writable.existsSync(parent); parent = dirname(parent)) {
toCreate.push(parent);
}
silence();
if (toCreate.length)
debug('COW: Creating parent directories: ' + toCreate.join(', '));
for (const path of toCreate.reverse()) {
this.writable.mkdirSync(path, this.statSync(path));
}
}
/**
* Create the needed parent directories on the writable storage should they not exist.
* Use modes from the read-only storage.
*/
async createParentDirectories(path) {
const toCreate = [];
const silence = canary(withErrno('EDEADLK', path));
for (let parent = dirname(path); !(await this.writable.exists(parent)); parent = dirname(parent)) {
toCreate.push(parent);
}
silence();
if (toCreate.length)
debug('COW: Creating parent directories: ' + toCreate.join(', '));
for (const path of toCreate.reverse()) {
await this.writable.mkdir(path, await this.stat(path));
}
}
/**
* Helper function:
* - Ensures p is on writable before proceeding. Throws an error if it doesn't exist.
* - Calls f to perform operation on writable.
*/
copyForWriteSync(path) {
if (!this.existsSync(path))
throw withErrno('ENOENT');
if (!this.writable.existsSync(dirname(path))) {
this.createParentDirectoriesSync(path);
}
if (!this.writable.existsSync(path)) {
this.copyToWritableSync(path);
}
}
async copyForWrite(path) {
if (!(await this.exists(path)))
throw withErrno('ENOENT');
if (!(await this.writable.exists(dirname(path)))) {
await this.createParentDirectories(path);
}
if (!(await this.writable.exists(path))) {
return this.copyToWritable(path);
}
}
/**
* Copy from readable to writable storage.
* PRECONDITION: File does not exist on writable storage.
*/
copyToWritableSync(path) {
const stats = this.readable.statSync(path);
if (isDirectory(stats)) {
this.writable.mkdirSync(path, stats);
for (const k of this.readable.readdirSync(path)) {
this.copyToWritableSync(join(path, k));
}
return;
}
const data = new Uint8Array(stats.size);
this.readable.readSync(path, data, 0, data.byteLength);
this.writable.createFileSync(path, stats);
this.writable.touchSync(path, stats);
this.writable.writeSync(path, data, 0);
}
async copyToWritable(path) {
const stats = await this.readable.stat(path);
if (isDirectory(stats)) {
await this.writable.mkdir(path, stats);
for (const k of await this.readable.readdir(path)) {
await this.copyToWritable(join(path, k));
}
return;
}
const data = new Uint8Array(stats.size);
await this.readable.read(path, data, 0, stats.size);
await this.writable.createFile(path, stats);
await this.writable.touch(path, stats);
await this.writable.write(path, data, 0);
}
}
const _CopyOnWrite = {
name: 'CopyOnWrite',
options: {
writable: { type: 'object', required: true },
readable: { type: 'object', required: true },
journal: { type: 'object', required: false },
},
async create(options) {
const readable = await resolveMountConfig(options.readable);
const writable = await resolveMountConfig(options.writable);
return new CopyOnWriteFS(readable, writable, options.journal);
},
};
/**
* Overlay 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.
* @category Backends and Configuration
* @internal
*/
export const CopyOnWrite = _CopyOnWrite;