pi-roundtable-web 0.23.2 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +82 -16
  3. package/dist/chunk-action-menu-7v5fce2v.js +1 -0
  4. package/dist/chunk-agent-2rw4ydw2.js +1 -0
  5. package/dist/chunk-agent-sheet-2q24dkkf.js +3 -0
  6. package/dist/chunk-character-creator-8q6ympxj.js +1 -0
  7. package/dist/chunk-codeeditor-fgmq298t.js +2 -0
  8. package/dist/chunk-connectors-mr53r7fh.js +1 -0
  9. package/dist/chunk-conversations-4vy2z6w9.js +1 -0
  10. package/dist/chunk-entry-jg1eem82.js +1 -0
  11. package/dist/chunk-group-scene-na95b1te.js +1 -0
  12. package/dist/chunk-index-11rjg8a8.js +1 -0
  13. package/dist/chunk-index-1y50d6sj.js +2 -0
  14. package/dist/chunk-index-5y2r0eaj.js +47 -0
  15. package/dist/chunk-index-6t68gbex.js +2 -0
  16. package/dist/chunk-index-7fphxxzd.js +14 -0
  17. package/dist/chunk-index-80f1zkp6.css +1 -0
  18. package/dist/chunk-index-87h45frg.js +2 -0
  19. package/dist/chunk-index-bnwmssyn.js +2 -0
  20. package/dist/chunk-index-ckgbszwt.js +51 -0
  21. package/dist/chunk-index-fmjjekhd.js +2 -0
  22. package/dist/chunk-index-mpm90318.js +2 -0
  23. package/dist/chunk-index-t1m65yn6.js +13 -0
  24. package/dist/chunk-index-t711t17m.js +2 -0
  25. package/dist/chunk-index-txv7qvyy.js +2 -0
  26. package/dist/chunk-index-wr3dg24d.js +2 -0
  27. package/dist/chunk-index-yx683d5n.js +2 -0
  28. package/dist/chunk-lang-css-fkdg3x8x.js +1 -0
  29. package/dist/chunk-lang-dockerfile-ey0nvhhm.js +1 -0
  30. package/dist/chunk-lang-go-403qsw9f.js +7 -0
  31. package/dist/chunk-lang-html-v8h9g977.js +1 -0
  32. package/dist/chunk-lang-javascript-ykb2vhaa.js +1 -0
  33. package/dist/chunk-lang-json-2rze3rpy.js +1 -0
  34. package/dist/chunk-lang-markdown-6w2htnfc.js +6 -0
  35. package/dist/chunk-lang-python-9jk5a49y.js +3 -0
  36. package/dist/chunk-lang-rust-24fggcqx.js +1 -0
  37. package/dist/chunk-lang-shell-cgq6q0kj.js +1 -0
  38. package/dist/chunk-lang-sql-gjywb9ke.js +2 -0
  39. package/dist/chunk-lang-toml-xh45nm68.js +1 -0
  40. package/dist/chunk-lang-typescript-qh6rebyb.js +1 -0
  41. package/dist/chunk-lang-xml-1mh879j9.js +1 -0
  42. package/dist/chunk-lang-yaml-jac98srt.js +1 -0
  43. package/dist/chunk-menu-items-zjpyj51b.js +2 -0
  44. package/dist/chunk-notes-sjba7kw3.js +1 -0
  45. package/dist/chunk-overview-26r77kb6.js +1 -0
  46. package/dist/chunk-room-confirms-851184ep.js +1 -0
  47. package/dist/chunk-room-menu-8y9c026y.js +1 -0
  48. package/dist/chunk-skills-56s4hdbn.js +1 -0
  49. package/dist/chunk-table-qykpvp32.js +1 -0
  50. package/dist/chunk-table2d-k60ressj.js +1 -0
  51. package/dist/chunk-table3d-bv2ajk39.js +4220 -0
  52. package/dist/chunk-transcript-vekz1mh6.js +1 -0
  53. package/dist/chunk-workspace-hevefd0d.js +1 -0
  54. package/dist/chunk-zh-tw-5r2wjre0.js +1 -0
  55. package/dist/index.html +2 -1
  56. package/dist/sw.js +1 -1
  57. package/package.json +21 -3
  58. package/src/api-types.ts +152 -0
  59. package/src/app/app.ts +11 -0
  60. package/src/app/rooms.ts +7 -1
  61. package/src/app/routes.ts +218 -1
  62. package/src/app/workspace-files.ts +1485 -0
  63. package/src/console-api.ts +5 -0
  64. package/src/i18n/zh-tw.ts +183 -24
  65. package/src/options.ts +148 -5
  66. package/src/push/mutes.ts +68 -0
  67. package/src/push/triggers.ts +30 -11
  68. package/src/web-plugin.ts +42 -2
  69. package/dist/chunk-character-creator-dwk6y4be.js +0 -1
  70. package/dist/chunk-connectors-pk0fh4y6.js +0 -1
  71. package/dist/chunk-conversations-sfsgmsdm.js +0 -1
  72. package/dist/chunk-entry-x0j9g57j.js +0 -1
  73. package/dist/chunk-index-19g8jmr8.js +0 -2
  74. package/dist/chunk-index-362dfedj.js +0 -1
  75. package/dist/chunk-index-6g69tqt0.js +0 -47
  76. package/dist/chunk-index-89q2qdkj.css +0 -1
  77. package/dist/chunk-index-9k42hnam.js +0 -41
  78. package/dist/chunk-index-k0z1ykph.js +0 -2
  79. package/dist/chunk-index-p8h0e861.js +0 -2
  80. package/dist/chunk-notes-v60aswry.js +0 -1
  81. package/dist/chunk-overview-c1c0ccda.js +0 -1
  82. package/dist/chunk-skills-r8rwbc5y.js +0 -1
  83. package/dist/chunk-table-pmr7zrgm.js +0 -1
  84. package/dist/chunk-table2d-ac2gez71.js +0 -1
  85. package/dist/chunk-table3d-g258jemr.js +0 -4229
  86. package/dist/chunk-transcript-wjqk37f5.js +0 -1
  87. package/dist/chunk-zh-tw-mtvm7sn7.js +0 -1
