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/.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/.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/.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/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. 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/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 2c0aeca3..6f847ff0 100644 --- a/packages/computer/src/stub.test.ts +++ b/packages/computer/src/stub.test.ts @@ -246,6 +246,63 @@ 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.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/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/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/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); } } 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); }); } 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";