Showing the changes to apps/server/src/workspace/Layers/WorkspaceFileSystem.ts (1 of 4 files). Show all
Unified · Split
| @@ -1,6 +1,3 @@ | ||
| 1 | −// @effect-diagnostics nodeBuiltinImport:off | |
| 2 | −import fsPromises from "node:fs/promises"; | |
| 3 | − | |
| 4 | 1 | import * as Effect from "effect/Effect"; |
| 5 | 2 | import * as FileSystem from "effect/FileSystem"; |
| 6 | 3 | import * as Layer from "effect/Layer"; |
| @@ -16,6 +13,25 @@ import { WorkspacePaths } from "../Services/WorkspacePaths.ts"; · 9 unmodified lines | ||
| 16 | 13 | |
| 17 | 14 | const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; |
| 18 | 15 | |
| 16 | +function makeReadFileError(input: { | |
| 17 | + readonly cwd: string; | |
| 18 | + readonly relativePath: string; | |
| 19 | + readonly detail: string; | |
| 20 | + readonly cause?: unknown; | |
| 21 | +}) { | |
| 22 | + return new WorkspaceFileSystemError({ | |
| 23 | + cwd: input.cwd, | |
| 24 | + relativePath: input.relativePath, | |
| 25 | + operation: "workspaceFileSystem.readFile", | |
| 26 | + detail: input.detail, | |
| 27 | + ...(input.cause === undefined ? {} : { cause: input.cause }), | |
| 28 | + }); | |
| 29 | +} | |
| 30 | + | |
| 31 | +function causeMessage(cause: { readonly message: string }) { | |
| 32 | + return cause.message; | |
| 33 | +} | |
| 34 | + | |
| 19 | 35 | export const makeWorkspaceFileSystem = Effect.gen(function* () { |
| 20 | 36 | const fileSystem = yield* FileSystem.FileSystem; |
| 21 | 37 | const path = yield* Path.Path; |
| @@ -29,54 +45,92 @@ export const makeWorkspaceFileSystem = Effect.gen(function* () { · 7 unmodified lines | ||
| 29 | 45 | relativePath: input.relativePath, |
| 30 | 46 | }); |
| 31 | 47 | |
| 32 | − const result = yield* Effect.tryPromise({ | |
| 33 | − try: async () => { | |
| 34 | − const [realWorkspaceRoot, realTargetPath] = await Promise.all([ | |
| 35 | − fsPromises.realpath(input.cwd), | |
| 36 | − fsPromises.realpath(target.absolutePath), | |
| 37 | − ]); | |
| 38 | − const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath); | |
| 39 | − if ( | |
| 40 | − relativeRealPath.startsWith(`..${path.sep}`) || | |
| 41 | − relativeRealPath === ".." || | |
| 42 | − path.isAbsolute(relativeRealPath) | |
| 43 | − ) { | |
| 44 | − throw new Error("Workspace file path resolves outside the project root."); | |
| 45 | − } | |
| 46 | − | |
| 47 | − const handle = await fsPromises.open(realTargetPath, "r"); | |
| 48 | − try { | |
| 49 | − const stat = await handle.stat(); | |
| 50 | − if (!stat.isFile()) { | |
| 51 | − throw new Error("Workspace path is not a file."); | |
| 52 | − } | |
| 53 | − const bytesToRead = Math.min(stat.size, PROJECT_READ_FILE_MAX_BYTES); | |
| 54 | − const buffer = Buffer.alloc(bytesToRead); | |
| 55 | − const { bytesRead } = await handle.read(buffer, 0, bytesToRead, 0); | |
| 56 | − const fileBytes = buffer.subarray(0, bytesRead); | |
| 57 | − if (fileBytes.includes(0)) { | |
| 58 | − throw new Error("Binary files cannot be previewed as text."); | |
| 59 | − } | |
| 60 | − const contents = new TextDecoder("utf-8").decode(fileBytes); | |
| 61 | − return { | |
| 62 | − relativePath: target.relativePath, | |
| 63 | − contents, | |
| 64 | − byteLength: stat.size, | |
| 65 | − truncated: stat.size > PROJECT_READ_FILE_MAX_BYTES, | |
| 66 | − }; | |
| 67 | − } finally { | |
| 68 | − await handle.close(); | |
| 69 | − } | |
| 70 | − }, | |
| 71 | − catch: (cause) => | |
| 72 | − new WorkspaceFileSystemError({ | |
| 48 | + const [realWorkspaceRoot, realTargetPath] = yield* Effect.all([ | |
| 49 | + fileSystem.realPath(input.cwd), | |
| 50 | + fileSystem.realPath(target.absolutePath), | |
| 51 | + ]).pipe( | |
| 52 | + Effect.mapError((cause) => | |
| 53 | + makeReadFileError({ | |
| 73 | 54 | cwd: input.cwd, |
| 74 | 55 | relativePath: input.relativePath, |
| 75 | − operation: "workspaceFileSystem.readFile", | |
| 76 | − detail: cause instanceof Error ? cause.message : String(cause), | |
| 56 | + detail: causeMessage(cause), | |
| 77 | 57 | cause, |
| 78 | 58 | }), |
| 79 | − }); | |
| 59 | + ), | |
| 60 | + ); | |
| 61 | + | |
| 62 | + const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath); | |
| 63 | + if ( | |
| 64 | + relativeRealPath.startsWith(`..${path.sep}`) || | |
| 65 | + relativeRealPath === ".." || | |
| 66 | + path.isAbsolute(relativeRealPath) | |
| 67 | + ) { | |
| 68 | + return yield* makeReadFileError({ | |
| 69 | + cwd: input.cwd, | |
| 70 | + relativePath: input.relativePath, | |
| 71 | + detail: "Workspace file path resolves outside the project root.", | |
| 72 | + }); | |
| 73 | + } | |
| 74 | + | |
| 75 | + const result = yield* Effect.scoped( | |
| 76 | + Effect.gen(function* () { | |
| 77 | + const handle = yield* fileSystem.open(realTargetPath, { flag: "r" }).pipe( | |
| 78 | + Effect.mapError((cause) => | |
| 79 | + makeReadFileError({ | |
| 80 | + cwd: input.cwd, | |
| 81 | + relativePath: input.relativePath, | |
| 82 | + detail: causeMessage(cause), | |
| 83 | + cause, | |
| 84 | + }), | |
| 85 | + ), | |
| 86 | + ); | |
| 87 | + const stat = yield* handle.stat.pipe( | |
| 88 | + Effect.mapError((cause) => | |
| 89 | + makeReadFileError({ | |
| 90 | + cwd: input.cwd, | |
| 91 | + relativePath: input.relativePath, | |
| 92 | + detail: causeMessage(cause), | |
| 93 | + cause, | |
| 94 | + }), | |
| 95 | + ), | |
| 96 | + ); | |
| 97 | + if (stat.type !== "File") { | |
| 98 | + return yield* makeReadFileError({ | |
| 99 | + cwd: input.cwd, | |
| 100 | + relativePath: input.relativePath, | |
| 101 | + detail: "Workspace path is not a file.", | |
| 102 | + }); | |
| 103 | + } | |
| 104 | + const truncated = stat.size > FileSystem.Size(PROJECT_READ_FILE_MAX_BYTES); | |
| 105 | + const bytesToRead = truncated ? PROJECT_READ_FILE_MAX_BYTES : Number(stat.size); | |
| 106 | + const buffer = new Uint8Array(bytesToRead); | |
| 107 | + const bytesRead = yield* handle.read(buffer).pipe( | |
| 108 | + Effect.mapError((cause) => | |
| 109 | + makeReadFileError({ | |
| 110 | + cwd: input.cwd, | |
| 111 | + relativePath: input.relativePath, | |
| 112 | + detail: causeMessage(cause), | |
| 113 | + cause, | |
| 114 | + }), | |
| 115 | + ), | |
| 116 | + ); | |
| 117 | + const fileBytes = buffer.subarray(0, Number(bytesRead)); | |
| 118 | + if (fileBytes.includes(0)) { | |
| 119 | + return yield* makeReadFileError({ | |
| 120 | + cwd: input.cwd, | |
| 121 | + relativePath: input.relativePath, | |
| 122 | + detail: "Binary files cannot be previewed as text.", | |
| 123 | + }); | |
| 124 | + } | |
| 125 | + const contents = new TextDecoder("utf-8").decode(fileBytes); | |
| 126 | + return { | |
| 127 | + relativePath: target.relativePath, | |
| 128 | + contents, | |
| 129 | + byteLength: Number(stat.size), | |
| 130 | + truncated, | |
| 131 | + }; | |
| 132 | + }), | |
| 133 | + ); | |
| 80 | 134 | |
| 81 | 135 | return result; |
| 82 | 136 | }, |