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 | import * as Effect from "effect/Effect"; | 1 | import * as Effect from "effect/Effect"; |
| 5 | import * as FileSystem from "effect/FileSystem"; | 2 | import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Layer from "effect/Layer"; | 3 | import * as Layer from "effect/Layer"; |
| @@ -16,6 +13,25 @@ import { WorkspacePaths } from "../Services/WorkspacePaths.ts"; · 9 unmodified lines | |||
| 16 | 13 | ||
| 17 | const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; | 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 | export const makeWorkspaceFileSystem = Effect.gen(function* () { | 35 | export const makeWorkspaceFileSystem = Effect.gen(function* () { |
| 20 | const fileSystem = yield* FileSystem.FileSystem; | 36 | const fileSystem = yield* FileSystem.FileSystem; |
| 21 | const path = yield* Path.Path; | 37 | const path = yield* Path.Path; |
| @@ -29,54 +45,92 @@ export const makeWorkspaceFileSystem = Effect.gen(function* () { · 7 unmodified lines | |||
| 29 | relativePath: input.relativePath, | 45 | relativePath: input.relativePath, |
| 30 | }); | 46 | }); |
| 31 | 47 | ||
| 32 | const result = yield* Effect.tryPromise({ | 48 | const [realWorkspaceRoot, realTargetPath] = yield* Effect.all([ |
| 33 | try: async () => { | 49 | fileSystem.realPath(input.cwd), |
| 34 | const [realWorkspaceRoot, realTargetPath] = await Promise.all([ | 50 | fileSystem.realPath(target.absolutePath), |
| 35 | fsPromises.realpath(input.cwd), | 51 | ]).pipe( |
| 36 | fsPromises.realpath(target.absolutePath), | 52 | Effect.mapError((cause) => |
| 37 | ]); | 53 | makeReadFileError({ |
| 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({ | ||
| 73 | cwd: input.cwd, | 54 | cwd: input.cwd, |
| 74 | relativePath: input.relativePath, | 55 | relativePath: input.relativePath, |
| 75 | operation: "workspaceFileSystem.readFile", | 56 | detail: causeMessage(cause), |
| 76 | detail: cause instanceof Error ? cause.message : String(cause), | ||
| 77 | cause, | 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 | return result; | 135 | return result; |
| 82 | }, | 136 | }, |