diff --git a/src/FileIndex.ts b/src/FileIndex.ts new file mode 100644 index 000000000..5a634021e --- /dev/null +++ b/src/FileIndex.ts @@ -0,0 +1,506 @@ +import { ApiError, ErrorCode } from './ApiError.js'; +import type { Cred } from './cred.js'; +import * as path from './emulation/path.js'; +import { NoSyncFile, type FileFlag } from './file.js'; +import { ReadonlyAsyncFileSystem, ReadonlySyncFileSystem } from './filesystem.js'; +import { FileType, Stats } from './stats.js'; + +/** + * @internal + */ +export type ListingTree = { [key: string]: ListingTree | null }; + +interface ListingQueueNode { + pwd: string; + tree: ListingTree; + parent: IndexDirInode; +} + +/** + * A simple class for storing a filesystem index. Assumes that all paths passed + * to it are *absolute* paths. + * + * Can be used as a partial or a full index, although care must be taken if used + * for the former purpose, especially when directories are concerned. + * + * @internal + */ +export class FileIndex { + /** + * Static method for constructing indices from a JSON listing. + * @param listing Directory listing generated by tools + * @return A new FileIndex object. + */ + public static FromListing(listing: ListingTree): FileIndex { + const index = new FileIndex(); + // Add a root DirNode. + const rootInode = new IndexDirInode(); + index._index.set('/', rootInode); + const queue: ListingQueueNode[] = [{ pwd: '', tree: listing, parent: rootInode }]; + while (queue.length > 0) { + let inode: IndexFileInode | IndexDirInode; + const { tree, pwd, parent } = queue.pop()!; + for (const node in tree) { + if (!Object.hasOwn(tree, node)) { + continue; + } + const children = tree[node]; + + if (children) { + const path = pwd + '/' + node; + inode = new IndexDirInode(); + index._index.set(path, inode); + queue.push({ pwd: path, tree: children, parent: inode }); + } else { + // This inode doesn't have correct size information, noted with -1. + inode = new IndexFileInode(new Stats(FileType.FILE, -1, 0o555)); + } + if (!parent) { + continue; + } + parent._listing.set(node, inode); + } + } + return index; + } + + // Maps directory paths to directory inodes, which contain files. + protected _index: Map> = new Map(); + + /** + * Constructs a new FileIndex. + */ + constructor() { + // _index is a single-level key,value store that maps *directory* paths to + // DirInodes. File information is only contained in DirInodes themselves. + // Create the root directory. + this.addPath('/', new IndexDirInode()); + } + + public files(): IndexFileInode[] { + const files: IndexFileInode[] = []; + + for (const dir of this._index.values()) { + for (const file of dir.listing) { + const item = dir.get(file); + if (!item?.isFile()) { + continue; + } + + files.push(item); + } + } + return files; + } + + /** + * Adds the given absolute path to the index if it is not already in the index. + * Creates any needed parent directories. + * @param path The path to add to the index. + * @param inode The inode for the + * path to add. + * @return 'True' if it was added or already exists, 'false' if there + * was an issue adding it (e.g. item in path is a file, item exists but is + * different). + * @todo If adding fails and implicitly creates directories, we do not clean up + * the new empty directories. + */ + public addPath(path: string, inode: IndexInode): boolean { + if (!inode) { + throw new Error('Inode must be specified'); + } + if (path[0] !== '/') { + throw new Error('Path must be absolute, got: ' + path); + } + + // Check if it already exists. + if (this._index.has(path)) { + return this._index.get(path) === inode; + } + + const splitPath = this.splitPath(path); + const dirpath = splitPath[0]; + const itemname = splitPath[1]; + // Try to add to its parent directory first. + let parent = this._index.get(dirpath); + if (!parent && path !== '/') { + // Create parent. + parent = new IndexDirInode(); + if (!this.addPath(dirpath, parent)) { + return false; + } + } + // Add myself to my parent. + if (path !== '/') { + if (!parent.add(itemname, inode)) { + return false; + } + } + // If I'm a directory, add myself to the index. + if (isIndexDirInode(inode)) { + this._index[path] = inode; + } + return true; + } + + /** + * Adds the given absolute path to the index if it is not already in the index. + * The path is added without special treatment (no joining of adjacent separators, etc). + * Creates any needed parent directories. + * @param path The path to add to the index. + * @param inode The inode for the + * path to add. + * @return 'True' if it was added or already exists, 'false' if there + * was an issue adding it (e.g. item in path is a file, item exists but is + * different). + * @todo If adding fails and implicitly creates directories, we do not clean up + * the new empty directories. + */ + public addPathFast(path: string, inode: IndexInode): boolean { + const itemNameMark = path.lastIndexOf('/'); + const parentPath = itemNameMark === 0 ? '/' : path.substring(0, itemNameMark); + const itemName = path.substring(itemNameMark + 1); + + // Try to add to its parent directory first. + let parent = this._index.get(parentPath); + if (!parent) { + // Create parent. + parent = new IndexDirInode(); + this.addPathFast(parentPath, parent); + } + + if (!parent.add(itemName, inode)) { + return false; + } + + // If adding a directory, add to the index as well. + if (inode.isDir()) { + this._index[path] = >inode; + } + return true; + } + + /** + * Removes the given path. Can be a file or a directory. + * @return The removed item, + * or null if it did not exist. + */ + public removePath(path: string): IndexInode | null { + const splitPath = this.splitPath(path); + const dirpath = splitPath[0]; + const itemname = splitPath[1]; + + // Try to remove it from its parent directory first. + const parent = this._index[dirpath]; + if (!parent) { + return; + } + // Remove myself from my parent. + const inode = parent.remove(itemname); + if (!inode) { + return; + } + // If I'm a directory, remove myself from the index, and remove my children. + if (!isIndexDirInode(inode)) { + return inode; + } + const children = inode.listing; + for (const child of children) { + this.removePath(path + '/' + child); + } + + // Remove the directory from the index, unless it's the root. + if (path !== '/') { + this._index.delete(path); + } + } + + /** + * Retrieves the directory listing of the given path. + * @return An array of files in the given path, or 'null' if it does not exist. + */ + public ls(path: string): string[] | null { + return this._index.get(path)?.listing; + } + + /** + * Returns the inode of the given item. + * @return Returns null if the item does not exist. + */ + public getInode(path: string): IndexInode | null { + const [dirpath, itemname] = this.splitPath(path); + // Retrieve from its parent directory. + const parent = this._index.get(dirpath); + // Root case + if (dirpath === path) { + return parent; + } + return parent?.get(itemname); + } + + /** + * Split into a (directory path, item name) pair + */ + protected splitPath(p: string): string[] { + const dirpath = path.dirname(p); + const itemname = p.slice(dirpath.length + (dirpath === '/' ? 0 : 1)); + return [dirpath, itemname]; + } +} + +/** + * Generic interface for file/directory inodes. + * Note that Stats objects are what we use for file inodes. + */ +export abstract class IndexInode { + constructor(public data?: T) {} + // Is this an inode for a file? + abstract isFile(): boolean; + // Is this an inode for a directory? + abstract isDir(): boolean; + //compatibility with other Inode types + abstract toStats(): Stats; +} + +/** + * Inode for a file. Stores an arbitrary (filesystem-specific) data payload. + */ +export class IndexFileInode extends IndexInode { + public isFile(): boolean { + return true; + } + public isDir(): boolean { + return false; + } + + public toStats(): Stats { + return new Stats(FileType.FILE, 4096, 0o666); + } +} + +/** + * Inode for a directory. Currently only contains the directory listing. + */ +export class IndexDirInode extends IndexInode { + /** + * @internal + */ + _listing: Map> = new Map(); + + public isFile(): boolean { + return false; + } + public isDir(): boolean { + return true; + } + + /** + * Return a Stats object for this inode. + * @todo Should probably remove this at some point. This isn't the responsibility of the FileIndex. + */ + public get stats(): Stats { + return new Stats(FileType.DIRECTORY, 4096, 0o555); + } + /** + * Alias of getStats() + * @todo Remove this at some point. This isn't the responsibility of the FileIndex. + */ + public toStats(): Stats { + return this.stats; + } + /** + * Returns the directory listing for this directory. Paths in the directory are + * relative to the directory's path. + * @return The directory listing for this directory. + */ + public get listing(): string[] { + return [...this._listing.keys()]; + } + + /** + * Returns the inode for the indicated item, or null if it does not exist. + * @param p Name of item in this directory. + */ + public get(p: string): IndexInode | null { + return this._listing.get(p); + } + /** + * Add the given item to the directory listing. Note that the given inode is + * not copied, and will be mutated by the DirInode if it is a DirInode. + * @param p Item name to add to the directory listing. + * @param inode The inode for the + * item to add to the directory inode. + * @return True if it was added, false if it already existed. + */ + public add(p: string, inode: IndexInode): boolean { + if (this._listing.has(p)) { + return false; + } + this._listing.set(p, inode); + return true; + } + /** + * Removes the given item from the directory listing. + * @param p Name of item to remove from the directory listing. + * @return Returns the item + * removed, or null if the item did not exist. + */ + public remove(p: string): IndexInode | null { + const item = this._listing.get(p); + if (item === undefined) { + return null; + } + this._listing.delete(p); + return item; + } +} + +/** + * @internal + */ +export function isIndexFileInode(inode?: IndexInode): inode is IndexFileInode { + return inode?.isFile(); +} + +/** + * @internal + */ +export function isIndexDirInode(inode?: IndexInode): inode is IndexDirInode { + return inode?.isDir(); +} + +export abstract class SyncFileIndexFS extends ReadonlySyncFileSystem { + protected _index: FileIndex; + + protected loadIndex(index: ListingTree): void { + this._index = FileIndex.FromListing(index); + } + + public statSync(path: string): Stats { + const inode = this._index.getInode(path); + if (!inode) { + throw ApiError.ENOENT(path); + } + + if (isIndexDirInode(inode)) { + return inode.stats; + } + + if (isIndexFileInode(inode)) { + return this.statFileInode(inode); + } + + throw new ApiError(ErrorCode.EINVAL, 'Invalid inode.'); + } + + protected abstract statFileInode(inode: IndexFileInode): Stats; + + public openFileSync(path: string, flag: FileFlag, cred: Cred): NoSyncFile { + if (flag.isWriteable()) { + // You can't write to files on this file system. + throw new ApiError(ErrorCode.EPERM, path); + } + + // Check if the path exists, and is a file. + const inode = this._index.getInode(path); + + if (!inode) { + throw ApiError.ENOENT(path); + } + + if (!inode.toStats().hasAccess(flag.mode, cred)) { + throw ApiError.EACCES(path); + } + + if (isIndexDirInode(inode)) { + const stats = inode.stats; + return new NoSyncFile(this, path, flag, stats, stats.fileData); + } + + return this.fileForFileInode(inode); + } + + protected abstract fileForFileInode(inode: IndexFileInode): NoSyncFile; + + public readdirSync(path: string): string[] { + // Check if it exists. + const inode = this._index.getInode(path); + if (!inode) { + throw ApiError.ENOENT(path); + } + + if (isIndexDirInode(inode)) { + return inode.listing; + } + + throw ApiError.ENOTDIR(path); + } +} + +export abstract class AsyncFileIndexFS extends ReadonlyAsyncFileSystem { + protected _index: FileIndex; + + protected loadIndex(index: ListingTree): void { + this._index = FileIndex.FromListing(index); + } + + public async stat(path: string): Promise { + const inode = this._index.getInode(path); + if (!inode) { + throw ApiError.ENOENT(path); + } + + if (isIndexDirInode(inode)) { + return inode.stats; + } + + if (isIndexFileInode(inode)) { + return this.statFileInode(inode); + } + + throw new ApiError(ErrorCode.EINVAL, 'Invalid inode.'); + } + + protected abstract statFileInode(inode: IndexFileInode): Promise; + + public async openFile(path: string, flag: FileFlag, cred: Cred): Promise> { + if (flag.isWriteable()) { + // You can't write to files on this file system. + throw new ApiError(ErrorCode.EPERM, path); + } + + // Check if the path exists, and is a file. + const inode = this._index.getInode(path); + + if (!inode) { + throw ApiError.ENOENT(path); + } + + if (!inode.toStats().hasAccess(flag.mode, cred)) { + throw ApiError.EACCES(path); + } + + if (isIndexDirInode(inode)) { + const stats = inode.stats; + return new NoSyncFile(this, path, flag, stats, stats.fileData); + } + + return this.fileForFileInode(inode); + } + + protected abstract fileForFileInode(inode: IndexFileInode): Promise>; + + public async readdir(path: string): Promise { + // Check if it exists. + const inode = this._index.getInode(path); + if (!inode) { + throw ApiError.ENOENT(path); + } + + if (isIndexDirInode(inode)) { + return inode.listing; + } + + throw ApiError.ENOTDIR(path); + } +}