diff --git a/CLAUDE.md b/CLAUDE.md index 2412e4fc..d4df2cfe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,8 +121,15 @@ Input Script → Parser (src/parser/) → AST (src/ast/) → Interpreter (src/in - Each command in its own directory with implementation + tests - Registry pattern via `registry.ts` -**Filesystem** (`src/fs.ts`, `src/overlay-fs/`): In-memory VFS with optional overlay on real filesystem - +**Filesystem** (`src/fs/`): In-memory VFS with pluggable backends + +- `interface.ts` - `IFileSystem` interface all backends implement +- `in-memory-fs/` - Pure in-memory filesystem (default) +- `overlay-fs/` - Copy-on-write over a real directory (reads from disk, writes to memory) +- `read-write-fs/` - Direct read-write to a real directory +- `http-fs/` - Read-only filesystem backed by HTTP `fetch()`. Manifest-driven (file tree declared up front), lazy-fetches on first read, caches in memory. Zero dependencies. +- `mountable-fs/` - Compose multiple `IFileSystem` backends at different mount points +- `mount.ts` - `mount()` helper for concise filesystem composition - `real-fs-utils.ts` - Shared security helpers for real-FS-backed implementations - `OverlayFs` / `ReadWriteFs` - Both default to `allowSymlinks: false` (symlinks blocked) - Symlink policy is enforced at central gate functions (`resolveAndValidate`, `validateRealPath_`) so new methods get protection automatically @@ -181,6 +188,27 @@ When adding comparison tests: 3. Commit both the test file and the generated fixture JSON 4. If manually adjusting for Linux behavior, add `"locked": true` to the fixture +## Composing Filesystems with `mount()` and `HttpFs` + +Use `mount()` to compose multiple `IFileSystem` backends into a unified namespace: + +```typescript +import { Bash, mount, HttpFs } from "just-bash"; + +const fs = mount({ + "/data": new HttpFs("https://cdn.example.com/dataset", [ + "train.csv", + "test.csv", + ]), +}); +const bash = new Bash({ fs }); +await bash.exec("cat /data/train.csv | wc -l"); +``` + +`HttpFs` accepts a file list (array of paths or `Record`), an optional `fetch` function, and optional `headers`. Files are fetched lazily and cached. All write operations throw `EROFS`. Use `prefetch()` to eagerly load all files in parallel. + +When `mount()` doesn't receive a `"/"` entry, it creates an `InMemoryFs` base pre-initialised with `/dev`, `/proc`, `/bin` so the shell works out of the box. + ## Filesystem Security: Default-Deny Symlinks `OverlayFs` and `ReadWriteFs` default to `allowSymlinks: false`. This means: @@ -196,6 +224,12 @@ When adding comparison tests: **In tests**: Pass `allowSymlinks: true` to the constructor when testing symlink behavior. The `cross-fs-no-symlinks.test.ts` file tests the default-deny behavior. +## Redirect Error Handling + +All filesystem writes in the redirection system (`src/interpreter/redirections.ts`) go through `redirectWrite()` and `redirectAppend()` helpers. These catch FS exceptions and convert them to bash-style error messages (e.g. `bash: /path: Read-only file system`). This ensures read-only backends like `HttpFs` don't crash the interpreter when scripts attempt writes via redirections. + +**When adding new redirect operators**: Use `redirectWrite()` / `redirectAppend()` for all FS writes. Never call `ctx.fs.writeFile()` or `ctx.fs.appendFile()` directly in redirection code. + ## Development Guidelines - Read AGENTS.md diff --git a/README.md b/README.md index d093a3d1..b7358a12 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,35 @@ const env = new Bash({ fs: rwfs }); await env.exec('echo "hello" > file.txt'); // writes to real filesystem ``` +**HttpFs** - Read-only filesystem backed by HTTP. Files are declared up front and fetched lazily on first read, then cached in memory. No dependencies beyond `fetch`: + +```typescript +import { Bash, HttpFs, mount } from "just-bash"; + +const fs = mount({ + "/data": new HttpFs("https://cdn.example.com/dataset", [ + "train.csv", + "test.csv", + "metadata.json", + ]), +}); + +const bash = new Bash({ fs }); + +await bash.exec("wc -l /data/train.csv"); // fetches once, then cached +await bash.exec("cat /data/metadata.json | jq .name"); // reads from cache +await bash.exec("ls /data"); // from manifest, no network +await bash.exec("echo x > /data/new.txt"); // EROFS: read-only file system +``` + +You can pass custom headers (e.g. for auth) and a custom fetch function: + +```typescript +const fs = new HttpFs("https://api.example.com/files", ["secret.json"], { + headers: { Authorization: "Bearer tok123" }, +}); +``` + **MountableFs** - Mount multiple filesystems at different paths. Combines read-only and read-write filesystems into a unified namespace: ```typescript @@ -188,6 +217,21 @@ const fs = new MountableFs({ }); ``` +**`mount()` helper** - Shorthand for composing filesystems. Automatically creates an initialised base `InMemoryFs` (with `/dev`, `/proc`, `/bin`) when you don't provide `"/"`: + +```typescript +import { Bash, mount, HttpFs } from "just-bash"; +import { OverlayFs } from "just-bash/fs/overlay-fs"; + +const fs = mount({ + "/project": new OverlayFs({ root: "./my-project", readOnly: true }), + "/data": new HttpFs("https://cdn.example.com/dataset", ["train.csv", "test.csv"]), +}); + +const bash = new Bash({ fs }); +await bash.exec("cat /data/train.csv | wc -l"); +``` + ### AI SDK Tool For AI agents, use [`bash-tool`](https://github.com/vercel-labs/bash-tool) which is optimized for just-bash and provides a ready-to-use [AI SDK](https://ai-sdk.dev/) tool: diff --git a/src/cli/just-bash.test.ts b/src/cli/just-bash.test.ts index 8b55dad6..c7baa078 100644 --- a/src/cli/just-bash.test.ts +++ b/src/cli/just-bash.test.ts @@ -118,7 +118,7 @@ describe("just-bash CLI", () => { tempDir, ]); expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("EROFS"); + expect(result.stderr).toContain("Read-only file system"); }); it("should block mkdir by default", () => { diff --git a/src/fs/http-fs/http-fs.test.ts b/src/fs/http-fs/http-fs.test.ts new file mode 100644 index 00000000..822b4e15 --- /dev/null +++ b/src/fs/http-fs/http-fs.test.ts @@ -0,0 +1,427 @@ +import { describe, expect, it, vi } from "vitest"; +import { HttpFs } from "./http-fs.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function mockFetch(files: Record) { + return vi.fn(async (url: string, init?: RequestInit) => { + void init; + for (const [path, content] of Object.entries(files)) { + if (url.endsWith(path)) { + const body = + typeof content === "string" + ? content + : new Blob([content as BlobPart]); + return new Response(body, { status: 200 }); + } + } + return new Response("Not Found", { status: 404 }); + }); +} + +function createFs( + files: string[], + served: Record, + options?: { headers?: Record; maxFileSize?: number }, +) { + const fetch = mockFetch(served); + const fs = new HttpFs("https://cdn.test", files, { fetch, ...options }); + return { fs, fetch }; +} + +// --------------------------------------------------------------------------- +// Tree construction +// --------------------------------------------------------------------------- + +describe("HttpFs", () => { + describe("tree construction", () => { + it("builds directories from file paths", async () => { + const { fs } = createFs( + ["src/index.ts", "src/utils.ts", "README.md"], + {}, + ); + + expect(await fs.exists("/")).toBe(true); + expect(await fs.exists("/src")).toBe(true); + expect(await fs.exists("/src/index.ts")).toBe(true); + expect(await fs.exists("/README.md")).toBe(true); + expect(await fs.exists("/nope")).toBe(false); + }); + + it("accepts paths with leading slashes", async () => { + const { fs } = createFs(["/a/b.txt", "/c.txt"], {}); + + expect(await fs.exists("/a")).toBe(true); + expect(await fs.exists("/a/b.txt")).toBe(true); + expect(await fs.exists("/c.txt")).toBe(true); + }); + + it("accepts a record with metadata", async () => { + const fetch = mockFetch({}); + const fs = new HttpFs( + "https://cdn.test", + { "data.csv": { size: 999 } }, + { fetch }, + ); + + const stat = await fs.stat("/data.csv"); + expect(stat.size).toBe(999); + expect(stat.isFile).toBe(true); + }); + + it("handles explicit directory entries (trailing slash)", async () => { + const { fs } = createFs(["empty/", "empty/sub/"], {}); + + expect(await fs.exists("/empty")).toBe(true); + expect((await fs.stat("/empty")).isDirectory).toBe(true); + expect(await fs.exists("/empty/sub")).toBe(true); + expect((await fs.stat("/empty/sub")).isDirectory).toBe(true); + }); + + it("handles deeply nested paths", async () => { + const { fs } = createFs(["a/b/c/d/e.txt"], {}); + + expect(await fs.exists("/a")).toBe(true); + expect(await fs.exists("/a/b")).toBe(true); + expect(await fs.exists("/a/b/c")).toBe(true); + expect(await fs.exists("/a/b/c/d")).toBe(true); + expect(await fs.exists("/a/b/c/d/e.txt")).toBe(true); + }); + }); + + // --------------------------------------------------------------------------- + // Reading files + // --------------------------------------------------------------------------- + + describe("readFile", () => { + it("fetches on first read, caches on second", async () => { + const { fs, fetch } = createFs(["hello.txt"], { + "/hello.txt": "Hello, world!", + }); + + const first = await fs.readFile("/hello.txt"); + expect(first).toBe("Hello, world!"); + expect(fetch).toHaveBeenCalledTimes(1); + + const second = await fs.readFile("/hello.txt"); + expect(second).toBe("Hello, world!"); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("fetches binary content via readFileBuffer", async () => { + const bytes = new Uint8Array([0x00, 0xff, 0x42]); + const fetch = mockFetch({ "/bin.dat": bytes }); + const fs = new HttpFs("https://cdn.test", ["bin.dat"], { fetch }); + + const buf = await fs.readFileBuffer("/bin.dat"); + expect(buf).toEqual(bytes); + }); + + it("throws ENOENT for files not in manifest", async () => { + const { fs } = createFs(["a.txt"], {}); + await expect(fs.readFile("/missing.txt")).rejects.toThrow("ENOENT"); + }); + + it("throws ENOENT when server returns 404", async () => { + const { fs } = createFs(["ghost.txt"], {}); + await expect(fs.readFile("/ghost.txt")).rejects.toThrow("ENOENT"); + }); + + it("throws EISDIR when reading a directory", async () => { + const { fs } = createFs(["dir/file.txt"], {}); + await expect(fs.readFile("/dir")).rejects.toThrow("EISDIR"); + }); + + it("throws EFBIG for oversized files", async () => { + const fetch = mockFetch({ "/big.txt": "x".repeat(100) }); + const fs = new HttpFs("https://cdn.test", ["big.txt"], { + fetch, + maxFileSize: 10, + }); + + await expect(fs.readFile("/big.txt")).rejects.toThrow("EFBIG"); + }); + }); + + // --------------------------------------------------------------------------- + // stat / lstat + // --------------------------------------------------------------------------- + + describe("stat", () => { + it("returns file stats", async () => { + const fetch = mockFetch({}); + const fs = new HttpFs( + "https://cdn.test", + { "f.txt": { size: 42 } }, + { fetch }, + ); + + const s = await fs.stat("/f.txt"); + expect(s.isFile).toBe(true); + expect(s.isDirectory).toBe(false); + expect(s.isSymbolicLink).toBe(false); + expect(s.size).toBe(42); + expect(s.mode).toBe(0o644); + }); + + it("returns directory stats", async () => { + const { fs } = createFs(["d/x.txt"], {}); + + const s = await fs.stat("/d"); + expect(s.isFile).toBe(false); + expect(s.isDirectory).toBe(true); + expect(s.mode).toBe(0o755); + }); + + it("updates size after fetch", async () => { + const { fs } = createFs(["f.txt"], { "/f.txt": "abcdef" }); + + expect((await fs.stat("/f.txt")).size).toBe(0); + await fs.readFile("/f.txt"); + expect((await fs.stat("/f.txt")).size).toBe(6); + }); + + it("throws ENOENT for missing paths", async () => { + const { fs } = createFs([], {}); + await expect(fs.stat("/nope")).rejects.toThrow("ENOENT"); + }); + + it("lstat behaves like stat (no symlinks)", async () => { + const { fs } = createFs(["a.txt"], {}); + const stat = await fs.stat("/a.txt"); + const lstat = await fs.lstat("/a.txt"); + expect(stat).toEqual(lstat); + }); + }); + + // --------------------------------------------------------------------------- + // readdir / readdirWithFileTypes + // --------------------------------------------------------------------------- + + describe("readdir", () => { + it("lists directory contents sorted", async () => { + const { fs } = createFs(["z.txt", "a.txt", "m/nested.txt"], {}); + + expect(await fs.readdir("/")).toEqual(["a.txt", "m", "z.txt"]); + expect(await fs.readdir("/m")).toEqual(["nested.txt"]); + }); + + it("throws ENOENT for missing directories", async () => { + const { fs } = createFs([], {}); + await expect(fs.readdir("/missing")).rejects.toThrow("ENOENT"); + }); + + it("throws ENOTDIR for files", async () => { + const { fs } = createFs(["f.txt"], {}); + await expect(fs.readdir("/f.txt")).rejects.toThrow("ENOTDIR"); + }); + }); + + describe("readdirWithFileTypes", () => { + it("returns entries with type info", async () => { + const { fs } = createFs(["dir/a.txt", "dir/sub/b.txt"], {}); + + const entries = await fs.readdirWithFileTypes("/dir"); + expect(entries).toEqual([ + { + name: "a.txt", + isFile: true, + isDirectory: false, + isSymbolicLink: false, + }, + { + name: "sub", + isFile: false, + isDirectory: true, + isSymbolicLink: false, + }, + ]); + }); + }); + + // --------------------------------------------------------------------------- + // Path resolution + // --------------------------------------------------------------------------- + + describe("resolvePath", () => { + it("resolves absolute paths", () => { + const { fs } = createFs([], {}); + expect(fs.resolvePath("/foo", "/bar")).toBe("/bar"); + }); + + it("resolves relative paths against base", () => { + const { fs } = createFs([], {}); + expect(fs.resolvePath("/foo", "bar")).toBe("/foo/bar"); + expect(fs.resolvePath("/", "bar")).toBe("/bar"); + }); + + it("handles .. and .", () => { + const { fs } = createFs([], {}); + expect(fs.resolvePath("/a/b", "../c")).toBe("/a/c"); + expect(fs.resolvePath("/a/b", "./c")).toBe("/a/b/c"); + }); + }); + + describe("getAllPaths", () => { + it("returns all paths sorted", () => { + const { fs } = createFs(["b.txt", "a/c.txt"], {}); + expect(fs.getAllPaths()).toEqual(["/", "/a", "/a/c.txt", "/b.txt"]); + }); + }); + + describe("realpath", () => { + it("returns normalized path for existing entries", async () => { + const { fs } = createFs(["a.txt"], {}); + expect(await fs.realpath("/a.txt")).toBe("/a.txt"); + expect(await fs.realpath("/")).toBe("/"); + }); + + it("throws ENOENT for missing paths", async () => { + const { fs } = createFs([], {}); + await expect(fs.realpath("/nope")).rejects.toThrow("ENOENT"); + }); + }); + + // --------------------------------------------------------------------------- + // Write operations (all EROFS) + // --------------------------------------------------------------------------- + + describe("write operations throw EROFS", () => { + it("writeFile", async () => { + const { fs } = createFs([], {}); + await expect(fs.writeFile("/x", "y")).rejects.toThrow("EROFS"); + }); + + it("appendFile", async () => { + const { fs } = createFs([], {}); + await expect(fs.appendFile("/x", "y")).rejects.toThrow("EROFS"); + }); + + it("mkdir", async () => { + const { fs } = createFs([], {}); + await expect(fs.mkdir("/x")).rejects.toThrow("EROFS"); + }); + + it("rm", async () => { + const { fs } = createFs(["a.txt"], {}); + await expect(fs.rm("/a.txt")).rejects.toThrow("EROFS"); + }); + + it("cp", async () => { + const { fs } = createFs(["a.txt"], {}); + await expect(fs.cp("/a.txt", "/b.txt")).rejects.toThrow("EROFS"); + }); + + it("mv", async () => { + const { fs } = createFs(["a.txt"], {}); + await expect(fs.mv("/a.txt", "/b.txt")).rejects.toThrow("EROFS"); + }); + + it("chmod", async () => { + const { fs } = createFs(["a.txt"], {}); + await expect(fs.chmod("/a.txt", 0o777)).rejects.toThrow("EROFS"); + }); + + it("symlink", async () => { + const { fs } = createFs([], {}); + await expect(fs.symlink("/a", "/b")).rejects.toThrow("EROFS"); + }); + + it("link", async () => { + const { fs } = createFs(["a.txt"], {}); + await expect(fs.link("/a.txt", "/b.txt")).rejects.toThrow("EROFS"); + }); + + it("utimes", async () => { + const { fs } = createFs(["a.txt"], {}); + const now = new Date(); + await expect(fs.utimes("/a.txt", now, now)).rejects.toThrow("EROFS"); + }); + + it("readlink", async () => { + const { fs } = createFs(["a.txt"], {}); + await expect(fs.readlink("/a.txt")).rejects.toThrow("EINVAL"); + }); + }); + + // --------------------------------------------------------------------------- + // Fetch behaviour + // --------------------------------------------------------------------------- + + describe("fetch behaviour", () => { + it("passes custom headers", async () => { + const fetch = mockFetch({ "/secret.txt": "classified" }); + const fs = new HttpFs("https://cdn.test", ["secret.txt"], { + fetch, + headers: { Authorization: "Bearer tok123" }, + }); + + await fs.readFile("/secret.txt"); + + const call = fetch.mock.calls[0] as unknown[]; + const init = call[1] as RequestInit | undefined; + expect(init?.headers).toEqual( + expect.objectContaining({ Authorization: "Bearer tok123" }), + ); + }); + + it("normalises base URL without trailing slash", async () => { + const fetch = mockFetch({ "/data.json": "{}" }); + const fs = new HttpFs("https://cdn.test", ["data.json"], { fetch }); + + await fs.readFile("/data.json"); + + expect(fetch.mock.calls[0][0]).toBe("https://cdn.test/data.json"); + }); + + it("normalises base URL with trailing slash", async () => { + const fetch = mockFetch({ "/data.json": "{}" }); + const fs = new HttpFs("https://cdn.test/", ["data.json"], { fetch }); + + await fs.readFile("/data.json"); + + expect(fetch.mock.calls[0][0]).toBe("https://cdn.test/data.json"); + }); + + it("constructs correct URLs for nested files", async () => { + const fetch = mockFetch({ "/a/b/c.txt": "deep" }); + const fs = new HttpFs("https://cdn.test", ["a/b/c.txt"], { fetch }); + + await fs.readFile("/a/b/c.txt"); + + expect(fetch.mock.calls[0][0]).toBe("https://cdn.test/a/b/c.txt"); + }); + + it("throws EIO for server errors", async () => { + const fetch = vi.fn(async () => new Response("err", { status: 500 })); + const fs = new HttpFs("https://cdn.test", ["f.txt"], { fetch }); + + await expect(fs.readFile("/f.txt")).rejects.toThrow("EIO"); + }); + }); + + // --------------------------------------------------------------------------- + // prefetch + // --------------------------------------------------------------------------- + + describe("prefetch", () => { + it("eagerly fetches all files", async () => { + const { fs, fetch } = createFs(["a.txt", "b.txt"], { + "/a.txt": "A", + "/b.txt": "B", + }); + + await fs.prefetch(); + + expect(fetch).toHaveBeenCalledTimes(2); + + const a = await fs.readFile("/a.txt"); + const b = await fs.readFile("/b.txt"); + expect(a).toBe("A"); + expect(b).toBe("B"); + expect(fetch).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/fs/http-fs/http-fs.ts b/src/fs/http-fs/http-fs.ts new file mode 100644 index 00000000..877dd8da --- /dev/null +++ b/src/fs/http-fs/http-fs.ts @@ -0,0 +1,372 @@ +import type { + BufferEncoding, + CpOptions, + DirentEntry, + FileContent, + FsStat, + IFileSystem, + MkdirOptions, + ReadFileOptions, + RmOptions, + WriteFileOptions, +} from "../interface.js"; + +export interface HttpFsFile { + size?: number; +} + +export interface HttpFsOptions { + fetch?: (url: string, init?: RequestInit) => Promise; + headers?: Record; + maxFileSize?: number; +} + +interface FileNode { + type: "file"; + size: number; + cached: Uint8Array | null; +} + +interface DirNode { + type: "directory"; + children: Set; +} + +type FsNode = FileNode | DirNode; + +/** + * A read-only filesystem backed by HTTP. + * + * Files are declared up front (the "manifest") and fetched lazily on first + * read. Once fetched, content is cached in memory for the lifetime of the + * instance. Directory structure is derived from the file paths -- no + * directory-listing endpoint is required on the server. + * + * @example + * ```ts + * const fs = new HttpFs("https://cdn.example.com/repo", [ + * "README.md", + * "src/index.ts", + * "src/utils.ts", + * ]); + * await fs.readFile("/README.md"); // fetches once, then cached + * await fs.readdir("/src"); // ["index.ts", "utils.ts"] + * ``` + */ +export class HttpFs implements IFileSystem { + private readonly baseUrl: string; + private readonly nodes: Map; + private readonly fetchFn: ( + url: string, + init?: RequestInit, + ) => Promise; + private readonly reqHeaders: Record; + private readonly maxFileSize: number; + private readonly epoch: Date; + + constructor( + baseUrl: string, + files: string[] | Record, + options?: HttpFsOptions, + ) { + this.baseUrl = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`; + this.fetchFn = options?.fetch ?? globalThis.fetch.bind(globalThis); + this.reqHeaders = Object.create(null) as Record; + if (options?.headers) { + for (const [k, v] of Object.entries(options.headers)) { + this.reqHeaders[k] = v; + } + } + this.maxFileSize = options?.maxFileSize ?? 10_485_760; + this.epoch = new Date(); + this.nodes = new Map(); + this.nodes.set("/", { type: "directory", children: new Set() }); + this.buildTree(files); + } + + // --------------------------------------------------------------------------- + // Tree construction + // --------------------------------------------------------------------------- + + private buildTree(files: string[] | Record): void { + const entries: Array<[string, HttpFsFile]> = Array.isArray(files) + ? files.map((f) => [f, {}]) + : Object.entries(files); + + for (const [raw, meta] of entries) { + const isDir = raw.endsWith("/"); + const path = normalizePath(raw); + if (path === "/") continue; + + const segments = path.split("/").filter(Boolean); + + // Ensure every ancestor directory exists + let current = ""; + for (let i = 0; i < segments.length; i++) { + const parent = current || "/"; + current = `${current}/${segments[i]}`; + const isLast = i === segments.length - 1; + + if (isLast && !isDir) { + // Leaf file + if (!this.nodes.has(current)) { + this.nodes.set(current, { + type: "file", + size: meta.size ?? 0, + cached: null, + }); + } + } else if (!this.nodes.has(current)) { + this.nodes.set(current, { type: "directory", children: new Set() }); + } + + // Register as child of parent + const parentNode = this.nodes.get(parent); + if (parentNode?.type === "directory") { + parentNode.children.add(segments[i]); + } + } + } + } + + // --------------------------------------------------------------------------- + // Internal helpers + // --------------------------------------------------------------------------- + + private resolve(path: string): FsNode | undefined { + return this.nodes.get(normalizePath(path)); + } + + private resolveFile(path: string, op: string): FileNode { + const node = this.resolve(path); + const p = normalizePath(path); + if (!node) throw fsError("ENOENT", op, p); + if (node.type === "directory") throw fsError("EISDIR", op, p); + return node; + } + + private resolveDir(path: string, op: string): DirNode { + const node = this.resolve(path); + const p = normalizePath(path); + if (!node) throw fsError("ENOENT", op, p); + if (node.type === "file") throw fsError("ENOTDIR", op, p); + return node; + } + + private resolveAny(path: string, op: string): FsNode { + const node = this.resolve(path); + if (!node) throw fsError("ENOENT", op, normalizePath(path)); + return node; + } + + private async fetchContent(path: string): Promise { + const node = this.resolveFile(path, "open"); + if (node.cached) return node.cached; + + const relative = normalizePath(path).slice(1); // strip leading / + const url = `${this.baseUrl}${relative}`; + + const resp = await this.fetchFn(url, { headers: { ...this.reqHeaders } }); + + if (!resp.ok) { + if (resp.status === 404) throw fsError("ENOENT", "open", path); + throw fsError("EIO", "open", path); + } + + const buf = new Uint8Array(await resp.arrayBuffer()); + if (buf.length > this.maxFileSize) { + throw fsError("EFBIG", "open", path); + } + + node.cached = buf; + node.size = buf.length; + return buf; + } + + // --------------------------------------------------------------------------- + // Public: reads + // --------------------------------------------------------------------------- + + async readFile( + path: string, + _options?: ReadFileOptions | BufferEncoding, + ): Promise { + const buf = await this.fetchContent(path); + return new TextDecoder().decode(buf); + } + + async readFileBuffer(path: string): Promise { + return this.fetchContent(path); + } + + async exists(path: string): Promise { + return this.resolve(path) !== undefined; + } + + async stat(path: string): Promise { + const node = this.resolveAny(path, "stat"); + return nodeToStat(node, this.epoch); + } + + async lstat(path: string): Promise { + return this.stat(path); + } + + async readdir(path: string): Promise { + const dir = this.resolveDir(path, "scandir"); + return Array.from(dir.children).sort(); + } + + async readdirWithFileTypes(path: string): Promise { + const dir = this.resolveDir(path, "scandir"); + const p = normalizePath(path); + const out: DirentEntry[] = []; + + for (const name of dir.children) { + const childPath = p === "/" ? `/${name}` : `${p}/${name}`; + const child = this.nodes.get(childPath); + out.push({ + name, + isFile: child?.type === "file", + isDirectory: child?.type === "directory", + isSymbolicLink: false, + }); + } + + return out.sort((a, b) => a.name.localeCompare(b.name)); + } + + async realpath(path: string): Promise { + this.resolveAny(path, "realpath"); + return normalizePath(path); + } + + resolvePath(base: string, path: string): string { + if (path.startsWith("/")) return normalizePath(path); + const combined = base === "/" ? `/${path}` : `${base}/${path}`; + return normalizePath(combined); + } + + getAllPaths(): string[] { + return Array.from(this.nodes.keys()).sort(); + } + + /** + * Eagerly fetch all files in the manifest. Useful when you know you'll + * need everything and want to parallelise the network I/O up front. + */ + async prefetch(): Promise { + const promises: Promise[] = []; + for (const [path, node] of this.nodes) { + if (node.type === "file" && !node.cached) { + promises.push(this.fetchContent(path).then(() => {})); + } + } + await Promise.all(promises); + } + + // --------------------------------------------------------------------------- + // Public: writes (all throw EROFS) + // --------------------------------------------------------------------------- + + async writeFile( + _path: string, + _content: FileContent, + _options?: WriteFileOptions | BufferEncoding, + ): Promise { + throw fsError("EROFS", "write", _path); + } + + async appendFile( + _path: string, + _content: FileContent, + _options?: WriteFileOptions | BufferEncoding, + ): Promise { + throw fsError("EROFS", "append", _path); + } + + async mkdir(_path: string, _options?: MkdirOptions): Promise { + throw fsError("EROFS", "mkdir", _path); + } + + async rm(_path: string, _options?: RmOptions): Promise { + throw fsError("EROFS", "rm", _path); + } + + async cp(_src: string, _dest: string, _options?: CpOptions): Promise { + throw fsError("EROFS", "cp", _dest); + } + + async mv(_src: string, _dest: string): Promise { + throw fsError("EROFS", "mv", _dest); + } + + async chmod(_path: string, _mode: number): Promise { + throw fsError("EROFS", "chmod", _path); + } + + async symlink(_target: string, _linkPath: string): Promise { + throw fsError("EROFS", "symlink", _linkPath); + } + + async link(_existingPath: string, _newPath: string): Promise { + throw fsError("EROFS", "link", _newPath); + } + + async readlink(path: string): Promise { + const node = this.resolveAny(path, "readlink"); + if (node.type !== "file") throw fsError("EINVAL", "readlink", path); + throw fsError("EINVAL", "readlink", path); + } + + async utimes(_path: string, _atime: Date, _mtime: Date): Promise { + throw fsError("EROFS", "utimes", _path); + } +} + +// ----------------------------------------------------------------------------- +// Utilities +// ----------------------------------------------------------------------------- + +function normalizePath(path: string): string { + if (!path || path === "/") return "/"; + let p = path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path; + if (!p.startsWith("/")) p = `/${p}`; + const parts = p.split("/").filter((s) => s && s !== "."); + const resolved: string[] = []; + for (const part of parts) { + if (part === "..") resolved.pop(); + else resolved.push(part); + } + return `/${resolved.join("/")}` || "/"; +} + +const ERROR_MESSAGES: Record = Object.assign( + Object.create(null), + { + ENOENT: "no such file or directory", + EISDIR: "illegal operation on a directory", + ENOTDIR: "not a directory", + EROFS: "read-only file system", + EFBIG: "file too large", + EIO: "input/output error", + EINVAL: "invalid argument", + EEXIST: "file already exists", + }, +); + +function fsError(code: string, op: string, path: string): Error { + const msg = ERROR_MESSAGES[code] ?? code; + return new Error(`${code}: ${msg}, ${op} '${path}'`); +} + +function nodeToStat(node: FsNode, mtime: Date): FsStat { + return { + isFile: node.type === "file", + isDirectory: node.type === "directory", + isSymbolicLink: false, + mode: node.type === "directory" ? 0o755 : 0o644, + size: node.type === "file" ? node.size : 0, + mtime, + }; +} diff --git a/src/fs/http-fs/index.ts b/src/fs/http-fs/index.ts new file mode 100644 index 00000000..217bc858 --- /dev/null +++ b/src/fs/http-fs/index.ts @@ -0,0 +1 @@ +export { HttpFs, type HttpFsFile, type HttpFsOptions } from "./http-fs.js"; diff --git a/src/fs/mount.test.ts b/src/fs/mount.test.ts new file mode 100644 index 00000000..0c02dfd8 --- /dev/null +++ b/src/fs/mount.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it, vi } from "vitest"; +import { Bash } from "../Bash.js"; +import { HttpFs } from "./http-fs/http-fs.js"; +import { InMemoryFs } from "./in-memory-fs/in-memory-fs.js"; +import { mount } from "./mount.js"; + +function mockFetch(files: Record) { + return vi.fn(async (url: string) => { + for (const [path, content] of Object.entries(files)) { + if (url.endsWith(path)) { + return new Response(content, { status: 200 }); + } + } + return new Response("Not Found", { status: 404 }); + }); +} + +describe("mount", () => { + it("creates a MountableFs with InMemoryFs base by default", async () => { + const fs = mount({}); + + expect(await fs.exists("/")).toBe(true); + expect(await fs.exists("/dev/null")).toBe(true); + expect(await fs.exists("/bin")).toBe(true); + }); + + it("uses a custom base when / is provided", async () => { + const base = new InMemoryFs({ "/custom.txt": "hi" }); + const fs = mount({ "/": base }); + + expect(await fs.readFile("/custom.txt")).toBe("hi"); + // custom base doesn't get auto-initialised + expect(await fs.exists("/dev/null")).toBe(false); + }); + + it("mounts HttpFs at a path", async () => { + const fetch = mockFetch({ "/readme.txt": "hello from http" }); + const httpFs = new HttpFs("https://cdn.test", ["readme.txt"], { fetch }); + + const fs = mount({ "/remote": httpFs }); + + expect(await fs.readFile("/remote/readme.txt")).toBe("hello from http"); + expect(await fs.readdir("/remote")).toEqual(["readme.txt"]); + }); + + it("composes multiple mounts", async () => { + const fetch = mockFetch({ + "/a.txt": "file-a", + "/b.txt": "file-b", + }); + + const fs = mount({ + "/alpha": new HttpFs("https://a.test", ["a.txt"], { fetch }), + "/beta": new HttpFs("https://b.test", ["b.txt"], { fetch }), + }); + + expect(await fs.readFile("/alpha/a.txt")).toBe("file-a"); + expect(await fs.readFile("/beta/b.txt")).toBe("file-b"); + expect(await fs.exists("/dev/null")).toBe(true); + }); + + it("works end-to-end with Bash", async () => { + const fetch = mockFetch({ + "/greeting.txt": "Hello from the network", + }); + + const fs = mount({ + "/data": new HttpFs("https://cdn.test", ["greeting.txt"], { fetch }), + }); + + const bash = new Bash({ fs }); + const result = await bash.exec("cat /data/greeting.txt"); + + expect(result.stdout).toBe("Hello from the network"); + expect(result.exitCode).toBe(0); + }); + + it("allows writes to the base while remote is read-only", async () => { + const fetch = mockFetch({ "/info.txt": "remote data" }); + + const fs = mount({ + "/remote": new HttpFs("https://cdn.test", ["info.txt"], { fetch }), + }); + + await fs.writeFile("/tmp/local.txt", "local data"); + expect(await fs.readFile("/tmp/local.txt")).toBe("local data"); + expect(await fs.readFile("/remote/info.txt")).toBe("remote data"); + + await expect(fs.writeFile("/remote/new.txt", "nope")).rejects.toThrow( + "EROFS", + ); + }); + + it("supports ls on mounted HttpFs directories via Bash", async () => { + const fetch = mockFetch({ + "/alpha.txt": "a", + "/beta.txt": "b", + }); + + const fs = mount({ + "/data": new HttpFs("https://cdn.test", ["alpha.txt", "beta.txt"], { + fetch, + }), + }); + + const bash = new Bash({ fs }); + const result = await bash.exec("ls /data"); + + expect(result.stdout).toBe("alpha.txt\nbeta.txt\n"); + expect(result.exitCode).toBe(0); + }); + + it("supports piping from mounted files", async () => { + const fetch = mockFetch({ + "/numbers.txt": "3\n1\n2\n", + }); + + const fs = mount({ + "/data": new HttpFs("https://cdn.test", ["numbers.txt"], { fetch }), + }); + + const bash = new Bash({ fs }); + const result = await bash.exec("cat /data/numbers.txt | sort"); + + expect(result.stdout).toBe("1\n2\n3\n"); + expect(result.exitCode).toBe(0); + }); + + it("supports grep on mounted files", async () => { + const fetch = mockFetch({ + "/log.txt": "INFO: started\nERROR: disk full\nINFO: done\n", + }); + + const fs = mount({ + "/logs": new HttpFs("https://cdn.test", ["log.txt"], { fetch }), + }); + + const bash = new Bash({ fs }); + const result = await bash.exec("grep ERROR /logs/log.txt"); + + expect(result.stdout).toBe("ERROR: disk full\n"); + expect(result.exitCode).toBe(0); + }); + + it("supports wc on mounted files", async () => { + const fetch = mockFetch({ + "/data.csv": "a,b,c\n1,2,3\n4,5,6\n", + }); + + const fs = mount({ + "/data": new HttpFs("https://cdn.test", ["data.csv"], { fetch }), + }); + + const bash = new Bash({ fs }); + const result = await bash.exec("wc -l /data/data.csv"); + + expect(result.stdout).toBe("3 /data/data.csv\n"); + expect(result.exitCode).toBe(0); + }); +}); diff --git a/src/fs/mount.ts b/src/fs/mount.ts new file mode 100644 index 00000000..e9c1c23c --- /dev/null +++ b/src/fs/mount.ts @@ -0,0 +1,42 @@ +import { InMemoryFs } from "./in-memory-fs/in-memory-fs.js"; +import { initFilesystem } from "./init.js"; +import type { IFileSystem } from "./interface.js"; +import { MountableFs } from "./mountable-fs/mountable-fs.js"; + +/** + * Compose multiple filesystems into a single unified namespace. + * + * Pass `"/"` to set the base filesystem. Everything else is mounted at the + * given path. When no `"/"` is provided, a fresh `InMemoryFs` (pre-initialised + * with `/dev`, `/proc`, `/bin`, etc.) is used as the base. + * + * @example + * ```ts + * import { mount, HttpFs, InMemoryFs } from "just-bash"; + * + * const fs = mount({ + * "/data": new HttpFs("https://cdn.example.com/dataset", [ + * "train.csv", + * "test.csv", + * "metadata.json", + * ]), + * }); + * + * const bash = new Bash({ fs }); + * await bash.exec("wc -l /data/train.csv"); + * ``` + */ +export function mount(mounts: Record): MountableFs { + const hasBase = "/" in mounts; + const base = hasBase ? mounts["/"] : new InMemoryFs(); + + if (!hasBase) { + initFilesystem(base, true); + } + + const configs = Object.entries(mounts) + .filter(([path]) => path !== "/") + .map(([mountPoint, filesystem]) => ({ mountPoint, filesystem })); + + return new MountableFs({ base, mounts: configs }); +} diff --git a/src/fs/mountable-fs/mountable-fs.ts b/src/fs/mountable-fs/mountable-fs.ts index c30ef8d8..46865d3c 100644 --- a/src/fs/mountable-fs/mountable-fs.ts +++ b/src/fs/mountable-fs/mountable-fs.ts @@ -680,4 +680,46 @@ export class MountableFs implements IFileSystem { const { fs, relativePath } = this.routePath(path); return fs.utimes(relativePath, atime, mtime); } + + // --------------------------------------------------------------------------- + // Sync helpers (delegated to base/mounted FS when they support them). + // Required so that initFilesystem() and registerCommand() work transparently + // when the top-level FS handed to Bash is a MountableFs. + // --------------------------------------------------------------------------- + + mkdirSync(path: string, options?: { recursive?: boolean }): void { + const { fs, relativePath } = this.routePath(path); + const maybeSyncFs = fs as { + mkdirSync?: (p: string, o?: { recursive?: boolean }) => void; + }; + if (typeof maybeSyncFs.mkdirSync === "function") { + maybeSyncFs.mkdirSync(relativePath, options); + } + } + + writeFileSync(path: string, content: string | Uint8Array): void { + const { fs, relativePath } = this.routePath(path); + const maybeSyncFs = fs as { + writeFileSync?: (p: string, c: string | Uint8Array) => void; + }; + if (typeof maybeSyncFs.writeFileSync === "function") { + maybeSyncFs.writeFileSync(relativePath, content); + } + } + + writeFileLazy( + path: string, + lazy: () => string | Uint8Array | Promise, + ): void { + const { fs, relativePath } = this.routePath(path); + const maybeSyncFs = fs as { + writeFileLazy?: ( + p: string, + l: () => string | Uint8Array | Promise, + ) => void; + }; + if (typeof maybeSyncFs.writeFileLazy === "function") { + maybeSyncFs.writeFileLazy(relativePath, lazy); + } + } } diff --git a/src/index.ts b/src/index.ts index edaf8653..56a85796 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,11 @@ export { // Custom commands API export type { CustomCommand, LazyCommand } from "./custom-commands.js"; export { defineCommand } from "./custom-commands.js"; +export { + HttpFs, + type HttpFsFile, + type HttpFsOptions, +} from "./fs/http-fs/index.js"; export { InMemoryFs } from "./fs/in-memory-fs/index.js"; export type { BufferEncoding, @@ -41,6 +46,7 @@ export type { RmOptions, SymlinkEntry, } from "./fs/interface.js"; +export { mount } from "./fs/mount.js"; export { MountableFs, type MountableFsOptions, diff --git a/src/interpreter/redirections.erofs.test.ts b/src/interpreter/redirections.erofs.test.ts new file mode 100644 index 00000000..6178fe4d --- /dev/null +++ b/src/interpreter/redirections.erofs.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from "vitest"; +import { Bash } from "../Bash.js"; +import { HttpFs } from "../fs/http-fs/http-fs.js"; +import { mount } from "../fs/mount.js"; + +function mockFetch(files: Record) { + return vi.fn(async (url: string) => { + for (const [path, content] of Object.entries(files)) { + if (url.endsWith(path)) { + return new Response(content, { status: 200 }); + } + } + return new Response("Not Found", { status: 404 }); + }); +} + +function createBashWithReadonlyMount(served: Record = {}) { + const files = Object.keys(served).map((p) => + p.startsWith("/") ? p.slice(1) : p, + ); + const fetch = mockFetch(served); + const fs = mount({ + "/ro": new HttpFs("https://cdn.test", files, { fetch }), + }); + return new Bash({ fs }); +} + +describe("redirections to read-only filesystem", () => { + describe("> (truncate)", () => { + it("reports error and exits 1", async () => { + const bash = createBashWithReadonlyMount(); + const r = await bash.exec("echo hello > /ro/file.txt"); + + expect(r.stdout).toBe(""); + expect(r.stderr).toBe("bash: /ro/file.txt: Read-only file system\n"); + expect(r.exitCode).toBe(1); + }); + + it("does not crash the interpreter", async () => { + const bash = createBashWithReadonlyMount(); + const r1 = await bash.exec("echo hello > /ro/file.txt"); + expect(r1.exitCode).toBe(1); + + const r2 = await bash.exec("echo still works"); + expect(r2.stdout).toBe("still works\n"); + expect(r2.exitCode).toBe(0); + }); + }); + + describe(">> (append)", () => { + it("reports error and exits 1", async () => { + const bash = createBashWithReadonlyMount({ "/data.txt": "old" }); + const r = await bash.exec("echo more >> /ro/data.txt"); + + expect(r.stdout).toBe(""); + expect(r.stderr).toBe("bash: /ro/data.txt: Read-only file system\n"); + expect(r.exitCode).toBe(1); + }); + }); + + describe("2> (stderr redirect)", () => { + it("reports error and exits 1", async () => { + const bash = createBashWithReadonlyMount(); + const r = await bash.exec("echo err >&2 2> /ro/err.log"); + + expect(r.stderr).toBe("bash: /ro/err.log: Read-only file system\n"); + expect(r.exitCode).toBe(1); + }); + }); + + describe("&> (both stdout and stderr)", () => { + it("reports error and exits 1", async () => { + const bash = createBashWithReadonlyMount(); + const r = await bash.exec("echo hello &> /ro/out.log"); + + expect(r.stdout).toBe(""); + expect(r.stderr).toBe("bash: /ro/out.log: Read-only file system\n"); + expect(r.exitCode).toBe(1); + }); + }); + + describe("&>> (append both)", () => { + it("reports error and exits 1", async () => { + const bash = createBashWithReadonlyMount(); + const r = await bash.exec("echo hello &>> /ro/out.log"); + + expect(r.stdout).toBe(""); + expect(r.stderr).toBe("bash: /ro/out.log: Read-only file system\n"); + expect(r.exitCode).toBe(1); + }); + }); + + describe("reads still work", () => { + it("can cat from read-only mount alongside failed write", async () => { + const bash = createBashWithReadonlyMount({ "/data.txt": "content" }); + + const r1 = await bash.exec("cat /ro/data.txt"); + expect(r1.stdout).toBe("content"); + expect(r1.exitCode).toBe(0); + + const r2 = await bash.exec("echo nope > /ro/data.txt"); + expect(r2.exitCode).toBe(1); + + const r3 = await bash.exec("cat /ro/data.txt"); + expect(r3.stdout).toBe("content"); + expect(r3.exitCode).toBe(0); + }); + }); + + describe("cross-mount redirect works", () => { + it("can pipe from readonly to writable", async () => { + const bash = createBashWithReadonlyMount({ + "/source.txt": "hello from remote", + }); + + const r = await bash.exec( + "cat /ro/source.txt > /tmp/local.txt && cat /tmp/local.txt", + ); + expect(r.stdout).toBe("hello from remote"); + expect(r.exitCode).toBe(0); + }); + }); +}); diff --git a/src/interpreter/redirections.ts b/src/interpreter/redirections.ts index f972a85e..2c4b5e3e 100644 --- a/src/interpreter/redirections.ts +++ b/src/interpreter/redirections.ts @@ -20,6 +20,70 @@ import { import { result as makeResult } from "./helpers/result.js"; import type { InterpreterContext } from "./types.js"; +// --------------------------------------------------------------------------- +// Safe filesystem I/O for redirections +// +// Every write in this file goes through one of these two functions. They +// convert FS-level exceptions (EROFS, EACCES, …) into the bash-style error +// strings that the caller folds into stderr, matching real bash behaviour: +// bash: /path: Read-only file system +// --------------------------------------------------------------------------- + +const FS_ERROR_LABELS: Record = Object.assign( + Object.create(null), + { + EROFS: "Read-only file system", + EACCES: "Permission denied", + ENOSPC: "No space left on device", + EISDIR: "Is a directory", + ENOENT: "No such file or directory", + EEXIST: "File exists", + }, +); + +function formatFsError(target: string, e: unknown): string { + const msg = (e as Error).message ?? ""; + for (const code of Object.keys(FS_ERROR_LABELS)) { + if (msg.includes(code)) + return `bash: ${target}: ${FS_ERROR_LABELS[code]}\n`; + } + return `bash: ${target}: Input/output error\n`; +} + +async function redirectWrite( + ctx: InterpreterContext, + filePath: string, + target: string, + content: string, + encoding?: string, +): Promise { + try { + await ctx.fs.writeFile(filePath, content, encoding as "binary" | undefined); + return null; + } catch (e) { + return formatFsError(target, e); + } +} + +async function redirectAppend( + ctx: InterpreterContext, + filePath: string, + target: string, + content: string, + encoding?: string, +): Promise { + try { + await ctx.fs.appendFile( + filePath, + content, + encoding as "binary" | undefined, + ); + return null; + } catch (e) { + return formatFsError(target, e); + } +} + /** * Check if a redirect target is valid for output (not a directory, respects noclobber). * Returns an error message string if invalid, null if valid. @@ -242,7 +306,8 @@ export async function processFdVariableRedirections( redir.operator === ">|" || redir.operator === "&>" ) { - await ctx.fs.writeFile(filePath, "", "binary"); + const err = await redirectWrite(ctx, filePath, target, "", "binary"); + if (err) return makeResult("", err, 1); } ctx.state.fileDescriptors.set(fd, `__file__:${filePath}`); } else if (redir.operator === "<<<") { @@ -371,7 +436,8 @@ export async function preOpenOutputRedirects( target !== "/dev/stderr" && target !== "/dev/full" ) { - await ctx.fs.writeFile(filePath, "", "binary"); + const err = await redirectWrite(ctx, filePath, target, "", "binary"); + if (err) return makeResult("", err, 1); } // /dev/full always returns ENOSPC when written to @@ -480,7 +546,17 @@ export async function applyRedirections( break; } // Smart encoding: binary for byte data, UTF-8 for Unicode text - await ctx.fs.writeFile(filePath, stdout, getFileEncoding(stdout)); + const wErr = await redirectWrite( + ctx, + filePath, + target, + stdout, + getFileEncoding(stdout), + ); + if (wErr) { + stderr += wErr; + exitCode = 1; + } stdout = ""; } else if (fd === 2) { // /dev/stderr is a no-op for stderr - output stays on stderr @@ -518,8 +594,19 @@ export async function applyRedirections( break; } // Smart encoding: binary for byte data, UTF-8 for Unicode text - await ctx.fs.writeFile(filePath, stderr, getFileEncoding(stderr)); - stderr = ""; + const wErr = await redirectWrite( + ctx, + filePath, + target, + stderr, + getFileEncoding(stderr), + ); + if (wErr) { + stderr = wErr; + exitCode = 1; + } else { + stderr = ""; + } } } break; @@ -559,7 +646,17 @@ export async function applyRedirections( break; } // Smart encoding: binary for byte data, UTF-8 for Unicode text - await ctx.fs.appendFile(filePath, stdout, getFileEncoding(stdout)); + const aErr = await redirectAppend( + ctx, + filePath, + target, + stdout, + getFileEncoding(stdout), + ); + if (aErr) { + stderr += aErr; + exitCode = 1; + } stdout = ""; } else if (fd === 2) { // /dev/stderr is a no-op for stderr - output stays on stderr @@ -591,8 +688,19 @@ export async function applyRedirections( break; } // Smart encoding: binary for byte data, UTF-8 for Unicode text - await ctx.fs.appendFile(filePath2, stderr, getFileEncoding(stderr)); - stderr = ""; + const aErr2 = await redirectAppend( + ctx, + filePath2, + target, + stderr, + getFileEncoding(stderr), + ); + if (aErr2) { + stderr = aErr2; + exitCode = 1; + } else { + stderr = ""; + } } break; } @@ -674,23 +782,34 @@ export async function applyRedirections( // Check if this is a valid user-allocated FD const fdInfo = ctx.state.fileDescriptors?.get(targetFd); if (fdInfo?.startsWith("__file__:")) { - // This FD is associated with a file - write to it - // The path is already resolved when the FD was allocated - const resolvedPath = fdInfo.slice(9); // Remove "__file__:" prefix + const resolvedPath = fdInfo.slice(9); if (fd === 1) { - await ctx.fs.appendFile( + const aErr = await redirectAppend( + ctx, resolvedPath, + target, stdout, getFileEncoding(stdout), ); + if (aErr) { + stderr += aErr; + exitCode = 1; + } stdout = ""; } else if (fd === 2) { - await ctx.fs.appendFile( + const aErr = await redirectAppend( + ctx, resolvedPath, + target, stderr, getFileEncoding(stderr), ); - stderr = ""; + if (aErr) { + stderr = aErr; + exitCode = 1; + } else { + stderr = ""; + } } } else if (fdInfo?.startsWith("__rw__:")) { // Read/write FD - extract path using proper format parsing @@ -698,19 +817,32 @@ export async function applyRedirections( const parsed = parseRwFdContent(fdInfo); if (parsed) { if (fd === 1) { - await ctx.fs.appendFile( + const aErr = await redirectAppend( + ctx, parsed.path, + target, stdout, getFileEncoding(stdout), ); + if (aErr) { + stderr += aErr; + exitCode = 1; + } stdout = ""; } else if (fd === 2) { - await ctx.fs.appendFile( + const aErr = await redirectAppend( + ctx, parsed.path, + target, stderr, getFileEncoding(stderr), ); - stderr = ""; + if (aErr) { + stderr = aErr; + exitCode = 1; + } else { + stderr = ""; + } } } } else if (fdInfo?.startsWith("__dupout__:")) { @@ -732,19 +864,32 @@ export async function applyRedirections( if (sourceInfo?.startsWith("__file__:")) { const resolvedPath = sourceInfo.slice(9); if (fd === 1) { - await ctx.fs.appendFile( + const aErr = await redirectAppend( + ctx, resolvedPath, + target, stdout, getFileEncoding(stdout), ); + if (aErr) { + stderr += aErr; + exitCode = 1; + } stdout = ""; } else if (fd === 2) { - await ctx.fs.appendFile( + const aErr = await redirectAppend( + ctx, resolvedPath, + target, stderr, getFileEncoding(stderr), ); - stderr = ""; + if (aErr) { + stderr = aErr; + exitCode = 1; + } else { + stderr = ""; + } } } } @@ -783,21 +928,49 @@ export async function applyRedirections( if (redir.fd == null) { // >&word (no explicit fd) - write both stdout and stderr to the file const combined = stdout + stderr; - await ctx.fs.writeFile( + const wErr = await redirectWrite( + ctx, filePath, + target, combined, getFileEncoding(combined), ); + if (wErr) { + stderr = wErr; + exitCode = 1; + } else { + stderr = ""; + } stdout = ""; - stderr = ""; } else if (fd === 1) { // 1>&word - redirect stdout to file - await ctx.fs.writeFile(filePath, stdout, getFileEncoding(stdout)); + const wErr = await redirectWrite( + ctx, + filePath, + target, + stdout, + getFileEncoding(stdout), + ); + if (wErr) { + stderr += wErr; + exitCode = 1; + } stdout = ""; } else if (fd === 2) { // 2>&word - redirect stderr to file - await ctx.fs.writeFile(filePath, stderr, getFileEncoding(stderr)); - stderr = ""; + const wErr = await redirectWrite( + ctx, + filePath, + target, + stderr, + getFileEncoding(stderr), + ); + if (wErr) { + stderr = wErr; + exitCode = 1; + } else { + stderr = ""; + } } } } @@ -824,9 +997,20 @@ export async function applyRedirections( } // Smart encoding: binary for byte data, UTF-8 for Unicode text const combined = stdout + stderr; - await ctx.fs.writeFile(filePath, combined, getFileEncoding(combined)); + const wErr = await redirectWrite( + ctx, + filePath, + target, + combined, + getFileEncoding(combined), + ); + if (wErr) { + stderr = wErr; + exitCode = 1; + } else { + stderr = ""; + } stdout = ""; - stderr = ""; break; } @@ -853,9 +1037,20 @@ export async function applyRedirections( } // Smart encoding: binary for byte data, UTF-8 for Unicode text const combined = stdout + stderr; - await ctx.fs.appendFile(filePath, combined, getFileEncoding(combined)); + const aErr = await redirectAppend( + ctx, + filePath, + target, + combined, + getFileEncoding(combined), + ); + if (aErr) { + stderr = aErr; + exitCode = 1; + } else { + stderr = ""; + } stdout = ""; - stderr = ""; break; } } @@ -870,13 +1065,32 @@ export async function applyRedirections( stderr += stdout; stdout = ""; } else if (fd1Info.startsWith("__file__:")) { - // fd 1 is redirected to a file - const filePath = fd1Info.slice(9); - await ctx.fs.appendFile(filePath, stdout, getFileEncoding(stdout)); + const fd1Path = fd1Info.slice(9); + const aErr = await redirectAppend( + ctx, + fd1Path, + fd1Path, + stdout, + getFileEncoding(stdout), + ); + if (aErr) { + stderr += aErr; + exitCode = 1; + } stdout = ""; } else if (fd1Info.startsWith("__file_append__:")) { - const filePath = fd1Info.slice(16); - await ctx.fs.appendFile(filePath, stdout, getFileEncoding(stdout)); + const fd1Path = fd1Info.slice(16); + const aErr = await redirectAppend( + ctx, + fd1Path, + fd1Path, + stdout, + getFileEncoding(stdout), + ); + if (aErr) { + stderr += aErr; + exitCode = 1; + } stdout = ""; } } @@ -889,13 +1103,35 @@ export async function applyRedirections( stdout += stderr; stderr = ""; } else if (fd2Info.startsWith("__file__:")) { - const filePath = fd2Info.slice(9); - await ctx.fs.appendFile(filePath, stderr, getFileEncoding(stderr)); - stderr = ""; + const fd2Path = fd2Info.slice(9); + const aErr = await redirectAppend( + ctx, + fd2Path, + fd2Path, + stderr, + getFileEncoding(stderr), + ); + if (aErr) { + stderr = aErr; + exitCode = 1; + } else { + stderr = ""; + } } else if (fd2Info.startsWith("__file_append__:")) { - const filePath = fd2Info.slice(16); - await ctx.fs.appendFile(filePath, stderr, getFileEncoding(stderr)); - stderr = ""; + const fd2Path = fd2Info.slice(16); + const aErr = await redirectAppend( + ctx, + fd2Path, + fd2Path, + stderr, + getFileEncoding(stderr), + ); + if (aErr) { + stderr = aErr; + exitCode = 1; + } else { + stderr = ""; + } } } diff --git a/src/readme.test.ts b/src/readme.test.ts index 53a15587..40c437f8 100644 --- a/src/readme.test.ts +++ b/src/readme.test.ts @@ -156,6 +156,12 @@ function addImpliedImports(code: string): string { if (code.includes("Sandbox") && !code.includes('from "just-bash"')) { imports.push('import { Sandbox } from "just-bash";'); } + if (code.includes("HttpFs") && !code.includes('from "just-bash"')) { + imports.push('import { HttpFs } from "just-bash";'); + } + if (code.includes("mount(") && !code.includes('from "just-bash"')) { + imports.push('import { mount } from "just-bash";'); + } // bash-tool imports are handled via ephemeral type definitions if ( code.includes("OverlayFs") &&