Open History to see the last commit
1// @effect-diagnostics nodeBuiltinImport:off - FileSystem cannot create a FIFO, and reads spy on native realpath.
2import * as NodeChildProcess from "node:child_process";
3import * as NodeFSP from "node:fs/promises";
4
5import * as NodePath from "@effect/platform-node/NodePath";
6import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
7import * as NodeServices from "@effect/platform-node/NodeServices";
8import { it, describe, expect } from "@effect/vitest";
9import * as Effect from "effect/Effect";
10import * as FileSystem from "effect/FileSystem";
11import * as Layer from "effect/Layer";
12import * as Path from "effect/Path";
13
14import * as ServerConfig from "../config.ts";
15import { makeFakeWslHost } from "../testUtils/fakeWsl.ts";
16import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
17import * as VcsProcess from "../vcs/VcsProcess.ts";
18import * as KnownWorkspaces from "./KnownWorkspaces.ts";
19import * as WorkspaceEntries from "./WorkspaceEntries.ts";
20import * as WorkspaceFileSystem from "./WorkspaceFileSystem.ts";
21import * as WorkspacePaths from "./WorkspacePaths.ts";
22import * as HostProcess from "@t3tools/shared/HostProcess";
23import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
24import { vi } from "vite-plus/test";
25
26vi.mock("node:fs/promises", async (importOriginal) => {
27 const actual = await importOriginal<typeof NodeFSP>();
28 return { ...actual, realpath: vi.fn(actual.realpath), open: vi.fn(actual.open) };
29});
30
31const injectShortReads = Effect.fn("injectShortReads")(function* (target: string) {
32 const native = yield* Effect.promise(() => vi.importActual<typeof NodeFSP>("node:fs/promises"));
33 const open = vi.mocked(NodeFSP.open).mockImplementation(async (path, flags, mode) => {
34 const handle = await native.open(path, flags, mode);
35 if (path === target) {
36 const read = handle.read.bind(handle);
37 Object.defineProperty(handle, "read", {
38 value: (buffer: Uint8Array, offset: number, length: number, position: number) =>
39 read(buffer, offset, Math.min(length, 2), position),
40 });
41 }
42 return handle;
43 });
44 yield* Effect.addFinalizer(() => Effect.sync(() => open.mockImplementation(native.open)));
45});
46
47const whitespaceFilePaths =
48 HostProcess.Platform.defaultValue() === "win32"
49 ? [" report.txt"]
50 : [" report.txt", "report.txt ", "\treport.txt\t", " "];
51
52const UNKNOWN_WORKSPACE_PREFIX = "t3code-unknown-workspace-";
53const layerKnownWorkspacesTest = Layer.mock(KnownWorkspaces.KnownWorkspaces)({
54 assertContains: (cwd) =>
55 cwd.includes(UNKNOWN_WORKSPACE_PREFIX)
56 ? Effect.fail(new KnownWorkspaces.UnknownWorkspaceError({ cwd }))
57 : Effect.void,
58});
59
60const layerProject = WorkspaceFileSystem.layer.pipe(
61 Layer.provide(layerKnownWorkspacesTest),
62 Layer.provide(WorkspacePaths.layer),
63 Layer.provide(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))),
64);
65
66const layerTest = Layer.empty.pipe(
67 Layer.provideMerge(layerProject),
68 Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))),
69 Layer.provideMerge(WorkspacePaths.layer),
70 Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))),
71 Layer.provideMerge(
72 ServerConfig.ServerConfig.layerTest(process.cwd(), {
73 prefix: "t3-workspace-files-test-",
74 }),
75 ),
76 Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
77);
78
79const makeTempDir = Effect.gen(function* () {
80 const fileSystem = yield* FileSystem.FileSystem;
81 return yield* fileSystem.makeTempDirectoryScoped({
82 prefix: "t3code-workspace-files-",
83 });
84});
85
86const writeTextFile = Effect.fn("writeTextFile")(function* (
87 cwd: string,
88 relativePath: string,
89 contents = "",
90) {
91 const fileSystem = yield* FileSystem.FileSystem;
92 const path = yield* Path.Path;
93 const absolutePath = path.join(cwd, relativePath);
94 yield* fileSystem
95 .makeDirectory(path.dirname(absolutePath), { recursive: true })
96 .pipe(Effect.orDie);
97 yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie);
98});
99
100const makeWslHost = Effect.gen(function* () {
101 const { mountRoot, underWsl } = yield* makeFakeWslHost;
102 const service = yield* underWsl(
103 WorkspaceFileSystem.make.pipe(Effect.provide(layerKnownWorkspacesTest)),
104 );
105 return { mountRoot, service, underWsl };
106});
107
108it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (it) => {
109 describe("getMetadata", () => {
110 it.effect(
111 "reports actual kinds for extensionless files, dotfiles, and dotted directories",
112 () =>
113 Effect.gen(function* () {
114 const service = yield* WorkspaceFileSystem.WorkspaceFileSystem;
115 const fs = yield* FileSystem.FileSystem;
116 const path = yield* Path.Path;
117 const cwd = yield* makeTempDir;
118 yield* writeTextFile(cwd, "custom-name", "hello");
119 yield* writeTextFile(cwd, ".npmrc", "registry=test");
120 yield* fs.makeDirectory(path.join(cwd, "folder.ts"));
121 const file = path.join(cwd, "custom-name");
122 const result = yield* service.getMetadata({
123 paths: [
124 file,
125 path.join(cwd, ".npmrc"),
126 path.join(cwd, "folder.ts"),
127 file,
128 path.join(cwd, "missing"),
129 "relative-path",
130 ],
131 });
132 expect(result.entries).toEqual([
133 { kind: "file", byteLength: 5 },
134 { kind: "file", byteLength: 13 },
135 { kind: "directory" },
136 { kind: "file", byteLength: 5 },
137 null,
138 null,
139 ]);
140 }),
141 );
142
143 it.effect("detects an extensionless script and image without returning their contents", () =>
144 Effect.gen(function* () {
145 const service = yield* WorkspaceFileSystem.WorkspaceFileSystem;
146 const fs = yield* FileSystem.FileSystem;
147 const path = yield* Path.Path;
148 const cwd = yield* makeTempDir;
149 const script = "#!/usr/bin/env python3\nprint('hello')\n";
150 yield* writeTextFile(cwd, "run", script);
151 const image = path.join(cwd, "image");
152 yield* fs.writeFile(
153 image,
154 new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
155 );
156 expect(
157 (yield* service.getMetadata({ paths: [path.join(cwd, "run"), image] })).entries,
158 ).toEqual([
159 { kind: "file", byteLength: script.length, mimeType: "text/x-python" },
160 { kind: "file", byteLength: 8, mimeType: "image/png" },
161 ]);
162 }),
163 );
164
165 // A file chip for `file://attacker.test/s/a.ts` in an agent's message sent
166 // the Windows user's NTLM credentials to attacker.test as it rendered.
167 it.effect("never stats a network path", () =>
168 Effect.gen(function* () {
169 const fs = yield* FileSystem.FileSystem;
170 const statted: Array<string> = [];
171 const service = yield* WorkspaceFileSystem.make.pipe(
172 Effect.provideService(
173 FileSystem.FileSystem,
174 FileSystem.FileSystem.of({
175 ...fs,
176 stat: (requestedPath) => {
177 statted.push(requestedPath);
178 return fs.stat(requestedPath);
179 },
180 }),
181 ),
182 Effect.provide(layerKnownWorkspacesTest),
183 );
184 const paths = [
185 "\\\\attacker.test\\s\\a.ts",
186 "//attacker.test/s/a.ts",
187 "/\\attacker.test\\s",
188 ];
189 expect((yield* service.getMetadata({ paths })).entries).toEqual([null, null, null]);
190 expect(statted).toEqual([]);
191 }),
192 );
193
194 it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")(
195 "reports a Windows drive path under WSL as the file it maps to",
196 () =>
197 Effect.gen(function* () {
198 const { mountRoot, service, underWsl } = yield* makeWslHost;
199 yield* writeTextFile(mountRoot, "c/Users/me/Documents/notes.md", "# Notes\n");
200
201 const { entries } = yield* underWsl(
202 service.getMetadata({
203 paths: [
204 "C:\\Users\\me\\Documents\\notes.md",
205 "C:/Users/me/Documents/notes.md",
206 "C:/Users/me/Documents/notes\u0000.md",
207 ],
208 }),
209 );
210
211 expect(entries).toEqual([
212 { kind: "file", byteLength: 8 },
213 { kind: "file", byteLength: 8 },
214 null,
215 ]);
216 }),
217 );
218 });
219
220 describe("readFile", () => {
221 it.effect("reads UTF-8 files relative to the workspace root", () =>
222 Effect.gen(function* () {
223 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
224 const cwd = yield* makeTempDir;
225 yield* writeTextFile(cwd, "src/index.ts", "export const answer = 42;\n");
226
227 const result = yield* workspaceFileSystem.readFile({
228 cwd,
229 relativePath: "src/index.ts",
230 });
231
232 expect(result).toEqual({
233 relativePath: "src/index.ts",
234 contents: "export const answer = 42;\n",
235 byteLength: 26,
236 truncated: false,
237 });
238
239 const bomContents = "\uFEFFexport const answer = 42;\n";
240 yield* writeTextFile(cwd, "bom.ts", bomContents);
241 const bomRead = yield* workspaceFileSystem.readFile({ cwd, relativePath: "bom.ts" });
242 yield* workspaceFileSystem.writeFile({
243 cwd,
244 relativePath: "bom.ts",
245 contents: bomRead.contents.replace("42", "43"),
246 });
247 const fs = yield* FileSystem.FileSystem;
248 const saved = yield* fs.readFile(`${cwd}/bom.ts`);
249 expect([...saved]).toEqual([...new TextEncoder().encode(bomContents.replace("42", "43"))]);
250
251 yield* writeTextFile(cwd, "report.txt", "ordinary file");
252 for (const relativePath of whitespaceFilePaths) {
253 yield* writeTextFile(cwd, relativePath, "whitespace file");
254 const read = yield* workspaceFileSystem.readFile({ cwd, relativePath });
255 expect(read.contents).toBe("whitespace file");
256 expect(read.relativePath).toBe(relativePath);
257 }
258 }),
259 );
260
261 it.effect("finishes short reads before decoding UTF-8 text", () =>
262 Effect.gen(function* () {
263 const files = yield* WorkspaceFileSystem.WorkspaceFileSystem;
264 const fs = yield* FileSystem.FileSystem;
265 const cwd = yield* makeTempDir;
266 const contents = "A café 🐈\n";
267 yield* writeTextFile(cwd, "short.txt", contents);
268 const target = yield* fs.realPath(`${cwd}/short.txt`);
269 yield* injectShortReads(target);
270 expect(yield* files.readFile({ cwd, relativePath: "short.txt" })).toEqual({
271 relativePath: "short.txt",
272 contents,
273 byteLength: Buffer.byteLength(contents),
274 truncated: false,
275 });
276 }),
277 );
278
279 it.effect("rejects binary bytes delivered after an initial short read", () =>
280 Effect.gen(function* () {
281 const files = yield* WorkspaceFileSystem.WorkspaceFileSystem;
282 const fs = yield* FileSystem.FileSystem;
283 const cwd = yield* makeTempDir;
284 yield* writeTextFile(cwd, "short.txt", "ab\0cd");
285 const target = yield* fs.realPath(`${cwd}/short.txt`);
286 yield* injectShortReads(target);
287 const error = yield* files.readFile({ cwd, relativePath: "short.txt" }).pipe(Effect.flip);
288 expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceBinaryFileError);
289 }),
290 );
291
292 it.effect("reads host files outside the workspace root by absolute path", () =>
293 Effect.gen(function* () {
294 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
295 const path = yield* Path.Path;
296 const cwd = yield* makeTempDir;
297 const outsideDir = yield* makeTempDir;
298 yield* writeTextFile(outsideDir, "cleanup-report.md", "# Report\n");
299 const absolutePath = path.join(outsideDir, "cleanup-report.md");
300
301 const result = yield* workspaceFileSystem.readFile({
302 cwd,
303 relativePath: absolutePath,
304 });
305
306 expect(result).toEqual({
307 relativePath: absolutePath,
308 contents: "# Report\n",
309 byteLength: 9,
310 truncated: false,
311 });
312
313 for (const relativePath of whitespaceFilePaths) {
314 yield* writeTextFile(outsideDir, relativePath, "whitespace file");
315 const requestedPath = path.join(outsideDir, relativePath);
316 const read = yield* workspaceFileSystem.readFile({ cwd, relativePath: requestedPath });
317 expect(read.contents).toBe("whitespace file");
318 expect(read.relativePath).toBe(requestedPath);
319 }
320 }),
321 );
322
323 it.effect("reads ~/ paths from the server home outside the workspace", () =>
324 Effect.gen(function* () {
325 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
326 const cwd = yield* makeTempDir;
327 const home = yield* makeTempDir;
328 yield* writeTextFile(home, ".claude/CLAUDE.md", "# Notes\n");
329 yield* writeTextFile(cwd, "~/.claude/CLAUDE.md", "Workspace decoy\n");
330
331 const result = yield* workspaceFileSystem
332 .readFile({ cwd, relativePath: "~/.claude/CLAUDE.md" })
333 .pipe(Effect.provideService(HostProcess.HomeDirectory, home));
334
335 expect(result).toEqual({
336 relativePath: "~/.claude/CLAUDE.md",
337 contents: "# Notes\n",
338 byteLength: 8,
339 truncated: false,
340 });
341 }),
342 );
343
344 it.effect("does not read server state files", () =>
345 Effect.gen(function* () {
346 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
347 const config = yield* ServerConfig.ServerConfig;
348 const path = yield* Path.Path;
349 const cwd = yield* makeTempDir;
350 yield* writeTextFile(config.secretsDir, "example.bin", "example\n");
351 yield* writeTextFile(config.stateDir, "statev2.sqlite", "example\n");
352
353 for (const input of [
354 { cwd, relativePath: path.join(config.secretsDir, "example.bin") },
355 { cwd: config.stateDir, relativePath: "statev2.sqlite" },
356 ]) {
357 const error = yield* workspaceFileSystem.readFile(input).pipe(Effect.flip);
358 expect(error).toBeInstanceOf(WorkspacePaths.WorkspacePathOutsideRootError);
359 }
360 const metadata = yield* workspaceFileSystem.getMetadata({
361 paths: [
362 path.join(config.secretsDir, "example.bin"),
363 path.join(config.stateDir, "statev2.sqlite"),
364 config.stateDir,
365 ],
366 });
367 expect(metadata.entries).toEqual([null, null, { kind: "directory" }]);
368 }),
369 );
370
371 it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")(
372 "rejects a FIFO without blocking on open",
373 () =>
374 Effect.gen(function* () {
375 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
376 const path = yield* Path.Path;
377 const cwd = yield* makeTempDir;
378 const outsideDir = yield* makeTempDir;
379 const fifoPath = path.join(outsideDir, "pipe");
380 yield* Effect.promise(
381 () =>
382 new Promise<void>((resolve, reject) =>
383 NodeChildProcess.execFile("mkfifo", [fifoPath], (error) =>
384 error ? reject(error) : resolve(),
385 ),
386 ),
387 );
388
389 const error = yield* workspaceFileSystem
390 .readFile({ cwd, relativePath: fifoPath })
391 .pipe(Effect.flip);
392
393 expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspacePathNotFileError);
394 expect((yield* workspaceFileSystem.getMetadata({ paths: [fifoPath] })).entries).toEqual([
395 { kind: "other" },
396 ]);
397 }),
398 );
399
400 it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")(
401 "reads a Windows drive path under WSL from where wslpath mounts it",
402 () =>
403 Effect.gen(function* () {
404 const { mountRoot, service, underWsl } = yield* makeWslHost;
405 const cwd = yield* makeTempDir;
406 yield* writeTextFile(mountRoot, "c/Users/me/Documents/notes.md", "# Notes\n");
407
408 for (const relativePath of [
409 "C:\\Users\\me\\Documents\\notes.md",
410 "C:/Users/me/Documents/notes.md",
411 ]) {
412 const result = yield* underWsl(service.readFile({ cwd, relativePath }));
413 expect(result).toMatchObject({ contents: "# Notes\n", truncated: false });
414 }
415 }),
416 );
417
418 it.effect("refuses a network path outside the workspace", () =>
419 Effect.gen(function* () {
420 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
421
422 const error = yield* workspaceFileSystem
423 .readFile({ cwd: "//nas.test/share/project", relativePath: "//attacker.test/s/a.md" })
424 .pipe(Effect.flip);
425
426 expect(error).toBeInstanceOf(WorkspacePaths.WorkspacePathOutsideRootError);
427 }),
428 );
429
430 // This resolves inside the project, but Windows reads `\\?\` paths as
431 // written, so realpath on the raw string signed in to attacker.test.
432 it.effect("reads the network path it checked", () =>
433 Effect.gen(function* () {
434 const workspaceFileSystem = yield* WorkspaceFileSystem.make.pipe(
435 Effect.provide(Layer.merge(layerKnownWorkspacesTest, NodePath.layerWin32)),
436 );
437 // Rejects without touching the filesystem, so a Windows runner never looks the share up.
438 const realpath = vi
439 .mocked(NodeFSP.realpath)
440 .mockClear()
441 .mockRejectedValueOnce(Object.assign(new Error("ENOENT"), { code: "ENOENT" }));
442
443 yield* workspaceFileSystem
444 .readFile({
445 cwd: "\\\\?\\UNC\\nas.test\\share\\project",
446 relativePath: "\\\\?\\UNC\\attacker.test\\s\\..\\..\\nas.test\\share\\project\\a.md",
447 })
448 .pipe(Effect.exit);
449
450 expect(realpath.mock.calls.map(([target]) => target)).toEqual([
451 "\\\\?\\UNC\\nas.test\\share\\project\\a.md",
452 ]);
453 }),
454 );
455
456 // Resolving `..` before realpath would read beside the link instead of its target.
457 // Windows collapses `..` before the filesystem sees a path, so it has no such reads.
458 it.effect.skipIf(!symlinksSupported || HostProcess.Platform.defaultValue() === "win32")(
459 "reads `..` after a link from the link's target",
460 () =>
461 Effect.gen(function* () {
462 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
463 const fs = yield* FileSystem.FileSystem;
464 const path = yield* Path.Path;
465 const root = yield* makeTempDir;
466 yield* writeTextFile(root, "target/parent.md", "beside the target\n");
467 yield* writeTextFile(root, "parent.md", "beside the link\n");
468 yield* fs.makeDirectory(path.join(root, "target", "child"));
469 yield* fs.symlink(path.join(root, "target", "child"), path.join(root, "link"));
470
471 const result = yield* workspaceFileSystem.readFile({
472 cwd: root,
473 relativePath: `${root}/link/../parent.md`,
474 });
475
476 expect(result.contents).toBe("beside the target\n");
477 }),
478 );
479
480 it.effect.skipIf(!symlinksSupported)(
481 "rejects symlinks that resolve outside the workspace root",
482 () =>
483 Effect.gen(function* () {
484 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
485 const fileSystem = yield* FileSystem.FileSystem;
486 const path = yield* Path.Path;
487 const cwd = yield* makeTempDir;
488 const outsideDir = yield* makeTempDir;
489 yield* writeTextFile(outsideDir, "secret.txt", "outside\n");
490 yield* fileSystem.symlink(
491 path.join(outsideDir, "secret.txt"),
492 path.join(cwd, "linked-secret.txt"),
493 );
494
495 const error = yield* workspaceFileSystem
496 .readFile({ cwd, relativePath: "linked-secret.txt" })
497 .pipe(Effect.flip);
498 const resolvedWorkspaceRoot = yield* fileSystem.realPath(cwd);
499 const resolvedPath = yield* fileSystem.realPath(path.join(outsideDir, "secret.txt"));
500
501 expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFilePathEscapeError);
502 expect(error).toMatchObject({
503 workspaceRoot: cwd,
504 relativePath: "linked-secret.txt",
505 resolvedWorkspaceRoot,
506 resolvedPath,
507 });
508 expect("cause" in error).toBe(false);
509
510 yield* fileSystem.symlink(outsideDir, path.join(cwd, "linked-dir"));
511 const missingChildError = yield* workspaceFileSystem
512 .readFile(
513 Object.assign(
514 { cwd, relativePath: "linked-dir/missing/t3.json" },
515 { allowMissing: true },
516 ),
517 )
518 .pipe(Effect.flip);
519 expect(missingChildError).toBeInstanceOf(
520 WorkspaceFileSystem.WorkspaceFilePathEscapeError,
521 );
522 }),
523 );
524
525 it.effect("drops a multibyte character split by the read limit", () =>
526 Effect.gen(function* () {
527 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
528 const fileSystem = yield* FileSystem.FileSystem;
529 const path = yield* Path.Path;
530 const cwd = yield* makeTempDir;
531 const limit = 1024 * 1024;
532 const euro = new TextEncoder().encode("€");
533 const bytes = new Uint8Array(limit - 1 + euro.length).fill(0x61);
534 bytes.set(euro, limit - 1);
535 yield* fileSystem.writeFile(path.join(cwd, "large.txt"), bytes);
536
537 const result = yield* workspaceFileSystem.readFile({ cwd, relativePath: "large.txt" });
538
539 expect(result.truncated).toBe(true);
540 expect(result.byteLength).toBe(bytes.length);
541 expect(result.contents).toBe("a".repeat(limit - 1));
542 }),
543 );
544
545 it.effect.each([
546 { name: "a NUL byte", bytes: [0x61, 0, 0x62] },
547 { name: "invalid UTF-8", bytes: [0x63, 0x61, 0x66, 0xe9] },
548 ])("rejects files with $name without leaking their contents", ({ bytes }) =>
549 Effect.gen(function* () {
550 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
551 const fileSystem = yield* FileSystem.FileSystem;
552 const path = yield* Path.Path;
553 const cwd = yield* makeTempDir;
554 const absolutePath = path.join(cwd, "asset.bin");
555 yield* fileSystem.writeFile(absolutePath, Uint8Array.from(bytes));
556
557 const error = yield* workspaceFileSystem
558 .readFile({ cwd, relativePath: "asset.bin" })
559 .pipe(Effect.flip);
560 const resolvedPath = yield* fileSystem.realPath(absolutePath);
561
562 expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceBinaryFileError);
563 expect(error).toMatchObject({
564 workspaceRoot: cwd,
565 relativePath: "asset.bin",
566 resolvedPath,
567 });
568 expect("cause" in error).toBe(false);
569 expect("contents" in error).toBe(false);
570 }),
571 );
572 });
573
574 describe("writeFile", () => {
575 it.effect("rejects writes outside a known workspace", () =>
576 Effect.gen(function* () {
577 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
578 const fileSystem = yield* FileSystem.FileSystem;
579 const cwd = yield* fileSystem.makeTempDirectoryScoped({
580 prefix: UNKNOWN_WORKSPACE_PREFIX,
581 });
582
583 const error = yield* workspaceFileSystem
584 .writeFile({ cwd, relativePath: "plan.md", contents: "# nope\n" })
585 .pipe(Effect.flip);
586
587 expect(error).toBeInstanceOf(KnownWorkspaces.UnknownWorkspaceError);
588 expect(yield* fileSystem.readDirectory(cwd)).toEqual([]);
589 }),
590 );
591
592 it.effect("rejects writes by absolute path", () =>
593 Effect.gen(function* () {
594 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
595 const path = yield* Path.Path;
596 const cwd = yield* makeTempDir;
597 const outsideDir = yield* makeTempDir;
598 const absolutePath = path.join(outsideDir, "cleanup-report.md");
599
600 const error = yield* workspaceFileSystem
601 .writeFile({ cwd, relativePath: absolutePath, contents: "# Edited\n" })
602 .pipe(Effect.flip);
603
604 expect(error).toBeInstanceOf(WorkspacePaths.WorkspacePathOutsideRootError);
605 }),
606 );
607
608 it.effect("invalidates workspace entry search cache after writes", () =>
609 Effect.gen(function* () {
610 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
611 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
612 const cwd = yield* makeTempDir;
613 yield* writeTextFile(cwd, "src/existing.ts", "export {};\n");
614
615 const beforeWrite = yield* workspaceEntries.list({ cwd });
616 expect(beforeWrite.entries.some((entry) => entry.path === "plans/effect-rpc.md")).toBe(
617 false,
618 );
619
620 yield* workspaceFileSystem.writeFile({
621 cwd,
622 relativePath: "plans/effect-rpc.md",
623 contents: "# Plan\n",
624 });
625
626 const afterWrite = yield* workspaceEntries.list({ cwd });
627 expect(afterWrite.entries).toEqual(
628 expect.arrayContaining([expect.objectContaining({ path: "plans/effect-rpc.md" })]),
629 );
630 expect(afterWrite.truncated).toBe(false);
631 }),
632 );
633
634 it.effect("saves over an existing file without rescanning the workspace", () =>
635 Effect.gen(function* () {
636 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
637 const fileSystem = yield* FileSystem.FileSystem;
638 const path = yield* Path.Path;
639 const cwd = yield* makeTempDir;
640 yield* writeTextFile(cwd, "src/main.ts", "export {};\n");
641
642 yield* workspaceFileSystem.writeFile({
643 cwd,
644 relativePath: "src/main.ts",
645 contents: "export const edited = true;\n",
646 });
647
648 expect(yield* fileSystem.readFileString(path.join(cwd, "src/main.ts"))).toBe(
649 "export const edited = true;\n",
650 );
651
652 yield* writeTextFile(cwd, "report.txt", "ordinary file");
653 for (const relativePath of whitespaceFilePaths) {
654 yield* writeTextFile(cwd, relativePath, "whitespace file");
655 const written = yield* workspaceFileSystem.writeFile({
656 cwd,
657 relativePath,
658 contents: "edited",
659 });
660 expect(yield* fileSystem.readFileString(path.join(cwd, "report.txt"))).toBe(
661 "ordinary file",
662 );
663 expect(yield* fileSystem.readFileString(path.join(cwd, relativePath))).toBe("edited");
664 expect(written.relativePath).toBe(relativePath);
665 }
666 }).pipe(
667 Effect.provide(
668 Layer.fresh(WorkspaceFileSystem.layer).pipe(
669 Layer.provide(layerKnownWorkspacesTest),
670 Layer.provide(WorkspacePaths.layer),
671 Layer.provide(
672 Layer.mock(WorkspaceEntries.WorkspaceEntries)({
673 refresh: () => Effect.die("An overwrite rescanned the workspace."),
674 }),
675 ),
676 ),
677 ),
678 ),
679 );
680
681 it.effect("rejects writes outside the workspace root", () =>
682 Effect.gen(function* () {
683 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
684 const cwd = yield* makeTempDir;
685 const path = yield* Path.Path;
686 const fileSystem = yield* FileSystem.FileSystem;
687
688 for (const relativePath of ["../escape.md", "../escape.md ", " folder/../../escape.md"]) {
689 const error = yield* workspaceFileSystem
690 .writeFile({ cwd, relativePath, contents: "# nope\n" })
691 .pipe(Effect.flip);
692
693 expect(error).toBeInstanceOf(WorkspacePaths.WorkspacePathOutsideRootError);
694 }
695
696 const escapedPath = path.resolve(cwd, "..", "escape.md");
697 const escapedStat = yield* fileSystem
698 .stat(escapedPath)
699 .pipe(Effect.orElseSucceed(() => null));
700 expect(escapedStat).toBeNull();
701 }),
702 );
703
704 it.effect.skipIf(!symlinksSupported)(
705 "rejects writes through symlinks that resolve outside the workspace root",
706 () =>
707 Effect.gen(function* () {
708 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
709 const fileSystem = yield* FileSystem.FileSystem;
710 const path = yield* Path.Path;
711 const cwd = yield* makeTempDir;
712 const outsideDir = yield* makeTempDir;
713 yield* writeTextFile(outsideDir, "existing.md", "outside\n");
714 yield* fileSystem.symlink(outsideDir, path.join(cwd, "linked-dir"));
715 yield* fileSystem.symlink(
716 path.join(outsideDir, "existing.md"),
717 path.join(cwd, "linked-file.md"),
718 );
719 yield* fileSystem.symlink(
720 path.join(outsideDir, "missing.md"),
721 path.join(cwd, "dangling.md"),
722 );
723
724 for (const relativePath of [
725 "linked-dir/new.md",
726 "linked-dir/nested/new.md",
727 "linked-file.md",
728 ]) {
729 const error = yield* workspaceFileSystem
730 .writeFile({ cwd, relativePath, contents: "# nope\n" })
731 .pipe(Effect.flip);
732 expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFilePathEscapeError);
733 }
734 const danglingError = yield* workspaceFileSystem
735 .writeFile({ cwd, relativePath: "dangling.md", contents: "# nope\n" })
736 .pipe(Effect.flip);
737 expect(danglingError).toBeInstanceOf(
738 WorkspaceFileSystem.WorkspaceFileSystemOperationError,
739 );
740 expect(danglingError).toMatchObject({ operation: "realpath-target" });
741
742 expect((yield* fileSystem.readDirectory(outsideDir)).toSorted()).toEqual(["existing.md"]);
743 expect(yield* fileSystem.readFileString(path.join(outsideDir, "existing.md"))).toBe(
744 "outside\n",
745 );
746 }),
747 );
748
749 it.effect.skipIf(!symlinksSupported)(
750 "writes through symlinks that stay inside the workspace root",
751 () =>
752 Effect.gen(function* () {
753 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
754 const fileSystem = yield* FileSystem.FileSystem;
755 const path = yield* Path.Path;
756 const parentDir = yield* makeTempDir;
757 const cwd = path.join(parentDir, "workspace");
758 const cwdAlias = path.join(parentDir, "workspace-alias");
759 yield* writeTextFile(cwd, "docs/README.md", "old\n");
760 yield* fileSystem.symlink(path.join(cwd, "docs"), path.join(cwd, "docs-alias"));
761 yield* fileSystem.symlink("docs/README.md", path.join(cwd, "README.md"));
762 yield* fileSystem.symlink(cwd, cwdAlias);
763
764 yield* workspaceFileSystem.writeFile({
765 cwd,
766 relativePath: "docs-alias/plans/plan.md",
767 contents: "# Plan\n",
768 });
769 yield* workspaceFileSystem.writeFile({
770 cwd,
771 relativePath: "README.md",
772 contents: "new\n",
773 });
774 yield* workspaceFileSystem.writeFile({
775 cwd: cwdAlias,
776 relativePath: "notes.md",
777 contents: "notes\n",
778 });
779 // `..` after a link in cwd is resolved by name, as for the relative path.
780 yield* fileSystem.symlink(path.join(cwd, "docs/plans"), path.join(cwd, "plans-alias"));
781 yield* workspaceFileSystem.writeFile({
782 cwd: `${path.join(cwd, "plans-alias")}${path.sep}..`,
783 relativePath: "dotdot.md",
784 contents: "dotdot\n",
785 });
786
787 expect(yield* fileSystem.readFileString(path.join(cwd, "docs/plans/plan.md"))).toBe(
788 "# Plan\n",
789 );
790 expect(yield* fileSystem.readFileString(path.join(cwd, "docs/README.md"))).toBe("new\n");
791 expect(yield* fileSystem.readLink(path.join(cwd, "README.md"))).toBe("docs/README.md");
792 expect(yield* fileSystem.readFileString(path.join(cwd, "notes.md"))).toBe("notes\n");
793 expect(yield* fileSystem.readFileString(path.join(cwd, "dotdot.md"))).toBe("dotdot\n");
794 }),
795 );
796
797 // On Windows `\` is a separator, so the name cannot exist there.
798 it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")(
799 "keeps a backslash in a file name as part of the name",
800 () =>
801 Effect.gen(function* () {
802 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
803 const fileSystem = yield* FileSystem.FileSystem;
804 const cwd = yield* makeTempDir;
805 yield* writeTextFile(cwd, "a\\b.md", "old\n");
806
807 yield* workspaceFileSystem.writeFile({ cwd, relativePath: "a\\b.md", contents: "new\n" });
808
809 expect(
810 (yield* workspaceFileSystem.readFile({ cwd, relativePath: "a\\b.md" })).contents,
811 ).toBe("new\n");
812 expect(yield* fileSystem.readDirectory(cwd)).toEqual(["a\\b.md"]);
813 }),
814 );
815
816 it.effect("refuses writes into git's own folders", () =>
817 Effect.gen(function* () {
818 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
819 const fileSystem = yield* FileSystem.FileSystem;
820 const path = yield* Path.Path;
821 const cwd = yield* makeTempDir;
822 yield* writeTextFile(cwd, ".git/config", "[core]\n");
823 yield* writeTextFile(cwd, ".bare/HEAD", "ref: refs/heads/main\n");
824 yield* writeTextFile(cwd, ".bare/objects/info/packs");
825 yield* writeTextFile(cwd, ".bare/refs/heads/main");
826 yield* writeTextFile(cwd, ".bare/hooks/pre-push.sample");
827 // Only a linked worktree's `.git` file ends the upward check, not any `.git` folder.
828 yield* fileSystem.makeDirectory(path.join(cwd, ".bare/hooks/.git"));
829 yield* writeTextFile(cwd, ".bare/worktrees/main/HEAD", "ref: refs/heads/main\n");
830 yield* writeTextFile(cwd, ".bare/worktrees/main/commondir", "../..\n");
831 if (symlinksSupported) yield* fileSystem.symlink(".git", path.join(cwd, "docs"));
832
833 for (const [folder, relativePath] of [
834 [cwd, ".git/config"],
835 [cwd, ".git/hooks/pre-commit"],
836 [cwd, "sub/.git"],
837 [cwd, ".GIT/config"],
838 [cwd, ".gıt/config"],
839 [cwd, ".git. /config"],
840 [cwd, ".git::$INDEX_ALLOCATION/config"],
841 [cwd, "GIT~1/config"],
842 [path.join(cwd, ".git"), "config"],
843 [cwd, ".bare/config"],
844 [path.join(cwd, ".bare"), "hooks/post-checkout"],
845 [path.join(cwd, ".bare/hooks"), "post-checkout"],
846 [path.join(cwd, ".bare/worktrees/main"), "commondir"],
847 ...(symlinksSupported ? [[cwd, "docs/config"] as const] : []),
848 ] as const) {
849 const error = yield* workspaceFileSystem
850 .writeFile({ cwd: folder, relativePath, contents: "[core]\n\tfsmonitor = x\n" })
851 .pipe(Effect.flip);
852 expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFilePathEscapeError);
853 }
854
855 expect(yield* fileSystem.readFileString(path.join(cwd, ".git/config"))).toBe("[core]\n");
856 expect(yield* fileSystem.exists(path.join(cwd, ".bare/config"))).toBe(false);
857 expect((yield* fileSystem.readDirectory(path.join(cwd, ".bare/hooks"))).toSorted()).toEqual(
858 [".git", "pre-push.sample"],
859 );
860 expect(yield* fileSystem.exists(path.join(cwd, "sub"))).toBe(false);
861 expect(
862 yield* fileSystem.readFileString(path.join(cwd, ".bare/worktrees/main/commondir")),
863 ).toBe("../..\n");
864 }),
865 );
866
867 it.effect.skipIf(!symlinksSupported)(
868 "refuses writes through a git config that links to a file outside git's folders",
869 () =>
870 Effect.gen(function* () {
871 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
872 const fileSystem = yield* FileSystem.FileSystem;
873 const path = yield* Path.Path;
874 const cwd = yield* makeTempDir;
875 yield* writeTextFile(cwd, "shared/config", "[core]\n");
876 yield* fileSystem.makeDirectory(path.join(cwd, ".git"));
877 yield* fileSystem.symlink("../shared/config", path.join(cwd, ".git/config"));
878
879 const error = yield* workspaceFileSystem
880 .writeFile({ cwd, relativePath: ".git/config", contents: "[core]\n\tfsmonitor = x\n" })
881 .pipe(Effect.flip);
882
883 expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFilePathEscapeError);
884 expect(yield* fileSystem.readFileString(path.join(cwd, "shared/config"))).toBe(
885 "[core]\n",
886 );
887 }),
888 );
889
890 it.effect("writes files that sit beside git's folders", () =>
891 Effect.gen(function* () {
892 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
893 const fileSystem = yield* FileSystem.FileSystem;
894 const path = yield* Path.Path;
895 const parentDir = yield* makeTempDir;
896 const bare = path.join(parentDir, "repo.git");
897 yield* writeTextFile(bare, "HEAD", "ref: refs/heads/main\n");
898 yield* writeTextFile(bare, "objects/info/packs");
899 yield* writeTextFile(bare, "refs/heads/main");
900 yield* writeTextFile(bare, "main/.git", `gitdir: ${path.join(bare, "worktrees/main")}\n`);
901
902 for (const [cwd, relativePath] of [
903 [path.join(bare, "main"), ".gitignore"],
904 [path.join(bare, "main"), ".gitattributes"],
905 [path.join(bare, "main"), ".github/workflows/ci.yml"],
906 [path.join(bare, "main"), "vendor/lib.git/README.md"],
907 [path.join(bare, "main/.github"), "CODEOWNERS"],
908 [bare, "main/plan.md"],
909 ] as const) {
910 yield* workspaceFileSystem.writeFile({ cwd, relativePath, contents: "ok\n" });
911 expect(yield* fileSystem.readFileString(path.join(cwd, relativePath))).toBe("ok\n");
912 }
913 }),
914 );
915 });
916});