Skip to content

Commit

Permalink
feat(fs): introduce the fs plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
denizenging committed Oct 10, 2023
1 parent db4ce55 commit e93eaff
Show file tree
Hide file tree
Showing 7 changed files with 262 additions and 5 deletions.
1 change: 1 addition & 0 deletions pkg/fs/.npmignore
39 changes: 39 additions & 0 deletions pkg/fs/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"name": "@slangroom/fs",
"version": "1.0.0",
"dependencies": {
"@slangroom/core": "workspace:*",
"axios": "^1.5.1",
"extract-zip": "^2.0.1"
},
"repository": "https://github.com/dyne/slangroom",
"license": "AGPL-3.0-only",
"type": "module",
"main": "./build/cjs/src/index.js",
"types": "./build/cjs/src/index.d.ts",
"exports": {
".": {
"import": {
"types": "./build/esm/src/index.d.ts",
"default": "./build/esm/src/index.js"
},
"require": {
"types": "./build/cjs/src/index.d.ts",
"default": "./build/cjs/src/index.js"
}
},
"./*": {
"import": {
"types": "./build/esm/src/*.d.ts",
"default": "./build/esm/src/*.js"
},
"require": {
"types": "./build/cjs/src/*.d.ts",
"default": "./build/cjs/src/*.js"
}
}
},
"publishConfig": {
"access": "public"
}
}
1 change: 1 addition & 0 deletions pkg/fs/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from '@slangroom/fs/plugins';
142 changes: 142 additions & 0 deletions pkg/fs/src/plugins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import type { PluginContext, PluginResult } from '@slangroom/core';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import axios from 'axios';
import extractZip from 'extract-zip';

export const SandboxDir = process.env['FILES_DIR'];
if (!SandboxDir) throw new Error('$FILE_DIR must be provided');

const resolveDirPath = (unsafe: string) => {
const normalized = path.normalize(unsafe);
// `/` and `..` prevent directory traversal
const doesDirectoryTraversal = normalized.startsWith('/') || normalized.startsWith('..');
// Unlike `resolveFilepath`, we allow `.` to be used here, obviously.
if (doesDirectoryTraversal) return { error: `dirpath is unsafe: ${unsafe}` };
return { dirpath: path.dirname(path.join(SandboxDir, normalized)) };
};

const resolveFilepath = (unsafe: string) => {
const normalized = path.normalize(unsafe);
// `/` and `..` prevent directory traversal
const doesDirectoryTraversal = normalized.startsWith('/') || normalized.startsWith('..');
// `.` ensures that `foo/bar` or `./foo` is valid, while `.` isn't
// (that is, no "real" filepath is provided)
const DoesntProvideFile = normalized.startsWith('.');
if (doesDirectoryTraversal || DoesntProvideFile)
return { error: `filepath is unsafe: ${unsafe}` };
return { filepath: path.join(SandboxDir, normalized) };
};

const readFile = async (safePath: string) => {
const str = await fs.readFile(safePath, 'utf8');
return JSON.parse(str);
};

/**
* @internal
*/
export const executeDownloadExtract = async (ctx: PluginContext): Promise<PluginResult> => {
const zipUrl = ctx.fetchConnect()[0];
const unsafe = ctx.fetch('path');
if (typeof unsafe !== 'string') return ctx.fail('path must be string');

const { dirpath: dirPath, error } = resolveDirPath(unsafe);
if (!dirPath) return ctx.fail(error);
await fs.mkdir(dirPath, { recursive: true });

try {
const resp = await axios.get(zipUrl, { responseType: 'arraybuffer' });
const tempdir = await fs.mkdtemp(path.join(os.tmpdir(), 'slangroom-'));
const tempfile = path.join(tempdir, 'downloaded');
await fs.writeFile(tempfile, resp.data);
await extractZip(tempfile, { dir: dirPath });
await fs.rm(tempdir, { recursive: true });
return ctx.pass('yes');
} catch (e) {
if (e instanceof Error) return ctx.fail(e.message);
return ctx.fail(`unknown error: ${e}`);
}
};

/**
* @internal
*/
export const executeReadFileContent = async (ctx: PluginContext): Promise<PluginResult> => {
const unsafe = ctx.fetch('path');
if (typeof unsafe !== 'string') return ctx.fail('path must be string');

const { filepath, error } = resolveFilepath(unsafe);
if (!filepath) return ctx.fail(error);

return ctx.pass(await readFile(unsafe));
};

/**
* @internal
*/
export const executeStoreInFile = async (ctx: PluginContext): Promise<PluginResult> => {
// TODO: should `ctx.fetch('content')` return a JsonableObject?
const content = JSON.stringify(ctx.fetch('content'));
const unsafe = ctx.fetch('path');
if (typeof unsafe !== 'string') return ctx.fail('path must be string');

const { filepath, error } = resolveFilepath(unsafe);
if (!filepath) return ctx.fail(error);

await fs.mkdir(path.dirname(filepath), { recursive: true });
await fs.writeFile(filepath, content);

return ctx.fail('TODO');
};

/**
* @internal
*/
export const executeListDirectoryContent = async (ctx: PluginContext): Promise<PluginResult> => {
const unsafe = ctx.fetch('path');
if (typeof unsafe !== 'string') return ctx.fail('path must be string');

const { dirpath, error } = resolveDirPath(unsafe);
if (!dirpath) return ctx.fail(error);

const filepaths = (await fs.readdir(dirpath)).map((f) => path.join(dirpath, f));
const stats = await Promise.all(filepaths.map((f) => fs.stat(f)));
const result = stats.map((stat, i) => {
const filepath = filepaths[i] as string;
return {
name: filepath,
mode: stat.mode.toString(8),
dev: stat.dev,
nlink: stat.nlink,
uid: stat.uid,
gid: stat.gid,
size: stat.size,
blksize: stat.blksize,
blocks: stat.blocks,
atime: stat.atime.toISOString(),
mtime: stat.mtime.toISOString(),
ctime: stat.ctime.toISOString(),
birthtime: stat.birthtime.toISOString(),
};
});
return ctx.pass(result);
};

const fsPlugin = async (ctx: PluginContext): Promise<PluginResult> => {
switch (ctx.phrase) {
case 'download extract':
return await executeDownloadExtract(ctx);
case 'read file content':
return await executeReadFileContent(ctx);
case 'store in file':
return await executeStoreInFile(ctx);
case 'list directory content':
return await executeListDirectoryContent(ctx);
default:
return ctx.fail('no math');
}
};

export const fsPlugins = new Set([fsPlugin]);
3 changes: 3 additions & 0 deletions pkg/fs/test/make-ava-happy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import test from 'ava';

test('ava is happy', (t) => t.true(true));
1 change: 1 addition & 0 deletions pkg/fs/tsconfig.json
80 changes: 75 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit e93eaff

Please sign in to comment.