From 9f7e1300b53010c3f116dea905455f262a31bde4 Mon Sep 17 00:00:00 2001 From: agent Date: Tue, 25 Aug 2026 08:12:25 +0000 Subject: [PATCH 1/4] dofs: follow symbolic links in mkdir mkdir walked the target path with a helper that read each dirent directly and treated every node other than a directory as ENOTDIR, so a symbolic link to a directory blocked directory creation rather than resolving through it. Every other write path, writeFile included, follows links and shares one forty-hop budget. The parent walk now expands intermediate links the way writeFile's does, tracking the resolved path so the new directory lands under the directory the link points at and the read-only mount guard sees the location actually written. Recursive creation places its missing ancestors under that resolved parent. A resolved parent that is a file still reports ENOTDIR, a dangling parent link reports ENOENT in both modes rather than being materialised, and a chain beyond forty hops reports ELOOP. Closes #119. --- .changeset/mkdir-follows-symlinks.md | 5 + packages/dofs/src/fs/mkdir.test.ts | 101 ++++++++++++++++ packages/dofs/src/fs/mkdir.ts | 165 +++++++++++++++++++++------ 3 files changed, 235 insertions(+), 36 deletions(-) create mode 100644 .changeset/mkdir-follows-symlinks.md diff --git a/.changeset/mkdir-follows-symlinks.md b/.changeset/mkdir-follows-symlinks.md new file mode 100644 index 00000000..814e2c9a --- /dev/null +++ b/.changeset/mkdir-follows-symlinks.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/dofs": patch +--- + +`mkdir` now follows symbolic links in intermediate path segments, so a link to a directory resolves transparently instead of failing with `ENOTDIR`. Creating `/alias/new-directory` where `/alias` points at `/real` creates `/real/new-directory`, and recursive creation places its missing ancestors under the resolved parent. A resolved parent that is a file still reports `ENOTDIR`, a dangling parent link reports `ENOENT`, and a chain longer than the shared forty-hop budget reports `ELOOP`. diff --git a/packages/dofs/src/fs/mkdir.test.ts b/packages/dofs/src/fs/mkdir.test.ts index e1fe0221..4a149ac8 100644 --- a/packages/dofs/src/fs/mkdir.test.ts +++ b/packages/dofs/src/fs/mkdir.test.ts @@ -3,7 +3,9 @@ import { describe, expect, it } from "vitest"; import { ROOT_INODE } from "../schema/index.js"; import { mkdir } from "./mkdir.js"; import { resolveInode } from "./resolve.js"; +import { symlink } from "./symlink.js"; import { withDB } from "./with-db.js"; +import { writeFile } from "./writeFile.js"; describe("mkdir", () => { it("creates a top-level directory with the default mode", async () => { @@ -137,4 +139,103 @@ describe("mkdir", () => { ); }); }); + + describe("symlinked parents", () => { + it("follows an absolute link in an intermediate segment", async () => { + await withDB((db) => { + mkdir(db, "/real", {}, () => 0); + symlink(db, "/real", "/alias", () => 0); + mkdir(db, "/alias/new-directory", {}, () => 0); + expect(resolveInode(db, "/real/new-directory")?.type).toBe("dir"); + }); + }); + + it("follows a relative link in an intermediate segment", async () => { + await withDB((db) => { + mkdir(db, "/base/real", { recursive: true }, () => 0); + symlink(db, "real", "/base/alias", () => 0); + mkdir(db, "/base/alias/child", {}, () => 0); + expect(resolveInode(db, "/base/real/child")?.type).toBe("dir"); + }); + }); + + it("follows a chain of links", async () => { + await withDB((db) => { + mkdir(db, "/real", {}, () => 0); + symlink(db, "/real", "/first", () => 0); + symlink(db, "/first", "/second", () => 0); + mkdir(db, "/second/child", {}, () => 0); + expect(resolveInode(db, "/real/child")?.type).toBe("dir"); + }); + }); + + it("creates recursive ancestors under the resolved parent", async () => { + await withDB((db) => { + mkdir(db, "/real", {}, () => 0); + symlink(db, "/real", "/alias", () => 0); + mkdir(db, "/alias/a/b/c", { recursive: true }, () => 0); + expect(resolveInode(db, "/real/a/b/c")?.type).toBe("dir"); + }); + }); + + it("is idempotent through a link when the target already exists", async () => { + await withDB((db) => { + mkdir(db, "/real/child", { recursive: true }, () => 0); + symlink(db, "/real", "/alias", () => 0); + expect(() => mkdir(db, "/alias/child", { recursive: true }, () => 0)).not.toThrow(); + }); + }); + + it("reports ENOTDIR when a link resolves to a file", async () => { + await withDB(async (db) => { + await writeFile(db, "/file", "x", {}, () => 0); + symlink(db, "/file", "/alias", () => 0); + expect(() => mkdir(db, "/alias/child", {}, () => 0)).toThrowError( + expect.objectContaining({ code: "ENOTDIR" }), + ); + }); + }); + + it("reports ENOENT for a dangling parent link", async () => { + await withDB((db) => { + symlink(db, "/missing", "/alias", () => 0); + expect(() => mkdir(db, "/alias/child", {}, () => 0)).toThrowError( + expect.objectContaining({ code: "ENOENT" }), + ); + }); + }); + + it("reports ENOENT for a dangling parent link under recursive", async () => { + await withDB((db) => { + symlink(db, "/missing", "/alias", () => 0); + expect(() => mkdir(db, "/alias/child", { recursive: true }, () => 0)).toThrowError( + expect.objectContaining({ code: "ENOENT" }), + ); + }); + }); + + it("reports ELOOP for a link cycle", async () => { + await withDB((db) => { + symlink(db, "/b", "/a", () => 0); + symlink(db, "/a", "/b", () => 0); + expect(() => mkdir(db, "/a/child", {}, () => 0)).toThrowError( + expect.objectContaining({ code: "ELOOP" }), + ); + }); + }); + + it("reports ELOOP after more than forty link traversals", async () => { + await withDB((db) => { + mkdir(db, "/real", {}, () => 0); + symlink(db, "/real", "/link0", () => 0); + for (let i = 1; i <= 41; i++) { + symlink(db, `/link${i - 1}`, `/link${i}`, () => 0); + } + expect(() => mkdir(db, "/link41/child", {}, () => 0)).toThrowError( + expect.objectContaining({ code: "ELOOP" }), + ); + expect(() => mkdir(db, "/link39/child", {}, () => 0)).not.toThrow(); + }); + }); + }); }); diff --git a/packages/dofs/src/fs/mkdir.ts b/packages/dofs/src/fs/mkdir.ts index ddc3b72a..a4520506 100644 --- a/packages/dofs/src/fs/mkdir.ts +++ b/packages/dofs/src/fs/mkdir.ts @@ -13,7 +13,16 @@ export interface MkdirOptions { interface ResolvedSegment { inode: number; - type: "file" | "dir"; + type: "file" | "dir" | "symlink"; + linkTarget: string | null; +} + +// Matches resolveInode's Linux-compatible SYMLOOP_MAX so every write +// path enforces the same budget on a single call. +const MAX_SYMLINK_FOLLOWS = 40; + +interface SymlinkFollowState { + count: number; } // Look up a child by name under a parent directory. Returns undefined @@ -27,14 +36,15 @@ function lookupChild(db: Database, parentInode: number, name: string): ResolvedS if (row === undefined) { return undefined; } - const node = db.one<{ inode: number; type: "file" | "dir" }>( - "SELECT inode, type FROM vfs_nodes WHERE inode = ?", - row.child_inode, - ); + const node = db.one<{ + inode: number; + type: "file" | "dir" | "symlink"; + link_target: string | null; + }>("SELECT inode, type, link_target FROM vfs_nodes WHERE inode = ?", row.child_inode); if (node === undefined) { return undefined; } - return node; + return { inode: node.inode, type: node.type, linkTarget: node.link_target }; } // Create one directory entry under `parentInode`, returning the new @@ -67,6 +77,108 @@ function createDir( return inode; } +function countSymlinkFollow(follows: SymlinkFollowState, path: string): void { + follows.count += 1; + if (follows.count > MAX_SYMLINK_FOLLOWS) { + throw createWorkspaceError("ELOOP", "too many symlinks resolving path", path); + } +} + +function pathFromParts(parts: string[]): string { + return `/${parts.join("/")}`; +} + +interface ResolvedParent { + inode: number; + // Path of the resolved parent with every intermediate link expanded, + // so the leaf lands under the directory the links point at. + realPath: string; +} + +// Walk every segment before the leaf, following symbolic links the way +// writeFile does. A link to a directory resolves transparently; a file +// still stops the walk with ENOTDIR, a missing or dangling segment with +// ENOENT, and a cycle with ELOOP once the shared budget is spent. +// +// `recursive` creates the missing segments, and it does so under the +// resolved parent rather than beside the link. +function resolveMkdirParent( + db: Database, + parts: string[], + canonical: string, + recursive: boolean, + mtime: number, + rev: number, +): ResolvedParent { + // Segments still to walk. A segment that came from a link target + // carries `fromLink` so a dangling link reports ENOENT rather than + // being materialised by a recursive create. + const pending: Array<{ name: string; fromLink: boolean }> = parts + .slice(0, -1) + .map((name) => ({ name, fromLink: false })); + const inodeStack = [ROOT_INODE]; + const realParts: string[] = []; + const follows: SymlinkFollowState = { count: 0 }; + + while (pending.length > 0) { + const segment = pending.shift(); + if (segment === undefined) continue; + const { name, fromLink } = segment; + if (name === "" || name === ".") continue; + if (name === "..") { + if (inodeStack.length > 1) { + inodeStack.pop(); + realParts.pop(); + } + continue; + } + + const parentInode = inodeStack[inodeStack.length - 1]; + const existing = lookupChild(db, parentInode, name); + if (existing === undefined) { + if (!recursive || fromLink) { + throw createWorkspaceError("ENOENT", `parent directory missing: ${canonical}`, canonical); + } + const created = createDir(db, parentInode, name, 0o755, mtime, rev); + inodeStack.push(created); + realParts.push(name); + // A newly created directory is empty, so a cached negative for + // its own path is the only stale entry possible; drop it exact. + invalidateResolveExact(db, pathFromParts(realParts)); + continue; + } + + if (existing.type === "symlink") { + countSymlinkFollow(follows, canonical); + const target = existing.linkTarget ?? ""; + if (target.startsWith("/")) { + inodeStack.splice(1); + realParts.splice(0); + } + // Re-queue the target's own segments so a link that points + // through further links, or through `..`, is expanded in + // filesystem order. + pending.unshift(...target.split("/").map((part) => ({ name: part, fromLink: true }))); + continue; + } + + if (existing.type !== "dir") { + throw createWorkspaceError( + "ENOTDIR", + `parent path segment is not a directory: ${canonical}`, + canonical, + ); + } + inodeStack.push(existing.inode); + realParts.push(name); + } + + return { + inode: inodeStack[inodeStack.length - 1], + realPath: realParts.length === 0 ? "/" : pathFromParts(realParts), + }; +} + export function mkdir(db: Database, path: string, options: MkdirOptions, now: () => number): void { mkdirWithGuard(db, path, options, now, assertNotReadOnly); } @@ -104,35 +216,15 @@ function mkdirWithGuard( const rev = incrementRev(db); const mtime = now(); - let parentInode = ROOT_INODE; - // Walk all but the final segment. Each must already exist as a - // directory; if `recursive`, we create missing ones. - for (let i = 0; i < parts.length - 1; i++) { - const name = parts[i]; - const existing = lookupChild(db, parentInode, name); - if (existing === undefined) { - if (!recursive) { - throw createWorkspaceError("ENOENT", `parent directory missing: ${canonical}`, canonical); - } - parentInode = createDir(db, parentInode, name, 0o755, mtime, rev); - // A newly created directory is empty, so a cached negative for - // its own path is the only stale entry possible; drop it exact. - invalidateResolveExact(db, `/${parts.slice(0, i + 1).join("/")}`); - continue; - } - if (existing.type !== "dir") { - throw createWorkspaceError( - "ENOTDIR", - `parent path segment is not a directory: ${canonical}`, - canonical, - ); - } - parentInode = existing.inode; - } - - // Final segment. + // Walk all but the final segment, following intermediate links. + const parent = resolveMkdirParent(db, parts, canonical, recursive, mtime, rev); const leafName = parts[parts.length - 1]; - const existing = lookupChild(db, parentInode, leafName); + const realPath = parent.realPath === "/" ? `/${leafName}` : `${parent.realPath}/${leafName}`; + // A path that traversed a link can land somewhere the caller's own + // path never named, so guard the resolved location too. + if (realPath !== canonical) guard(db, realPath); + + const existing = lookupChild(db, parent.inode, leafName); if (existing !== undefined) { // EEXIST is correct for both "already a directory" and // "already a file" per docs/04. Recursive only swallows the @@ -143,7 +235,8 @@ function mkdirWithGuard( throw createWorkspaceError("EEXIST", `path exists: ${canonical}`, canonical); } - createDir(db, parentInode, leafName, mode, mtime, rev); - invalidateResolveExact(db, canonical); + createDir(db, parent.inode, leafName, mode, mtime, rev); + invalidateResolveExact(db, realPath); + if (realPath !== canonical) invalidateResolveExact(db, canonical); }); } From 9c3b24910edf909109c4afd39995bf06f439c09b Mon Sep 17 00:00:00 2001 From: agent Date: Tue, 25 Aug 2026 08:13:29 +0000 Subject: [PATCH 2/4] dofs, computer: add exclusion globs to find The walker tested the inclusion glob before yielding an entry but descended into every directory regardless, so a search in a workspace holding node_modules, .git, or generated build output paid for those trees even when the caller wanted nothing from them. FindOptions gains exclude, a list of globs of the same shape as the inclusion pattern and matched against the same directory-relative path. An exclusion is decided before inclusion, so it always wins, and before any child query, so an excluded directory takes its whole subtree with it rather than being filtered out afterwards. Traversal stays deterministic and limit and offset apply to what survives. The option reaches the public find tool, whose schema now advertises it. Closes #121. --- .changeset/find-exclude-globs.md | 12 +++ docs/09_tool_interface.md | 7 +- packages/computer/src/stub.test.ts | 13 +++ packages/computer/src/tools/fs/find.ts | 11 ++- packages/dofs/src/fs/find.test.ts | 117 +++++++++++++++++++++++++ packages/dofs/src/fs/find.ts | 41 +++++++-- 6 files changed, 189 insertions(+), 12 deletions(-) create mode 100644 .changeset/find-exclude-globs.md diff --git a/.changeset/find-exclude-globs.md b/.changeset/find-exclude-globs.md new file mode 100644 index 00000000..e1472639 --- /dev/null +++ b/.changeset/find-exclude-globs.md @@ -0,0 +1,12 @@ +--- +"@cloudflare/dofs": minor +"@cloudflare/computer": minor +--- + +`find` accepts `exclude`, a list of glob patterns matched against the same directory-relative path as the inclusion glob. Exclusion is decided first, so it always wins, and an excluded directory is pruned during traversal: neither it nor anything below it is read. `limit` and `offset` apply to the matches that survive. The option travels through `WorkspaceFilesystem`, `WorkspaceFilesystemStub`, and the public find tool. + +```ts +const sources = await workspace.fs.find("/workspace", "**/*.ts", { + exclude: ["node_modules", "node_modules/**", ".git", ".git/**"], +}); +``` diff --git a/docs/09_tool_interface.md b/docs/09_tool_interface.md index 97d5b36e..93bfb800 100644 --- a/docs/09_tool_interface.md +++ b/docs/09_tool_interface.md @@ -166,15 +166,18 @@ Entries are in name order. A non-final page includes `nextOffset`; pass it as th ```ts { - path?: string; // default /workspace + path?: string; // default /workspace pattern: string; - limit?: number; // default 200, maximum 1000 + exclude?: string[]; + limit?: number; // default 200, maximum 1000 offset?: number; } ``` The pattern is relative to `path`. `*` stays within one path segment, `**` crosses directories, and `?` matches one non-separator character. Results contain `path` and `type`; a non-final page includes `nextOffset`. Pagination reaches `workspace.fs.find`, which walks directory children in fixed-size pages and stops after collecting the requested page instead of materializing every match. +`exclude` takes globs of the same shape, matched against the same relative path, and beats the inclusion pattern. An excluded directory is pruned rather than filtered, so `exclude: ["node_modules", "node_modules/**"]` keeps the walk out of a package tree instead of walking it and discarding the results. + ## `grep` ```ts diff --git a/packages/computer/src/stub.test.ts b/packages/computer/src/stub.test.ts index 2c0aeca3..e32226d3 100644 --- a/packages/computer/src/stub.test.ts +++ b/packages/computer/src/stub.test.ts @@ -246,6 +246,19 @@ describe("WorkspaceStub", () => { }); }); + it("fs.find forwards exclusion patterns", async () => { + await withStub(async (ws) => { + const stub = ws.stub(); + await ws.fs.mkdir("/keep"); + await ws.fs.mkdir("/node_modules/dep", { recursive: true }); + await ws.fs.writeFile("/keep/a.ts", ""); + await ws.fs.writeFile("/node_modules/dep/b.ts", ""); + expect( + await stub.fs.find("/", "**/*.ts", { exclude: ["node_modules", "node_modules/**"] }), + ).toEqual([{ path: "/keep/a.ts", type: "file" }]); + }); + }); + it("fs.stat propagates ENOENT for missing paths", async () => { await withStub(async (ws) => { const stub = ws.stub(); diff --git a/packages/computer/src/tools/fs/find.ts b/packages/computer/src/tools/fs/find.ts index f64822f2..b19b23f9 100644 --- a/packages/computer/src/tools/fs/find.ts +++ b/packages/computer/src/tools/fs/find.ts @@ -11,7 +11,7 @@ export interface FindWorkspaceLike { find( directory: string, pattern?: string, - options?: { limit?: number; offset?: number }, + options?: { limit?: number; offset?: number; exclude?: string[] }, ): Promise; }; } @@ -28,6 +28,12 @@ const inputSchema = z.object({ pattern: z .string() .describe('Glob pattern relative to path, for example "**/*.ts" or "src/?.js".'), + exclude: z + .array(z.string()) + .optional() + .describe( + 'Glob patterns to leave out, for example ["node_modules/**", "**/.git/**"]. An excluded directory is skipped along with everything below it.', + ), limit: z.number().int().min(1).max(MAX_LIMIT).optional(), offset: z.number().int().min(0).optional(), }); @@ -37,13 +43,14 @@ export function createFindTool(options: FindToolOptions): Tool { + execute: async ({ path, pattern, exclude, limit, offset }) => { try { const pageSize = limit ?? DEFAULT_LIMIT; const pageOffset = offset ?? 0; const matches = await options.workspace.fs.find(path, pattern, { limit: pageSize + 1, offset: pageOffset, + exclude, }); const truncated = matches.length > pageSize; const entries = truncated ? matches.slice(0, pageSize) : matches; diff --git a/packages/dofs/src/fs/find.test.ts b/packages/dofs/src/fs/find.test.ts index b2b470ba..af377ab2 100644 --- a/packages/dofs/src/fs/find.test.ts +++ b/packages/dofs/src/fs/find.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { find } from "./find.js"; import { mkdir } from "./mkdir.js"; +import { resolveInode } from "./resolve.js"; import { withDB } from "./with-db.js"; import { writeFile } from "./writeFile.js"; @@ -131,6 +132,122 @@ describe("find", () => { }); }); + describe("exclude", () => { + it("leaves an excluded file out of the results", async () => { + await withDB(async (db) => { + mkdir(db, "/a", {}, () => 0); + await writeFile(db, "/a/keep.ts", "", {}, () => 0); + await writeFile(db, "/a/skip.ts", "", {}, () => 0); + const paths = find(db, "/a", "**/*.ts", { exclude: ["skip.ts"] }).map((e) => e.path); + expect(paths).toEqual(["/a/keep.ts"]); + }); + }); + + it("matches exclusions relative to the search root", async () => { + await withDB(async (db) => { + mkdir(db, "/root/pkg/node_modules", { recursive: true }, () => 0); + await writeFile(db, "/root/pkg/index.ts", "", {}, () => 0); + await writeFile(db, "/root/pkg/node_modules/dep.ts", "", {}, () => 0); + // The pattern names the path below /root, not the absolute one. + const paths = find(db, "/root", "**/*.ts", { exclude: ["pkg/node_modules/**"] }).map( + (e) => e.path, + ); + expect(paths).toEqual(["/root/pkg/index.ts"]); + }); + }); + + it("accepts several patterns", async () => { + await withDB(async (db) => { + mkdir(db, "/a/node_modules", { recursive: true }, () => 0); + mkdir(db, "/a/.git", { recursive: true }, () => 0); + await writeFile(db, "/a/index.ts", "", {}, () => 0); + await writeFile(db, "/a/node_modules/dep.ts", "", {}, () => 0); + await writeFile(db, "/a/.git/hook.ts", "", {}, () => 0); + const paths = find(db, "/a", "**/*.ts", { + exclude: ["node_modules", "node_modules/**", ".git", ".git/**"], + }).map((e) => e.path); + expect(paths).toEqual(["/a/index.ts"]); + }); + }); + + it("takes precedence over the inclusion glob", async () => { + await withDB(async (db) => { + mkdir(db, "/a", {}, () => 0); + await writeFile(db, "/a/x.ts", "", {}, () => 0); + expect(find(db, "/a", "**/*.ts", { exclude: ["**/*.ts"] })).toEqual([]); + }); + }); + + it("drops an excluded directory as well as its contents", async () => { + await withDB(async (db) => { + mkdir(db, "/a/build/nested", { recursive: true }, () => 0); + await writeFile(db, "/a/keep.txt", "", {}, () => 0); + await writeFile(db, "/a/build/out.txt", "", {}, () => 0); + await writeFile(db, "/a/build/nested/deep.txt", "", {}, () => 0); + const paths = find(db, "/a", undefined, { exclude: ["build"] }) + .map((e) => e.path) + .sort(); + expect(paths).toEqual(["/a/keep.txt"]); + }); + }); + + it("prunes the excluded subtree instead of filtering it afterwards", async () => { + await withDB(async (db) => { + mkdir(db, "/a/build/nested", { recursive: true }, () => 0); + await writeFile(db, "/a/keep.txt", "", {}, () => 0); + await writeFile(db, "/a/build/nested/deep.txt", "", {}, () => 0); + + const buildInode = resolveInode(db, "/a/build")?.inode; + const nestedInode = resolveInode(db, "/a/build/nested")?.inode; + expect(buildInode).toBeDefined(); + expect(nestedInode).toBeDefined(); + + // Record the parent inode of every child listing the walk asks + // for. A pruned directory is never listed. + const listedParents: unknown[] = []; + const all = db.all.bind(db); + // biome-ignore lint/suspicious/noExplicitAny: test spy over the generic method + (db as any).all = (query: string, ...bindings: unknown[]) => { + if (query.includes("FROM vfs_dirents d")) listedParents.push(bindings[0]); + return all(query, ...bindings); + }; + try { + find(db, "/a", undefined, { exclude: ["build"] }); + } finally { + // biome-ignore lint/suspicious/noExplicitAny: restore the spied method + (db as any).all = all; + } + + expect(listedParents).not.toContain(buildInode); + expect(listedParents).not.toContain(nestedInode); + }); + }); + + it("applies limit and offset to the surviving matches", async () => { + await withDB(async (db) => { + mkdir(db, "/a/skip", { recursive: true }, () => 0); + await writeFile(db, "/a/1.ts", "", {}, () => 0); + await writeFile(db, "/a/2.ts", "", {}, () => 0); + await writeFile(db, "/a/3.ts", "", {}, () => 0); + await writeFile(db, "/a/skip/x.ts", "", {}, () => 0); + expect( + find(db, "/a", "**/*.ts", { exclude: ["skip", "skip/**"], offset: 1, limit: 1 }), + ).toEqual([{ path: "/a/2.ts", type: "file" }]); + }); + }); + + it("ignores an empty exclusion list and empty patterns", async () => { + await withDB(async (db) => { + mkdir(db, "/a", {}, () => 0); + await writeFile(db, "/a/x.ts", "", {}, () => 0); + expect(find(db, "/a", "**/*.ts", { exclude: [] }).map((e) => e.path)).toEqual(["/a/x.ts"]); + expect(find(db, "/a", "**/*.ts", { exclude: [""] }).map((e) => e.path)).toEqual([ + "/a/x.ts", + ]); + }); + }); + }); + it("escapes regex metacharacters in literal segments of a pattern", async () => { await withDB(async (db) => { mkdir(db, "/a", {}, () => 0); diff --git a/packages/dofs/src/fs/find.ts b/packages/dofs/src/fs/find.ts index 5580dcc2..0e11b270 100644 --- a/packages/dofs/src/fs/find.ts +++ b/packages/dofs/src/fs/find.ts @@ -13,6 +13,13 @@ export interface FindOptions { limit?: number; /** Matching entries to skip in traversal order. */ offset?: number; + /** + * Glob patterns whose matches are left out of the result. Matched + * against the same directory-relative path as the inclusion glob and + * applied first, so an exclusion always wins. An excluded directory + * is pruned: neither it nor anything below it is visited. + */ + exclude?: string[]; } interface ChildRow { @@ -26,6 +33,7 @@ interface WalkStart { path: string; prefix: string; regex: RegExp | undefined; + excludes: RegExp[]; } const CHILD_PAGE_SIZE = 128; @@ -36,7 +44,7 @@ export function find( pattern?: string, options: FindOptions = {}, ): WorkspaceFoundEntry[] { - const start = prepareWalk(db, directory, pattern); + const start = prepareWalk(db, directory, pattern, options.exclude); const limit = options.limit ?? Number.MAX_SAFE_INTEGER; if (!Number.isSafeInteger(limit) || limit < 0) { throw new TypeError("find limit must be a non-negative safe integer"); @@ -49,7 +57,7 @@ export function find( const out: WorkspaceFoundEntry[] = []; let seen = 0; - for (const entry of walk(db, start.inode, start.path, start.prefix, start.regex)) { + for (const entry of walk(db, start.inode, start.path, start)) { if (seen >= offset) { out.push(entry); if (out.length >= limit) break; @@ -63,12 +71,18 @@ export function* iterateFoundEntries( db: Database, directory: string, pattern?: string, + exclude?: string[], ): IterableIterator { - const start = prepareWalk(db, directory, pattern); - yield* walk(db, start.inode, start.path, start.prefix, start.regex); + const start = prepareWalk(db, directory, pattern, exclude); + yield* walk(db, start.inode, start.path, start); } -function prepareWalk(db: Database, directory: string, pattern: string | undefined): WalkStart { +function prepareWalk( + db: Database, + directory: string, + pattern: string | undefined, + exclude: string[] | undefined, +): WalkStart { const { path: canonical } = canonicalizePath(directory); const node = resolveInode(db, canonical); if (node === null) { @@ -82,11 +96,16 @@ function prepareWalk(db: Database, directory: string, pattern: string | undefine // everything rather than compiling it into `^$`, which would match // only empty relative paths and yield no results. const regex = pattern ? compileGlob(pattern) : undefined; + // An empty exclusion pattern is dropped rather than compiled: like + // the inclusion glob it would only match the empty relative path, + // which no candidate ever has. + const excludes = (exclude ?? []).filter((glob) => glob !== "").map(compileGlob); return { inode: node.inode, path: canonical, prefix: canonical === "/" ? "/" : `${canonical}/`, regex, + excludes, }; } @@ -94,9 +113,9 @@ function* walk( db: Database, parentInode: number, parentPath: string, - prefix: string, - regex: RegExp | undefined, + start: WalkStart, ): IterableIterator { + const { prefix, regex, excludes } = start; let afterName = ""; while (true) { const children = readChildren(db, parentInode, afterName); @@ -105,11 +124,17 @@ function* walk( for (const child of children) { const childPath = parentPath === "/" ? `/${child.name}` : `${parentPath}/${child.name}`; const relativePath = childPath.slice(prefix.length); + // Exclusion is decided before inclusion, and before any child + // query: an excluded directory takes its whole subtree with it, + // so the walker never reads below it. + if (excludes.some((excluded) => excluded.test(relativePath))) { + continue; + } if (regex === undefined || regex.test(relativePath)) { yield { path: childPath, type: child.type }; } if (child.type === "dir") { - yield* walk(db, child.child_inode, childPath, prefix, regex); + yield* walk(db, child.child_inode, childPath, start); } } From 98fa7c4d2d281c5ac6a2d167f97799c7c72dbdee Mon Sep 17 00:00:00 2001 From: agent Date: Tue, 25 Aug 2026 08:13:42 +0000 Subject: [PATCH 3/4] dofs, computer: expose rename on the public filesystem The store has implemented transactional file, directory, and symbolic link moves for some time, covering destination replacement, non-empty directories, read-only mounts, tombstones, revision stamping, and subtree tracking. None of that reached Workspace.fs, so a caller had to copy the source and then delete it, and the Worker shell used that fallback for mv. A failure between the two steps left the entry at both paths or a directory half copied. WorkspaceFilesystem now forwards rename, and WorkspaceFilesystemStub mirrors it with the usual filesystem observation span, so the Workers RPC surface matches the in-process one. The shell adapter calls it and keeps copy-then-delete only for a destination rename refuses to replace, which is what the shell expects when it merges a tree. No new method crosses the Cap'n Web boundary: the existing synchronisation protocol already carries the resulting live entries and tombstones. Closes #120. --- .changeset/expose-fs-rename.md | 6 +++ docs/12_worker_backend.md | 4 +- .../src/backends/worker-shell/adapter.test.ts | 28 ++++++++++++ .../src/backends/worker-shell/adapter.ts | 22 +++++++--- packages/computer/src/stub.test.ts | 44 +++++++++++++++++++ packages/computer/src/stub.ts | 13 ++++++ .../computer/tests/worker-backend.test.ts | 11 +++++ packages/dofs/README.md | 2 +- packages/dofs/src/fs/filesystem.test.ts | 41 +++++++++++++++++ packages/dofs/src/fs/filesystem.ts | 16 +++++++ packages/dofs/src/index.ts | 1 + 11 files changed, 180 insertions(+), 8 deletions(-) create mode 100644 .changeset/expose-fs-rename.md diff --git a/.changeset/expose-fs-rename.md b/.changeset/expose-fs-rename.md new file mode 100644 index 00000000..7b2b8a39 --- /dev/null +++ b/.changeset/expose-fs-rename.md @@ -0,0 +1,6 @@ +--- +"@cloudflare/dofs": minor +"@cloudflare/computer": minor +--- + +`Workspace.fs` gains `rename(oldPath, newPath)`, exposing the store's existing transactional move through the public surface and through `WorkspaceFilesystemStub`. An existing destination is replaced when the two ends agree on kind — a file or symbolic link for a file or symbolic link, an empty directory for a directory — and the operation reports `ENOENT`, `ENOTEMPTY`, `EISDIR`, `ENOTDIR`, `EINVAL`, and `EROFS` as documented in `docs/04_filesystem_interface.md`. The Worker shell's `mv` now calls it, so an interrupted move no longer leaves the entry at both paths or a directory half copied. diff --git a/docs/12_worker_backend.md b/docs/12_worker_backend.md index ab9a6253..5bc7c951 100644 --- a/docs/12_worker_backend.md +++ b/docs/12_worker_backend.md @@ -223,7 +223,9 @@ policy. The backend does not own the external runtime's lifecycle. - **No hard links, no `utimes`.** The adapter throws `ENOSYS` on `link` (the store has no hard-link model) and no-ops on `utimes` (no atime column). `chmod`, `symlink`, `readlink`, - and `lstat` all work end-to-end against the DO's store. + `lstat`, and `rename` all work end-to-end against the DO's + store, so `mv` moves an entry in one operation instead of + copying and then deleting it. - **No cross-request reattach.** `ShellWorker.getExec` always returns ENOENT; `killExec` is a no-op. Each exec is scoped to its own call. The previous in-isolate event log shape didn't diff --git a/packages/computer/src/backends/worker-shell/adapter.test.ts b/packages/computer/src/backends/worker-shell/adapter.test.ts index d92f30a8..95ac19df 100644 --- a/packages/computer/src/backends/worker-shell/adapter.test.ts +++ b/packages/computer/src/backends/worker-shell/adapter.test.ts @@ -275,6 +275,34 @@ describe("WorkspaceFsAdapter — composites", () => { expect(await workspace.fs.readFile("/dst", "utf8")).toBe("hello"); await expect(workspace.fs.stat("/src")).rejects.toMatchObject({ code: "ENOENT" }); }); + + it("mv moves through the store's rename rather than copy and delete", async () => { + await workspace.fs.writeFile("/src", "hello"); + const rename = vi.spyOn(stub, "rename"); + const writeFile = vi.spyOn(stub, "writeFile"); + await adapter.mv("/src", "/dst"); + expect(rename).toHaveBeenCalledWith("/src", "/dst"); + expect(writeFile).not.toHaveBeenCalled(); + }); + + it("mv moves a directory tree in one operation", async () => { + await workspace.fs.mkdir("/src/inner", { recursive: true }); + await workspace.fs.writeFile("/src/inner/b", "b"); + await adapter.mv("/src", "/dst"); + expect(await workspace.fs.readFile("/dst/inner/b", "utf8")).toBe("b"); + await expect(workspace.fs.stat("/src")).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("mv falls back to copy and delete when rename cannot replace the destination", async () => { + await workspace.fs.mkdir("/src", { recursive: true }); + await workspace.fs.writeFile("/src/a", "a"); + await workspace.fs.mkdir("/dst", { recursive: true }); + await workspace.fs.writeFile("/dst/keep", "keep"); + await adapter.mv("/src", "/dst"); + expect(await workspace.fs.readFile("/dst/a", "utf8")).toBe("a"); + expect(await workspace.fs.readFile("/dst/keep", "utf8")).toBe("keep"); + await expect(workspace.fs.stat("/src")).rejects.toMatchObject({ code: "ENOENT" }); + }); }); describe("WorkspaceFsAdapter — pure utilities", () => { diff --git a/packages/computer/src/backends/worker-shell/adapter.ts b/packages/computer/src/backends/worker-shell/adapter.ts index 75e97d9e..2d857fb0 100644 --- a/packages/computer/src/backends/worker-shell/adapter.ts +++ b/packages/computer/src/backends/worker-shell/adapter.ts @@ -3,8 +3,8 @@ // // The adapter is a thin façade. Operations that map one-for-one // (writeFile, readdir, mkdir, rm, chmod, symlink, readlink, stat, -// lstat) forward directly. Operations the stub doesn't expose — -// appendFile, cp, mv, exists — synthesize from the available +// lstat, rename) forward directly. Operations the stub doesn't +// expose — appendFile, cp — synthesize from the available // primitives. Hard links and utimes aren't supported: link throws // ENOSYS so a script that depends on them fails loudly; utimes // is a documented no-op because the store has no atime column. @@ -50,6 +50,7 @@ export interface WorkspaceFs { rm(path: string, options?: RmOptions): Promise; chmod(path: string, mode: number): Promise; symlink(target: string, path: string): Promise; + rename(oldPath: string, newPath: string): Promise; } // Matches the subset of just-bash's IFileSystem the adapter @@ -213,10 +214,19 @@ export class WorkspaceFsAdapter { } async mv(src: string, dest: string): Promise { - // The store doesn't have a native rename today, so model mv as - // copy+delete. POSIX mv is atomic when src and dest live on - // the same filesystem; this approach isn't, but it matches - // what just-bash's other adapters do. + // The store renames in one transaction, so an interrupted move can + // no longer leave the bytes at both paths or a directory half + // copied. A destination that rename refuses to replace — a + // non-empty directory, or a directory and a non-directory in + // either order — still falls back to copy-then-delete, which is + // what the shell's own `mv` expects when it merges a tree. + try { + await this.#fs.rename(src, dest); + return; + } catch (err) { + const code = (err as { code?: string }).code; + if (code !== "ENOTEMPTY" && code !== "EISDIR" && code !== "ENOTDIR") throw err; + } await this.cp(src, dest, { recursive: true }); await this.#fs.rm(src, { recursive: true }); } diff --git a/packages/computer/src/stub.test.ts b/packages/computer/src/stub.test.ts index e32226d3..6f847ff0 100644 --- a/packages/computer/src/stub.test.ts +++ b/packages/computer/src/stub.test.ts @@ -259,6 +259,50 @@ describe("WorkspaceStub", () => { }); }); + it("fs.rename moves a file in one call", async () => { + await withStub(async (ws) => { + const stub = ws.stub(); + await ws.fs.writeFile("/old.txt", "payload"); + await stub.fs.rename("/old.txt", "/new.txt"); + expect(await ws.fs.readFile("/new.txt", "utf8")).toBe("payload"); + await expect(ws.fs.stat("/old.txt")).rejects.toMatchObject({ code: "ENOENT" }); + }); + }); + + it("fs.rename moves a directory subtree", async () => { + await withStub(async (ws) => { + const stub = ws.stub(); + await ws.fs.mkdir("/src/nested", { recursive: true }); + await ws.fs.writeFile("/src/nested/a.txt", "a"); + await stub.fs.rename("/src", "/dst"); + expect(await ws.fs.readFile("/dst/nested/a.txt", "utf8")).toBe("a"); + await expect(ws.fs.stat("/src")).rejects.toMatchObject({ code: "ENOENT" }); + }); + }); + + it("fs.rename replaces an existing file and reports POSIX errors", async () => { + await withStub(async (ws) => { + const stub = ws.stub(); + await ws.fs.writeFile("/a.txt", "a"); + await ws.fs.writeFile("/b.txt", "b"); + await stub.fs.rename("/a.txt", "/b.txt"); + expect(await ws.fs.readFile("/b.txt", "utf8")).toBe("a"); + + await expect(stub.fs.rename("/missing", "/somewhere")).rejects.toMatchObject({ + code: "ENOENT", + }); + + await ws.fs.mkdir("/dir"); + await ws.fs.writeFile("/dir/child.txt", "c"); + await ws.fs.writeFile("/file.txt", "f"); + await expect(stub.fs.rename("/file.txt", "/dir")).rejects.toMatchObject({ code: "EISDIR" }); + await ws.fs.mkdir("/other"); + await expect(stub.fs.rename("/other", "/dir")).rejects.toMatchObject({ code: "ENOTEMPTY" }); + await expect(stub.fs.rename("/dir", "/file.txt")).rejects.toMatchObject({ code: "ENOTDIR" }); + await expect(stub.fs.rename("/file.txt", "/")).rejects.toMatchObject({ code: "EINVAL" }); + }); + }); + it("fs.stat propagates ENOENT for missing paths", async () => { await withStub(async (ws) => { const stub = ws.stub(); diff --git a/packages/computer/src/stub.ts b/packages/computer/src/stub.ts index dd000de1..682bc0fb 100644 --- a/packages/computer/src/stub.ts +++ b/packages/computer/src/stub.ts @@ -268,6 +268,19 @@ export class WorkspaceFilesystemStub extends RpcTarget { ); } + // Move a path in one store operation. Replaces the copy-then-delete + // dance callers used to write, so a failure can no longer leave both + // ends behind. Overwrite and error behavior is documented in + // docs/04_filesystem_interface.md. + rename(oldPath: string, newPath: string): Promise { + return withSpan( + this.#ws.observer, + "workspace.fs.rename", + { "workspace.fs.path": oldPath, "workspace.fs.destination": newPath }, + () => this.#ws.fs.rename(oldPath, newPath), + ); + } + chmod(path: string, mode: number): Promise { return withSpan( this.#ws.observer, diff --git a/packages/computer/tests/worker-backend.test.ts b/packages/computer/tests/worker-backend.test.ts index 6bc42510..21f12882 100644 --- a/packages/computer/tests/worker-backend.test.ts +++ b/packages/computer/tests/worker-backend.test.ts @@ -111,6 +111,17 @@ describe("WorkerShellBackend end-to-end", () => { expect(text).toBe("from inside the shell\n"); }); + it("moves a file with mv through the host's rename over Workers RPC", async () => { + const id = freshId(); + await write(id, "/workspace/old.txt", "payload"); + // `test -e` on the source proves the adapter renamed rather than + // copying and leaving both ends behind. + const result = await exec(id, "mv old.txt new.txt && cat new.txt && test -e old.txt"); + expect(result.exitCode).not.toBe(0); + expect(result.stdout).toBe("payload"); + expect(await read(id, "/workspace/new.txt")).toBe("payload"); + }); + it("reports a non-zero exit code with stderr captured", async () => { const id = freshId(); const result = await exec(id, "ls /nope 2>&1; echo done"); diff --git a/packages/dofs/README.md b/packages/dofs/README.md index 94bd285f..e687bc2e 100644 --- a/packages/dofs/README.md +++ b/packages/dofs/README.md @@ -15,7 +15,7 @@ Durable Object SQLite-backed virtual filesystem for Cloudflare Computer. This package exposes a JavaScript module, not a CLI. It bundles three layers that can be used independently: - A `Database` wrapper around Durable Object SQL storage plus `initializeSchema` for the `vfs_*` tables. -- Filesystem primitives under `src/fs/*` (`mkdir`, `writeFile`, `readFile`, `rm`, `readdir`, `stat`, `lstat`, `chmod`, `find`, `ls`, `grep`, `symlink`, `readlink`, `gc`, `watch`) operating on a `Database`. +- Filesystem primitives under `src/fs/*` (`mkdir`, `writeFile`, `readFile`, `rm`, `rename`, `readdir`, `stat`, `lstat`, `chmod`, `find`, `ls`, `grep`, `symlink`, `readlink`, `gc`, `watch`) operating on a `Database`. - `SQLiteWorkspaceProvider`, a `@platformatic/vfs` adapter that composes those primitives into a node-shaped filesystem (fd table, positional `readSync`/`writeSync`, `watchSync`, symlinks). This is what `computerd` mounts via FUSE. - Sync protocol building blocks operating on the same `Database`: `applyChanges`, `stageBlob`, `materialiseChange`, `coalesceChanges`, `fetchChanges`, `fetchObjects`, `hasObjects`, `pushObjects`, `buildManifest`, `currentRev`, `compareChangeCursors`, `readWatermark`/`writeWatermark`, `assertAppliedPushCursor`, and the opt-in ignore matcher `isIgnored` (the default ignore list is empty). The wire wiring lives in `@cloudflare/computer-rpc`. diff --git a/packages/dofs/src/fs/filesystem.test.ts b/packages/dofs/src/fs/filesystem.test.ts index 412faf94..2f6cbad9 100644 --- a/packages/dofs/src/fs/filesystem.test.ts +++ b/packages/dofs/src/fs/filesystem.test.ts @@ -115,6 +115,47 @@ describe("WorkspaceFilesystem", () => { }); }); + it("rename moves a file, a directory tree and a symbolic link", async () => { + await withFs(async (fs) => { + await fs.writeFile("/old.txt", "payload"); + await fs.rename("/old.txt", "/new.txt"); + expect(await fs.readFile("/new.txt", "utf8")).toBe("payload"); + await expect(fs.stat("/old.txt")).rejects.toMatchObject({ code: "ENOENT" }); + + await fs.mkdir("/tree/inner", { recursive: true }); + await fs.writeFile("/tree/inner/a.txt", "a"); + await fs.rename("/tree", "/moved"); + expect(await fs.readFile("/moved/inner/a.txt", "utf8")).toBe("a"); + await expect(fs.stat("/tree")).rejects.toMatchObject({ code: "ENOENT" }); + + await fs.symlink("/new.txt", "/link"); + await fs.rename("/link", "/link2"); + expect(await fs.readlink("/link2")).toBe("/new.txt"); + }); + }); + + it("rename replaces a file and reports the documented errors", async () => { + await withFs(async (fs) => { + await fs.writeFile("/a.txt", "a"); + await fs.writeFile("/b.txt", "b"); + await fs.rename("/a.txt", "/b.txt"); + expect(await fs.readFile("/b.txt", "utf8")).toBe("a"); + + await expect(fs.rename("/missing", "/elsewhere")).rejects.toMatchObject({ code: "ENOENT" }); + await expect(fs.rename("/b.txt", "/no/such/dir/b.txt")).rejects.toMatchObject({ + code: "ENOENT", + }); + + await fs.mkdir("/full"); + await fs.writeFile("/full/child", "c"); + await fs.mkdir("/empty"); + await expect(fs.rename("/empty", "/full")).rejects.toMatchObject({ code: "ENOTEMPTY" }); + await expect(fs.rename("/b.txt", "/full")).rejects.toMatchObject({ code: "EISDIR" }); + await expect(fs.rename("/full", "/b.txt")).rejects.toMatchObject({ code: "ENOTDIR" }); + await expect(fs.rename("/b.txt", "/")).rejects.toMatchObject({ code: "EINVAL" }); + }); + }); + it("chmod updates the stored mode", async () => { await withFs(async (fs) => { await fs.writeFile("/a", "hi"); diff --git a/packages/dofs/src/fs/filesystem.ts b/packages/dofs/src/fs/filesystem.ts index 2757d4ff..b7929617 100644 --- a/packages/dofs/src/fs/filesystem.ts +++ b/packages/dofs/src/fs/filesystem.ts @@ -22,6 +22,7 @@ import { type MkdirOptions, mkdir } from "./mkdir.js"; import { type ReaddirOptions, readdir, type WorkspaceDirentResult } from "./readdir.js"; import { type ReadFileOptions, readFile } from "./readFile.js"; import { readlink } from "./readlink.js"; +import { rename } from "./rename.js"; import { type RmOptions, rm } from "./rm.js"; import { lstat, stat, type WorkspaceStatResult } from "./stat.js"; import { symlink } from "./symlink.js"; @@ -121,6 +122,21 @@ export class WorkspaceFilesystem { rm(this.db, path, options); } + // Move a file, directory or symbolic link in one transaction. An + // existing destination is replaced when the two ends agree on kind: + // a file or symbolic link replaces a file or symbolic link, and a + // directory replaces an empty directory. + // + // Errors: ENOENT when the source is missing or the destination's + // parent does not exist, ENOTEMPTY when the destination is a + // non-empty directory, EISDIR when a non-directory would replace a + // directory, ENOTDIR when a directory would replace a + // non-directory, EINVAL for the root at either end or a directory + // moved into itself, and EROFS under a read-only mount. + async rename(oldPath: string, newPath: string): Promise { + rename(this.db, oldPath, newPath); + } + // Change the permission bits on a path. Follows symlinks like // POSIX chmod — the change lands on the target, not the link. // The supplied mode is masked to twelve bits. diff --git a/packages/dofs/src/index.ts b/packages/dofs/src/index.ts index 468ec22a..934bd2d6 100644 --- a/packages/dofs/src/index.ts +++ b/packages/dofs/src/index.ts @@ -27,6 +27,7 @@ export { export type { ReaddirOptions, WorkspaceDirentResult } from "./fs/readdir.js"; export type { ReadFileOptions } from "./fs/readFile.js"; export { readlink } from "./fs/readlink.js"; +export { rename } from "./fs/rename.js"; export type { RmOptions } from "./fs/rm.js"; export { lstat, stat, type WorkspaceStatResult } from "./fs/stat.js"; export { symlink } from "./fs/symlink.js"; From 2201c5994928e43a06c38e08a07a77f2288e1844 Mon Sep 17 00:00:00 2001 From: agent Date: Tue, 25 Aug 2026 08:13:51 +0000 Subject: [PATCH 4/4] docs: describe the shipped symbolic-link surface The filesystem specification said symbolic links were an internal primitive, that Workspace.fs exposed neither symlink nor readlink, that there was no lstat, and that an existing file's mode could not be changed. The shipped API contradicts all four: WorkspaceFilesystem exposes symlink, readlink, lstat, and chmod, the stub mirrors them across the Workers RPC boundary, and the Dynamic Worker filesystem adapters rely on them for the node:fs behaviour a shell expects. Removing the methods would be a breaking change and would leave those adapters without a way to serve ln -s, readlink, or test -L, so the document follows the code. Each of the four methods gains a section with its return value and its errors, the comparison with node:fs/promises maps them rather than striking them out, and the note on symbolic links now states the two rules that cover the surface: intermediate segments are always followed, and a trailing link is followed by everything except lstat and readlink. The rename and find entries added alongside are documented in the same pass. Closes #118. --- .changeset/document-symlink-surface.md | 5 + docs/04_filesystem_interface.md | 177 ++++++++++++++++++++++--- 2 files changed, 160 insertions(+), 22 deletions(-) create mode 100644 .changeset/document-symlink-surface.md diff --git a/.changeset/document-symlink-surface.md b/.changeset/document-symlink-surface.md new file mode 100644 index 00000000..12783bc3 --- /dev/null +++ b/.changeset/document-symlink-surface.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computer": patch +--- + +Document the symbolic-link filesystem surface. `docs/04_filesystem_interface.md` claimed that symbolic links were internal and that `Workspace.fs` had no `symlink`, `readlink`, `lstat`, or `chmod`, none of which matched the shipped API. Those four methods now have sections of their own covering return values and the `ENOENT`, `EINVAL`, and `ELOOP` cases, the comparison with `node:fs/promises` maps them, and the specification explains that `stat` follows a trailing link while `lstat` reports the link itself. diff --git a/docs/04_filesystem_interface.md b/docs/04_filesystem_interface.md index 1ce88505..d5f6eaa5 100644 --- a/docs/04_filesystem_interface.md +++ b/docs/04_filesystem_interface.md @@ -190,8 +190,10 @@ stat(path: string): Promise<{ `name` is the last segment of the canonicalized path. For the workspace root this is the empty string: `(await fs.stat("/")).name === ""`. -`stat` follows symlinks transparently; there is no `lstat`. See the -note on internal symlink support in the appendix. +`stat` follows a trailing symbolic link, so it reports the file or +directory the link points at. Use [`lstat`](#lstat) to inspect the link +itself. A dangling link makes `stat` report `ENOENT` while `lstat` +succeeds. > When a parent path segment is itself a file, `stat` reports `ENOENT` > (because resolution returns `null` for that case) rather than @@ -203,6 +205,117 @@ const s = await fs.stat("/workspace/build/out.wasm"); console.log(`${s.size} bytes, modified ${new Date(s.mtime).toISOString()}`); ``` +### `lstat` + +```ts +lstat(path: string): Promise<{ + name: string; + mode: number; + mtime: number; // ms since epoch + size: number; + isFile: boolean; + isDirectory: boolean; + isSymbolicLink: boolean; +}> +``` + +Same shape as `stat`, but a trailing symbolic link is reported as the +link rather than followed. `size` is then the byte length of the stored +target string, and `isSymbolicLink` is true. Intermediate segments are +still followed, so a link in the middle of the path resolves as usual. +Throws `ENOENT` when the path does not exist and `ELOOP` when an +intermediate chain exceeds 40 hops. + +```ts +await fs.symlink("/workspace/real.txt", "/workspace/alias.txt"); +(await fs.stat("/workspace/alias.txt")).isSymbolicLink; // false +(await fs.lstat("/workspace/alias.txt")).isSymbolicLink; // true +``` + +### `symlink` + +```ts +symlink(target: string, path: string): Promise +``` + +Creates a symbolic link at `path` pointing at `target`, with the +argument order of `node:fs/promises`. The target is stored verbatim: it +may be absolute or relative, and it is allowed to dangle. Reads and +writes that walk through the link follow it, with the same 40-hop cap as +every other resolution. + +Throws `EEXIST` when `path` already exists (the link is never replaced +silently), `ENOENT` when the parent directory is missing, `ENOTDIR` when +a parent segment is a file, and `EROFS` under a read-only mount. + +```ts +await fs.symlink("../shared/config.json", "/workspace/app/config.json"); +``` + +### `readlink` + +```ts +readlink(path: string): Promise +``` + +Returns the stored target of a symbolic link, exactly as it was +written — relative targets are not resolved. Throws `EINVAL` when `path` +is not a symbolic link and `ENOENT` when it does not exist. + +```ts +await fs.readlink("/workspace/app/config.json"); // "../shared/config.json" +``` + +### `chmod` + +```ts +chmod(path: string, mode: number): Promise +``` + +Changes the permission bits of an existing path without rewriting its +bytes. The mode is masked to twelve bits. Like POSIX `chmod`, a trailing +symbolic link is followed, so the change lands on the target rather than +the link. Throws `ENOENT` for a missing path and `EROFS` under a +read-only mount. + +```ts +await fs.chmod("/workspace/bin/run.sh", 0o755); +``` + +### `rename` + +```ts +rename(oldPath: string, newPath: string): Promise +``` + +Moves a file, directory, or symbolic link in a single transaction, so an +interrupted call can never leave the entry at both paths or a directory +partly copied. The moved entry keeps its inode, its bytes, and its mode; +a directory move carries its whole subtree. + +Overwrite behavior follows POSIX `rename(2)`: an existing destination is +replaced when the two ends agree on kind. A file or symbolic link +replaces a file or symbolic link, and a directory replaces an *empty* +directory. Nothing else is replaced. + +| Code | When | +| --- | --- | +| `ENOENT` | `oldPath` does not exist, or `newPath`'s parent directory is missing. | +| `ENOTEMPTY` | `newPath` is a directory with children. | +| `EISDIR` | `newPath` is a directory and `oldPath` is not. | +| `ENOTDIR` | `oldPath` is a directory and `newPath` is not. | +| `EINVAL` | Either end is the root, or a directory would be moved inside itself. | +| `EROFS` | Either end falls under a read-only mount. | + +```ts +// Publish a build atomically. +await fs.writeFile("/workspace/site/index.html.tmp", html); +await fs.rename("/workspace/site/index.html.tmp", "/workspace/site/index.html"); + +// Move a whole tree. +await fs.rename("/workspace/draft", "/workspace/published"); +``` + ### `find` ```ts @@ -212,6 +325,7 @@ find( options?: { limit?: number; offset?: number; + exclude?: string[]; }, ): Promise> ``` @@ -225,12 +339,24 @@ its absolute path — so `**/*.ts` under `/workspace/src` matches The glob supports `*`, `**`, `**/`, and `?`. Character classes and brace expansions are matched literally. +`exclude` takes globs of the same shape, matched against the same +relative path. An exclusion is decided before the inclusion glob, so it +always wins. When an excluded entry is a directory the walk prunes it: +neither the directory nor anything beneath it is read, which is what +makes skipping `node_modules` or `.git` cheap rather than merely quiet. +`limit` and `offset` then paginate whatever survives. + ```ts // Every TypeScript file in the project. const ts = await fs.find("/workspace/src", "**/*.ts"); // Everything under a directory (no pattern). const all = await fs.find("/workspace/notes"); + +// Skip generated trees without descending into them. +const sources = await fs.find("/workspace", "**/*.ts", { + exclude: ["node_modules", "node_modules/**", ".git", ".git/**"], +}); ``` ### `ls` @@ -321,12 +447,12 @@ so handlers from Node code port over directly. | Code | When | | --- | --- | | `ENOENT` | Path does not exist and `force` is not true. Also raised by `stat` when a parent segment turns out to be a file. | -| `ENOTEMPTY` | Path is a non-empty directory and `recursive` is not true. | -| `ENOTDIR` | A parent path segment is a file (raised explicitly by `mkdir` and `writeFile`; `find` raises it when its `directory` argument is a file). | -| `EISDIR` | Expected a file, got a directory (e.g. `readFile` on a dir, `writeFile` on `/`). | -| `EEXIST` | `mkdir` without `recursive: true` on an existing path. | -| `EINVAL` | Invalid path or unsupported options. | -| `ELOOP` | Symlink traversal exceeded 40 hops. Thrown by the internal resolver when the `node:vfs` adapter wires up a cycle. | +| `ENOTEMPTY` | Path is a non-empty directory and `recursive` is not true. Also raised by `rename` when the destination directory has children. | +| `ENOTDIR` | A parent path segment is a file (raised explicitly by `mkdir` and `writeFile`; `find` raises it when its `directory` argument is a file; `rename` raises it when a directory would replace a non-directory). | +| `EISDIR` | Expected a file, got a directory (e.g. `readFile` on a dir, `writeFile` on `/`, `rename` of a file onto a directory). | +| `EEXIST` | `mkdir` without `recursive: true` on an existing path, or `symlink` onto an existing path. | +| `EINVAL` | Invalid path or unsupported options: `readlink` on something that is not a symbolic link, `rename` of the root or of a directory into itself. | +| `ELOOP` | Symbolic-link traversal exceeded 40 hops. Every path-walking method shares that budget, so a cycle surfaces from `stat`, `readFile`, `writeFile`, `mkdir`, and the rest alike. | | `EPERM` | Operation is forbidden, e.g. deleting the workspace root. | | `EIO` | Backing storage failed unexpectedly. | | `EACCES` | *Reserved for future mount layer (see [06. Mount Interface](./06_mount_interface.md)).* No code path in `workspace-fs` currently throws it. | @@ -379,28 +505,35 @@ maps to `Workspace.fs`: | `rm` | `rm` | `{ recursive: true }` for non-empty dirs. | | `unlink` | `rm` | Same. | | `readdir` | `readdir` | Always returns dirent-shaped entries. | -| `stat` / `lstat` | `stat` | No `lstat`; `stat` follows symlinks. See note below. | +| `stat` / `lstat` | `stat` / `lstat` | `stat` follows a trailing symbolic link; `lstat` reports the link. | | `truncate` | — | Read, slice, write. | -| `chmod` | — | Pass `mode` to `writeFile` / `mkdir` at create time. There is no way to chmod an existing file without rewriting its bytes. | +| `chmod` | `chmod` | Mode masked to twelve bits; follows a trailing symbolic link. `mode` can also be passed to `writeFile` / `mkdir` at create time. | | `chown` | — | No ownership model. | | `utimes` | — | `mtime` is managed by the VFS. | | `cp` / `copyFile` | — | Read + write. | -| `rename` | — | Read + write + delete. | +| `rename` | `rename` | One transaction; replaces a destination of the same kind. | | `realpath` | — | Paths are already canonical. | -| `symlink` / `readlink` | — | Not on the public surface; see note below. | +| `symlink` / `readlink` | `symlink` / `readlink` | Same argument order as Node. Targets are stored verbatim and may dangle. | | `watch` | — | Low-level primitive in `fs/watch.ts` (`createWatcher`, `createWatchAsyncIterable`, `WatchHandle`, `WatchOptions`); not exposed on the `WorkspaceFilesystem` class. | | `open` / `FileHandle` | — | Use streams instead. | -| `glob` | `find` | Limited glob support (`*`, `**`, `**/`, and `?`). | +| `glob` | `find` | Limited glob support (`*`, `**`, `**/`, and `?`), plus `exclude` for pruning subtrees. | | — | `grep` | Not in `node:fs`; literal by default, with optional regular expressions. | | — | `find` | Recursive directory walk with an optional glob, relative-rooted. | | — | `ls` | Flat list of file paths under a directory (segment-aware). | -### Note: symlinks - -Symlinks exist as an **internal primitive** used by the `node:vfs` -adapter — the schema supports a `'symlink'` node type with a -`link_target`, and the resolver in `fs/resolve.ts` follows them with a -40-hop cap (throws `ELOOP` on overflow). They are **not** part of the -public `WorkspaceFilesystem` surface: there are no `fs.symlink` or -`fs.readlink` methods on `Workspace.fs`, and callers should treat all -visible paths as if they pointed straight at real files. +### Note: symbolic links + +Symbolic links are part of the public surface. The schema carries a +`'symlink'` node type with a `link_target`, the resolver in +`fs/resolve.ts` follows them with a 40-hop cap (throws `ELOOP` on +overflow), and `Workspace.fs` exposes `symlink`, `readlink`, and +`lstat` on top of that. `WorkspaceFilesystemStub` mirrors all three +across the Workers RPC boundary, which is how the Dynamic Worker +filesystem adapters provide the `node:fs` behavior a shell expects from +`ln -s`, `readlink`, and `test -L`. + +Two rules cover the whole surface. Intermediate segments are always +followed, so a link to a directory behaves like the directory for every +method, `mkdir` included. A trailing link is followed by everything +except `lstat` and `readlink`, which are the two methods whose purpose +is to describe the link itself.