From 8bf36b95bdb04af69bf0dbcfea65dcb8aff96539 Mon Sep 17 00:00:00 2001 From: Mavdol Date: Fri, 24 Apr 2026 12:15:50 +0200 Subject: [PATCH 1/5] implement full stat data support and resolve symlinks in fs polyfills --- .../javascript/src/polyfills/fs.ts | 101 +++++++++++++----- 1 file changed, 75 insertions(+), 26 deletions(-) diff --git a/crates/capsule-sdk/javascript/src/polyfills/fs.ts b/crates/capsule-sdk/javascript/src/polyfills/fs.ts index a073bc2..1ec2cf9 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; @@ -462,41 +472,64 @@ export interface StatResult { mode: number; } -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'; return { - isFile: () => type === 'file', - isDirectory: () => type === 'directory', - isSymbolicLink: () => false, - size: Number(size), - mtimeMs: 0, - atimeMs: 0, - ctimeMs: 0, - mode: type === 'directory' ? 0o40755 : 0o100644, + isFile: () => s.type === 'regular-file', + isDirectory: () => isDir, + isSymbolicLink: () => isSym, + size: Number(s.size), + mtimeMs: datetimeToMs(s.dataModificationTimestamp), + atimeMs: datetimeToMs(s.dataAccessTimestamp), + ctimeMs: datetimeToMs(s.statusChangeTimestamp), + mode: isDir ? 0o40755 : 0o100644, }; } /** - * 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 +607,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 +716,16 @@ export async function unlink(path: string): Promise { } /** - * Returns 'file', 'directory', or 'notfound' for a given path. + * 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 { @@ -897,14 +933,27 @@ 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 rmdir(path: string, options?: RmdirOptions): Promise { From b4eab4c96c8211c14a95d33accd8c62aae4828a4 Mon Sep 17 00:00:00 2001 From: Mavdol Date: Fri, 24 Apr 2026 15:00:29 +0200 Subject: [PATCH 2/5] expand fs polyfills with Dirent, stat/lstat support, and extended readdir capabilities --- .../javascript/src/polyfills/fs.ts | 236 ++++++++++++++++-- 1 file changed, 209 insertions(+), 27 deletions(-) diff --git a/crates/capsule-sdk/javascript/src/polyfills/fs.ts b/crates/capsule-sdk/javascript/src/polyfills/fs.ts index 1ec2cf9..9fea5ed 100644 --- a/crates/capsule-sdk/javascript/src/polyfills/fs.ts +++ b/crates/capsule-sdk/javascript/src/polyfills/fs.ts @@ -286,14 +286,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, @@ -324,7 +349,7 @@ export function readFile( } /** - * Write file contents (async/callback style) + * Write file contents */ export function writeFile( path: string, @@ -345,17 +370,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)))); } @@ -435,9 +510,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; @@ -450,13 +525,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' }); } } @@ -465,11 +572,30 @@ 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 datetimeToMs(dt: WasiDatetime | null | undefined): number { @@ -478,17 +604,32 @@ function datetimeToMs(dt: WasiDatetime | null | undefined): number { } function makeStatResult(s: DescriptorStat): StatResult { - const isDir = s.type === 'directory'; - const isSym = s.type === 'symbolic-link'; + 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: () => s.type === 'regular-file', - isDirectory: () => isDir, - isSymbolicLink: () => isSym, - size: Number(s.size), - mtimeMs: datetimeToMs(s.dataModificationTimestamp), - atimeMs: datetimeToMs(s.dataAccessTimestamp), - ctimeMs: datetimeToMs(s.statusChangeTimestamp), + 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), }; } @@ -917,8 +1058,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 { @@ -956,6 +1097,28 @@ export const promises = { } }, + 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 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 { await rmdir(path, options); }, @@ -977,17 +1140,33 @@ 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, unlink, rmdir, rm, mkdir, copyFile, cp, + // Sync readFileSync, writeFileSync, appendFileSync, @@ -1004,6 +1183,9 @@ const fs = { existsSync, // Promises API promises, + // Constants + constants, + Dirent, }; export default fs; From 384ac9be79caef54c1e229e008ceb3afce18e439 Mon Sep 17 00:00:00 2001 From: Mavdol Date: Fri, 24 Apr 2026 20:59:10 +0200 Subject: [PATCH 3/5] bump project version to 0.8.10 --- crates/capsule-cli/Cargo.lock | 4 ++-- crates/capsule-cli/Cargo.toml | 4 ++-- crates/capsule-cli/npm/package.json | 10 +++++----- crates/capsule-cli/pyproject.toml | 2 +- crates/capsule-core/Cargo.lock | 2 +- crates/capsule-core/Cargo.toml | 2 +- crates/capsule-sdk/javascript/package-lock.json | 4 ++-- crates/capsule-sdk/javascript/package.json | 2 +- crates/capsule-sdk/python/pyproject.toml | 2 +- 9 files changed, 16 insertions(+), 16 deletions(-) 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/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" From 1883118fc4d6d17906c8fb18136d828a5ceb3583 Mon Sep 17 00:00:00 2001 From: Mavdol Date: Fri, 24 Apr 2026 21:16:57 +0200 Subject: [PATCH 4/5] implement readlink and symlink polyfills for fs module --- .../javascript/src/polyfills/fs.ts | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/crates/capsule-sdk/javascript/src/polyfills/fs.ts b/crates/capsule-sdk/javascript/src/polyfills/fs.ts index 9fea5ed..762c680 100644 --- a/crates/capsule-sdk/javascript/src/polyfills/fs.ts +++ b/crates/capsule-sdk/javascript/src/polyfills/fs.ts @@ -48,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 { @@ -856,6 +858,81 @@ export async function unlink(path: string): Promise { } } +/** + * 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. */ @@ -1111,6 +1188,14 @@ export const promises = { } }, + 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); }, @@ -1154,12 +1239,15 @@ export const constants = { }; const fs = { + // Async / callback readFile, writeFile, appendFile, readdir, stat, lstat, + readlink, + symlink, unlink, rmdir, rm, @@ -1173,6 +1261,8 @@ const fs = { readdirSync, statSync, lstatSync, + readlinkSync, + symlinkSync, mkdirSync, rmdirSync, rmSync, From 806daf240d7d46bd3391babacfdde366d0488174 Mon Sep 17 00:00:00 2001 From: Mavdol Date: Fri, 24 Apr 2026 21:27:30 +0200 Subject: [PATCH 5/5] reduce logo width in README from 100 to 80 pixels --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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`