From cd5d0ca5dc3f451c62c4b6fb2eeb953fac202cf7 Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Thu, 23 Oct 2025 15:48:20 -0400 Subject: [PATCH] dev(extensions): [+] puterfs (copies memoryfs) This extension has a copy of memoryfs which is exposed as `testfs`. The purpose of this is to register a new filesystem type from an extension to ensure it works as expected and to get feedback on a working example. --- extensions/puterfs/main.js | 605 ++++++++++++++++++++++++++++++++ extensions/puterfs/package.json | 6 + 2 files changed, 611 insertions(+) create mode 100644 extensions/puterfs/main.js create mode 100644 extensions/puterfs/package.json diff --git a/extensions/puterfs/main.js b/extensions/puterfs/main.js new file mode 100644 index 000000000..ab0b3e708 --- /dev/null +++ b/extensions/puterfs/main.js @@ -0,0 +1,605 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +const _path = require('node:path'); +const uuidv4 = require('uuid').v4; + +const { capabilities, selectors } = extension.import('fs'); +const { APIError } = extension.import('core'); + +const { + NodePathSelector, + NodeUIDSelector, + NodeChildSelector, + try_infer_attributes, +} = selectors; + + +class MemoryFile { + /** + * @param {Object} param + * @param {string} param.path - Relative path from the mountpoint. + * @param {boolean} param.is_dir + * @param {Buffer|null} param.content - The content of the file, `null` if the file is a directory. + * @param {string|null} [param.parent_uid] - UID of parent directory; null for root. + */ + constructor({ path, is_dir, content, parent_uid = null }) { + this.uuid = uuidv4(); + + this.is_public = true; + this.path = path; + this.name = _path.basename(path); + this.is_dir = is_dir; + + this.content = content; + + // parent_uid should reflect the actual parent's uid; null for root + this.parent_uid = parent_uid; + + // TODO (xiaochen): return sensible values for "user_id", currently + // it must be 2 (admin) to pass the test. + this.user_id = 2; + + // TODO (xiaochen): return sensible values for following fields + this.id = 123; + this.parent_id = 123; + this.immutable = 0; + this.is_shortcut = 0; + this.is_symlink = 0; + this.symlink_path = null; + this.created = Math.floor(Date.now() / 1000); + this.accessed = Math.floor(Date.now() / 1000); + this.modified = Math.floor(Date.now() / 1000); + this.size = is_dir ? 0 : content ? content.length : 0; + } +} + +class MemoryFSProvider { + constructor(mountpoint) { + this.mountpoint = mountpoint; + + // key: relative path from the mountpoint, always starts with `/` + // value: entry uuid + this.entriesByPath = new Map(); + + // key: entry uuid + // value: entry (MemoryFile) + // + // We declare 2 maps to support 2 lookup apis: by-path/by-uuid. + this.entriesByUUID = new Map(); + + const root = new MemoryFile({ + path: '/', + is_dir: true, + content: null, + parent_uid: null, + }); + this.entriesByPath.set('/', root.uuid); + this.entriesByUUID.set(root.uuid, root); + } + + /** + * Get the capabilities of this filesystem provider. + * + * @returns {Set} - Set of capabilities supported by this provider. + */ + get_capabilities() { + return new Set([ + capabilities.READDIR_UUID_MODE, + capabilities.UUID, + capabilities.READ, + capabilities.WRITE, + capabilities.COPY_TREE, + ]); + } + + /** + * Normalize the path to be relative to the mountpoint. Returns `/` if the path is empty/undefined. + * + * @param {string} path - The path to normalize. + * @returns {string} - The normalized path, always starts with `/`. + */ + _inner_path(path) { + if (!path) { + return '/'; + } + + if (path.startsWith(this.mountpoint)) { + path = path.slice(this.mountpoint.length); + } + + if (!path.startsWith('/')) { + path = '/' + path; + } + + return path; + } + + /** + * Check the integrity of the whole memory filesystem. Throws error if any violation is found. + * + * @returns {Promise} + */ + _integrity_check() { + if (config.env !== 'dev') { + // only check in debug mode since it's expensive + return; + } + + // check the 2 maps are consistent + if (this.entriesByPath.size !== this.entriesByUUID.size) { + throw new Error('Path map and UUID map have different sizes'); + } + + for (const [inner_path, uuid] of this.entriesByPath) { + const entry = this.entriesByUUID.get(uuid); + + // entry should exist + if (!entry) { + throw new Error(`Entry ${uuid} does not exist`); + } + + // path should match + if (this._inner_path(entry.path) !== inner_path) { + throw new Error(`Path ${inner_path} does not match entry ${uuid}`); + } + + // uuid should match + if (entry.uuid !== uuid) { + throw new Error(`UUID ${uuid} does not match entry ${entry.uuid}`); + } + + // parent should exist + if (entry.parent_uid) { + const parent_entry = this.entriesByUUID.get(entry.parent_uid); + if (!parent_entry) { + throw new Error(`Parent ${entry.parent_uid} does not exist`); + } + } + + // parent's path should be a prefix of the entry's path + if (entry.parent_uid) { + const parent_entry = this.entriesByUUID.get(entry.parent_uid); + if (!entry.path.startsWith(parent_entry.path)) { + throw new Error( + `Parent ${entry.parent_uid} path ${parent_entry.path} is not a prefix of entry ${entry.path}`, + ); + } + } + + // parent should be a directory + if (entry.parent_uid) { + const parent_entry = this.entriesByUUID.get(entry.parent_uid); + if (!parent_entry.is_dir) { + throw new Error(`Parent ${entry.parent_uid} is not a directory`); + } + } + } + } + + /** + * Check if a given node exists. + * + * @param {Object} param + * @param {NodePathSelector | NodeUIDSelector | NodeChildSelector | RootNodeSelector | NodeRawEntrySelector} param.selector - The selector used for checking. + * @returns {Promise} - True if the node exists, false otherwise. + */ + async quick_check({ selector }) { + if (selector instanceof NodePathSelector) { + const inner_path = this._inner_path(selector.value); + return this.entriesByPath.has(inner_path); + } + + if (selector instanceof NodeUIDSelector) { + return this.entriesByUUID.has(selector.value); + } + + // fallback to stat + const entry = await this.stat({ selector }); + return !!entry; + } + + /** + * Performs a stat operation using the given selector. + * + * NB: Some returned fields currently contain placeholder values. And the + * `path` of the absolute path from the root. + * + * @param {Object} param + * @param {NodePathSelector | NodeUIDSelector | NodeChildSelector | RootNodeSelector | NodeRawEntrySelector} param.selector - The selector to stat. + * @returns {Promise} - The result of the stat operation, or `null` if the node doesn't exist. + */ + async stat({ selector }) { + try_infer_attributes(selector); + + let entry_uuid = null; + + if (selector instanceof NodePathSelector) { + // stat by path + const inner_path = this._inner_path(selector.value); + entry_uuid = this.entriesByPath.get(inner_path); + } else if (selector instanceof NodeUIDSelector) { + // stat by uid + entry_uuid = selector.value; + } else if (selector instanceof NodeChildSelector) { + if (selector.path) { + // Shouldn't care about about parent when the "path" is present + // since it might have different provider. + return await this.stat({ + selector: new NodePathSelector(selector.path), + }); + } else { + // recursively stat the parent and then stat the child + const parent_entry = await this.stat({ + selector: selector.parent, + }); + if (parent_entry) { + const full_path = _path.join(parent_entry.path, selector.name); + return await this.stat({ + selector: new NodePathSelector(full_path), + }); + } + } + } else { + // other selectors shouldn't reach here, i.e., it's an internal logic error + throw APIError.create('invalid_node'); + } + + const entry = this.entriesByUUID.get(entry_uuid); + if (!entry) { + return null; + } + + // Return a copied entry with `full_path`, since external code only cares + // about full path. + const copied_entry = { ...entry }; + copied_entry.path = _path.join(this.mountpoint, entry.path); + return copied_entry; + } + + /** + * Read directory contents. + * + * @param {Object} param + * @param {Context} param.context - The context of the operation. + * @param {FSNodeContext} param.node - The directory node to read. + * @returns {Promise} - Array of child UUIDs. + */ + async readdir({ context, node }) { + // prerequistes: get required path via stat + const entry = await this.stat({ selector: node.selector }); + if (!entry) { + throw APIError.create('invalid_node'); + } + + const inner_path = this._inner_path(entry.path); + const child_uuids = []; + + // Find all entries that are direct children of this directory + for (const [path, uuid] of this.entriesByPath) { + if (path === inner_path) { + continue; // Skip the directory itself + } + + const dirname = _path.dirname(path); + if (dirname === inner_path) { + child_uuids.push(uuid); + } + } + + return child_uuids; + } + + /** + * Create a new directory. + * + * @param {Object} param + * @param {Context} param.context - The context of the operation. + * @param {FSNodeContext} param.parent - The parent node to create the directory in. Must exist and be a directory. + * @param {string} param.name - The name of the new directory. + * @returns {Promise} - The new directory node. + */ + async mkdir({ context, parent, name }) { + // prerequistes: get required path via stat + const parent_entry = await this.stat({ selector: parent.selector }); + if (!parent_entry) { + throw APIError.create('invalid_node'); + } + + const full_path = _path.join(parent_entry.path, name); + const inner_path = this._inner_path(full_path); + + let entry = null; + if (this.entriesByPath.has(inner_path)) { + throw APIError.create('item_with_same_name_exists', null, { + entry_name: full_path, + }); + } else { + entry = new MemoryFile({ + path: inner_path, + is_dir: true, + content: null, + parent_uid: parent_entry.uuid, + }); + this.entriesByPath.set(inner_path, entry.uuid); + this.entriesByUUID.set(entry.uuid, entry); + } + + // create the node + const fs = context.get('services').get('filesystem'); + const node = await fs.node(new NodeUIDSelector(entry.uuid)); + await node.fetchEntry(); + + this._integrity_check(); + + return node; + } + + /** + * Remove a directory. + * + * @param {Object} param + * @param {Context} param.context + * @param {FSNodeContext} param.node: The directory to remove. + * @param {Object} param.options: The options for the operation. + * @returns {Promise} + */ + async rmdir({ context, node, options = {} }) { + this._integrity_check(); + + // prerequistes: get required path via stat + const entry = await this.stat({ selector: node.selector }); + if (!entry) { + throw APIError.create('invalid_node'); + } + + const inner_path = this._inner_path(entry.path); + + // for mode: non-recursive + if (!options.recursive) { + const children = await this.readdir({ context, node }); + if (children.length > 0) { + throw APIError.create('not_empty'); + } + } + + // remove all descendants + for (const [other_inner_path, other_entry_uuid] of this.entriesByPath) { + if (other_entry_uuid === entry.uuid) { + // skip the directory itself + continue; + } + + if (other_inner_path.startsWith(inner_path)) { + this.entriesByPath.delete(other_inner_path); + this.entriesByUUID.delete(other_entry_uuid); + } + } + + // for mode: non-descendants-only + if (!options.descendants_only) { + // remove the directory itself + this.entriesByPath.delete(inner_path); + this.entriesByUUID.delete(entry.uuid); + } + + this._integrity_check(); + } + + /** + * Remove a file. + * + * @param {Object} param + * @param {Context} param.context + * @param {FSNodeContext} param.node: The file to remove. + * @returns {Promise} + */ + async unlink({ context, node }) { + // prerequistes: get required path via stat + const entry = await this.stat({ selector: node.selector }); + if (!entry) { + throw APIError.create('invalid_node'); + } + + const inner_path = this._inner_path(entry.path); + this.entriesByPath.delete(inner_path); + this.entriesByUUID.delete(entry.uuid); + } + + /** + * Move a file. + * + * @param {Object} param + * @param {Context} param.context + * @param {FSNodeContext} param.node: The file to move. + * @param {FSNodeContext} param.new_parent: The new parent directory of the file. + * @param {string} param.new_name: The new name of the file. + * @param {Object} param.metadata: The metadata of the file. + * @returns {Promise} + */ + async move({ context, node, new_parent, new_name, metadata }) { + // prerequistes: get required path via stat + const new_parent_entry = await this.stat({ selector: new_parent.selector }); + if (!new_parent_entry) { + throw APIError.create('invalid_node'); + } + + // create the new entry + const new_full_path = _path.join(new_parent_entry.path, new_name); + const new_inner_path = this._inner_path(new_full_path); + const entry = new MemoryFile({ + path: new_inner_path, + is_dir: node.entry.is_dir, + content: node.entry.content, + parent_uid: new_parent_entry.uuid, + }); + entry.uuid = node.entry.uuid; + this.entriesByPath.set(new_inner_path, entry.uuid); + this.entriesByUUID.set(entry.uuid, entry); + + // remove the old entry + const inner_path = this._inner_path(node.path); + this.entriesByPath.delete(inner_path); + // NB: should not delete the entry by uuid because uuid does not change + // after the move. + + this._integrity_check(); + + return entry; + } + + /** + * Copy a tree of files and directories. + * + * @param {Object} param + * @param {Context} param.context + * @param {FSNodeContext} param.source - The source node to copy. + * @param {FSNodeContext} param.parent - The parent directory for the copy. + * @param {string} param.target_name - The name for the copied item. + * @returns {Promise} - The copied node. + */ + async copy_tree({ context, source, parent, target_name }) { + const fs = context.get('services').get('filesystem'); + + if (source.entry.is_dir) { + // Create the directory + const new_dir = await this.mkdir({ context, parent, name: target_name }); + + // Copy all children + const children = await this.readdir({ context, node: source }); + for (const child_uuid of children) { + const child_node = await fs.node(new NodeUIDSelector(child_uuid)); + await child_node.fetchEntry(); + const child_name = child_node.entry.name; + + await this.copy_tree({ + context, + source: child_node, + parent: new_dir, + target_name: child_name, + }); + } + + return new_dir; + } else { + // Copy the file + const new_file = await this.write_new({ + context, + parent, + name: target_name, + file: { stream: { read: () => source.entry.content } }, + }); + return new_file; + } + } + + /** + * Write a new file to the filesystem. Throws an error if the destination + * already exists. + * + * @param {Object} param + * @param {Context} param.context + * @param {FSNodeContext} param.parent: The parent directory of the destination directory. + * @param {string} param.name: The name of the destination directory. + * @param {Object} param.file: The file to write. + * @returns {Promise} + */ + async write_new({ context, parent, name, file }) { + // prerequistes: get required path via stat + const parent_entry = await this.stat({ selector: parent.selector }); + if (!parent_entry) { + throw APIError.create('invalid_node'); + } + const full_path = _path.join(parent_entry.path, name); + const inner_path = this._inner_path(full_path); + + let entry = null; + if (this.entriesByPath.has(inner_path)) { + throw APIError.create('item_with_same_name_exists', null, { + entry_name: full_path, + }); + } else { + entry = new MemoryFile({ + path: inner_path, + is_dir: false, + content: file.stream.read(), + parent_uid: parent_entry.uuid, + }); + this.entriesByPath.set(inner_path, entry.uuid); + this.entriesByUUID.set(entry.uuid, entry); + } + + const fs = context.get('services').get('filesystem'); + const node = await fs.node(new NodeUIDSelector(entry.uuid)); + await node.fetchEntry(); + + this._integrity_check(); + + return node; + } + + /** + * Overwrite an existing file. Throws an error if the destination does not + * exist. + * + * @param {Object} param + * @param {Context} param.context + * @param {FSNodeContext} param.node: The node to write to. + * @param {Object} param.file: The file to write. + * @returns {Promise} + */ + async write_overwrite({ context, node, file }) { + const entry = await this.stat({ selector: node.selector }); + if (!entry) { + throw APIError.create('invalid_node'); + } + const inner_path = this._inner_path(entry.path); + + this.entriesByPath.set(inner_path, entry.uuid); + let original_entry = this.entriesByUUID.get(entry.uuid); + if (!original_entry) { + throw new Error(`File ${entry.path} does not exist`); + } else { + if (original_entry.is_dir) { + throw new Error(`Cannot overwrite a directory`); + } + + original_entry.content = file.stream.read(); + original_entry.modified = Math.floor(Date.now() / 1000); + original_entry.size = original_entry.content ? original_entry.content.length : 0; + this.entriesByUUID.set(entry.uuid, original_entry); + } + + const fs = context.get('services').get('filesystem'); + node = await fs.node(new NodeUIDSelector(original_entry.uuid)); + await node.fetchEntry(); + + this._integrity_check(); + + return node; + } +} + +extension.on('create.filesystem-types', event => { + event.createFilesystemType('testfs', { + mount ({ path }) { + return new MemoryFSProvider(path); + } + }); +}); diff --git a/extensions/puterfs/package.json b/extensions/puterfs/package.json new file mode 100644 index 000000000..46406097a --- /dev/null +++ b/extensions/puterfs/package.json @@ -0,0 +1,6 @@ +{ + "main": "main.js", + "dependencies": { + "uuid": "^13.0.0" + } +}