Skip to content

Commit 2201c59

Browse files
author
agent
committed
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.
1 parent 98fa7c4 commit 2201c59

2 files changed

Lines changed: 160 additions & 22 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cloudflare/computer": patch
3+
---
4+
5+
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.

docs/04_filesystem_interface.md

Lines changed: 155 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -190,8 +190,10 @@ stat(path: string): Promise<{
190190
`name` is the last segment of the canonicalized path. For the workspace
191191
root this is the empty string: `(await fs.stat("/")).name === ""`.
192192

193-
`stat` follows symlinks transparently; there is no `lstat`. See the
194-
note on internal symlink support in the appendix.
193+
`stat` follows a trailing symbolic link, so it reports the file or
194+
directory the link points at. Use [`lstat`](#lstat) to inspect the link
195+
itself. A dangling link makes `stat` report `ENOENT` while `lstat`
196+
succeeds.
195197

196198
> When a parent path segment is itself a file, `stat` reports `ENOENT`
197199
> (because resolution returns `null` for that case) rather than
@@ -203,6 +205,117 @@ const s = await fs.stat("/workspace/build/out.wasm");
203205
console.log(`${s.size} bytes, modified ${new Date(s.mtime).toISOString()}`);
204206
```
205207

208+
### `lstat`
209+
210+
```ts
211+
lstat(path: string): Promise<{
212+
name: string;
213+
mode: number;
214+
mtime: number; // ms since epoch
215+
size: number;
216+
isFile: boolean;
217+
isDirectory: boolean;
218+
isSymbolicLink: boolean;
219+
}>
220+
```
221+
222+
Same shape as `stat`, but a trailing symbolic link is reported as the
223+
link rather than followed. `size` is then the byte length of the stored
224+
target string, and `isSymbolicLink` is true. Intermediate segments are
225+
still followed, so a link in the middle of the path resolves as usual.
226+
Throws `ENOENT` when the path does not exist and `ELOOP` when an
227+
intermediate chain exceeds 40 hops.
228+
229+
```ts
230+
await fs.symlink("/workspace/real.txt", "/workspace/alias.txt");
231+
(await fs.stat("/workspace/alias.txt")).isSymbolicLink; // false
232+
(await fs.lstat("/workspace/alias.txt")).isSymbolicLink; // true
233+
```
234+
235+
### `symlink`
236+
237+
```ts
238+
symlink(target: string, path: string): Promise<void>
239+
```
240+
241+
Creates a symbolic link at `path` pointing at `target`, with the
242+
argument order of `node:fs/promises`. The target is stored verbatim: it
243+
may be absolute or relative, and it is allowed to dangle. Reads and
244+
writes that walk through the link follow it, with the same 40-hop cap as
245+
every other resolution.
246+
247+
Throws `EEXIST` when `path` already exists (the link is never replaced
248+
silently), `ENOENT` when the parent directory is missing, `ENOTDIR` when
249+
a parent segment is a file, and `EROFS` under a read-only mount.
250+
251+
```ts
252+
await fs.symlink("../shared/config.json", "/workspace/app/config.json");
253+
```
254+
255+
### `readlink`
256+
257+
```ts
258+
readlink(path: string): Promise<string>
259+
```
260+
261+
Returns the stored target of a symbolic link, exactly as it was
262+
written — relative targets are not resolved. Throws `EINVAL` when `path`
263+
is not a symbolic link and `ENOENT` when it does not exist.
264+
265+
```ts
266+
await fs.readlink("/workspace/app/config.json"); // "../shared/config.json"
267+
```
268+
269+
### `chmod`
270+
271+
```ts
272+
chmod(path: string, mode: number): Promise<void>
273+
```
274+
275+
Changes the permission bits of an existing path without rewriting its
276+
bytes. The mode is masked to twelve bits. Like POSIX `chmod`, a trailing
277+
symbolic link is followed, so the change lands on the target rather than
278+
the link. Throws `ENOENT` for a missing path and `EROFS` under a
279+
read-only mount.
280+
281+
```ts
282+
await fs.chmod("/workspace/bin/run.sh", 0o755);
283+
```
284+
285+
### `rename`
286+
287+
```ts
288+
rename(oldPath: string, newPath: string): Promise<void>
289+
```
290+
291+
Moves a file, directory, or symbolic link in a single transaction, so an
292+
interrupted call can never leave the entry at both paths or a directory
293+
partly copied. The moved entry keeps its inode, its bytes, and its mode;
294+
a directory move carries its whole subtree.
295+
296+
Overwrite behavior follows POSIX `rename(2)`: an existing destination is
297+
replaced when the two ends agree on kind. A file or symbolic link
298+
replaces a file or symbolic link, and a directory replaces an *empty*
299+
directory. Nothing else is replaced.
300+
301+
| Code | When |
302+
| --- | --- |
303+
| `ENOENT` | `oldPath` does not exist, or `newPath`'s parent directory is missing. |
304+
| `ENOTEMPTY` | `newPath` is a directory with children. |
305+
| `EISDIR` | `newPath` is a directory and `oldPath` is not. |
306+
| `ENOTDIR` | `oldPath` is a directory and `newPath` is not. |
307+
| `EINVAL` | Either end is the root, or a directory would be moved inside itself. |
308+
| `EROFS` | Either end falls under a read-only mount. |
309+
310+
```ts
311+
// Publish a build atomically.
312+
await fs.writeFile("/workspace/site/index.html.tmp", html);
313+
await fs.rename("/workspace/site/index.html.tmp", "/workspace/site/index.html");
314+
315+
// Move a whole tree.
316+
await fs.rename("/workspace/draft", "/workspace/published");
317+
```
318+
206319
### `find`
207320

208321
```ts
@@ -212,6 +325,7 @@ find(
212325
options?: {
213326
limit?: number;
214327
offset?: number;
328+
exclude?: string[];
215329
},
216330
): Promise<Array<{ path; type: "file" | "dir" }>>
217331
```
@@ -225,12 +339,24 @@ its absolute path — so `**/*.ts` under `/workspace/src` matches
225339
The glob supports `*`, `**`, `**/`, and `?`. Character classes and
226340
brace expansions are matched literally.
227341

342+
`exclude` takes globs of the same shape, matched against the same
343+
relative path. An exclusion is decided before the inclusion glob, so it
344+
always wins. When an excluded entry is a directory the walk prunes it:
345+
neither the directory nor anything beneath it is read, which is what
346+
makes skipping `node_modules` or `.git` cheap rather than merely quiet.
347+
`limit` and `offset` then paginate whatever survives.
348+
228349
```ts
229350
// Every TypeScript file in the project.
230351
const ts = await fs.find("/workspace/src", "**/*.ts");
231352

232353
// Everything under a directory (no pattern).
233354
const all = await fs.find("/workspace/notes");
355+
356+
// Skip generated trees without descending into them.
357+
const sources = await fs.find("/workspace", "**/*.ts", {
358+
exclude: ["node_modules", "node_modules/**", ".git", ".git/**"],
359+
});
234360
```
235361

236362
### `ls`
@@ -321,12 +447,12 @@ so handlers from Node code port over directly.
321447
| Code | When |
322448
| --- | --- |
323449
| `ENOENT` | Path does not exist and `force` is not true. Also raised by `stat` when a parent segment turns out to be a file. |
324-
| `ENOTEMPTY` | Path is a non-empty directory and `recursive` is not true. |
325-
| `ENOTDIR` | A parent path segment is a file (raised explicitly by `mkdir` and `writeFile`; `find` raises it when its `directory` argument is a file). |
326-
| `EISDIR` | Expected a file, got a directory (e.g. `readFile` on a dir, `writeFile` on `/`). |
327-
| `EEXIST` | `mkdir` without `recursive: true` on an existing path. |
328-
| `EINVAL` | Invalid path or unsupported options. |
329-
| `ELOOP` | Symlink traversal exceeded 40 hops. Thrown by the internal resolver when the `node:vfs` adapter wires up a cycle. |
450+
| `ENOTEMPTY` | Path is a non-empty directory and `recursive` is not true. Also raised by `rename` when the destination directory has children. |
451+
| `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). |
452+
| `EISDIR` | Expected a file, got a directory (e.g. `readFile` on a dir, `writeFile` on `/`, `rename` of a file onto a directory). |
453+
| `EEXIST` | `mkdir` without `recursive: true` on an existing path, or `symlink` onto an existing path. |
454+
| `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. |
455+
| `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. |
330456
| `EPERM` | Operation is forbidden, e.g. deleting the workspace root. |
331457
| `EIO` | Backing storage failed unexpectedly. |
332458
| `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`:
379505
| `rm` | `rm` | `{ recursive: true }` for non-empty dirs. |
380506
| `unlink` | `rm` | Same. |
381507
| `readdir` | `readdir` | Always returns dirent-shaped entries. |
382-
| `stat` / `lstat` | `stat` | No `lstat`; `stat` follows symlinks. See note below. |
508+
| `stat` / `lstat` | `stat` / `lstat` | `stat` follows a trailing symbolic link; `lstat` reports the link. |
383509
| `truncate` || Read, slice, write. |
384-
| `chmod` | | Pass `mode` to `writeFile` / `mkdir` at create time. There is no way to chmod an existing file without rewriting its bytes. |
510+
| `chmod` | `chmod` | Mode masked to twelve bits; follows a trailing symbolic link. `mode` can also be passed to `writeFile` / `mkdir` at create time. |
385511
| `chown` || No ownership model. |
386512
| `utimes` || `mtime` is managed by the VFS. |
387513
| `cp` / `copyFile` || Read + write. |
388-
| `rename` | | Read + write + delete. |
514+
| `rename` | `rename` | One transaction; replaces a destination of the same kind. |
389515
| `realpath` || Paths are already canonical. |
390-
| `symlink` / `readlink` | | Not on the public surface; see note below. |
516+
| `symlink` / `readlink` | `symlink` / `readlink` | Same argument order as Node. Targets are stored verbatim and may dangle. |
391517
| `watch` || Low-level primitive in `fs/watch.ts` (`createWatcher`, `createWatchAsyncIterable`, `WatchHandle`, `WatchOptions`); not exposed on the `WorkspaceFilesystem` class. |
392518
| `open` / `FileHandle` || Use streams instead. |
393-
| `glob` | `find` | Limited glob support (`*`, `**`, `**/`, and `?`). |
519+
| `glob` | `find` | Limited glob support (`*`, `**`, `**/`, and `?`), plus `exclude` for pruning subtrees. |
394520
|| `grep` | Not in `node:fs`; literal by default, with optional regular expressions. |
395521
|| `find` | Recursive directory walk with an optional glob, relative-rooted. |
396522
|| `ls` | Flat list of file paths under a directory (segment-aware). |
397523

398-
### Note: symlinks
399-
400-
Symlinks exist as an **internal primitive** used by the `node:vfs`
401-
adapter — the schema supports a `'symlink'` node type with a
402-
`link_target`, and the resolver in `fs/resolve.ts` follows them with a
403-
40-hop cap (throws `ELOOP` on overflow). They are **not** part of the
404-
public `WorkspaceFilesystem` surface: there are no `fs.symlink` or
405-
`fs.readlink` methods on `Workspace.fs`, and callers should treat all
406-
visible paths as if they pointed straight at real files.
524+
### Note: symbolic links
525+
526+
Symbolic links are part of the public surface. The schema carries a
527+
`'symlink'` node type with a `link_target`, the resolver in
528+
`fs/resolve.ts` follows them with a 40-hop cap (throws `ELOOP` on
529+
overflow), and `Workspace.fs` exposes `symlink`, `readlink`, and
530+
`lstat` on top of that. `WorkspaceFilesystemStub` mirrors all three
531+
across the Workers RPC boundary, which is how the Dynamic Worker
532+
filesystem adapters provide the `node:fs` behavior a shell expects from
533+
`ln -s`, `readlink`, and `test -L`.
534+
535+
Two rules cover the whole surface. Intermediate segments are always
536+
followed, so a link to a directory behaves like the directory for every
537+
method, `mkdir` included. A trailing link is followed by everything
538+
except `lstat` and `readlink`, which are the two methods whose purpose
539+
is to describe the link itself.

0 commit comments

Comments
 (0)