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.
This commit is contained in:
KernelDeimos
2025-10-23 15:48:20 -04:00
committed by Eric Dubé
parent dda1c00614
commit cd5d0ca5dc
2 changed files with 611 additions and 0 deletions

605
extensions/puterfs/main.js Normal file
View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<void>}
*/
_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<boolean>} - 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<MemoryFile|null>} - 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<string[]>} - 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<FSNodeContext>} - 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<void>}
*/
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<void>}
*/
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<MemoryFile>}
*/
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<FSNodeContext>} - 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<FSNodeContext>}
*/
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<FSNodeContext>}
*/
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);
}
});
});

View File

@@ -0,0 +1,6 @@
{
"main": "main.js",
"dependencies": {
"uuid": "^13.0.0"
}
}