@@ -0,0 +1,1485 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { constants, realpathSync, type Stats } from "node:fs";
4
+ import type { FileHandle } from "node:fs/promises";
5
+ import {
6
+ link,
7
+ lstat,
8
+ mkdir,
9
+ open,
10
+ opendir,
11
+ readFile,
12
+ realpath,
13
+ rename,
14
+ rm,
15
+ stat,
16
+ unlink,
17
+ writeFile,
18
+ } from "node:fs/promises";
19
+ import {
20
+ basename,
21
+ dirname,
22
+ isAbsolute,
23
+ join,
24
+ relative,
25
+ resolve,
26
+ sep,
27
+ } from "node:path";
28
+ import { Readable } from "node:stream";
29
+ import type { Logger } from "pi-roundtable";
30
+ import { PluginError } from "pi-roundtable";
31
+ import { within } from "pi-roundtable/kit";
32
+ import type {
33
+ WorkspaceEntry,
34
+ WorkspaceFileView,
35
+ WorkspaceLanguage,
36
+ WorkspaceListView,
37
+ WorkspaceMedia,
38
+ WorkspaceNew,
39
+ WorkspaceReadOnlyReason,
40
+ WorkspaceRootView,
41
+ WorkspaceSave,
42
+ WorkspaceSaved,
43
+ WorkspaceTrashItem,
44
+ WorkspaceTrashView,
45
+ } from "../api-types.ts";
46
+ import { safeName } from "./files.ts";
47
+
48
+ /** Entries one directory lists before it says there are more. */
49
+ export const LIST_MAX = 2000;
50
+ /** The largest file the pane shows with its text (and highlights): 2 MiB. */
51
+ export const VIEW_MAX_BYTES = 2 * 1024 * 1024;
52
+ /** The largest file, and text, the pane edits and saves: 1 MiB. */
53
+ export const EDIT_MAX_BYTES = 1024 * 1024;
54
+ /** The largest file the download route sends: 50 MiB. */
55
+ export const DOWNLOAD_MAX_BYTES = 50 * 1024 * 1024;
56
+ /** The longest path the pane takes, in bytes. */
57
+ const PATH_MAX_BYTES = 4096;
58
+ /** How much of a file's start tells binary from text, and an image by its signature. */
59
+ const HEAD_BYTES = 8192;
60
+ /** The directory of each root that holds what was deleted. */
61
+ export const TRASH_DIR = ".rt-trash";
62
+ const GIT_DIR = ".git";
63
+
64
+ /** The guarded names are compared without case and in compatibility form (`ſ` is `s` on some volumes): a volume that ignores it takes `.GIT` for `.git`. */
65
+ const isGitDir = (name: string): boolean =>
66
+ name.normalize("NFKC").toLowerCase() === GIT_DIR;
67
+ const isTrashDir = (name: string | undefined): boolean =>
68
+ name?.normalize("NFKC").toLowerCase() === TRASH_DIR;
69
+ const TRASH_ID =
70
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
71
+ const ROOT_ID = /^[a-z][a-z0-9-]{0,31}$/;
72
+ const OPEN_READ =
73
+ constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK;
74
+
75
+ /** A place whose files the pane shows. */
76
+ export interface WorkspaceRoot {
77
+ id: string;
78
+ label: string;
79
+ /** An absolute directory; it may not exist yet. */
80
+ dir: string;
81
+ writable: boolean;
82
+ }
83
+
84
+ /** Why a request was refused; each reason has its own wording and status in the routes. */
85
+ export type WorkspaceReason =
86
+ | "bad-path"
87
+ | "unknown-root"
88
+ | "not-found"
89
+ | "not-a-dir"
90
+ | "is-a-dir"
91
+ | "is-root"
92
+ | "outside"
93
+ | "special"
94
+ | "symlink"
95
+ | "read-only-root"
96
+ | "git-dir"
97
+ | "trash-dir"
98
+ | "binary"
99
+ | "not-utf8"
100
+ | "too-large"
101
+ | "text-too-large"
102
+ | "into-itself"
103
+ | "cross-device"
104
+ | "permission";
105
+
106
+ /** A request the pane refuses; `status` is the HTTP answer. */
107
+ export class WorkspaceRefusal extends Error {
108
+ override name = "WorkspaceRefusal";
109
+ constructor(
110
+ readonly status: number,
111
+ readonly reason: WorkspaceReason,
112
+ ) {
113
+ super(reason);
114
+ }
115
+ }
116
+
117
+ /** A write that does not fit the file as it is: changed since the writer read it, gone, or already there. */
118
+ export class WorkspaceConflictError extends Error {
119
+ override name = "WorkspaceConflictError";
120
+ constructor(
121
+ readonly conflict: "changed" | "gone" | "exists",
122
+ readonly current?: WorkspaceSaved,
123
+ ) {
124
+ super(conflict);
125
+ }
126
+ }
127
+
128
+ export interface WorkspaceFilesOptions {
129
+ roots: readonly WorkspaceRoot[];
130
+ logger: Logger;
131
+ /** The host's own directories (data, keys): a root that first appears as a link to one is refused. */
132
+ protectedDirs?: readonly string[];
133
+ /** For tests: runs after a save's temporary file is written and before the checks that precede the rename. */
134
+ hooks?: {
135
+ beforeReplace?: (tmp: string, target: string) => void | Promise<void>;
136
+ };
137
+ }
138
+
139
+ /** The real directory of a path that may not exist: its nearest existing ancestor, resolved, plus the rest. */
140
+ function realish(path: string): string {
141
+ const rest: string[] = [];
142
+ let current = resolve(path);
143
+ for (;;) {
144
+ try {
145
+ return join(realpathSync(current), ...rest.reverse());
146
+ } catch {
147
+ const parent = dirname(current);
148
+ if (parent === current) return resolve(path);
149
+ rest.push(basename(current));
150
+ current = parent;
151
+ }
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Checks the roots a host configured, once at the start: each id a short lower-case word and
157
+ * given once, each directory absolute, and none of them the host's data directory or a directory
158
+ * that contains it (that holds the sessions, keys and credentials), symlinks resolved.
159
+ */
160
+ export function checkRoots(
161
+ roots: readonly WorkspaceRoot[],
162
+ protectedDirs: readonly string[],
163
+ ): void {
164
+ const seen = new Set<string>();
165
+ const guarded = protectedDirs.map(realish);
166
+ for (const root of roots) {
167
+ if (!ROOT_ID.test(root.id))
168
+ throw new PluginError(
169
+ `pi-roundtable-web: workspace root id ${JSON.stringify(root.id)} must be a lower-case word such as agents`,
170
+ );
171
+ if (seen.has(root.id))
172
+ throw new PluginError(
173
+ `pi-roundtable-web: workspace root id ${root.id} is used twice`,
174
+ );
175
+ seen.add(root.id);
176
+ if (!isAbsolute(root.dir))
177
+ throw new PluginError(
178
+ `pi-roundtable-web: workspace root ${root.id} must be an absolute directory, not ${JSON.stringify(root.dir)}`,
179
+ );
180
+ const real = realish(root.dir);
181
+ for (const dir of guarded) {
182
+ if (real === dir)
183
+ throw new PluginError(
184
+ `pi-roundtable-web: workspace root ${root.id} is ${dir}, which holds the host's data and keys; choose a directory inside it or beside it`,
185
+ );
186
+ if (within(real, dir))
187
+ throw new PluginError(
188
+ `pi-roundtable-web: workspace root ${root.id} (${real}) contains ${dir}, which holds the host's data and keys; choose a narrower directory`,
189
+ );
190
+ }
191
+ }
192
+ }
193
+
194
+ const hashOf = (data: Uint8Array): string =>
195
+ createHash("sha256").update(data).digest("hex");
196
+
197
+ const BY_EXTENSION: Record<string, WorkspaceLanguage> = {
198
+ ts: "typescript",
199
+ tsx: "typescript",
200
+ mts: "typescript",
201
+ cts: "typescript",
202
+ js: "javascript",
203
+ jsx: "javascript",
204
+ mjs: "javascript",
205
+ cjs: "javascript",
206
+ json: "json",
207
+ jsonc: "json",
208
+ json5: "json",
209
+ md: "markdown",
210
+ markdown: "markdown",
211
+ mdx: "markdown",
212
+ css: "css",
213
+ scss: "css",
214
+ html: "html",
215
+ htm: "html",
216
+ py: "python",
217
+ pyi: "python",
218
+ yml: "yaml",
219
+ yaml: "yaml",
220
+ go: "go",
221
+ rs: "rust",
222
+ sql: "sql",
223
+ xml: "xml",
224
+ svg: "xml",
225
+ plist: "xml",
226
+ sh: "shell",
227
+ bash: "shell",
228
+ zsh: "shell",
229
+ toml: "toml",
230
+ };
231
+
232
+ const BY_NAME: Record<string, WorkspaceLanguage> = {
233
+ dockerfile: "dockerfile",
234
+ containerfile: "dockerfile",
235
+ ".bashrc": "shell",
236
+ ".zshrc": "shell",
237
+ ".profile": "shell",
238
+ ".bash_profile": "shell",
239
+ ".gitignore": "plain",
240
+ };
241
+
242
+ /** The language to highlight a file in, from its name. */
243
+ export function languageOf(name: string): WorkspaceLanguage {
244
+ const lower = name.toLowerCase();
245
+ const byName = BY_NAME[lower];
246
+ if (byName) return byName;
247
+ if (lower.startsWith("dockerfile.")) return "dockerfile";
248
+ const dot = lower.lastIndexOf(".");
249
+ return dot <= 0 ? "plain" : (BY_EXTENSION[lower.slice(dot + 1)] ?? "plain");
250
+ }
251
+
252
+ /** What a request says about the version and the part it wants. */
253
+ export interface RawConditions {
254
+ /** The `Range` header. */
255
+ range?: string | null | undefined;
256
+ /** The `If-Range` header: the part is sent only when this is still the file's ETag. */
257
+ ifRange?: string | null | undefined;
258
+ /** The `If-None-Match` header. */
259
+ ifNoneMatch?: string | null | undefined;
260
+ }
261
+
262
+ /** Whether an `If-None-Match` names this ETag (weakly, as a GET compares) or is `*`. */
263
+ function matchesEtag(header: string | null | undefined, etag: string): boolean {
264
+ if (!header) return false;
265
+ const bare = (tag: string) => tag.trim().replace(/^W\//, "");
266
+ return header
267
+ .split(",")
268
+ .some((tag) => tag.trim() === "*" || bare(tag) === bare(etag));
269
+ }
270
+
271
+ /**
272
+ * One plain byte range (`a-b`, `a-` or `-n`) of a file of `size` bytes; anything else is not a range
273
+ * and the whole file is sent, which a server may always do. A start past the end is unsatisfiable.
274
+ */
275
+ function parseRange(
276
+ header: string | null | undefined,
277
+ size: number,
278
+ ): { start: number; end: number } | "unsatisfiable" | undefined {
279
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header ?? "");
280
+ if (!match || (match[1] === "" && match[2] === "")) return undefined;
281
+ const first = match[1] === "" ? undefined : Number(match[1]);
282
+ const last = match[2] === "" ? undefined : Number(match[2]);
283
+ if (first === undefined) {
284
+ const tail = last ?? 0;
285
+ if (tail === 0) return "unsatisfiable";
286
+ return { start: Math.max(0, size - tail), end: size - 1 };
287
+ }
288
+ if (last !== undefined && last < first) return undefined;
289
+ if (first >= size) return "unsatisfiable";
290
+ return { start: first, end: Math.min(last ?? size - 1, size - 1) };
291
+ }
292
+
293
+ const startsWith = (head: Uint8Array, offset: number, text: string): boolean =>
294
+ [...text].every((char, i) => head[offset + i] === char.charCodeAt(0));
295
+ const startsWithBytes = (head: Uint8Array, bytes: number[]): boolean =>
296
+ bytes.every((byte, i) => head[i] === byte);
297
+ /** An ISO base media file (MP4, M4A, MOV, AVIF): `ftyp` after a four-byte size. */
298
+ const hasFtyp = (head: Uint8Array): boolean => startsWith(head, 4, "ftyp");
299
+
300
+ /** What a preview shows and the type the route answers with; the type comes from here, never from the file. */
301
+ interface MediaType {
302
+ kind: WorkspaceMedia;
303
+ type: string;
304
+ /** Whether the file's first bytes are those of the kind its name claims. */
305
+ is(head: Uint8Array): boolean;
306
+ }
307
+
308
+ const RIFF_WAVE = (head: Uint8Array) =>
309
+ startsWith(head, 0, "RIFF") && startsWith(head, 8, "WAVE");
310
+ const EBML = (head: Uint8Array) =>
311
+ startsWithBytes(head, [0x1a, 0x45, 0xdf, 0xa3]);
312
+ const JPEG = (head: Uint8Array) => startsWithBytes(head, [0xff, 0xd8, 0xff]);
313
+ const OGG = (head: Uint8Array) => startsWith(head, 0, "OggS");
314
+ const MP4 = (type: string, kind: "audio" | "video"): MediaType => ({
315
+ kind,
316
+ type,
317
+ is: hasFtyp,
318
+ });
319
+
320
+ /** By extension; a name alone never previews, the bytes must agree. SVG alone has no signature. */
321
+ const MEDIA_BY_EXTENSION: Record<string, MediaType> = {
322
+ png: {
323
+ kind: "image",
324
+ type: "image/png",
325
+ is: (head) =>
326
+ startsWithBytes(head, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
327
+ },
328
+ jpg: { kind: "image", type: "image/jpeg", is: JPEG },
329
+ jpeg: { kind: "image", type: "image/jpeg", is: JPEG },
330
+ gif: {
331
+ kind: "image",
332
+ type: "image/gif",
333
+ is: (head) =>
334
+ startsWith(head, 0, "GIF87a") || startsWith(head, 0, "GIF89a"),
335
+ },
336
+ webp: {
337
+ kind: "image",
338
+ type: "image/webp",
339
+ is: (head) => startsWith(head, 0, "RIFF") && startsWith(head, 8, "WEBP"),
340
+ },
341
+ avif: {
342
+ kind: "image",
343
+ type: "image/avif",
344
+ is: (head) =>
345
+ hasFtyp(head) &&
346
+ (startsWith(head, 8, "avif") || startsWith(head, 8, "avis")),
347
+ },
348
+ bmp: {
349
+ kind: "image",
350
+ type: "image/bmp",
351
+ is: (head) => startsWith(head, 0, "BM"),
352
+ },
353
+ ico: {
354
+ kind: "image",
355
+ type: "image/x-icon",
356
+ is: (head) =>
357
+ startsWithBytes(head, [0, 0, 1, 0]) ||
358
+ startsWithBytes(head, [0, 0, 2, 0]),
359
+ },
360
+ svg: {
361
+ kind: "image",
362
+ type: "image/svg+xml",
363
+ is: (head) =>
364
+ !head.includes(0) &&
365
+ Buffer.from(head).toString("latin1").includes("<svg"),
366
+ },
367
+ pdf: {
368
+ kind: "pdf",
369
+ type: "application/pdf",
370
+ is: (head) => Buffer.from(head.subarray(0, 1024)).includes("%PDF-"),
371
+ },
372
+ mp3: {
373
+ kind: "audio",
374
+ type: "audio/mpeg",
375
+ is: (head) =>
376
+ startsWith(head, 0, "ID3") ||
377
+ (head[0] === 0xff && ((head[1] ?? 0) & 0xe0) === 0xe0),
378
+ },
379
+ m4a: MP4("audio/mp4", "audio"),
380
+ aac: {
381
+ kind: "audio",
382
+ type: "audio/aac",
383
+ is: (head) => head[0] === 0xff && ((head[1] ?? 0) & 0xf0) === 0xf0,
384
+ },
385
+ ogg: { kind: "audio", type: "audio/ogg", is: OGG },
386
+ opus: { kind: "audio", type: "audio/ogg", is: OGG },
387
+ wav: { kind: "audio", type: "audio/wav", is: RIFF_WAVE },
388
+ flac: {
389
+ kind: "audio",
390
+ type: "audio/flac",
391
+ is: (head) => startsWith(head, 0, "fLaC"),
392
+ },
393
+ weba: { kind: "audio", type: "audio/webm", is: EBML },
394
+ mp4: MP4("video/mp4", "video"),
395
+ m4v: MP4("video/mp4", "video"),
396
+ mov: MP4("video/quicktime", "video"),
397
+ webm: { kind: "video", type: "video/webm", is: EBML },
398
+ };
399
+
400
+ const RASTER_BY_SIGNATURE = ["png", "jpg", "gif", "webp"] as const;
401
+
402
+ /** The preview a file is, by its first bytes for the four common pictures and by name and bytes for the rest. */
403
+ function mediaOf(name: string, head: Uint8Array): MediaType | undefined {
404
+ for (const extension of RASTER_BY_SIGNATURE) {
405
+ const found = MEDIA_BY_EXTENSION[extension];
406
+ if (found?.is(head)) return found;
407
+ }
408
+ const dot = name.lastIndexOf(".");
409
+ if (dot <= 0) return undefined;
410
+ const found = MEDIA_BY_EXTENSION[name.slice(dot + 1).toLowerCase()];
411
+ return found?.is(head) ? found : undefined;
412
+ }
413
+
414
+ /** Extensions that a browser would run or render as a page: always downloads. */
415
+ const ACTIVE = new Set(["html", "htm", "xhtml", "xml", "xsl", "mht"]);
416
+
417
+ /**
418
+ * What a preview may load: an image or a sound from the place itself. A picture, a sound and a video
419
+ * are sandboxed, so nothing in them runs even when the address is opened on its own; a PDF is not,
420
+ * because the browser's viewer does not run under a sandbox.
421
+ */
422
+ const MEDIA_POLICY =
423
+ "sandbox; frame-ancestors 'self'; default-src 'none'; img-src 'self' data:; media-src 'self'; style-src 'unsafe-inline'";
424
+ const PDF_POLICY =
425
+ "frame-ancestors 'self'; default-src 'none'; style-src 'unsafe-inline'; img-src data:";
426
+
427
+ // The byte order mark is stripped by hand, once: a second one is text and stays.
428
+ const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
429
+
430
+ /** What a file's bytes are, for the view and for a save. */
431
+ function classify(bytes: Uint8Array): {
432
+ binary: boolean;
433
+ text?: string;
434
+ bom: boolean;
435
+ eol: "\n" | "\r\n";
436
+ notUtf8: boolean;
437
+ } {
438
+ const head = bytes.subarray(0, HEAD_BYTES);
439
+ const base = { bom: false, eol: "\n" as const };
440
+ if (head.includes(0)) return { ...base, binary: true, notUtf8: false };
441
+ const bom = bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf;
442
+ let text: string;
443
+ try {
444
+ text = decoder.decode(bom ? bytes.subarray(3) : bytes);
445
+ } catch {
446
+ return { ...base, binary: false, notUtf8: true };
447
+ }
448
+ let crlf = 0;
449
+ let lf = 0;
450
+ for (let i = text.indexOf("\n"); i !== -1; i = text.indexOf("\n", i + 1))
451
+ if (text[i - 1] === "\r") crlf++;
452
+ else lf++;
453
+ return {
454
+ binary: false,
455
+ notUtf8: false,
456
+ text,
457
+ bom,
458
+ eol: crlf > lf ? "\r\n" : "\n",
459
+ };
460
+ }
461
+
462
+ /** A path under a root: the segments, or a refusal. */
463
+ function parsePath(raw: string): string[] {
464
+ if (
465
+ typeof raw !== "string" ||
466
+ raw.includes("\0") ||
467
+ raw.startsWith("/") ||
468
+ Buffer.byteLength(raw) > PATH_MAX_BYTES
469
+ )
470
+ throw new WorkspaceRefusal(400, "bad-path");
471
+ if (raw === "") return [];
472
+ const segments = raw.split("/");
473
+ if (segments.some((s) => s === "" || s === "." || s === ".."))
474
+ throw new WorkspaceRefusal(400, "bad-path");
475
+ return segments;
476
+ }
477
+
478
+ const errno = (error: unknown): string | undefined =>
479
+ (error as { code?: string } | undefined)?.code;
480
+
481
+ /** An fs failure as the refusal it means, or the failure itself. */
482
+ function mapFs(error: unknown): never {
483
+ if (
484
+ error instanceof WorkspaceRefusal ||
485
+ error instanceof WorkspaceConflictError
486
+ )
487
+ throw error;
488
+ switch (errno(error)) {
489
+ case "ENOENT":
490
+ throw new WorkspaceRefusal(404, "not-found");
491
+ case "ENOTDIR":
492
+ throw new WorkspaceRefusal(404, "not-found");
493
+ case "ELOOP":
494
+ throw new WorkspaceRefusal(403, "symlink");
495
+ case "EACCES":
496
+ case "EPERM":
497
+ case "EROFS":
498
+ throw new WorkspaceRefusal(403, "permission");
499
+ case "EXDEV":
500
+ throw new WorkspaceRefusal(400, "cross-device");
501
+ case "ENAMETOOLONG":
502
+ throw new WorkspaceRefusal(400, "bad-path");
503
+ default:
504
+ throw error;
505
+ }
506
+ }
507
+
508
+ interface Located {
509
+ root: WorkspaceRoot;
510
+ /** The real directory of the root. */
511
+ real: string;
512
+ segments: string[];
513
+ }
514
+
515
+ /** A parent directory, resolved and checked to lie inside the root. */
516
+ interface Parent {
517
+ /** The real directory the leaf is in. */
518
+ dir: string;
519
+ /** The leaf's name. */
520
+ name: string;
521
+ /** Path of the leaf under the real parent. */
522
+ path: string;
523
+ /** The real parent relative to the real root. */
524
+ relative: string[];
525
+ }
526
+
527
+ /**
528
+ * The owner's view of the files in some directories, with the rules that keep it inside them.
529
+ * Every request names a root by id and a path relative to it; the path is checked as text first,
530
+ * then each directory it passes through is resolved and must be inside the real root, and a file
531
+ * is opened without following a link, its handle compared with what was checked. A write goes to
532
+ * a temporary file in the same directory and replaces the original by rename.
533
+ */
534
+ export class WorkspaceFiles {
535
+ readonly #roots: Map<string, WorkspaceRoot>;
536
+ readonly #logger: Logger;
537
+ readonly #hooks: NonNullable<WorkspaceFilesOptions["hooks"]>;
538
+ /** Where each root really was when it was first seen; a root that resolves elsewhere later is refused. */
539
+ readonly #pinned = new Map<string, string>();
540
+ readonly #guarded: readonly string[];
541
+ readonly #speaker = new AsyncLocalStorage<string>();
542
+
543
+ constructor(options: WorkspaceFilesOptions) {
544
+ this.#roots = new Map(options.roots.map((root) => [root.id, root]));
545
+ this.#guarded = (options.protectedDirs ?? []).map(realish);
546
+ for (const root of options.roots) {
547
+ try {
548
+ this.#pinned.set(root.id, realpathSync(root.dir));
549
+ } catch {
550
+ // A root that is not there yet is pinned where it first appears.
551
+ }
552
+ }
553
+ this.#logger = options.logger;
554
+ this.#hooks = options.hooks ?? {};
555
+ }
556
+
557
+ /** Runs `run` so that what it refuses is logged with who asked. */
558
+ speaking<T>(by: string, run: () => Promise<T>): Promise<T> {
559
+ return this.#speaker.run(by, run);
560
+ }
561
+
562
+ roots(): WorkspaceRootView[] {
563
+ return [...this.#roots.values()].map(({ id, label, writable }) => ({
564
+ id,
565
+ label,
566
+ writable,
567
+ }));
568
+ }
569
+
570
+ #refuse(
571
+ rootId: string,
572
+ path: string,
573
+ status: number,
574
+ reason: WorkspaceReason,
575
+ ): WorkspaceRefusal {
576
+ this.#logger.warn(
577
+ {
578
+ by: this.#speaker.getStore(),
579
+ root: rootId,
580
+ path: path.slice(0, 200),
581
+ reason,
582
+ },
583
+ "workspace path refused",
584
+ );
585
+ return new WorkspaceRefusal(status, reason);
586
+ }
587
+
588
+ /** The root and the path's segments, checked as text only; no file is touched. */
589
+ async #locate(rootId: string, raw: string): Promise<Located | "missing"> {
590
+ const root = this.#roots.get(rootId);
591
+ if (!root) throw new WorkspaceRefusal(404, "unknown-root");
592
+ let segments: string[];
593
+ try {
594
+ segments = parsePath(raw);
595
+ } catch (error) {
596
+ if (error instanceof WorkspaceRefusal)
597
+ throw this.#refuse(rootId, raw, error.status, error.reason);
598
+ throw error;
599
+ }
600
+ let real: string;
601
+ try {
602
+ real = await realpath(root.dir);
603
+ } catch {
604
+ return "missing";
605
+ }
606
+ const pinned = this.#pinned.get(root.id);
607
+ if (pinned === undefined) {
608
+ // A root missing at the start is pinned where it first appears, under the start-time rule.
609
+ if (this.#guarded.some((dir) => real === dir || within(real, dir)))
610
+ throw this.#refuse(root.id, raw, 403, "outside");
611
+ this.#pinned.set(root.id, real);
612
+ } else if (pinned !== real)
613
+ throw this.#refuse(root.id, raw, 403, "outside");
614
+ return { root, real, segments };
615
+ }
616
+
617
+ async #located(rootId: string, raw: string): Promise<Located> {
618
+ const located = await this.#locate(rootId, raw);
619
+ if (located === "missing") throw new WorkspaceRefusal(404, "not-found");
620
+ return located;
621
+ }
622
+
623
+ /** Resolves the directory a leaf is in and refuses one that leaves the root. */
624
+ async #parentOf(located: Located, raw: string): Promise<Parent> {
625
+ const { real, root, segments } = located;
626
+ const name = segments.at(-1);
627
+ if (name === undefined) throw new WorkspaceRefusal(400, "is-root");
628
+ let dir: string;
629
+ try {
630
+ dir = await realpath(join(real, ...segments.slice(0, -1)));
631
+ } catch (error) {
632
+ return mapFs(error);
633
+ }
634
+ if (!within(real, dir)) throw this.#refuse(root.id, raw, 403, "outside");
635
+ return {
636
+ dir,
637
+ name,
638
+ path: join(dir, name),
639
+ relative: relative(real, dir).split(sep).filter(Boolean),
640
+ };
641
+ }
642
+
643
+ /** A write is refused in a read-only root, in `.git` and in the trash, by name and by where the parent really is. */
644
+ #guardWrite(located: Located, parent: Parent | undefined, raw: string): void {
645
+ const { root, segments } = located;
646
+ if (!root.writable) throw this.#refuse(root.id, raw, 403, "read-only-root");
647
+ const names = [...segments, ...(parent?.relative ?? [])];
648
+ if (names.some(isGitDir)) throw this.#refuse(root.id, raw, 403, "git-dir");
649
+ if (isTrashDir(segments[0]) || isTrashDir(parent?.relative[0]))
650
+ throw this.#refuse(root.id, raw, 403, "trash-dir");
651
+ }
652
+
653
+ /** Opens a regular file without following a link; the handle must be the file that was checked. */
654
+ async #openRegular(
655
+ path: string,
656
+ checked: { dev: number; ino: number },
657
+ rootId: string,
658
+ raw: string,
659
+ ): Promise<FileHandle> {
660
+ let handle: FileHandle;
661
+ try {
662
+ handle = await open(path, OPEN_READ);
663
+ } catch (error) {
664
+ if (errno(error) === "ELOOP")
665
+ throw this.#refuse(rootId, raw, 403, "symlink");
666
+ return mapFs(error);
667
+ }
668
+ const info = await handle.stat();
669
+ if (
670
+ !info.isFile() ||
671
+ info.dev !== checked.dev ||
672
+ info.ino !== checked.ino
673
+ ) {
674
+ await handle.close();
675
+ throw this.#refuse(rootId, raw, 403, "special");
676
+ }
677
+ return handle;
678
+ }
679
+
680
+ /**
681
+ * Where a file read really is, and its stat: a link inside the root is followed to its target,
682
+ * a link to the outside is refused, anything that is not a file or folder is refused.
683
+ */
684
+ async #target(
685
+ located: Located,
686
+ raw: string,
687
+ ): Promise<{ path: string; info: Stats; viaLink: boolean }> {
688
+ const { root, real, segments } = located;
689
+ if (segments.length === 0)
690
+ return { path: real, info: await stat(real), viaLink: false };
691
+ const parent = await this.#parentOf(located, raw);
692
+ let leaf: Stats;
693
+ try {
694
+ leaf = await lstat(parent.path);
695
+ } catch (error) {
696
+ return mapFs(error);
697
+ }
698
+ let path = parent.path;
699
+ let viaLink = false;
700
+ if (leaf.isSymbolicLink()) {
701
+ viaLink = true;
702
+ try {
703
+ path = await realpath(parent.path);
704
+ } catch (error) {
705
+ if (errno(error) === "ENOENT")
706
+ throw this.#refuse(root.id, raw, 403, "outside");
707
+ return mapFs(error);
708
+ }
709
+ if (!within(real, path)) throw this.#refuse(root.id, raw, 403, "outside");
710
+ }
711
+ let info: Stats;
712
+ try {
713
+ info = await stat(path);
714
+ } catch (error) {
715
+ return mapFs(error);
716
+ }
717
+ if (!info.isFile() && !info.isDirectory())
718
+ throw this.#refuse(root.id, raw, 403, "special");
719
+ return { path, info, viaLink };
720
+ }
721
+
722
+ async list(rootId: string, raw: string): Promise<WorkspaceListView> {
723
+ const located = await this.#locate(rootId, raw);
724
+ if (located === "missing")
725
+ return { path: raw, entries: [], truncated: false, missing: true };
726
+ const { real, segments } = located;
727
+ const target = await this.#target(located, raw);
728
+ if (!target.info.isDirectory())
729
+ throw new WorkspaceRefusal(400, "not-a-dir");
730
+ const entries: WorkspaceEntry[] = [];
731
+ let truncated = false;
732
+ const directory = await opendir(target.path).catch(mapFs);
733
+ try {
734
+ for await (const item of directory) {
735
+ if (segments.length === 0 && isTrashDir(item.name)) continue;
736
+ if (entries.length >= LIST_MAX) {
737
+ truncated = true;
738
+ break;
739
+ }
740
+ const entryPath = join(target.path, item.name);
741
+ let info: Stats;
742
+ try {
743
+ info = await lstat(entryPath);
744
+ } catch {
745
+ continue;
746
+ }
747
+ const entry: WorkspaceEntry = {
748
+ name: item.name,
749
+ kind: info.isFile()
750
+ ? "file"
751
+ : info.isDirectory()
752
+ ? "dir"
753
+ : info.isSymbolicLink()
754
+ ? "symlink"
755
+ : "other",
756
+ size: info.size,
757
+ mtimeMs: info.mtimeMs,
758
+ };
759
+ if (info.isSymbolicLink()) {
760
+ let inside = false;
761
+ let kind: "file" | "dir" | "other" | undefined;
762
+ try {
763
+ const resolved = await realpath(entryPath);
764
+ inside = within(real, resolved);
765
+ if (inside) {
766
+ const targetInfo = await stat(resolved);
767
+ kind = targetInfo.isFile()
768
+ ? "file"
769
+ : targetInfo.isDirectory()
770
+ ? "dir"
771
+ : "other";
772
+ }
773
+ } catch {
774
+ inside = false;
775
+ }
776
+ entry.inside = inside;
777
+ if (kind) entry.target = kind;
778
+ }
779
+ entries.push(entry);
780
+ }
781
+ } finally {
782
+ await directory.close().catch(() => undefined);
783
+ }
784
+ entries.sort((a, b) => {
785
+ const folder =
786
+ Number(b.kind === "dir" || b.target === "dir") -
787
+ Number(a.kind === "dir" || a.target === "dir");
788
+ return (
789
+ folder || a.name.localeCompare(b.name, undefined, { numeric: true })
790
+ );
791
+ });
792
+ return { path: raw, entries, truncated };
793
+ }
794
+
795
+ /** Reads a regular file whole, up to `limit` bytes; one that has grown past it is a refusal. */
796
+ async #readAll(
797
+ handle: FileHandle,
798
+ size: number,
799
+ limit: number,
800
+ ): Promise<Uint8Array> {
801
+ if (size > limit) throw new WorkspaceRefusal(403, "too-large");
802
+ const buffer = Buffer.allocUnsafe(Math.min(size, limit) + 1);
803
+ let filled = 0;
804
+ for (;;) {
805
+ const { bytesRead } = await handle.read(
806
+ buffer,
807
+ filled,
808
+ buffer.length - filled,
809
+ filled,
810
+ );
811
+ if (bytesRead === 0) break;
812
+ filled += bytesRead;
813
+ if (filled === buffer.length)
814
+ throw new WorkspaceRefusal(403, "too-large");
815
+ }
816
+ return buffer.subarray(0, filled);
817
+ }
818
+
819
+ async read(rootId: string, raw: string): Promise<WorkspaceFileView> {
820
+ const located = await this.#located(rootId, raw);
821
+ const { root, segments } = located;
822
+ const target = await this.#target(located, raw);
823
+ if (target.info.isDirectory()) throw new WorkspaceRefusal(400, "is-a-dir");
824
+ const handle = await this.#openRegular(
825
+ target.path,
826
+ target.info,
827
+ root.id,
828
+ raw,
829
+ );
830
+ try {
831
+ const info = await handle.stat();
832
+ const head = Buffer.alloc(Math.min(HEAD_BYTES, info.size));
833
+ await handle.read(head, 0, head.length, 0);
834
+ const name = segments.at(-1) ?? "";
835
+ const media = mediaOf(name, head);
836
+ const view: WorkspaceFileView = {
837
+ path: raw,
838
+ size: info.size,
839
+ mtimeMs: info.mtimeMs,
840
+ hash: "",
841
+ editable: false,
842
+ binary: false,
843
+ tooLarge: info.size > VIEW_MAX_BYTES,
844
+ eol: "\n",
845
+ bom: false,
846
+ language: languageOf(name),
847
+ ...(media ? { media: media.kind } : {}),
848
+ };
849
+ let reason = this.#whyReadOnly(located, target.viaLink, raw);
850
+ if (view.tooLarge) {
851
+ view.binary = head.includes(0);
852
+ view.reason = reason ?? (view.binary ? "binary" : "too-large");
853
+ return view;
854
+ }
855
+ const bytes = await this.#readAll(handle, info.size, VIEW_MAX_BYTES);
856
+ view.hash = hashOf(bytes);
857
+ const kind = classify(bytes);
858
+ view.binary = kind.binary;
859
+ view.bom = kind.bom;
860
+ view.eol = kind.eol;
861
+ if (kind.text !== undefined) view.text = kind.text;
862
+ reason ??= kind.binary
863
+ ? "binary"
864
+ : kind.notUtf8
865
+ ? "not-utf8"
866
+ : info.size > EDIT_MAX_BYTES
867
+ ? "too-large"
868
+ : undefined;
869
+ if (reason) view.reason = reason;
870
+ else view.editable = true;
871
+ return view;
872
+ } finally {
873
+ await handle.close();
874
+ }
875
+ }
876
+
877
+ /** The structural reason a file is not editable, if there is one. */
878
+ #whyReadOnly(
879
+ located: Located,
880
+ viaLink: boolean,
881
+ _raw: string,
882
+ ): WorkspaceReadOnlyReason | undefined {
883
+ const { root, segments } = located;
884
+ if (viaLink) return "symlink";
885
+ if (!root.writable) return "read-only-root";
886
+ if (segments.some(isGitDir)) return "git-dir";
887
+ if (isTrashDir(segments[0])) return "trash";
888
+ return undefined;
889
+ }
890
+
891
+ async raw(
892
+ rootId: string,
893
+ raw: string,
894
+ download: boolean,
895
+ conditions: RawConditions = {},
896
+ ): Promise<Response> {
897
+ const located = await this.#located(rootId, raw);
898
+ const { root, segments } = located;
899
+ const target = await this.#target(located, raw);
900
+ if (target.info.isDirectory()) throw new WorkspaceRefusal(400, "is-a-dir");
901
+ if (target.info.size > DOWNLOAD_MAX_BYTES)
902
+ throw new WorkspaceRefusal(413, "too-large");
903
+ const handle = await this.#openRegular(
904
+ target.path,
905
+ target.info,
906
+ root.id,
907
+ raw,
908
+ );
909
+ try {
910
+ const info = await handle.stat();
911
+ const head = Buffer.alloc(Math.min(HEAD_BYTES, info.size));
912
+ await handle.read(head, 0, head.length, 0);
913
+ const name = segments.at(-1) ?? "file";
914
+ const media = mediaOf(name, head);
915
+ const extension = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
916
+ const asText =
917
+ !media && !head.includes(0) && !ACTIVE.has(extension) && !download;
918
+ const inline = !download && (media !== undefined || asText);
919
+ const encoded = encodeURIComponent(safeName(name)).replace(
920
+ /['()*]/g,
921
+ (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
922
+ );
923
+ // The size is what the open handle says now, and the stream stops there, so a file that
924
+ // grows while it is sent cannot make the body longer than its `content-length`.
925
+ if (info.size > DOWNLOAD_MAX_BYTES)
926
+ throw new WorkspaceRefusal(413, "too-large");
927
+ // Strong, so that `If-Range` may match it: the inode and the time to the nanosecond.
928
+ const exact = await handle.stat({ bigint: true });
929
+ const etag = `"${exact.ino.toString(16)}-${exact.size.toString(16)}-${exact.mtimeNs.toString(16)}"`;
930
+ const headers: Record<string, string> = {
931
+ "content-type": media
932
+ ? media.type
933
+ : asText
934
+ ? "text/plain; charset=utf-8"
935
+ : "application/octet-stream",
936
+ "content-disposition": inline
937
+ ? "inline"
938
+ : `attachment; filename*=UTF-8''${encoded}`,
939
+ "content-security-policy": media
940
+ ? media.kind === "pdf"
941
+ ? PDF_POLICY
942
+ : MEDIA_POLICY
943
+ : "sandbox; frame-ancestors 'self'; default-src 'none'",
944
+ "x-content-type-options": "nosniff",
945
+ "accept-ranges": "bytes",
946
+ };
947
+ if (media && inline) {
948
+ // A preview may be asked for again, in pieces: it is checked each time, never kept as is.
949
+ headers["cache-control"] = "no-store";
950
+ headers.etag = etag;
951
+ headers["last-modified"] = new Date(info.mtimeMs).toUTCString();
952
+ } else headers["cache-control"] = "no-store";
953
+ if (media && inline && matchesEtag(conditions.ifNoneMatch, etag)) {
954
+ await handle.close();
955
+ return new Response(null, { status: 304, headers });
956
+ }
957
+ let start = 0;
958
+ let end = info.size - 1;
959
+ let status = 200;
960
+ const range =
961
+ info.size > 0 && (!conditions.ifRange || conditions.ifRange === etag)
962
+ ? parseRange(conditions.range, info.size)
963
+ : undefined;
964
+ if (range === "unsatisfiable") {
965
+ await handle.close();
966
+ headers["content-range"] = `bytes */${info.size}`;
967
+ headers["content-length"] = "0";
968
+ return new Response(null, { status: 416, headers });
969
+ }
970
+ if (range) {
971
+ ({ start, end } = range);
972
+ status = 206;
973
+ headers["content-range"] = `bytes ${start}-${end}/${info.size}`;
974
+ }
975
+ headers["content-length"] = String(info.size === 0 ? 0 : end - start + 1);
976
+ let body: ReadableStream<Uint8Array> | null = null;
977
+ if (info.size === 0) await handle.close();
978
+ else
979
+ body = Readable.toWeb(
980
+ handle.createReadStream({ start, end, autoClose: true }),
981
+ ) as ReadableStream<Uint8Array>;
982
+ return new Response(body, { status, headers });
983
+ } catch (error) {
984
+ await handle.close().catch(() => undefined);
985
+ throw error;
986
+ }
987
+ }
988
+
989
+ async save(
990
+ rootId: string,
991
+ raw: string,
992
+ body: WorkspaceSave,
993
+ by: string,
994
+ ): Promise<WorkspaceSaved> {
995
+ const located = await this.#located(rootId, raw);
996
+ const { root } = located;
997
+ if (Buffer.byteLength(body.text) > EDIT_MAX_BYTES)
998
+ throw new WorkspaceRefusal(413, "text-too-large");
999
+ const parent = await this.#parentOf(located, raw);
1000
+ this.#guardWrite(located, parent, raw);
1001
+ let leaf: Stats;
1002
+ try {
1003
+ leaf = await lstat(parent.path);
1004
+ } catch (error) {
1005
+ if (errno(error) === "ENOENT") throw new WorkspaceConflictError("gone");
1006
+ return mapFs(error);
1007
+ }
1008
+ if (leaf.isSymbolicLink()) throw this.#refuse(root.id, raw, 403, "symlink");
1009
+ if (!leaf.isFile()) throw this.#refuse(root.id, raw, 403, "special");
1010
+ const current = await this.#readCurrent(parent.path, leaf, root.id, raw);
1011
+ const kind = classify(current.bytes);
1012
+ if (kind.binary) throw new WorkspaceRefusal(403, "binary");
1013
+ if (kind.notUtf8) throw new WorkspaceRefusal(403, "not-utf8");
1014
+ const from = hashOf(current.bytes);
1015
+ if (from !== body.baseHash)
1016
+ throw new WorkspaceConflictError("changed", {
1017
+ hash: from,
1018
+ mtimeMs: current.mtimeMs,
1019
+ size: current.bytes.byteLength,
1020
+ });
1021
+ const payload = Buffer.concat([
1022
+ kind.bom ? Buffer.from([0xef, 0xbb, 0xbf]) : Buffer.alloc(0),
1023
+ Buffer.from(body.text, "utf8"),
1024
+ ]);
1025
+ const tmp = join(
1026
+ parent.dir,
1027
+ `.${parent.name.slice(0, 100)}.rt-${randomUUID()}.tmp`,
1028
+ );
1029
+ let handle: FileHandle | undefined;
1030
+ let created: { dev: number; ino: number } | undefined;
1031
+ try {
1032
+ handle = await open(tmp, "wx", current.mode);
1033
+ await handle.chmod(current.mode);
1034
+ await handle.writeFile(payload);
1035
+ await handle.sync();
1036
+ const tmpInfo = await handle.stat();
1037
+ created = { dev: tmpInfo.dev, ino: tmpInfo.ino };
1038
+ await this.#hooks.beforeReplace?.(tmp, parent.path);
1039
+ // The directory is the one that was checked, the file is still a regular file, and nobody
1040
+ // changed it while the temporary file was written.
1041
+ let again: string;
1042
+ try {
1043
+ again = await realpath(dirname(tmp));
1044
+ } catch {
1045
+ throw this.#refuse(root.id, raw, 403, "outside");
1046
+ }
1047
+ if (again !== parent.dir || !within(located.real, again))
1048
+ throw this.#refuse(root.id, raw, 403, "outside");
1049
+ let leafNow: Stats;
1050
+ try {
1051
+ leafNow = await lstat(parent.path);
1052
+ } catch (error) {
1053
+ if (errno(error) === "ENOENT") throw new WorkspaceConflictError("gone");
1054
+ return mapFs(error);
1055
+ }
1056
+ if (leafNow.isSymbolicLink())
1057
+ throw this.#refuse(root.id, raw, 403, "symlink");
1058
+ if (!leafNow.isFile()) throw this.#refuse(root.id, raw, 403, "special");
1059
+ const latest = await this.#readCurrent(
1060
+ parent.path,
1061
+ leafNow,
1062
+ root.id,
1063
+ raw,
1064
+ );
1065
+ const now = hashOf(latest.bytes);
1066
+ if (now !== body.baseHash)
1067
+ throw new WorkspaceConflictError("changed", {
1068
+ hash: now,
1069
+ mtimeMs: latest.mtimeMs,
1070
+ size: latest.bytes.byteLength,
1071
+ });
1072
+ await rename(tmp, parent.path);
1073
+ } catch (error) {
1074
+ await this.#discard(tmp, created, handle);
1075
+ throw error instanceof WorkspaceRefusal ||
1076
+ error instanceof WorkspaceConflictError
1077
+ ? error
1078
+ : this.#unwrap(error);
1079
+ } finally {
1080
+ await handle?.close().catch(() => undefined);
1081
+ }
1082
+ const info = await stat(parent.path);
1083
+ const to = hashOf(payload);
1084
+ this.#logger.info(
1085
+ {
1086
+ by,
1087
+ root: root.id,
1088
+ path: raw,
1089
+ bytes: payload.byteLength,
1090
+ from: from.slice(0, 12),
1091
+ to: to.slice(0, 12),
1092
+ },
1093
+ "workspace file saved by an owner",
1094
+ );
1095
+ return { hash: to, mtimeMs: info.mtimeMs, size: payload.byteLength };
1096
+ }
1097
+
1098
+ #unwrap(error: unknown): never {
1099
+ return mapFs(error);
1100
+ }
1101
+
1102
+ /**
1103
+ * Removes a temporary file. When its directory can no longer be found at the same place (it
1104
+ * was moved away), the file is emptied through its handle instead, so no text stays behind.
1105
+ */
1106
+ async #discard(
1107
+ tmp: string,
1108
+ created: { dev: number; ino: number } | undefined,
1109
+ handle: FileHandle | undefined,
1110
+ ): Promise<void> {
1111
+ let here = false;
1112
+ try {
1113
+ const info = await lstat(tmp);
1114
+ here =
1115
+ created !== undefined &&
1116
+ info.ino === created.ino &&
1117
+ info.dev === created.dev;
1118
+ } catch {
1119
+ here = false;
1120
+ }
1121
+ if (here) await unlink(tmp).catch(() => undefined);
1122
+ else if (created) await handle?.truncate(0).catch(() => undefined);
1123
+ else await unlink(tmp).catch(() => undefined);
1124
+ }
1125
+
1126
+ /** The bytes of a regular file, opened without following a link and compared with its earlier stat. */
1127
+ async #readCurrent(
1128
+ path: string,
1129
+ checked: { dev: number; ino: number },
1130
+ rootId: string,
1131
+ raw: string,
1132
+ ): Promise<{ bytes: Uint8Array; mtimeMs: number; mode: number }> {
1133
+ const handle = await this.#openRegular(path, checked, rootId, raw);
1134
+ try {
1135
+ const info = await handle.stat();
1136
+ if (info.size > EDIT_MAX_BYTES)
1137
+ throw new WorkspaceRefusal(403, "too-large");
1138
+ const bytes = await this.#readAll(handle, info.size, EDIT_MAX_BYTES);
1139
+ return { bytes, mtimeMs: info.mtimeMs, mode: info.mode & 0o7777 };
1140
+ } finally {
1141
+ await handle.close();
1142
+ }
1143
+ }
1144
+
1145
+ async create(
1146
+ rootId: string,
1147
+ raw: string,
1148
+ body: WorkspaceNew,
1149
+ by: string,
1150
+ ): Promise<WorkspaceSaved> {
1151
+ const located = await this.#located(rootId, raw);
1152
+ const { root } = located;
1153
+ const text = body.text ?? "";
1154
+ if (Buffer.byteLength(text) > EDIT_MAX_BYTES)
1155
+ throw new WorkspaceRefusal(413, "text-too-large");
1156
+ const parent = await this.#parentOf(located, raw);
1157
+ this.#guardWrite(located, parent, raw);
1158
+ try {
1159
+ if (body.kind === "dir") await mkdir(parent.path, { mode: 0o755 });
1160
+ else await writeFile(parent.path, text, { flag: "wx", mode: 0o644 });
1161
+ } catch (error) {
1162
+ if (errno(error) === "EEXIST") throw new WorkspaceConflictError("exists");
1163
+ return mapFs(error);
1164
+ }
1165
+ this.#logger.info(
1166
+ { by, root: root.id, path: raw, kind: body.kind },
1167
+ "workspace entry created by an owner",
1168
+ );
1169
+ const info = await lstat(parent.path);
1170
+ return {
1171
+ hash: body.kind === "dir" ? "" : hashOf(Buffer.from(text)),
1172
+ mtimeMs: info.mtimeMs,
1173
+ size: info.size,
1174
+ };
1175
+ }
1176
+
1177
+ /** Moves `from` to a path that does not exist; a regular file by link and unlink, so it never replaces one. */
1178
+ async #moveNew(
1179
+ from: string,
1180
+ to: string,
1181
+ kind: "file" | "other",
1182
+ ): Promise<void> {
1183
+ if (kind === "file") {
1184
+ try {
1185
+ await link(from, to);
1186
+ } catch (error) {
1187
+ // A file system without hard links falls back to the check made just before.
1188
+ if (
1189
+ !["EPERM", "ENOTSUP", "EOPNOTSUPP", "ENOSYS"].includes(
1190
+ errno(error) ?? "",
1191
+ )
1192
+ )
1193
+ throw error;
1194
+ await rename(from, to);
1195
+ return;
1196
+ }
1197
+ await unlink(from);
1198
+ return;
1199
+ }
1200
+ await rename(from, to);
1201
+ }
1202
+
1203
+ async rename(
1204
+ rootId: string,
1205
+ fromRaw: string,
1206
+ toRaw: string,
1207
+ by: string,
1208
+ ): Promise<void> {
1209
+ const located = await this.#located(rootId, fromRaw);
1210
+ const destination = await this.#located(rootId, toRaw);
1211
+ const { root } = located;
1212
+ const fromParent = await this.#parentOf(located, fromRaw);
1213
+ const toParent = await this.#parentOf(destination, toRaw);
1214
+ this.#guardWrite(located, fromParent, fromRaw);
1215
+ this.#guardWrite(destination, toParent, toRaw);
1216
+ let leaf: Stats;
1217
+ try {
1218
+ leaf = await lstat(fromParent.path);
1219
+ } catch (error) {
1220
+ return mapFs(error);
1221
+ }
1222
+ if (!leaf.isFile() && !leaf.isDirectory() && !leaf.isSymbolicLink())
1223
+ throw this.#refuse(root.id, fromRaw, 403, "special");
1224
+ if (leaf.isDirectory()) {
1225
+ const realFrom = fromParent.path;
1226
+ if (within(realFrom, toParent.path))
1227
+ throw new WorkspaceRefusal(400, "into-itself");
1228
+ }
1229
+ try {
1230
+ await lstat(toParent.path);
1231
+ throw new WorkspaceConflictError("exists");
1232
+ } catch (error) {
1233
+ if (error instanceof WorkspaceConflictError) throw error;
1234
+ if (errno(error) !== "ENOENT") return mapFs(error);
1235
+ }
1236
+ try {
1237
+ await this.#moveNew(
1238
+ fromParent.path,
1239
+ toParent.path,
1240
+ leaf.isFile() ? "file" : "other",
1241
+ );
1242
+ } catch (error) {
1243
+ if (errno(error) === "EEXIST") throw new WorkspaceConflictError("exists");
1244
+ return mapFs(error);
1245
+ }
1246
+ this.#logger.info(
1247
+ { by, root: root.id, path: fromRaw, to: toRaw },
1248
+ "workspace entry renamed by an owner",
1249
+ );
1250
+ }
1251
+
1252
+ /** The trash directory of a root, made when there is none; a link or a file in its place is refused. */
1253
+ async #trashDir(real: string, create: boolean): Promise<string | undefined> {
1254
+ const dir = join(real, TRASH_DIR);
1255
+ try {
1256
+ const info = await lstat(dir);
1257
+ if (!info.isDirectory()) throw new WorkspaceRefusal(403, "trash-dir");
1258
+ return dir;
1259
+ } catch (error) {
1260
+ if (error instanceof WorkspaceRefusal) throw error;
1261
+ if (errno(error) !== "ENOENT") return mapFs(error);
1262
+ }
1263
+ if (!create) return undefined;
1264
+ await mkdir(dir, { mode: 0o700 }).catch((error) => {
1265
+ if (errno(error) !== "EEXIST") mapFs(error);
1266
+ });
1267
+ const info = await lstat(dir);
1268
+ if (!info.isDirectory()) throw new WorkspaceRefusal(403, "trash-dir");
1269
+ return dir;
1270
+ }
1271
+
1272
+ async remove(
1273
+ rootId: string,
1274
+ raw: string,
1275
+ by: string,
1276
+ ): Promise<WorkspaceTrashItem> {
1277
+ const located = await this.#located(rootId, raw);
1278
+ const { root, real, segments } = located;
1279
+ const parent = await this.#parentOf(located, raw);
1280
+ this.#guardWrite(located, parent, raw);
1281
+ let leaf: Stats;
1282
+ try {
1283
+ leaf = await lstat(parent.path);
1284
+ } catch (error) {
1285
+ return mapFs(error);
1286
+ }
1287
+ if (!leaf.isFile() && !leaf.isDirectory() && !leaf.isSymbolicLink())
1288
+ throw this.#refuse(root.id, raw, 403, "special");
1289
+ const trash = await this.#trashDir(real, true);
1290
+ if (!trash) throw new WorkspaceRefusal(403, "trash-dir");
1291
+ const id = randomUUID();
1292
+ const itemDir = join(trash, id);
1293
+ await mkdir(itemDir, { mode: 0o700 });
1294
+ const kind = leaf.isDirectory()
1295
+ ? "dir"
1296
+ : leaf.isSymbolicLink()
1297
+ ? "symlink"
1298
+ : "file";
1299
+ const item: WorkspaceTrashItem = {
1300
+ id,
1301
+ path: segments.join("/"),
1302
+ name: parent.name,
1303
+ kind,
1304
+ size: leaf.size,
1305
+ deletedAt: new Date().toISOString(),
1306
+ };
1307
+ try {
1308
+ await rename(parent.path, join(itemDir, "item"));
1309
+ await writeFile(
1310
+ join(itemDir, "meta.json"),
1311
+ JSON.stringify({ v: 1, ...item, by }),
1312
+ {
1313
+ mode: 0o600,
1314
+ },
1315
+ );
1316
+ } catch (error) {
1317
+ // The item goes back first; the folder is removed only when it did, or the only copy would be lost.
1318
+ const back = await this.#moveNew(
1319
+ join(itemDir, "item"),
1320
+ parent.path,
1321
+ kind === "file" ? "file" : "other",
1322
+ ).then(
1323
+ () => true,
1324
+ () => false,
1325
+ );
1326
+ if (back)
1327
+ await rm(itemDir, { recursive: true, force: true }).catch(
1328
+ () => undefined,
1329
+ );
1330
+ else {
1331
+ // Left in the trash with a note of its own, so that Restore and Purge can reach it.
1332
+ await writeFile(
1333
+ join(itemDir, "meta.json"),
1334
+ JSON.stringify({ v: 1, ...item, by }),
1335
+ { mode: 0o600 },
1336
+ ).catch(() => undefined);
1337
+ this.#logger.warn(
1338
+ { by, root: root.id, path: raw, trash: id },
1339
+ "workspace entry could not be moved back; it stays in the trash",
1340
+ );
1341
+ }
1342
+ return mapFs(error);
1343
+ }
1344
+ this.#logger.info(
1345
+ { by, root: root.id, path: raw, trash: id },
1346
+ "workspace entry moved to the trash by an owner",
1347
+ );
1348
+ return item;
1349
+ }
1350
+
1351
+ async trash(rootId: string): Promise<WorkspaceTrashView> {
1352
+ const located = await this.#locate(rootId, "");
1353
+ if (located === "missing") return { items: [] };
1354
+ const dir = await this.#trashDir(located.real, false);
1355
+ if (!dir) return { items: [] };
1356
+ const items: WorkspaceTrashItem[] = [];
1357
+ const directory = await opendir(dir);
1358
+ try {
1359
+ for await (const entry of directory) {
1360
+ if (!TRASH_ID.test(entry.name)) continue;
1361
+ try {
1362
+ const meta = JSON.parse(
1363
+ await readFile(join(dir, entry.name, "meta.json"), "utf8"),
1364
+ ) as Partial<WorkspaceTrashItem>;
1365
+ if (
1366
+ meta.id === entry.name &&
1367
+ typeof meta.path === "string" &&
1368
+ typeof meta.name === "string" &&
1369
+ typeof meta.deletedAt === "string" &&
1370
+ (meta.kind === "file" ||
1371
+ meta.kind === "dir" ||
1372
+ meta.kind === "symlink")
1373
+ )
1374
+ items.push({
1375
+ id: meta.id,
1376
+ path: meta.path,
1377
+ name: meta.name,
1378
+ kind: meta.kind,
1379
+ size: typeof meta.size === "number" ? meta.size : 0,
1380
+ deletedAt: meta.deletedAt,
1381
+ });
1382
+ } catch {
1383
+ // A folder without readable notes is not something this app put there.
1384
+ }
1385
+ }
1386
+ } finally {
1387
+ await directory.close().catch(() => undefined);
1388
+ }
1389
+ return {
1390
+ items: items.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt)),
1391
+ };
1392
+ }
1393
+
1394
+ #trashId(rootId: string, id: string): string {
1395
+ if (!TRASH_ID.test(id)) throw this.#refuse(rootId, id, 400, "bad-path");
1396
+ return id;
1397
+ }
1398
+
1399
+ async restore(
1400
+ rootId: string,
1401
+ id: string,
1402
+ by: string,
1403
+ ): Promise<{ path: string }> {
1404
+ const root = this.#roots.get(rootId);
1405
+ if (!root) throw new WorkspaceRefusal(404, "unknown-root");
1406
+ if (!root.writable) throw this.#refuse(rootId, id, 403, "read-only-root");
1407
+ this.#trashId(rootId, id);
1408
+ const located = await this.#located(rootId, "");
1409
+ const dir = await this.#trashDir(located.real, false);
1410
+ if (!dir) throw new WorkspaceRefusal(404, "not-found");
1411
+ const itemDir = join(dir, id);
1412
+ // The folder of an item is one this code made; a link in its place is something an agent planted.
1413
+ const folder = await lstat(itemDir).catch(mapFs);
1414
+ if (!folder.isDirectory()) throw this.#refuse(rootId, id, 403, "special");
1415
+ let recorded: { path?: unknown };
1416
+ try {
1417
+ recorded = JSON.parse(await readFile(join(itemDir, "meta.json"), "utf8"));
1418
+ } catch (error) {
1419
+ return mapFs(error);
1420
+ }
1421
+ // The recorded place is only text in a file an agent could have written: it goes through
1422
+ // every check a new path does.
1423
+ const raw = typeof recorded.path === "string" ? recorded.path : "";
1424
+ const target = await this.#located(rootId, raw);
1425
+ const parent = await this.#parentOf(target, raw).catch((error) => {
1426
+ if (error instanceof WorkspaceRefusal && error.reason === "not-found")
1427
+ throw new WorkspaceConflictError("gone");
1428
+ throw error;
1429
+ });
1430
+ this.#guardWrite(target, parent, raw);
1431
+ const itemPath = join(itemDir, "item");
1432
+ let leaf: Stats;
1433
+ try {
1434
+ leaf = await lstat(itemPath);
1435
+ } catch (error) {
1436
+ return mapFs(error);
1437
+ }
1438
+ try {
1439
+ await lstat(parent.path);
1440
+ throw new WorkspaceConflictError("exists");
1441
+ } catch (error) {
1442
+ if (error instanceof WorkspaceConflictError) throw error;
1443
+ if (errno(error) !== "ENOENT") return mapFs(error);
1444
+ }
1445
+ try {
1446
+ await this.#moveNew(
1447
+ itemPath,
1448
+ parent.path,
1449
+ leaf.isFile() ? "file" : "other",
1450
+ );
1451
+ } catch (error) {
1452
+ if (errno(error) === "EEXIST") throw new WorkspaceConflictError("exists");
1453
+ return mapFs(error);
1454
+ }
1455
+ await rm(itemDir, { recursive: true, force: true });
1456
+ this.#logger.info(
1457
+ { by, root: rootId, path: raw, trash: id },
1458
+ "workspace entry restored by an owner",
1459
+ );
1460
+ return { path: raw };
1461
+ }
1462
+
1463
+ async purge(rootId: string, id: string, by: string): Promise<void> {
1464
+ const root = this.#roots.get(rootId);
1465
+ if (!root) throw new WorkspaceRefusal(404, "unknown-root");
1466
+ if (!root.writable) throw this.#refuse(rootId, id, 403, "read-only-root");
1467
+ this.#trashId(rootId, id);
1468
+ const located = await this.#located(rootId, "");
1469
+ const dir = await this.#trashDir(located.real, false);
1470
+ if (!dir) throw new WorkspaceRefusal(404, "not-found");
1471
+ const itemDir = join(dir, id);
1472
+ let info: Stats;
1473
+ try {
1474
+ info = await lstat(itemDir);
1475
+ } catch (error) {
1476
+ return mapFs(error);
1477
+ }
1478
+ if (!info.isDirectory()) throw new WorkspaceRefusal(404, "not-found");
1479
+ await rm(itemDir, { recursive: true, force: true });
1480
+ this.#logger.info(
1481
+ { by, root: rootId, trash: id },
1482
+ "workspace trash item deleted for good by an owner",
1483
+ );
1484
+ }
1485
+ }