diff --git a/README.md b/README.md index 7d0f3e4..9edc56c 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ - Capsule + Capsule # `Capsule` diff --git a/crates/capsule-cli/Cargo.lock b/crates/capsule-cli/Cargo.lock index 5ca96fa..3739e4e 100644 --- a/crates/capsule-cli/Cargo.lock +++ b/crates/capsule-cli/Cargo.lock @@ -322,7 +322,7 @@ dependencies = [ [[package]] name = "capsule-core" -version = "0.8.9" +version = "0.8.10" dependencies = [ "anyhow", "blake3", @@ -351,7 +351,7 @@ dependencies = [ [[package]] name = "capsule-run" -version = "0.8.9" +version = "0.8.10" dependencies = [ "capsule-core", "clap", diff --git a/crates/capsule-cli/Cargo.toml b/crates/capsule-cli/Cargo.toml index 082f474..5ab5d65 100644 --- a/crates/capsule-cli/Cargo.toml +++ b/crates/capsule-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "capsule-run" -version = "0.8.9" +version = "0.8.10" edition = "2024" description = "Secure WASM runtime to execute untrusted code" license = "Apache-2.0" @@ -16,7 +16,7 @@ path = "src/main.rs" [dependencies] clap = { version = "4.5.53", features = ["derive"] } -capsule-core = { version= "0.8.9", path = "../capsule-core" } +capsule-core = { version= "0.8.10", path = "../capsule-core" } tokio = { version = "1.48.0", features = ["rt", "rt-multi-thread", "macros", "io-util", "sync"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/capsule-cli/npm/package.json b/crates/capsule-cli/npm/package.json index 5136012..2961e6e 100644 --- a/crates/capsule-cli/npm/package.json +++ b/crates/capsule-cli/npm/package.json @@ -1,6 +1,6 @@ { "name": "@capsule-run/cli", - "version": "0.8.9", + "version": "0.8.10", "description": "Secure WASM runtime to execute untrusted code", "bin": { "capsule": "./bin/capsule.js" @@ -29,9 +29,9 @@ "node": ">=18" }, "optionalDependencies": { - "@capsule-run/cli-darwin-arm64": "0.8.9", - "@capsule-run/cli-darwin-x64": "0.8.9", - "@capsule-run/cli-linux-x64": "0.8.9", - "@capsule-run/cli-win32-x64": "0.8.9" + "@capsule-run/cli-darwin-arm64": "0.8.10", + "@capsule-run/cli-darwin-x64": "0.8.10", + "@capsule-run/cli-linux-x64": "0.8.10", + "@capsule-run/cli-win32-x64": "0.8.10" } } diff --git a/crates/capsule-cli/pyproject.toml b/crates/capsule-cli/pyproject.toml index 4f7ccba..28bbd93 100644 --- a/crates/capsule-cli/pyproject.toml +++ b/crates/capsule-cli/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "capsule-run" -version = "0.8.9" +version = "0.8.10" description = "Secure WASM runtime to execute untrusted code" readme = "docs/README-pypi.md" license = {text = "Apache-2.0"} diff --git a/crates/capsule-core/Cargo.lock b/crates/capsule-core/Cargo.lock index 69ff3cd..594c72b 100644 --- a/crates/capsule-core/Cargo.lock +++ b/crates/capsule-core/Cargo.lock @@ -302,7 +302,7 @@ dependencies = [ [[package]] name = "capsule-core" -version = "0.8.9" +version = "0.8.10" dependencies = [ "anyhow", "blake3", diff --git a/crates/capsule-core/Cargo.toml b/crates/capsule-core/Cargo.toml index bd2cdd5..cd51e97 100644 --- a/crates/capsule-core/Cargo.toml +++ b/crates/capsule-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "capsule-core" -version = "0.8.9" +version = "0.8.10" edition = "2024" description = "Core library for Capsule" license = "Apache-2.0" diff --git a/crates/capsule-sdk/javascript/package-lock.json b/crates/capsule-sdk/javascript/package-lock.json index 9b7c4c0..dba17b4 100644 --- a/crates/capsule-sdk/javascript/package-lock.json +++ b/crates/capsule-sdk/javascript/package-lock.json @@ -1,12 +1,12 @@ { "name": "@capsule-run/sdk", - "version": "0.8.9", + "version": "0.8.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@capsule-run/sdk", - "version": "0.8.9", + "version": "0.8.10", "license": "Apache-2.0", "dependencies": { "@bytecodealliance/jco": "^1.0.0", diff --git a/crates/capsule-sdk/javascript/package.json b/crates/capsule-sdk/javascript/package.json index 74f0413..b195501 100644 --- a/crates/capsule-sdk/javascript/package.json +++ b/crates/capsule-sdk/javascript/package.json @@ -1,6 +1,6 @@ { "name": "@capsule-run/sdk", - "version": "0.8.9", + "version": "0.8.10", "description": "Capsule JavaScript SDK - Execute untrusted code in secure WASM sandboxes", "type": "module", "main": "./dist/index.js", diff --git a/crates/capsule-sdk/javascript/src/polyfills/fs.ts b/crates/capsule-sdk/javascript/src/polyfills/fs.ts index a073bc2..762c680 100644 --- a/crates/capsule-sdk/javascript/src/polyfills/fs.ts +++ b/crates/capsule-sdk/javascript/src/polyfills/fs.ts @@ -10,9 +10,18 @@ declare const globalThis: { 'wasi:filesystem/preopens': any; }; +interface WasiDatetime { + seconds: bigint; + nanoseconds: number; +} + interface DescriptorStat { + type: string; + linkCount: bigint; size: bigint; - type?: string; + dataAccessTimestamp?: WasiDatetime | null; + dataModificationTimestamp?: WasiDatetime | null; + statusChangeTimestamp?: WasiDatetime | null; } interface DirectoryEntry { @@ -28,6 +37,7 @@ interface Descriptor { read(length: bigint, offset: bigint): [Uint8Array, boolean]; write(buffer: Uint8Array, offset: bigint): bigint; stat(): DescriptorStat; + statAt(pathFlags: { symlinkFollow?: boolean }, path: string): DescriptorStat; readDirectory(): DirectoryStream; unlinkFileAt(path: string): void; removeDirectoryAt(path: string): void; @@ -38,6 +48,8 @@ interface Descriptor { openFlags: { create?: boolean; directory?: boolean; exclusive?: boolean; truncate?: boolean }, descriptorFlags: { read?: boolean; write?: boolean; mutateDirectory?: boolean } ): Descriptor; + readlinkAt(path: string): string; + symlinkAt(oldPath: string, newPath: string): void; } interface PreopenedDir { @@ -276,14 +288,39 @@ type Encoding = 'utf8' | 'utf-8' | 'buffer' | null | undefined; interface ReadFileOptions { encoding?: Encoding; + flag?: string; } interface WriteFileOptions { encoding?: Encoding; + flag?: string; + mode?: number; +} + +interface ReaddirOptions { + encoding?: Encoding; + withFileTypes?: boolean; + recursive?: boolean; +} + +export class Dirent { + name: string; + private _type: string; + constructor(name: string, type: string) { + this.name = name; + this._type = type; + } + isFile() { return this._type === 'regular-file'; } + isDirectory() { return this._type === 'directory'; } + isSymbolicLink() { return this._type === 'symbolic-link'; } + isFIFO() { return this._type === 'fifo'; } + isBlockDevice() { return this._type === 'block-device'; } + isCharacterDevice() { return this._type === 'character-device'; } + isSocket() { return this._type === 'socket'; } } /** - * Read file contents (async/callback style) + * Read file contents */ export function readFile( path: string, @@ -314,7 +351,7 @@ export function readFile( } /** - * Write file contents (async/callback style) + * Write file contents */ export function writeFile( path: string, @@ -335,17 +372,67 @@ export function writeFile( } /** - * Read directory contents (async/callback style) + * Read directory contents */ export function readdir( path: string, - optionsOrCallback: any | ((err: Error | null, files?: string[]) => void), - callback?: (err: Error | null, files?: string[]) => void + optionsOrCallback: ReaddirOptions | ((err: Error | null, files?: string[] | Dirent[]) => void), + callback?: (err: Error | null, files?: string[] | Dirent[]) => void ): void { + const options: ReaddirOptions = typeof optionsOrCallback === 'function' ? {} : (optionsOrCallback as ReaddirOptions) ?? {}; const cb = typeof optionsOrCallback === 'function' ? optionsOrCallback : callback; - list(path) - .then((files) => cb?.(null, files)) + if (options.withFileTypes) { + listWithTypes(path) + .then((entries) => cb?.(null, entries)) + .catch((err) => cb?.(err instanceof Error ? err : new Error(String(err)))); + } else { + list(path) + .then((files) => cb?.(null, files)) + .catch((err) => cb?.(err instanceof Error ? err : new Error(String(err)))); + } +} + +/** + * stat callback + */ +export function stat( + path: string, + callback: (err: Error | null, stats?: StatResult) => void +): void { + Promise.resolve() + .then(() => statSync(path)) + .then((s) => callback(null, s)) + .catch((err) => callback(err instanceof Error ? err : new Error(String(err)))); +} + +/** + * lstat callback + */ +export function lstat( + path: string, + callback: (err: Error | null, stats?: StatResult) => void +): void { + Promise.resolve() + .then(() => lstatSync(path)) + .then((s) => callback(null, s)) + .catch((err) => callback(err instanceof Error ? err : new Error(String(err)))); +} + +/** + * appendFile callback + */ +export function appendFile( + path: string, + data: string | Uint8Array, + optionsOrCallback: WriteFileOptions | Encoding | ((err: Error | null) => void), + callback?: (err: Error | null) => void +): void { + const cb: ((err: Error | null) => void) | undefined = + typeof optionsOrCallback === 'function' ? optionsOrCallback : callback; + Promise.resolve() + .then(() => appendFileSync(path, data)) + .then(() => cb?.(null)) .catch((err) => cb?.(err instanceof Error ? err : new Error(String(err)))); } @@ -425,9 +512,9 @@ export function appendFileSync(path: string, data: string | Uint8Array, _options /** * Read directory contents synchronously. */ -export function readdirSync(path: string, _options?: any): string[] { +async function listWithTypes(path: string): Promise { const resolved = resolvePath(path); - if (!resolved) throw enoent(path); + if (!resolved) throw Object.assign(new Error(`ENOENT: no such file or directory, scandir '${path}'`), { code: 'ENOENT' }); try { let targetDir = resolved.dir; @@ -440,13 +527,45 @@ export function readdirSync(path: string, _options?: any): string[] { ); } const stream = targetDir.readDirectory(); - const entries: string[] = []; - let entry; + const entries: Dirent[] = []; + let entry: DirectoryEntry | null | undefined; while ((entry = stream.readDirectoryEntry()) && entry) { - if (entry.name) entries.push(entry.name); + if (entry.name) entries.push(new Dirent(entry.name, entry.type ?? 'unknown')); } return entries; - } catch (e) { + } catch { + throw Object.assign(new Error(`ENOENT: no such file or directory, scandir '${path}'`), { code: 'ENOENT' }); + } +} + +export function readdirSync(path: string, options?: ReaddirOptions): string[] | Dirent[] { + const resolved = resolvePath(path); + if (!resolved) throw enoent(path); + + try { + let targetDir = resolved.dir; + if (resolved.relativePath !== '.') { + targetDir = resolved.dir.openAt( + { symlinkFollow: false }, + resolved.relativePath, + { directory: true }, + { read: true } + ); + } + const stream = targetDir.readDirectory(); + const names: string[] = []; + const dirents: Dirent[] = []; + let entry: DirectoryEntry | null | undefined; + while ((entry = stream.readDirectoryEntry()) && entry) { + if (!entry.name) continue; + if (options?.withFileTypes) { + dirents.push(new Dirent(entry.name, entry.type ?? 'unknown')); + } else { + names.push(entry.name); + } + } + return options?.withFileTypes ? dirents : names; + } catch { throw Object.assign(new Error(`ENOENT: no such file or directory, scandir '${path}'`), { code: 'ENOENT' }); } } @@ -455,48 +574,105 @@ export interface StatResult { isFile: () => boolean; isDirectory: () => boolean; isSymbolicLink: () => boolean; + isFIFO: () => boolean; + isBlockDevice: () => boolean; + isCharacterDevice: () => boolean; + isSocket: () => boolean; + dev: number; + ino: number; + mode: number; + nlink: number; + uid: number; + gid: number; + rdev: number; size: number; - mtimeMs: number; + blksize: number; + blocks: number; + // timestamp (ms) atimeMs: number; + mtimeMs: number; ctimeMs: number; - mode: number; + birthtimeMs: number; + // timestamp (Date) — used by glob, fast-glob, chokidar etc. + atime: Date; + mtime: Date; + ctime: Date; + birthtime: Date; } -function makeStatResult(type: 'file' | 'directory' | 'notfound', size: bigint = BigInt(0)): StatResult { +function datetimeToMs(dt: WasiDatetime | null | undefined): number { + if (!dt) return 0; + return Number(dt.seconds) * 1000 + Math.floor(dt.nanoseconds / 1_000_000); +} + +function makeStatResult(s: DescriptorStat): StatResult { + const isDir = s.type === 'directory'; + const isSym = s.type === 'symbolic-link'; + const isFile = s.type === 'regular-file'; + const mtimeMs = datetimeToMs(s.dataModificationTimestamp); + const atimeMs = datetimeToMs(s.dataAccessTimestamp); + const ctimeMs = datetimeToMs(s.statusChangeTimestamp); + const size = Number(s.size); return { - isFile: () => type === 'file', - isDirectory: () => type === 'directory', - isSymbolicLink: () => false, - size: Number(size), - mtimeMs: 0, - atimeMs: 0, - ctimeMs: 0, - mode: type === 'directory' ? 0o40755 : 0o100644, + isFile: () => isFile, + isDirectory: () => isDir, + isSymbolicLink: () => isSym, + isFIFO: () => s.type === 'fifo', + isBlockDevice: () => s.type === 'block-device', + isCharacterDevice: () => s.type === 'character-device', + isSocket: () => s.type === 'socket', + dev: 0, ino: 0, nlink: isDir ? 2 : 1, + uid: 0, gid: 0, rdev: 0, + size, + blksize: 4096, + blocks: Math.ceil(size / 512), + mode: isDir ? 0o40755 : 0o100644, + atimeMs, mtimeMs, ctimeMs, birthtimeMs: mtimeMs, + atime: new Date(atimeMs), + mtime: new Date(mtimeMs), + ctime: new Date(ctimeMs), + birthtime: new Date(mtimeMs), }; } /** - * Get file stats synchronously. + * Get file stats synchronously (follows symlinks). */ export function statSync(path: string): StatResult { const resolved = resolvePath(path); if (!resolved) throw enoent(path); try { - const fd = resolved.dir.openAt({ symlinkFollow: false }, resolved.relativePath, {}, { read: true }); - const s = fd.stat(); - const type = s.type === 'directory' ? 'directory' : 'file'; - return makeStatResult(type, s.size); - } catch (e) { + if (typeof resolved.dir.statAt === 'function') { + const s = resolved.dir.statAt({ symlinkFollow: true }, resolved.relativePath); + return makeStatResult(s); + } + + const fd = resolved.dir.openAt({ symlinkFollow: true }, resolved.relativePath, {}, { read: true }); + return makeStatResult(fd.stat()); + } catch { throw Object.assign(new Error(`ENOENT: no such file or directory, stat '${path}'`), { code: 'ENOENT' }); } } /** - * Get file stats synchronously (no symlink follow — same as stat in WASI 0.2). + * Get file stats synchronously without following symlinks (lstat). */ export function lstatSync(path: string): StatResult { - return statSync(path); + const resolved = resolvePath(path); + if (!resolved) throw enoent(path); + + try { + if (typeof resolved.dir.statAt === 'function') { + const s = resolved.dir.statAt({ symlinkFollow: false }, resolved.relativePath); + return makeStatResult(s); + } + + const fd = resolved.dir.openAt({ symlinkFollow: false }, resolved.relativePath, {}, { read: true }); + return makeStatResult(fd.stat()); + } catch { + throw Object.assign(new Error(`ENOENT: no such file or directory, lstat '${path}'`), { code: 'ENOENT' }); + } } /** @@ -574,8 +750,10 @@ export function rmSync(path: string, options?: RmOptions): void { } try { - const fd = resolved.dir.openAt({ symlinkFollow: false }, resolved.relativePath, {}, { read: true }); - const s = fd.stat(); + const s = typeof resolved.dir.statAt === 'function' + ? resolved.dir.statAt({ symlinkFollow: false }, resolved.relativePath) + : (() => { const fd = resolved.dir.openAt({ symlinkFollow: false }, resolved.relativePath, {}, { read: true }); return fd.stat(); })(); + if (s.type === 'directory') { if (!options?.recursive) { throw Object.assign( @@ -681,15 +859,91 @@ export async function unlink(path: string): Promise { } /** - * Returns 'file', 'directory', or 'notfound' for a given path. + * Read the target of a symlink synchronously. + */ +export function readlinkSync(path: string): string { + const resolved = resolvePath(path); + if (!resolved) throw enoent(path); + try { + if (typeof resolved.dir.readlinkAt !== 'function') { + throw Object.assign( + new Error(`ENOSYS: function not implemented, readlink '${path}'`), + { code: 'ENOSYS' } + ); + } + return resolved.dir.readlinkAt(resolved.relativePath); + } catch (e) { + if (e instanceof Error && (e as any).code) throw e; + throw Object.assign( + new Error(`EINVAL: invalid argument, readlink '${path}'`), + { code: 'EINVAL' } + ); + } +} + +/** + * Read the target of a symlink asynchronously (callback style). + */ +export function readlink( + path: string, + callback: (err: Error | null, linkString?: string) => void +): void { + Promise.resolve() + .then(() => readlinkSync(path)) + .then((target) => callback(null, target)) + .catch((err) => callback(err instanceof Error ? err : new Error(String(err)))); +} + +/** + * Create a symbolic link synchronously. + * target: the link destination (relative to the sandbox — cannot escape it). + * path: the new symlink path. + */ +export function symlinkSync(target: string, path: string): void { + const resolved = resolvePath(path); + if (!resolved) throw enoent(path); + try { + if (typeof resolved.dir.symlinkAt !== 'function') { + throw Object.assign( + new Error(`ENOSYS: function not implemented, symlink '${target}' -> '${path}'`), + { code: 'ENOSYS' } + ); + } + resolved.dir.symlinkAt(target, resolved.relativePath); + } catch (e) { + if (e instanceof Error && (e as any).code) throw e; + throw Object.assign( + new Error(`ENOSYS: function not implemented, symlink '${target}' -> '${path}'`), + { code: 'ENOSYS' } + ); + } +} + +/** + * Create a symbolic link asynchronously (callback style). + */ +export function symlink( + target: string, + path: string, + callback: (err: Error | null) => void +): void { + Promise.resolve() + .then(() => symlinkSync(target, path)) + .then(() => callback(null)) + .catch((err) => callback(err instanceof Error ? err : new Error(String(err)))); +} + +/** + * Internal helper — returns the entry type for a path without building a full StatResult. */ async function statPath(path: string): Promise<'file' | 'directory' | 'notfound'> { const resolved = resolvePath(path); if (!resolved) return 'notfound'; try { - const fd = resolved.dir.openAt({ symlinkFollow: false }, resolved.relativePath, {}, { read: true }); - const s = fd.stat(); + const s = typeof resolved.dir.statAt === 'function' + ? resolved.dir.statAt({ symlinkFollow: false }, resolved.relativePath) + : (() => { const fd = resolved.dir.openAt({ symlinkFollow: false }, resolved.relativePath, {}, { read: true }); return fd.stat(); })(); if (s.type === 'directory') return 'directory'; return 'file'; } catch { @@ -881,8 +1135,8 @@ export const promises = { } }, - async readdir(path: string): Promise { - return list(path); + async readdir(path: string, options?: ReaddirOptions): Promise { + return options?.withFileTypes ? listWithTypes(path) : list(path); }, async access(path: string): Promise { @@ -897,14 +1151,57 @@ export const promises = { }, async stat(path: string): Promise { - const kind = await statPath(path); - if (kind === 'notfound') { + const resolved = resolvePath(path); + if (!resolved) { + throw Object.assign( + new Error(`ENOENT: no such file or directory, stat '${path}'`), + { code: 'ENOENT' } + ); + } + try { + if (typeof resolved.dir.statAt === 'function') { + const s = resolved.dir.statAt({ symlinkFollow: true }, resolved.relativePath); + return makeStatResult(s); + } + + const fd = resolved.dir.openAt({ symlinkFollow: true }, resolved.relativePath, {}, { read: true }); + return makeStatResult(fd.stat()); + } catch { throw Object.assign( new Error(`ENOENT: no such file or directory, stat '${path}'`), { code: 'ENOENT' } ); } - return makeStatResult(kind); + }, + + async lstat(path: string): Promise { + const resolved = resolvePath(path); + if (!resolved) throw Object.assign(new Error(`ENOENT: no such file or directory, lstat '${path}'`), { code: 'ENOENT' }); + try { + if (typeof resolved.dir.statAt === 'function') { + return makeStatResult(resolved.dir.statAt({ symlinkFollow: false }, resolved.relativePath)); + } + const fd = resolved.dir.openAt({ symlinkFollow: false }, resolved.relativePath, {}, { read: true }); + return makeStatResult(fd.stat()); + } catch { + throw Object.assign(new Error(`ENOENT: no such file or directory, lstat '${path}'`), { code: 'ENOENT' }); + } + }, + + async readlink(path: string): Promise { + return readlinkSync(path); + }, + + async symlink(target: string, path: string): Promise { + symlinkSync(target, path); + }, + + async appendFile(path: string, data: string | Uint8Array): Promise { + appendFileSync(path, data); + }, + + async rename(oldPath: string, newPath: string): Promise { + renameSync(oldPath, newPath); }, async rmdir(path: string, options?: RmdirOptions): Promise { @@ -928,23 +1225,44 @@ export const promises = { }, }; +export const constants = { + F_OK: 0, + R_OK: 4, + W_OK: 2, + X_OK: 1, + O_RDONLY: 0, + O_WRONLY: 1, + O_RDWR: 2, + O_CREAT: 64, + O_TRUNC: 512, + O_APPEND: 1024, +}; + const fs = { // Async / callback readFile, writeFile, + appendFile, readdir, + stat, + lstat, + readlink, + symlink, unlink, rmdir, rm, mkdir, copyFile, cp, + // Sync readFileSync, writeFileSync, appendFileSync, readdirSync, statSync, lstatSync, + readlinkSync, + symlinkSync, mkdirSync, rmdirSync, rmSync, @@ -955,6 +1273,9 @@ const fs = { existsSync, // Promises API promises, + // Constants + constants, + Dirent, }; export default fs; diff --git a/crates/capsule-sdk/python/pyproject.toml b/crates/capsule-sdk/python/pyproject.toml index 9816f53..c1411e0 100644 --- a/crates/capsule-sdk/python/pyproject.toml +++ b/crates/capsule-sdk/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "capsule" -version = "0.8.9" +version = "0.8.10" description = "Capsule Python SDK - run untrusted code in secure WASM sandboxes" readme = "README.md" requires-python = ">=3.10"