1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFS from "node:fs";
3import * as NodeFSP from "node:fs/promises";
4
5import type {
6 FilesystemEntryMetadata,
7 FilesystemGetMetadataInput,
8 FilesystemGetMetadataResult,
9 ProjectReadFileInput,
10 ProjectReadFileResult,
11 ProjectWriteFileInput,
12 ProjectWriteFileResult,
13} from "@t3tools/contracts";
14import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
15import * as HostProcess from "@t3tools/shared/HostProcess";
16import { isNetworkPath } from "@t3tools/shared/path";
17import * as Context from "effect/Context";
18import * as Effect from "effect/Effect";
19import * as FileSystem from "effect/FileSystem";
20import * as Layer from "effect/Layer";
21import * as Path from "effect/Path";
22import * as Schema from "effect/Schema";
23
24import { makeIsServerInternalPath } from "../auth/serverInternalPaths.ts";
25import * as KnownWorkspaces from "./KnownWorkspaces.ts";
26import * as WorkspaceEntries from "./WorkspaceEntries.ts";
27import * as WorkspacePaths from "./WorkspacePaths.ts";
28import { fileHeaderMimeType } from "./fileHeaderMimeType.ts";
29
30const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024;
31
32// NTFS reaches .git through trailing dots/spaces, stream suffixes and GIT~1; compare uppercased names.
33const isGitMetadataName = (segment: string) => {
34 const name = (segment.split(":")[0] ?? "").replace(/[. ]+$/, "").toUpperCase();
35 return name === ".GIT" || name === "GIT~1";
36};
37
38export class WorkspaceFileSystemOperationError extends Schema.TaggedError<WorkspaceFileSystemOperationError>()(
39 "WorkspaceFileSystemOperationError",
40 {
41 workspaceRoot: Schema.String,
42 relativePath: Schema.String,
43 resolvedPath: Schema.String,
44 operationPath: Schema.String,
45 operation: Schema.Literals([
46 "realpath-workspace-root",
47 "realpath-target",
48 "open",
49 "stat",
50 "read",
51 "close",
52 "make-directory",
53 "write-file",
54 ]),
55 cause: Schema.Defect(),
56 },
57) {
58 override get message(): string {
59 return `Workspace file operation '${this.operation}' failed at '${this.operationPath}' for resolved path '${this.resolvedPath}' (requested as '${this.relativePath}' in '${this.workspaceRoot}').`;
60 }
61}
62
63export class WorkspaceFilePathEscapeError extends Schema.TaggedError<WorkspaceFilePathEscapeError>()(
64 "WorkspaceFilePathEscapeError",
65 {
66 workspaceRoot: Schema.String,
67 relativePath: Schema.String,
68 resolvedWorkspaceRoot: Schema.String,
69 resolvedPath: Schema.String,
70 },
71) {
72 override get message(): string {
73 return `Workspace file '${this.relativePath}' resolves outside workspace root '${this.workspaceRoot}': ${this.resolvedPath}`;
74 }
75}
76
77export class WorkspacePathNotFileError extends Schema.TaggedError<WorkspacePathNotFileError>()(
78 "WorkspacePathNotFileError",
79 {
80 workspaceRoot: Schema.String,
81 relativePath: Schema.String,
82 resolvedPath: Schema.String,
83 },
84) {
85 override get message(): string {
86 return `Workspace path '${this.relativePath}' in '${this.workspaceRoot}' is not a file: ${this.resolvedPath}`;
87 }
88}
89
90export class WorkspaceBinaryFileError extends Schema.TaggedError<WorkspaceBinaryFileError>()(
91 "WorkspaceBinaryFileError",
92 {
93 workspaceRoot: Schema.String,
94 relativePath: Schema.String,
95 resolvedPath: Schema.String,
96 },
97) {
98 override get message(): string {
99 return `Workspace file '${this.relativePath}' in '${this.workspaceRoot}' is binary and cannot be previewed as text.`;
100 }
101}
102
103export const WorkspaceFileSystemError = Schema.Union([
104 WorkspaceFileSystemOperationError,
105 WorkspaceFilePathEscapeError,
106 WorkspacePathNotFileError,
107 WorkspaceBinaryFileError,
108]);
109export type WorkspaceFileSystemError = typeof WorkspaceFileSystemError.Type;
110
111export class WorkspaceFileSystem extends Context.Service<
112 WorkspaceFileSystem,
113 {
114 readonly getMetadata: (
115 input: FilesystemGetMetadataInput,
116 ) => Effect.Effect<FilesystemGetMetadataResult>;
117
118 readonly readFile: (
119 input: ProjectReadFileInput,
120 ) => Effect.Effect<
121 ProjectReadFileResult,
122 WorkspaceFileSystemError | WorkspacePaths.WorkspacePathOutsideRootError
123 >;
124 /** Writes inside a known workspace only. */
125 readonly writeFile: (
126 input: ProjectWriteFileInput,
127 ) => Effect.Effect<
128 ProjectWriteFileResult,
129 | WorkspaceFileSystemError
130 | WorkspacePaths.WorkspacePathOutsideRootError
131 | KnownWorkspaces.UnknownWorkspaceError
132 >;
133 }
134>()("t3/workspace/WorkspaceFileSystem") {}
135
136/** @public */
137export const make = Effect.gen(function* () {
138 const fileSystem = yield* FileSystem.FileSystem;
139 const path = yield* Path.Path;
140 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
141 const knownWorkspaces = yield* KnownWorkspaces.KnownWorkspaces;
142 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
143 const isServerInternalPath = yield* makeIsServerInternalPath;
144
145 const metadataForPath = Effect.fnUntraced(function* (requestedPath: string) {
146 const expandedPath = expandHomePath(requestedPath, yield* HostProcess.HomeDirectory);
147 // Chips ask as soon as an agent's message renders, and touching a network
148 // path makes Windows sign in to its host with the user's NTLM credentials.
149 if (!path.isAbsolute(expandedPath) || isNetworkPath(expandedPath)) return null;
150 const stat = yield* fileSystem.stat(expandedPath).pipe(Effect.option);
151 // Server state files answer like missing paths, as readFile refuses them.
152 if (stat._tag === "None" || (yield* isServerInternalPath(expandedPath))) return null;
153 if (stat.value.type === "Directory") return { kind: "directory" } as const;
154 if (stat.value.type !== "File") return { kind: "other" } as const;
155 let mimeType: string | undefined;
156 if (path.extname(expandedPath) === "" && stat.value.size > 0) {
157 mimeType = yield* Effect.tryPromise({
158 try: async () => {
159 const handle = await NodeFSP.open(
160 expandedPath,
161 NodeFS.constants.O_RDONLY | NodeFS.constants.O_NONBLOCK,
162 );
163 try {
164 if (!(await handle.stat()).isFile()) return undefined;
165 const buffer = Buffer.alloc(512);
166 const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
167 return fileHeaderMimeType(buffer.subarray(0, bytesRead));
168 } finally {
169 await handle.close();
170 }
171 },
172 catch: () => undefined,
173 }).pipe(Effect.orElseSucceed(() => undefined));
174 }
175 return {
176 kind: "file",
177 byteLength: Number(stat.value.size),
178 ...(mimeType === undefined ? {} : { mimeType }),
179 } satisfies FilesystemEntryMetadata;
180 });
181
182 const getMetadata: WorkspaceFileSystem["Service"]["getMetadata"] = Effect.fn(
183 "WorkspaceFileSystem.getMetadata",
184 )(function* (input) {
185 const paths = [...new Set(input.paths)];
186 const entries = yield* Effect.forEach(paths, metadataForPath, { concurrency: 8 });
187 const byPath = new Map(paths.map((value, index) => [value, entries[index] ?? null]));
188 return { entries: input.paths.map((value) => byPath.get(value) ?? null) };
189 });
190
191 /** Both paths must already be real paths. */
192 const isInsideRealRoot = (realWorkspaceRoot: string, realPath: string) => {
193 const relative = path.relative(realWorkspaceRoot, realPath);
194 return !(
195 relative.startsWith(`..${path.sep}`) ||
196 relative === ".." ||
197 path.isAbsolute(relative)
198 );
199 };
200
201 // Reject Git folders, including renamed bare repos whose config/hooks can execute; paths must be normalized.
202 // Stop at linked worktrees' .git files since worktrees can sit inside bare repos.
203 const isInGitFolder = async (absolutePath: string) => {
204 if (absolutePath.split(path.sep).some(isGitMetadataName)) return true;
205 for (let folder = path.dirname(absolutePath); ; folder = path.dirname(folder)) {
206 const [head, commondir, objects, refs, dotGit] = await Promise.all(
207 ["HEAD", "commondir", "objects", "refs", ".git"].map((name) =>
208 NodeFSP.lstat(path.join(folder, name)).catch((error: NodeJS.ErrnoException) => {
209 // Any other failure leaves the folder unknown, so the write is refused.
210 if (error.code === "ENOENT" || error.code === "ENOTDIR") return null;
211 throw error;
212 }),
213 ),
214 );
215 // Git recognizes HEAD with commondir or objects/refs as a repository.
216 if (head && (commondir || (objects && refs))) return true;
217 if (dotGit?.isFile() || path.dirname(folder) === folder) return false;
218 }
219 };
220
221 /**
222 * Workspace-relative paths must stay inside the root, symlinks included. An
223 * absolute or `~/` path reads a host file in place, such as a report an agent
224 * wrote to a temp directory; it gets no root check. A network path does, since
225 * Windows signs in to its host with the user's NTLM credentials.
226 */
227 const resolveReadTarget = Effect.fn("WorkspaceFileSystem.resolveReadTarget")(function* (
228 input: ProjectReadFileInput,
229 ) {
230 const requestedPath = input.relativePath;
231 const hostPath = requestedPath.startsWith("~/")
232 ? expandHomePath(requestedPath, yield* HostProcess.HomeDirectory)
233 : requestedPath;
234 if (
235 isNetworkPath(hostPath) &&
236 !KnownWorkspaces.isWithinRoot(path, path.resolve(hostPath), path.resolve(input.cwd))
237 ) {
238 return yield* new WorkspacePaths.WorkspacePathOutsideRootError({
239 workspaceRoot: input.cwd,
240 relativePath: input.relativePath,
241 });
242 }
243 if (path.isAbsolute(hostPath)) {
244 // Native realpath hands Windows a `\\?\` path as written, `..` included,
245 // so a network path must be the resolved one the check above approved.
246 // Others keep POSIX `link/..` semantics, which resolving first would change.
247 const absolutePath = isNetworkPath(hostPath) ? path.resolve(hostPath) : hostPath;
248 const realTargetPath = yield* Effect.tryPromise({
249 try: () => NodeFSP.realpath(absolutePath),
250 catch: (cause) =>
251 new WorkspaceFileSystemOperationError({
252 workspaceRoot: input.cwd,
253 relativePath: input.relativePath,
254 resolvedPath: absolutePath,
255 operationPath: absolutePath,
256 operation: "realpath-target",
257 cause,
258 }),
259 }).pipe(
260 Effect.catchIf(
261 (error) =>
262 input.allowMissing === true && (error.cause as NodeJS.ErrnoException).code === "ENOENT",
263 () => Effect.succeed(null),
264 ),
265 );
266 return { relativePath: requestedPath, realTargetPath };
267 }
268
269 const target = yield* workspacePaths.resolveRelativePathWithinRoot({
270 workspaceRoot: input.cwd,
271 relativePath: input.relativePath,
272 });
273
274 const realWorkspaceRoot = yield* Effect.tryPromise({
275 try: () => NodeFSP.realpath(path.resolve(input.cwd)),
276 catch: (cause) =>
277 new WorkspaceFileSystemOperationError({
278 workspaceRoot: input.cwd,
279 relativePath: input.relativePath,
280 resolvedPath: target.absolutePath,
281 operationPath: input.cwd,
282 operation: "realpath-workspace-root",
283 cause,
284 }),
285 });
286 const resolved = yield* Effect.tryPromise({
287 try: async () => {
288 let candidate = target.absolutePath;
289 for (;;) {
290 try {
291 return {
292 realTargetPath: path.join(
293 await NodeFSP.realpath(candidate),
294 path.relative(candidate, target.absolutePath),
295 ),
296 exists: candidate === target.absolutePath,
297 };
298 } catch (error) {
299 if (!input.allowMissing || (error as NodeJS.ErrnoException).code !== "ENOENT") {
300 throw error;
301 }
302 // Like writes, missing reads must check the nearest existing ancestor.
303 // An existing entry that cannot resolve is a dangling link, not absence.
304 const exists = await NodeFSP.lstat(candidate).then(
305 () => true,
306 (cause: NodeJS.ErrnoException) => {
307 if (cause.code !== "ENOENT") throw cause;
308 return false;
309 },
310 );
311 if (exists || path.dirname(candidate) === candidate) throw error;
312 candidate = path.dirname(candidate);
313 }
314 }
315 },
316 catch: (cause) =>
317 new WorkspaceFileSystemOperationError({
318 workspaceRoot: input.cwd,
319 relativePath: input.relativePath,
320 resolvedPath: target.absolutePath,
321 operationPath: target.absolutePath,
322 operation: "realpath-target",
323 cause,
324 }),
325 });
326 if (!isInsideRealRoot(realWorkspaceRoot, resolved.realTargetPath)) {
327 return yield* new WorkspaceFilePathEscapeError({
328 workspaceRoot: input.cwd,
329 relativePath: input.relativePath,
330 resolvedWorkspaceRoot: realWorkspaceRoot,
331 resolvedPath: resolved.realTargetPath,
332 });
333 }
334 return {
335 relativePath: target.relativePath,
336 realTargetPath: resolved.exists ? resolved.realTargetPath : null,
337 };
338 });
339
340 const readFile: WorkspaceFileSystem["Service"]["readFile"] = Effect.fn(
341 "WorkspaceFileSystem.readFile",
342 )(function* (input) {
343 const target = yield* resolveReadTarget(input);
344 const absentResult = {
345 relativePath: target.relativePath,
346 contents: "",
347 byteLength: 0,
348 truncated: false,
349 exists: false,
350 };
351 const realTargetPath = target.realTargetPath;
352 if (realTargetPath === null) return absentResult;
353 if (yield* isServerInternalPath(realTargetPath)) {
354 return yield* new WorkspacePaths.WorkspacePathOutsideRootError({
355 workspaceRoot: input.cwd,
356 relativePath: input.relativePath,
357 });
358 }
359
360 return yield* Effect.acquireUseRelease(
361 Effect.tryPromise({
362 // Non-blocking opens prevent FIFO hangs before stat rejects them; Windows lacks the flag.
363 try: () =>
364 NodeFSP.open(
365 realTargetPath,
366 NodeFS.constants.O_RDONLY | (NodeFS.constants.O_NONBLOCK ?? 0),
367 ),
368 catch: (cause) =>
369 new WorkspaceFileSystemOperationError({
370 workspaceRoot: input.cwd,
371 relativePath: input.relativePath,
372 resolvedPath: realTargetPath,
373 operationPath: realTargetPath,
374 operation: "open",
375 cause,
376 }),
377 }).pipe(
378 Effect.catchIf(
379 (error) =>
380 input.allowMissing === true && (error.cause as NodeJS.ErrnoException).code === "ENOENT",
381 () => Effect.succeed(null),
382 ),
383 ),
384 (handle) =>
385 Effect.gen(function* () {
386 if (handle === null) return absentResult;
387 const stat = yield* Effect.tryPromise({
388 try: () => handle.stat(),
389 catch: (cause) =>
390 new WorkspaceFileSystemOperationError({
391 workspaceRoot: input.cwd,
392 relativePath: input.relativePath,
393 resolvedPath: realTargetPath,
394 operationPath: realTargetPath,
395 operation: "stat",
396 cause,
397 }),
398 });
399 if (!stat.isFile()) {
400 return yield* new WorkspacePathNotFileError({
401 workspaceRoot: input.cwd,
402 relativePath: input.relativePath,
403 resolvedPath: realTargetPath,
404 });
405 }
406
407 const bytesToRead = Math.min(stat.size, PROJECT_READ_FILE_MAX_BYTES);
408 const buffer = Buffer.alloc(bytesToRead);
409 let offset = 0;
410 while (offset < bytesToRead) {
411 const { bytesRead } = yield* Effect.tryPromise({
412 try: () => handle.read(buffer, offset, bytesToRead - offset, offset),
413 catch: (cause) =>
414 new WorkspaceFileSystemOperationError({
415 workspaceRoot: input.cwd,
416 relativePath: input.relativePath,
417 resolvedPath: realTargetPath,
418 operationPath: realTargetPath,
419 operation: "read",
420 cause,
421 }),
422 });
423 if (bytesRead <= 0) break;
424 offset += bytesRead;
425 }
426 const fileBytes = buffer.subarray(0, offset);
427 if (fileBytes.includes(0)) {
428 return yield* new WorkspaceBinaryFileError({
429 workspaceRoot: input.cwd,
430 relativePath: input.relativePath,
431 resolvedPath: realTargetPath,
432 });
433 }
434
435 const truncated = stat.size > PROJECT_READ_FILE_MAX_BYTES;
436 return {
437 relativePath: target.relativePath,
438 // Streaming decode drops a character the byte limit split, instead of ending in U+FFFD.
439 contents: new TextDecoder("utf-8").decode(fileBytes, { stream: truncated }),
440 byteLength: stat.size,
441 truncated,
442 };
443 }),
444 (handle) =>
445 handle === null
446 ? Effect.void
447 : Effect.tryPromise({
448 try: () => handle.close(),
449 catch: (cause) =>
450 new WorkspaceFileSystemOperationError({
451 workspaceRoot: input.cwd,
452 relativePath: input.relativePath,
453 resolvedPath: realTargetPath,
454 operationPath: realTargetPath,
455 operation: "close",
456 cause,
457 }),
458 }),
459 );
460 });
461
462 // Resolve existing ancestors and reject dangling links so creation cannot follow symlinks outside root.
463 const resolveWriteTarget = Effect.fn("WorkspaceFileSystem.resolveWriteTarget")(function* (
464 input: ProjectWriteFileInput,
465 ) {
466 const target = yield* workspacePaths.resolveRelativePathWithinRoot({
467 workspaceRoot: input.cwd,
468 relativePath: input.relativePath,
469 });
470 const realWorkspaceRoot = yield* Effect.tryPromise({
471 // Resolve `..` in cwd the way the relative path was resolved, before following links.
472 try: () => NodeFSP.realpath(path.resolve(input.cwd)),
473 catch: (cause) =>
474 new WorkspaceFileSystemOperationError({
475 workspaceRoot: input.cwd,
476 relativePath: input.relativePath,
477 resolvedPath: target.absolutePath,
478 operationPath: input.cwd,
479 operation: "realpath-workspace-root",
480 cause,
481 }),
482 });
483
484 // Split the host path, not the display relativePath: on POSIX `\` is a file name character.
485 const segments = path.relative(input.cwd, target.absolutePath).split(path.sep);
486 const realTargetPath = yield* Effect.tryPromise({
487 try: async () => {
488 for (let depth = segments.length; depth > 0; depth--) {
489 const candidate = path.join(realWorkspaceRoot, ...segments.slice(0, depth));
490 try {
491 return path.join(await NodeFSP.realpath(candidate), ...segments.slice(depth));
492 } catch (error) {
493 // Only a missing entry is skipped; one that exists but cannot resolve is a dangling link.
494 const exists = await NodeFSP.lstat(candidate).then(
495 () => true,
496 () => false,
497 );
498 if ((error as NodeJS.ErrnoException).code !== "ENOENT" || exists) throw error;
499 }
500 }
501 return path.join(realWorkspaceRoot, ...segments);
502 },
503 catch: (cause) =>
504 new WorkspaceFileSystemOperationError({
505 workspaceRoot: input.cwd,
506 relativePath: input.relativePath,
507 resolvedPath: target.absolutePath,
508 operationPath: target.absolutePath,
509 operation: "realpath-target",
510 cause,
511 }),
512 });
513 // The path as named is checked too: a `.git/config` symlink resolves away the `.git`.
514 const namedTargetPath = path.join(realWorkspaceRoot, ...segments);
515 const inGitFolder = Effect.fn("WorkspaceFileSystem.inGitFolder")((checkedPath: string) =>
516 Effect.tryPromise({
517 try: () => isInGitFolder(checkedPath),
518 catch: (cause) =>
519 new WorkspaceFileSystemOperationError({
520 workspaceRoot: input.cwd,
521 relativePath: input.relativePath,
522 resolvedPath: realTargetPath,
523 operationPath: checkedPath,
524 operation: "stat",
525 cause,
526 }),
527 }),
528 );
529 if (
530 !isInsideRealRoot(realWorkspaceRoot, realTargetPath) ||
531 (yield* inGitFolder(realTargetPath)) ||
532 (yield* inGitFolder(namedTargetPath))
533 ) {
534 return yield* new WorkspaceFilePathEscapeError({
535 workspaceRoot: input.cwd,
536 relativePath: input.relativePath,
537 resolvedWorkspaceRoot: realWorkspaceRoot,
538 resolvedPath: realTargetPath,
539 });
540 }
541 return { relativePath: target.relativePath, realTargetPath };
542 });
543
544 const writeFile: WorkspaceFileSystem["Service"]["writeFile"] = Effect.fn(
545 "WorkspaceFileSystem.writeFile",
546 )(function* (input) {
547 yield* knownWorkspaces.assertContains(input.cwd);
548 const target = yield* resolveWriteTarget(input);
549 const realTargetPath = target.realTargetPath;
550 const replacesFile = yield* fileSystem
551 .exists(realTargetPath)
552 .pipe(Effect.orElseSucceed(() => false));
553
554 yield* fileSystem.makeDirectory(path.dirname(realTargetPath), { recursive: true }).pipe(
555 Effect.mapError(
556 (cause) =>
557 new WorkspaceFileSystemOperationError({
558 workspaceRoot: input.cwd,
559 relativePath: input.relativePath,
560 resolvedPath: realTargetPath,
561 operationPath: path.dirname(realTargetPath),
562 operation: "make-directory",
563 cause,
564 }),
565 ),
566 );
567 yield* Effect.tryPromise({
568 // A symlink swapped in after the check is not followed. Windows lacks O_NOFOLLOW.
569 try: () =>
570 NodeFSP.writeFile(realTargetPath, input.contents, {
571 flag:
572 NodeFS.constants.O_WRONLY |
573 NodeFS.constants.O_CREAT |
574 NodeFS.constants.O_TRUNC |
575 (NodeFS.constants.O_NOFOLLOW ?? 0),
576 }),
577 catch: (cause) =>
578 new WorkspaceFileSystemOperationError({
579 workspaceRoot: input.cwd,
580 relativePath: input.relativePath,
581 resolvedPath: realTargetPath,
582 operationPath: realTargetPath,
583 operation: "write-file",
584 cause,
585 }),
586 });
587 // Editor autosave overwrites existing files, which leaves the indexed paths unchanged, and
588 // fff's watcher applies the new contents on its own. A forced rescan re-walks the whole
589 // workspace, and the save would wait for it.
590 if (!replacesFile) {
591 yield* workspaceEntries.refresh(input.cwd);
592 }
593 return { relativePath: target.relativePath };
594 });
595
596 return WorkspaceFileSystem.of({ getMetadata, readFile, writeFile });
597});
598
599export const layer = Layer.effect(WorkspaceFileSystem, make);