From 9a3f41385e6ea8d564d4cf561ae7e64eee79262b Mon Sep 17 00:00:00 2001 From: Shaar <40307680+Shaar-games@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:16:44 +0200 Subject: [PATCH 1/2] Add VirtualFs: runtime-generated filesystem with pluggable sources Introduces a new IFileSystem implementation that generates content on-the-fly through async hooks, enabling synthetic filesystems backed by APIs, databases, or any custom data source. Key features: - VirtualFsSource interface with required readFile/readdir hooks - Optional stat/exists hooks with automatic derivation - Optional write hooks (writeFile, mkdir, rm, etc.) or EROFS rejection - defineVirtualFs() factory helper for type-safe source creation - Full test coverage (70 tests) including Bash integration - Example implementations (report DB, metrics API) Typical use case: mount dynamic content inside MountableFs so shell commands can access virtual files as if they were real (ls, cat, grep, wc, etc.). Made-with: Cursor --- examples/virtual-fs/README.md | 65 +++ examples/virtual-fs/main.ts | 108 +++++ examples/virtual-fs/package.json | 13 + examples/virtual-fs/pnpm-lock.yaml | 13 + examples/virtual-fs/sources.ts | 262 ++++++++++ examples/virtual-fs/tsconfig.json | 16 + src/fs/virtual-fs/index.ts | 7 + src/fs/virtual-fs/virtual-fs.test.ts | 699 +++++++++++++++++++++++++++ src/fs/virtual-fs/virtual-fs.ts | 272 +++++++++++ src/index.ts | 7 + 10 files changed, 1462 insertions(+) create mode 100644 examples/virtual-fs/README.md create mode 100644 examples/virtual-fs/main.ts create mode 100644 examples/virtual-fs/package.json create mode 100644 examples/virtual-fs/pnpm-lock.yaml create mode 100644 examples/virtual-fs/sources.ts create mode 100644 examples/virtual-fs/tsconfig.json create mode 100644 src/fs/virtual-fs/index.ts create mode 100644 src/fs/virtual-fs/virtual-fs.test.ts create mode 100644 src/fs/virtual-fs/virtual-fs.ts diff --git a/examples/virtual-fs/README.md b/examples/virtual-fs/README.md new file mode 100644 index 00000000..68640cf0 --- /dev/null +++ b/examples/virtual-fs/README.md @@ -0,0 +1,65 @@ +# Virtual Filesystem Example + +Demonstrates how to create synthetic filesystems whose content is generated at +runtime by async hooks. The shell never knows the content is virtual — it just +runs `ls`, `cat`, `grep`, `wc` as usual. + +## Sources Included + +- **reportDbSource** — simulates a project report database. Each report is + generated from in-memory records, as if fetched from a real DB. +- **metricsApiSource** — simulates a monitoring API. The directory tree + (`/cpu/.txt`, `/memory/.txt`, `/status.json`) is computed from + live node metrics. + +## Running the Example + +```bash +# Install dependencies +pnpm install + +# Run the demo +pnpm start +``` + +## Creating Your Own Source + +Use `defineVirtualFs` to create a typed factory: + +```typescript +import { defineVirtualFs, VirtualFs, MountableFs, Bash } from "just-bash"; + +const mySource = defineVirtualFs((opts: { userId: string }) => ({ + async readFile(path) { + // Return file content or null when not found + return path === "/hello.txt" ? `Hello, ${opts.userId}!` : null; + }, + async readdir(path) { + // Return entries or null when not a directory + if (path === "/") { + return [{ name: "hello.txt", isFile: true, isDirectory: false }]; + } + return null; + }, +})); + +const bash = new Bash({ + fs: new MountableFs({ + mounts: [ + { mountPoint: "/data", filesystem: new VirtualFs(mySource({ userId: "alice" })) }, + ], + }), +}); + +await bash.exec("cat /data/hello.txt"); // → Hello, alice! +``` + +## VirtualFsSource Hooks + +| Hook | Required | Description | +|------|----------|-------------| +| `readFile(path)` | Yes | Return content (`string` or `Uint8Array`) or `null` | +| `readdir(path)` | Yes | Return `{ name, isFile, isDirectory }[]` or `null` | +| `stat(path)` | No | Return `FsStat` or `null` — derived from readdir/readFile when absent | +| `exists(path)` | No | Return `boolean` — derived from stat when absent | +| `dispose()` | No | Called by `VirtualFs.dispose()` to release resources | diff --git a/examples/virtual-fs/main.ts b/examples/virtual-fs/main.ts new file mode 100644 index 00000000..c7248686 --- /dev/null +++ b/examples/virtual-fs/main.ts @@ -0,0 +1,108 @@ +/** + * Virtual Filesystem Example + * + * Demonstrates how to create synthetic filesystems whose content is + * generated at runtime by async hooks — the shell never knows the + * content is virtual. + * Run with: npx tsx main.ts + */ + +import { Bash, MountableFs, VirtualFs } from "just-bash"; +import { metricsApiSource, reportDbSource } from "./sources.js"; + +const bash = new Bash({ + fs: new MountableFs({ + mounts: [ + { + mountPoint: "/reports", + filesystem: new VirtualFs( + reportDbSource({ userId: "alice" }), + ), + }, + { + mountPoint: "/metrics", + filesystem: new VirtualFs( + metricsApiSource({ cluster: "production" }), + ), + }, + ], + }), +}); + +async function run(cmd: string) { + const result = await bash.exec(cmd); + console.log(`$ ${cmd}`); + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + console.log(); + return result; +} + +// ── demos ────────────────────────────────────────────────── + +async function demoReports() { + console.log("=== 1. List sprint reports ===\n"); + await run("ls /reports"); + + console.log("=== 2. Read a single report ===\n"); + await run("cat /reports/sprint-24"); + + console.log("=== 3. Search for errors across all reports ===\n"); + await run("grep ERROR /reports/sprint-23 /reports/sprint-24 /reports/sprint-25"); + + console.log("=== 4. Count lines per report ===\n"); + await run("wc -l /reports/sprint-23 /reports/sprint-24 /reports/sprint-25"); +} + +async function demoMetrics() { + console.log("=== 5. Browse the metrics tree ===\n"); + await run("ls /metrics"); + await run("ls /metrics/cpu"); + + console.log("=== 6. Read cluster status ===\n"); + await run("cat /metrics/status.json"); + + console.log("=== 7. Read individual node metrics ===\n"); + await run("cat /metrics/cpu/node-2.txt"); + await run("cat /metrics/memory/node-2.txt"); + + console.log("=== 8. Find critical nodes ===\n"); + await run("grep critical /metrics/cpu/node-1.txt /metrics/cpu/node-2.txt /metrics/cpu/node-3.txt"); +} + +async function demoPipelines() { + console.log("=== 9. Pipeline: extract latency values ===\n"); + await run("cat /reports/sprint-23 /reports/sprint-24 /reports/sprint-25 | grep latency"); + + console.log("=== 10. Pipeline: count warnings across all reports ===\n"); + await run("cat /reports/sprint-23 /reports/sprint-24 /reports/sprint-25 | grep WARN | wc -l"); +} + +async function demoWriteHooks() { + console.log("=== 11. Write a new report (writeFile hook) ===\n"); + await run("echo '# Sprint 26 — New Features' > /reports/sprint-26"); + await run("ls /reports"); + await run("cat /reports/sprint-26"); + + console.log("=== 12. Append to an existing report (appendFile hook) ===\n"); + await run("echo '- P95 latency: 80ms' >> /reports/sprint-26"); + await run("cat /reports/sprint-26"); + + console.log("=== 13. Delete a report (rm hook) ===\n"); + await run("rm /reports/sprint-26"); + await run("ls /reports"); + + console.log("=== 14. Write to metrics fails (no write hooks) ===\n"); + const result = await bash.exec("echo test > /metrics/cpu/fake.txt").catch((e) => e); + console.log(`$ echo test > /metrics/cpu/fake.txt`); + console.log(`→ ${result instanceof Error ? result.message : "unexpected success"}\n`); +} + +async function main() { + await demoReports(); + await demoMetrics(); + await demoPipelines(); + await demoWriteHooks(); +} + +main().catch(console.error); diff --git a/examples/virtual-fs/package.json b/examples/virtual-fs/package.json new file mode 100644 index 00000000..aa3659a4 --- /dev/null +++ b/examples/virtual-fs/package.json @@ -0,0 +1,13 @@ +{ + "name": "virtual-fs-example", + "version": "1.0.0", + "description": "Example of virtual filesystems backed by async hooks in just-bash", + "type": "module", + "scripts": { + "start": "npx tsx main.ts", + "typecheck": "npx tsc --noEmit" + }, + "dependencies": { + "just-bash": "link:../.." + } +} diff --git a/examples/virtual-fs/pnpm-lock.yaml b/examples/virtual-fs/pnpm-lock.yaml new file mode 100644 index 00000000..48c00920 --- /dev/null +++ b/examples/virtual-fs/pnpm-lock.yaml @@ -0,0 +1,13 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + just-bash: + specifier: link:../.. + version: link:../.. diff --git a/examples/virtual-fs/sources.ts b/examples/virtual-fs/sources.ts new file mode 100644 index 00000000..f7d33f25 --- /dev/null +++ b/examples/virtual-fs/sources.ts @@ -0,0 +1,262 @@ +/** + * Virtual filesystem sources for the demo + * + * Simulated data providers — no external dependencies. + */ + +import { defineVirtualFs } from "just-bash"; + +// ── Report database ───────────────────────────────────────── + +interface Report { + id: string; + content: string; +} + +const REPORTS_DB: Record = { + alice: [ + { + id: "sprint-23", + content: [ + "# Sprint 23 — Platform Stability", + "", + "## Completed", + "- [x] Add retry logic to payment gateway", + "- [x] Upgrade Nextjs to 16.0.7", + "", + "## Metrics", + "- Uptime: 99.97%", + "- ERROR count: 3", + "- WARN count: 12", + "- P95 latency: 120ms", + "", + ].join("\n"), + }, + { + id: "sprint-24", + content: [ + "# Sprint 24 — API Migration", + "", + "## Completed", + "- [x] Migrate /v1/users to /v2/users", + "- [x] Add rate limiting to public endpoints", + "- [ ] Update SDK documentation", + "", + "## Metrics", + "- Uptime: 99.91%", + "- ERROR count: 17", + "- WARN count: 45", + "- P95 latency: 340ms", + "", + "## Incidents", + "- 2024-03-15: ERROR — Database failover during peak", + "- 2024-03-18: ERROR — Rate limiter misconfiguration (503s)", + "", + ].join("\n"), + }, + { + id: "sprint-25", + content: [ + "# Sprint 25 — Observability", + "", + "## Completed", + "- [x] Deploy distributed tracing", + "- [x] Add custom metrics", + "", + "## Metrics", + "- Uptime: 99.99%", + "- ERROR count: 1", + "- WARN count: 8", + "- P95 latency: 95ms", + "", + "## Notes", + "- Trace coverage: 87% of endpoints", + "- Alert noise reduced by 60%", + "", + ].join("\n"), + }, + ], +}; + +/** + * Simulates a report database. + * Files are generated from in-memory records, as if fetched from a DB. + * Write hooks allow the shell to create, update and delete reports. + */ +export const reportDbSource = defineVirtualFs( + (opts: { userId: string }) => { + const reports = REPORTS_DB[opts.userId] ?? []; + + return { + async readFile(path: string) { + const id = path.slice(1); + const report = reports.find((r) => r.id === id); + return report?.content ?? null; + }, + async readdir(path: string) { + if (path === "/") { + return reports.map((r) => ({ + name: r.id, + isFile: true, + isDirectory: false, + })); + } + return null; + }, + + async writeFile(path: string, content) { + const id = path.slice(1); + const existing = reports.find((r) => r.id === id); + if (existing) { + existing.content = String(content); + } else { + reports.push({ id, content: String(content) }); + } + }, + + async rm(path: string) { + const id = path.slice(1); + const idx = reports.findIndex((r) => r.id === id); + if (idx === -1) { + throw new Error(`ENOENT: no such file or directory, rm '${path}'`); + } + reports.splice(idx, 1); + }, + + async appendFile(path: string, content) { + const id = path.slice(1); + const existing = reports.find((r) => r.id === id); + if (existing) { + existing.content += String(content); + } else { + reports.push({ id, content: String(content) }); + } + }, + }; + }, +); + +// ── Metrics API ───────────────────────────────────────────── + +interface NodeMetrics { + cpu: number; + memory: number; + loadAvg: [number, number, number]; + totalMemoryGb: number; + status: "healthy" | "warning" | "critical"; +} + +const CLUSTER_NODES: Record> = { + production: { + "node-1": { + cpu: 45.2, + memory: 72.1, + loadAvg: [2.1, 1.8, 1.5], + totalMemoryGb: 32, + status: "healthy", + }, + "node-2": { + cpu: 89.7, + memory: 91.3, + loadAvg: [14.2, 13.1, 12.8], + totalMemoryGb: 64, + status: "critical", + }, + "node-3": { + cpu: 23.1, + memory: 55.8, + loadAvg: [0.9, 0.7, 0.5], + totalMemoryGb: 16, + status: "healthy", + }, + }, +}; + +/** + * Simulates a monitoring API. + * Directory tree and file content are computed from live metrics. + * + * Tree: + * /status.json + * /cpu/.txt + * /memory/.txt + */ +export const metricsApiSource = defineVirtualFs( + (opts: { cluster: string }) => { + const nodes = CLUSTER_NODES[opts.cluster] ?? {}; + const nodeNames = Object.keys(nodes); + + return { + async readFile(path: string) { + if (path === "/status.json") { + const summary = nodeNames.map((name) => ({ + node: name, + status: nodes[name].status, + cpu: nodes[name].cpu, + memory: nodes[name].memory, + })); + return ( + JSON.stringify( + { cluster: opts.cluster, nodes: summary }, + null, + 2, + ) + "\n" + ); + } + + const match = path.match( + /^\/(cpu|memory)\/(.+)\.txt$/, + ); + if (match) { + const [, metric, nodeName] = match; + const m = nodes[nodeName]; + if (!m) return null; + + if (metric === "cpu") { + return [ + `usage: ${m.cpu}%`, + `load_avg: ${m.loadAvg.join(" ")}`, + `status: ${m.status}`, + "", + ].join("\n"); + } + if (metric === "memory") { + const availableGb = + m.totalMemoryGb * (1 - m.memory / 100); + return [ + `used: ${m.memory}%`, + `total_gb: ${m.totalMemoryGb}`, + `available_gb: ${availableGb.toFixed(1)}`, + `status: ${m.status}`, + "", + ].join("\n"); + } + } + + return null; + }, + + async readdir(path: string) { + if (path === "/") { + return [ + { name: "cpu", isFile: false, isDirectory: true }, + { name: "memory", isFile: false, isDirectory: true }, + { + name: "status.json", + isFile: true, + isDirectory: false, + }, + ]; + } + if (path === "/cpu" || path === "/memory") { + return nodeNames.map((name) => ({ + name: `${name}.txt`, + isFile: true, + isDirectory: false, + })); + } + return null; + }, + }; + }, +); diff --git a/examples/virtual-fs/tsconfig.json b/examples/virtual-fs/tsconfig.json new file mode 100644 index 00000000..79a314b3 --- /dev/null +++ b/examples/virtual-fs/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "paths": { + "just-bash": ["../../src/index.ts"] + } + }, + "include": ["*.ts", "../../src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/src/fs/virtual-fs/index.ts b/src/fs/virtual-fs/index.ts new file mode 100644 index 00000000..8fa07290 --- /dev/null +++ b/src/fs/virtual-fs/index.ts @@ -0,0 +1,7 @@ +export { + defineVirtualFs, + VirtualFs, + type VirtualDirent, + type VirtualFsSource, + type VirtualFsWriteHooks, +} from "./virtual-fs.js"; diff --git a/src/fs/virtual-fs/virtual-fs.test.ts b/src/fs/virtual-fs/virtual-fs.test.ts new file mode 100644 index 00000000..c6296f04 --- /dev/null +++ b/src/fs/virtual-fs/virtual-fs.test.ts @@ -0,0 +1,699 @@ +import { describe, expect, it, vi } from "vitest"; +import { Bash } from "../../Bash.js"; +import { InMemoryFs } from "../in-memory-fs/in-memory-fs.js"; +import { MountableFs } from "../mountable-fs/mountable-fs.js"; +import type { VirtualFsSource } from "./virtual-fs.js"; +import { VirtualFs, defineVirtualFs } from "./virtual-fs.js"; + +// ── helpers ──────────────────────────────────────────────── + +function createReportSource(): VirtualFsSource { + const reports: Record = { + "report-1": "Status: OK\nMetric: 42\n", + "report-2": "Status: ERROR\nMetric: 0\n", + "report-3": "Status: WARN\nMetric: 17\n", + }; + + return { + async readFile(path) { + const name = path.slice(1); + return reports[name] ?? null; + }, + async readdir(path) { + if (path === "/") { + return Object.keys(reports).map((name) => ({ + name, + isFile: true, + isDirectory: false, + })); + } + return null; + }, + }; +} + +function createNestedSource(): VirtualFsSource { + return { + async readFile(path) { + if (path === "/cpu/node-1.txt") return "usage: 45%\n"; + if (path === "/cpu/node-2.txt") return "usage: 89%\n"; + if (path === "/status.json") return '{"ok":true}\n'; + return null; + }, + async readdir(path) { + if (path === "/") { + return [ + { name: "cpu", isFile: false, isDirectory: true }, + { name: "status.json", isFile: true, isDirectory: false }, + ]; + } + if (path === "/cpu") { + return [ + { name: "node-1.txt", isFile: true, isDirectory: false }, + { name: "node-2.txt", isFile: true, isDirectory: false }, + ]; + } + return null; + }, + }; +} + +// ── tests ────────────────────────────────────────────────── + +describe("VirtualFs", () => { + // ── readFile ───────────────────────────────────────────── + + describe("readFile", () => { + it("should return content for an existing file", async () => { + const fs = new VirtualFs(createReportSource()); + const content = await fs.readFile("/report-1"); + expect(content).toBe("Status: OK\nMetric: 42\n"); + }); + + it("should throw ENOENT for missing files", async () => { + const fs = new VirtualFs(createReportSource()); + await expect(fs.readFile("/missing")).rejects.toThrow("ENOENT"); + }); + + it("should handle Uint8Array content", async () => { + const bytes = new TextEncoder().encode("binary data"); + const fs = new VirtualFs({ + async readFile() { + return bytes; + }, + async readdir() { + return null; + }, + }); + const content = await fs.readFile("/file"); + expect(content).toBe("binary data"); + }); + + it("should normalize paths", async () => { + const fs = new VirtualFs(createReportSource()); + const content = await fs.readFile("/./report-1"); + expect(content).toBe("Status: OK\nMetric: 42\n"); + }); + }); + + // ── readFileBuffer ─────────────────────────────────────── + + describe("readFileBuffer", () => { + it("should return Uint8Array for string content", async () => { + const fs = new VirtualFs(createReportSource()); + const buf = await fs.readFileBuffer("/report-1"); + expect(buf).toBeInstanceOf(Uint8Array); + expect(new TextDecoder().decode(buf)).toBe("Status: OK\nMetric: 42\n"); + }); + + it("should pass through Uint8Array content", async () => { + const original = new Uint8Array([0x00, 0xff, 0x42]); + const fs = new VirtualFs({ + async readFile() { + return original; + }, + async readdir() { + return null; + }, + }); + const buf = await fs.readFileBuffer("/bin"); + expect(buf).toBe(original); + }); + + it("should throw ENOENT for missing files", async () => { + const fs = new VirtualFs(createReportSource()); + await expect(fs.readFileBuffer("/nope")).rejects.toThrow("ENOENT"); + }); + }); + + // ── readdir ────────────────────────────────────────────── + + describe("readdir", () => { + it("should return sorted entry names", async () => { + const fs = new VirtualFs(createReportSource()); + const entries = await fs.readdir("/"); + expect(entries).toEqual(["report-1", "report-2", "report-3"]); + }); + + it("should throw ENOENT for non-directories", async () => { + const fs = new VirtualFs(createReportSource()); + await expect(fs.readdir("/report-1")).rejects.toThrow("ENOENT"); + }); + + it("should handle nested directories", async () => { + const fs = new VirtualFs(createNestedSource()); + expect(await fs.readdir("/")).toEqual(["cpu", "status.json"]); + expect(await fs.readdir("/cpu")).toEqual(["node-1.txt", "node-2.txt"]); + }); + }); + + // ── readdirWithFileTypes ───────────────────────────────── + + describe("readdirWithFileTypes", () => { + it("should return DirentEntry with isSymbolicLink: false", async () => { + const fs = new VirtualFs(createNestedSource()); + const entries = await fs.readdirWithFileTypes("/"); + expect(entries).toEqual([ + { name: "cpu", isFile: false, isDirectory: true, isSymbolicLink: false }, + { + name: "status.json", + isFile: true, + isDirectory: false, + isSymbolicLink: false, + }, + ]); + }); + + it("should throw ENOENT for non-directories", async () => { + const fs = new VirtualFs(createReportSource()); + await expect(fs.readdirWithFileTypes("/missing")).rejects.toThrow( + "ENOENT", + ); + }); + }); + + // ── stat (derived) ────────────────────────────────────── + + describe("stat (derived from readdir/readFile)", () => { + it("should return directory stat for root", async () => { + const fs = new VirtualFs(createReportSource()); + const s = await fs.stat("/"); + expect(s.isDirectory).toBe(true); + expect(s.isFile).toBe(false); + }); + + it("should return directory stat when readdir succeeds", async () => { + const fs = new VirtualFs(createNestedSource()); + const s = await fs.stat("/cpu"); + expect(s.isDirectory).toBe(true); + }); + + it("should return file stat when readFile succeeds", async () => { + const fs = new VirtualFs(createReportSource()); + const s = await fs.stat("/report-1"); + expect(s.isFile).toBe(true); + expect(s.isDirectory).toBe(false); + expect(s.size).toBeGreaterThan(0); + }); + + it("should compute correct byte size for string content", async () => { + const fs = new VirtualFs({ + async readFile(path) { + return path === "/emoji.txt" ? "café ☕" : null; + }, + async readdir() { + return null; + }, + }); + const s = await fs.stat("/emoji.txt"); + expect(s.size).toBe(new TextEncoder().encode("café ☕").length); + }); + + it("should throw ENOENT for missing paths", async () => { + const fs = new VirtualFs(createReportSource()); + await expect(fs.stat("/nope")).rejects.toThrow("ENOENT"); + }); + }); + + // ── stat (user-provided) ──────────────────────────────── + + describe("stat (user-provided hook)", () => { + it("should delegate to source.stat when provided", async () => { + const customStat = { + isFile: true, + isDirectory: false, + isSymbolicLink: false, + mode: 0o444, + size: 999, + mtime: new Date("2025-01-01"), + }; + const fs = new VirtualFs({ + async readFile() { + return "data"; + }, + async readdir() { + return null; + }, + async stat(path) { + return path === "/special" ? customStat : null; + }, + }); + const s = await fs.stat("/special"); + expect(s).toEqual(customStat); + }); + + it("should throw ENOENT when source.stat returns null", async () => { + const fs = new VirtualFs({ + async readFile() { + return null; + }, + async readdir() { + return null; + }, + async stat() { + return null; + }, + }); + await expect(fs.stat("/gone")).rejects.toThrow("ENOENT"); + }); + + it("should still return directory for root even if source.stat returns null", async () => { + const fs = new VirtualFs({ + async readFile() { + return null; + }, + async readdir() { + return null; + }, + async stat() { + return null; + }, + }); + const s = await fs.stat("/"); + expect(s.isDirectory).toBe(true); + }); + }); + + // ── exists ────────────────────────────────────────────── + + describe("exists", () => { + it("should return true for existing files (derived)", async () => { + const fs = new VirtualFs(createReportSource()); + expect(await fs.exists("/report-1")).toBe(true); + }); + + it("should return true for root (derived)", async () => { + const fs = new VirtualFs(createReportSource()); + expect(await fs.exists("/")).toBe(true); + }); + + it("should return false for missing paths (derived)", async () => { + const fs = new VirtualFs(createReportSource()); + expect(await fs.exists("/missing")).toBe(false); + }); + + it("should delegate to source.exists when provided", async () => { + const spy = vi.fn(async (path: string) => path === "/found"); + const fs = new VirtualFs({ + async readFile() { + return null; + }, + async readdir() { + return null; + }, + exists: spy, + }); + expect(await fs.exists("/found")).toBe(true); + expect(await fs.exists("/lost")).toBe(false); + expect(spy).toHaveBeenCalledTimes(2); + }); + }); + + // ── lstat ─────────────────────────────────────────────── + + describe("lstat", () => { + it("should delegate to stat", async () => { + const fs = new VirtualFs(createReportSource()); + const s = await fs.lstat("/report-2"); + expect(s.isFile).toBe(true); + }); + }); + + // ── realpath ──────────────────────────────────────────── + + describe("realpath", () => { + it("should return normalized path for existing files", async () => { + const fs = new VirtualFs(createReportSource()); + expect(await fs.realpath("/./report-1")).toBe("/report-1"); + }); + + it("should throw ENOENT for missing paths", async () => { + const fs = new VirtualFs(createReportSource()); + await expect(fs.realpath("/missing")).rejects.toThrow("ENOENT"); + }); + }); + + // ── resolvePath ───────────────────────────────────────── + + describe("resolvePath", () => { + it("should resolve relative paths", () => { + const fs = new VirtualFs(createReportSource()); + expect(fs.resolvePath("/a", "b")).toBe("/a/b"); + expect(fs.resolvePath("/a/b", "../c")).toBe("/a/c"); + }); + + it("should return absolute paths unchanged", () => { + const fs = new VirtualFs(createReportSource()); + expect(fs.resolvePath("/a", "/b")).toBe("/b"); + }); + }); + + // ── getAllPaths ────────────────────────────────────────── + + describe("getAllPaths", () => { + it("should return empty array (dynamic content cannot be enumerated)", () => { + const fs = new VirtualFs(createReportSource()); + expect(fs.getAllPaths()).toEqual([]); + }); + }); + + // ── read-only enforcement ─────────────────────────────── + + describe("read-only enforcement (EROFS)", () => { + const now = new Date(); + const ops: Array<[string, (fs: VirtualFs) => Promise]> = [ + ["writeFile", (fs) => fs.writeFile("/x", "data")], + ["appendFile", (fs) => fs.appendFile("/x", "data")], + ["mkdir", (fs) => fs.mkdir("/x")], + ["rm", (fs) => fs.rm("/x")], + ["cp", (fs) => fs.cp("/a", "/b")], + ["mv", (fs) => fs.mv("/a", "/b")], + ["chmod", (fs) => fs.chmod("/x", 0o644)], + ["symlink", (fs) => fs.symlink("/a", "/b")], + ["link", (fs) => fs.link("/a", "/b")], + ["utimes", (fs) => fs.utimes("/x", now, now)], + ]; + + for (const [name, op] of ops) { + it(`${name} should throw EROFS when source has no hook`, async () => { + const fs = new VirtualFs(createReportSource()); + await expect(op(fs)).rejects.toThrow("EROFS"); + }); + } + }); + + // ── write hooks ──────────────────────────────────────── + + describe("write hooks (delegate to source)", () => { + it("writeFile hook should be called with correct args", async () => { + const spy = vi.fn(async () => {}); + const fs = new VirtualFs({ ...createReportSource(), writeFile: spy }); + await fs.writeFile("/new", "content"); + expect(spy).toHaveBeenCalledWith("/new", "content"); + }); + + it("appendFile hook should be called", async () => { + const spy = vi.fn(async () => {}); + const fs = new VirtualFs({ ...createReportSource(), appendFile: spy }); + await fs.appendFile("/file", "more"); + expect(spy).toHaveBeenCalledWith("/file", "more"); + }); + + it("mkdir hook should be called", async () => { + const spy = vi.fn(async () => {}); + const fs = new VirtualFs({ ...createReportSource(), mkdir: spy }); + await fs.mkdir("/dir", { recursive: true }); + expect(spy).toHaveBeenCalledWith("/dir", { recursive: true }); + }); + + it("rm hook should be called", async () => { + const spy = vi.fn(async () => {}); + const fs = new VirtualFs({ ...createReportSource(), rm: spy }); + await fs.rm("/file"); + expect(spy).toHaveBeenCalledWith("/file"); + }); + + it("cp hook should be called", async () => { + const spy = vi.fn(async () => {}); + const fs = new VirtualFs({ ...createReportSource(), cp: spy }); + await fs.cp("/a", "/b"); + expect(spy).toHaveBeenCalledWith("/a", "/b"); + }); + + it("mv hook should be called", async () => { + const spy = vi.fn(async () => {}); + const fs = new VirtualFs({ ...createReportSource(), mv: spy }); + await fs.mv("/old", "/new"); + expect(spy).toHaveBeenCalledWith("/old", "/new"); + }); + + it("chmod hook should be called", async () => { + const spy = vi.fn(async () => {}); + const fs = new VirtualFs({ ...createReportSource(), chmod: spy }); + await fs.chmod("/file", 0o755); + expect(spy).toHaveBeenCalledWith("/file", 0o755); + }); + + it("symlink hook should be called", async () => { + const spy = vi.fn(async () => {}); + const fs = new VirtualFs({ ...createReportSource(), symlink: spy }); + await fs.symlink("/target", "/link"); + expect(spy).toHaveBeenCalledWith("/target", "/link"); + }); + + it("link hook should be called", async () => { + const spy = vi.fn(async () => {}); + const fs = new VirtualFs({ ...createReportSource(), link: spy }); + await fs.link("/existing", "/new"); + expect(spy).toHaveBeenCalledWith("/existing", "/new"); + }); + + it("utimes hook should be called", async () => { + const spy = vi.fn(async () => {}); + const now = new Date(); + const fs = new VirtualFs({ ...createReportSource(), utimes: spy }); + await fs.utimes("/file", now, now); + expect(spy).toHaveBeenCalledWith("/file", now, now); + }); + + it("hook error should propagate", async () => { + const fs = new VirtualFs({ + ...createReportSource(), + async writeFile() { + throw new Error("disk full"); + }, + }); + await expect(fs.writeFile("/x", "data")).rejects.toThrow("disk full"); + }); + + it("partial hooks: provided hook delegates, missing rejects with EROFS", async () => { + const writeSpy = vi.fn(async () => {}); + const fs = new VirtualFs({ + ...createReportSource(), + writeFile: writeSpy, + }); + await fs.writeFile("/ok", "data"); + expect(writeSpy).toHaveBeenCalledOnce(); + await expect(fs.mkdir("/dir")).rejects.toThrow("EROFS"); + await expect(fs.rm("/file")).rejects.toThrow("EROFS"); + }); + }); + + // ── readlink ──────────────────────────────────────────── + + describe("readlink", () => { + it("should throw EINVAL", async () => { + const fs = new VirtualFs(createReportSource()); + await expect(fs.readlink("/report-1")).rejects.toThrow("EINVAL"); + }); + }); + + // ── defineVirtualFs ───────────────────────────────────── + + describe("defineVirtualFs", () => { + it("should create a parameterized factory", async () => { + const factory = defineVirtualFs((opts: { prefix: string }) => ({ + async readFile(path) { + return `${opts.prefix}:${path}`; + }, + async readdir() { + return null; + }, + })); + + const source = factory({ prefix: "test" }); + const fs = new VirtualFs(source); + expect(await fs.readFile("/hello")).toBe("test:/hello"); + }); + }); + + // ── dispose ───────────────────────────────────────────── + + describe("dispose", () => { + it("should call source.dispose when provided", async () => { + const disposeFn = vi.fn(async () => {}); + const fs = new VirtualFs({ + ...createReportSource(), + dispose: disposeFn, + }); + await fs.dispose(); + expect(disposeFn).toHaveBeenCalledOnce(); + }); + + it("should succeed when source has no dispose", async () => { + const fs = new VirtualFs(createReportSource()); + await expect(fs.dispose()).resolves.toBeUndefined(); + }); + }); + + // ── MountableFs integration ───────────────────────────── + + describe("MountableFs integration", () => { + it("should serve files through a mount point", async () => { + const mfs = new MountableFs({ + mounts: [ + { + mountPoint: "/reports", + filesystem: new VirtualFs(createReportSource()), + }, + ], + }); + const content = await mfs.readFile("/reports/report-1"); + expect(content).toBe("Status: OK\nMetric: 42\n"); + }); + + it("should list entries through a mount point", async () => { + const mfs = new MountableFs({ + mounts: [ + { + mountPoint: "/reports", + filesystem: new VirtualFs(createReportSource()), + }, + ], + }); + const entries = await mfs.readdir("/reports"); + expect(entries).toEqual(["report-1", "report-2", "report-3"]); + }); + + it("should coexist with regular files on the base fs", async () => { + const base = new InMemoryFs({ "/readme.txt": "hello" }); + const mfs = new MountableFs({ + base, + mounts: [ + { + mountPoint: "/data", + filesystem: new VirtualFs(createReportSource()), + }, + ], + }); + expect(await mfs.readFile("/readme.txt")).toBe("hello"); + expect(await mfs.readFile("/data/report-1")).toContain("OK"); + }); + + it("should handle nested virtual directories through mount", async () => { + const mfs = new MountableFs({ + mounts: [ + { + mountPoint: "/metrics", + filesystem: new VirtualFs(createNestedSource()), + }, + ], + }); + expect(await mfs.readdir("/metrics")).toEqual(["cpu", "status.json"]); + expect(await mfs.readdir("/metrics/cpu")).toEqual([ + "node-1.txt", + "node-2.txt", + ]); + expect(await mfs.readFile("/metrics/cpu/node-1.txt")).toBe( + "usage: 45%\n", + ); + }); + }); + + // ── Bash e2e ──────────────────────────────────────────── + + describe("Bash e2e", () => { + function createBash(): Bash { + return new Bash({ + fs: new MountableFs({ + mounts: [ + { + mountPoint: "/reports", + filesystem: new VirtualFs(createReportSource()), + }, + { + mountPoint: "/metrics", + filesystem: new VirtualFs(createNestedSource()), + }, + ], + }), + }); + } + + it("ls should list virtual files", async () => { + const bash = createBash(); + const result = await bash.exec("ls /reports"); + expect(result.stdout).toContain("report-1"); + expect(result.stdout).toContain("report-2"); + expect(result.stdout).toContain("report-3"); + expect(result.exitCode).toBe(0); + }); + + it("cat should read virtual file content", async () => { + const bash = createBash(); + const result = await bash.exec("cat /reports/report-2"); + expect(result.stdout).toContain("ERROR"); + expect(result.exitCode).toBe(0); + }); + + it("cat should fail on missing virtual files", async () => { + const bash = createBash(); + const result = await bash.exec("cat /reports/nope"); + expect(result.exitCode).not.toBe(0); + }); + + it("grep should search across virtual files", async () => { + const bash = createBash(); + const result = await bash.exec("grep ERROR /reports/report-2"); + expect(result.stdout).toContain("ERROR"); + expect(result.exitCode).toBe(0); + }); + + it("wc should count lines in virtual files", async () => { + const bash = createBash(); + const result = await bash.exec("wc -l /reports/report-1"); + expect(result.exitCode).toBe(0); + expect(result.stdout.trim()).toContain("2"); + }); + + it("pipelines should work with virtual files", async () => { + const bash = createBash(); + const result = await bash.exec( + "cat /reports/report-1 | grep Metric", + ); + expect(result.stdout).toContain("42"); + expect(result.exitCode).toBe(0); + }); + + it("ls should list nested virtual directories", async () => { + const bash = createBash(); + const result = await bash.exec("ls /metrics/cpu"); + expect(result.stdout).toContain("node-1.txt"); + expect(result.stdout).toContain("node-2.txt"); + expect(result.exitCode).toBe(0); + }); + + it("cat should read from nested virtual paths", async () => { + const bash = createBash(); + const result = await bash.exec("cat /metrics/cpu/node-1.txt"); + expect(result.stdout).toBe("usage: 45%\n"); + }); + + it("shell should not be able to write to virtual fs without hooks", async () => { + const bash = createBash(); + await expect( + bash.exec("echo hello > /reports/new-file"), + ).rejects.toThrow("EROFS"); + }); + + it("shell write should succeed when writeFile hook is provided", async () => { + const written: Record = {}; + const source: VirtualFsSource = { + ...createReportSource(), + async writeFile(path: string, content) { + written[path] = String(content); + }, + }; + const bash = new Bash({ + fs: new MountableFs({ + mounts: [ + { mountPoint: "/reports", filesystem: new VirtualFs(source) }, + ], + }), + }); + const result = await bash.exec("echo hello > /reports/new-file"); + expect(result.exitCode).toBe(0); + expect(written["/new-file"]).toContain("hello"); + }); + }); +}); diff --git a/src/fs/virtual-fs/virtual-fs.ts b/src/fs/virtual-fs/virtual-fs.ts new file mode 100644 index 00000000..fe054995 --- /dev/null +++ b/src/fs/virtual-fs/virtual-fs.ts @@ -0,0 +1,272 @@ +/** + * IFileSystem backed by user-provided async hooks. + * + * Content is generated on the fly by a {@link VirtualFsSource} — + * the filesystem never stores anything itself. Write operations + * delegate to optional source hooks or reject with EROFS. + */ + +import { fromBuffer, getEncoding, toBuffer } from "../encoding.js"; +import type { + BufferEncoding, + DirentEntry, + FsStat, + IFileSystem, + ReadFileOptions, +} from "../interface.js"; +import { + DEFAULT_DIR_MODE, + DEFAULT_FILE_MODE, + normalizePath, + resolvePath, + validatePath, +} from "../path-utils.js"; + +const textEncoder = new TextEncoder(); + +/** Simplified DirentEntry without isSymbolicLink, used by source hooks. */ +export type VirtualDirent = Omit; + +/** Optional write hooks — signatures picked straight from {@link IFileSystem}. */ +export type VirtualFsWriteHooks = Partial>; + +/** + * Async hooks that drive a {@link VirtualFs}. + * + * Only {@link readFile} and {@link readdir} are required. + * `stat` and `exists` are derived automatically when not provided. + * Write hooks are optional — absent hooks reject with EROFS. + */ +export interface VirtualFsSource extends VirtualFsWriteHooks { + /** Return file content or `null` when the path does not exist. */ + readFile(path: string): Promise; + + /** Return directory entries or `null` when the path is not a directory. */ + readdir(path: string): Promise; + + /** Optional — derived from readdir/readFile when absent. */ + stat?(path: string): Promise; + + /** Optional — derived from stat when absent. Same contract as {@link IFileSystem.exists}. */ + exists?: IFileSystem["exists"]; + + /** Called by {@link VirtualFs.dispose} to release external resources. */ + dispose?(): Promise; +} + +/** Create a typed factory for virtual filesystem sources. */ +export function defineVirtualFs( + factory: (options: T) => VirtualFsSource, +): (options: T) => VirtualFsSource { + return factory; +} + +/** + * {@link IFileSystem} whose content is generated at runtime + * by a pluggable {@link VirtualFsSource}. + * + * Write operations delegate to optional source hooks or reject with EROFS. + * Typically mounted inside a {@link MountableFs} so the shell can + * access the virtual tree with standard bash commands. + */ +export class VirtualFs implements IFileSystem { + private source: VirtualFsSource; + + constructor(source: VirtualFsSource) { + this.source = source; + } + + // ── reads ──────────────────────────────────────────────── + + async readFile( + path: string, + options?: ReadFileOptions | BufferEncoding, + ): Promise { + const content = await this.fetchContent(path); + return content instanceof Uint8Array + ? fromBuffer(content, getEncoding(options)) + : content; + } + + async readFileBuffer(path: string): Promise { + const content = await this.fetchContent(path); + return content instanceof Uint8Array ? content : toBuffer(content); + } + + async readdir(path: string): Promise { + const entries = await this.fetchEntries(path); + return entries.map((e) => e.name).sort(); + } + + async readdirWithFileTypes(path: string): Promise { + const entries = await this.fetchEntries(path); + return entries + .map((e) => ({ ...e, isSymbolicLink: false })) + .sort((a, b) => a.name.localeCompare(b.name)); + } + + // ── stat / exists ──────────────────────────────────────── + + async stat(path: string): Promise { + const normalized = this.normalize(path); + + // User-provided stat takes precedence + if (this.source.stat) { + const result = await this.source.stat(normalized); + if (result) return result; + if (normalized === "/") return this.dirStat(); + throw new Error( + `ENOENT: no such file or directory, stat '${path}'`, + ); + } + + // Root is always a directory + if (normalized === "/") return this.dirStat(); + + // Derive from readdir (directory?) then readFile (file?) + const entries = await this.source.readdir(normalized); + if (entries !== null) return this.dirStat(); + + const content = await this.source.readFile(normalized); + if (content !== null) { + const size = + typeof content === "string" + ? textEncoder.encode(content).length + : content.length; + return { + isFile: true, + isDirectory: false, + isSymbolicLink: false, + mode: DEFAULT_FILE_MODE, + size, + mtime: new Date(), + }; + } + + throw new Error( + `ENOENT: no such file or directory, stat '${path}'`, + ); + } + + async lstat(path: string): Promise { + return this.stat(path); + } + + async exists(path: string): Promise { + const normalized = this.normalize(path); + if (this.source.exists) { + return this.source.exists(normalized); + } + try { + await this.stat(path); + return true; + } catch { + return false; + } + } + + // ── path helpers ───────────────────────────────────────── + + resolvePath(base: string, path: string): string { + return resolvePath(base, path); + } + + getAllPaths(): string[] { + return []; + } + + async realpath(path: string): Promise { + const normalized = this.normalize(path); + await this.stat(path); + return normalized; + } + + // ── write ops — delegate to source hook or reject with EROFS ── + + writeFile(...args: Parameters): Promise { + return this.source.writeFile ? this.source.writeFile(...args) : Promise.reject(this.readOnlyError("writeFile")); + } + appendFile(...args: Parameters): Promise { + return this.source.appendFile ? this.source.appendFile(...args) : Promise.reject(this.readOnlyError("appendFile")); + } + mkdir(...args: Parameters): Promise { + return this.source.mkdir ? this.source.mkdir(...args) : Promise.reject(this.readOnlyError("mkdir")); + } + rm(...args: Parameters): Promise { + return this.source.rm ? this.source.rm(...args) : Promise.reject(this.readOnlyError("rm")); + } + cp(...args: Parameters): Promise { + return this.source.cp ? this.source.cp(...args) : Promise.reject(this.readOnlyError("cp")); + } + mv(...args: Parameters): Promise { + return this.source.mv ? this.source.mv(...args) : Promise.reject(this.readOnlyError("mv")); + } + chmod(...args: Parameters): Promise { + return this.source.chmod ? this.source.chmod(...args) : Promise.reject(this.readOnlyError("chmod")); + } + symlink(...args: Parameters): Promise { + return this.source.symlink ? this.source.symlink(...args) : Promise.reject(this.readOnlyError("symlink")); + } + link(...args: Parameters): Promise { + return this.source.link ? this.source.link(...args) : Promise.reject(this.readOnlyError("link")); + } + utimes(...args: Parameters): Promise { + return this.source.utimes ? this.source.utimes(...args) : Promise.reject(this.readOnlyError("utimes")); + } + + readlink(path: string): Promise { + return Promise.reject(new Error(`EINVAL: invalid argument, readlink '${path}'`)); + } + + // ── lifecycle ──────────────────────────────────────────── + + /** Release resources held by the underlying source. */ + async dispose(): Promise { + if (this.source.dispose) { + await this.source.dispose(); + } + } + + // ── private ────────────────────────────────────────────── + + private async fetchContent(path: string): Promise { + const normalized = this.normalize(path); + const content = await this.source.readFile(normalized); + if (content === null) { + throw new Error(`ENOENT: no such file or directory, open '${path}'`); + } + return content; + } + + private async fetchEntries(path: string): Promise { + const normalized = this.normalize(path); + const entries = await this.source.readdir(normalized); + if (entries === null) { + throw new Error(`ENOENT: no such file or directory, scandir '${path}'`); + } + return entries; + } + + private normalize(path: string): string { + validatePath(path, "access"); + return normalizePath(path); + } + + private dirStat(): FsStat { + return { + isFile: false, + isDirectory: true, + isSymbolicLink: false, + mode: DEFAULT_DIR_MODE, + size: 0, + mtime: new Date(), + }; + } + + private readOnlyError(operation: string): Error { + return new Error(`EROFS: read-only file system, ${operation}`); + } +} diff --git a/src/index.ts b/src/index.ts index ac364c7f..54a0b872 100644 --- a/src/index.ts +++ b/src/index.ts @@ -58,6 +58,13 @@ export { ReadWriteFs, type ReadWriteFsOptions, } from "./fs/read-write-fs/index.js"; +export { + defineVirtualFs, + VirtualFs, + type VirtualDirent, + type VirtualFsSource, + type VirtualFsWriteHooks, +} from "./fs/virtual-fs/index.js"; export type { AllowedUrl, AllowedUrlEntry, From 393124a6bf7733928dc8070863f338d427da8f13 Mon Sep 17 00:00:00 2001 From: Shaar <40307680+Shaar-games@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:52:09 +0200 Subject: [PATCH 2/2] Normalize paths in VirtualFs write operations Apply consistent path normalization and null-byte validation to all write operations (writeFile, appendFile, mkdir, rm, cp, mv, chmod, symlink, link, utimes) before passing paths to source hooks, matching the behavior of read operations for security and consistency. Made-with: Cursor --- src/fs/virtual-fs/virtual-fs.ts | 50 ++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/src/fs/virtual-fs/virtual-fs.ts b/src/fs/virtual-fs/virtual-fs.ts index fe054995..c9dfb33f 100644 --- a/src/fs/virtual-fs/virtual-fs.ts +++ b/src/fs/virtual-fs/virtual-fs.ts @@ -186,35 +186,45 @@ export class VirtualFs implements IFileSystem { // ── write ops — delegate to source hook or reject with EROFS ── - writeFile(...args: Parameters): Promise { - return this.source.writeFile ? this.source.writeFile(...args) : Promise.reject(this.readOnlyError("writeFile")); + writeFile(path: string, ...rest: Parameters extends [string, ...infer R] ? R : never): Promise { + if (!this.source.writeFile) return Promise.reject(this.readOnlyError("writeFile")); + return this.source.writeFile(this.normalize(path), ...rest); } - appendFile(...args: Parameters): Promise { - return this.source.appendFile ? this.source.appendFile(...args) : Promise.reject(this.readOnlyError("appendFile")); + appendFile(path: string, ...rest: Parameters extends [string, ...infer R] ? R : never): Promise { + if (!this.source.appendFile) return Promise.reject(this.readOnlyError("appendFile")); + return this.source.appendFile(this.normalize(path), ...rest); } - mkdir(...args: Parameters): Promise { - return this.source.mkdir ? this.source.mkdir(...args) : Promise.reject(this.readOnlyError("mkdir")); + mkdir(path: string, ...rest: Parameters extends [string, ...infer R] ? R : never): Promise { + if (!this.source.mkdir) return Promise.reject(this.readOnlyError("mkdir")); + return this.source.mkdir(this.normalize(path), ...rest); } - rm(...args: Parameters): Promise { - return this.source.rm ? this.source.rm(...args) : Promise.reject(this.readOnlyError("rm")); + rm(path: string, ...rest: Parameters extends [string, ...infer R] ? R : never): Promise { + if (!this.source.rm) return Promise.reject(this.readOnlyError("rm")); + return this.source.rm(this.normalize(path), ...rest); } - cp(...args: Parameters): Promise { - return this.source.cp ? this.source.cp(...args) : Promise.reject(this.readOnlyError("cp")); + cp(src: string, dest: string, ...rest: Parameters extends [string, string, ...infer R] ? R : never): Promise { + if (!this.source.cp) return Promise.reject(this.readOnlyError("cp")); + return this.source.cp(this.normalize(src), this.normalize(dest), ...rest); } - mv(...args: Parameters): Promise { - return this.source.mv ? this.source.mv(...args) : Promise.reject(this.readOnlyError("mv")); + mv(src: string, dest: string, ...rest: Parameters extends [string, string, ...infer R] ? R : never): Promise { + if (!this.source.mv) return Promise.reject(this.readOnlyError("mv")); + return this.source.mv(this.normalize(src), this.normalize(dest), ...rest); } - chmod(...args: Parameters): Promise { - return this.source.chmod ? this.source.chmod(...args) : Promise.reject(this.readOnlyError("chmod")); + chmod(path: string, ...rest: Parameters extends [string, ...infer R] ? R : never): Promise { + if (!this.source.chmod) return Promise.reject(this.readOnlyError("chmod")); + return this.source.chmod(this.normalize(path), ...rest); } - symlink(...args: Parameters): Promise { - return this.source.symlink ? this.source.symlink(...args) : Promise.reject(this.readOnlyError("symlink")); + symlink(target: string, path: string, ...rest: Parameters extends [string, string, ...infer R] ? R : never): Promise { + if (!this.source.symlink) return Promise.reject(this.readOnlyError("symlink")); + return this.source.symlink(this.normalize(target), this.normalize(path), ...rest); } - link(...args: Parameters): Promise { - return this.source.link ? this.source.link(...args) : Promise.reject(this.readOnlyError("link")); + link(target: string, path: string, ...rest: Parameters extends [string, string, ...infer R] ? R : never): Promise { + if (!this.source.link) return Promise.reject(this.readOnlyError("link")); + return this.source.link(this.normalize(target), this.normalize(path), ...rest); } - utimes(...args: Parameters): Promise { - return this.source.utimes ? this.source.utimes(...args) : Promise.reject(this.readOnlyError("utimes")); + utimes(path: string, ...rest: Parameters extends [string, ...infer R] ? R : never): Promise { + if (!this.source.utimes) return Promise.reject(this.readOnlyError("utimes")); + return this.source.utimes(this.normalize(path), ...rest); } readlink(path: string): Promise {