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..c9dfb33f --- /dev/null +++ b/src/fs/virtual-fs/virtual-fs.ts @@ -0,0 +1,282 @@ +/** + * 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(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(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(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(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(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(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(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(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(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(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 { + 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,