History

[codex] Structure workspace file system errors (#3274)

Julius Marminge committed 4 months ago

Commit cdfecb31 parent f4ef3568 files · +707 −119Browse files

Showing the changes to apps/server/src/workspace/WorkspaceFileSystem.ts (1 of 8 files). Show all

Unified · Split

apps/server/src/workspace/WorkspaceFileSystem.ts+177−49
@@ -27,25 +27,79 @@ import * as WorkspacePaths from "./WorkspacePaths.ts"; · 26 unmodified lines
27 27
28const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024;28const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024;
29 29
30export class WorkspaceFileSystemError extends Schema.TaggedErrorClass<WorkspaceFileSystemError>()(30export class WorkspaceFileSystemOperationError extends Schema.TaggedErrorClass<WorkspaceFileSystemOperationError>()(
31 "WorkspaceFileSystemError",31 "WorkspaceFileSystemOperationError",
32 {32 {
33 cwd: Schema.String,33 workspaceRoot: Schema.String,
34 relativePath: Schema.optional(Schema.String),34 relativePath: Schema.String,
35 resolvedPath: Schema.String,
36 operationPath: Schema.String,
35 operation: Schema.Literals([37 operation: Schema.Literals([
36 "workspaceFileSystem.readFile",38 "realpath-workspace-root",
37 "workspaceFileSystem.makeDirectory",39 "realpath-target",
38 "workspaceFileSystem.writeFile",40 "open",
41 "stat",
42 "read",
43 "close",
44 "make-directory",
45 "write-file",
39 ]),46 ]),
40 cause: Schema.Defect(),47 cause: Schema.Defect(),
41 },48 },
42) {49) {
43 override get message(): string {50 override get message(): string {
44 const target = this.relativePath ? `'${this.relativePath}' in '${this.cwd}'` : `'${this.cwd}'`;51 return `Workspace file operation '${this.operation}' failed at '${this.operationPath}' for resolved path '${this.resolvedPath}' (requested as '${this.relativePath}' in '${this.workspaceRoot}').`;
45 return `Workspace file operation '${this.operation}' failed for ${target}.`;
46 }52 }
47}53}
48 54
55export class WorkspaceFilePathEscapeError extends Schema.TaggedErrorClass<WorkspaceFilePathEscapeError>()(
56 "WorkspaceFilePathEscapeError",
57 {
58 workspaceRoot: Schema.String,
59 relativePath: Schema.String,
60 resolvedWorkspaceRoot: Schema.String,
61 resolvedPath: Schema.String,
62 },
63) {
64 override get message(): string {
65 return `Workspace file '${this.relativePath}' resolves outside workspace root '${this.workspaceRoot}': ${this.resolvedPath}`;
66 }
67}
68
69export class WorkspacePathNotFileError extends Schema.TaggedErrorClass<WorkspacePathNotFileError>()(
70 "WorkspacePathNotFileError",
71 {
72 workspaceRoot: Schema.String,
73 relativePath: Schema.String,
74 resolvedPath: Schema.String,
75 },
76) {
77 override get message(): string {
78 return `Workspace path '${this.relativePath}' in '${this.workspaceRoot}' is not a file: ${this.resolvedPath}`;
79 }
80}
81
82export class WorkspaceBinaryFileError extends Schema.TaggedErrorClass<WorkspaceBinaryFileError>()(
83 "WorkspaceBinaryFileError",
84 {
85 workspaceRoot: Schema.String,
86 relativePath: Schema.String,
87 resolvedPath: Schema.String,
88 },
89) {
90 override get message(): string {
91 return `Workspace file '${this.relativePath}' in '${this.workspaceRoot}' is binary and cannot be previewed as text.`;
92 }
93}
94
95export const WorkspaceFileSystemError = Schema.Union([
96 WorkspaceFileSystemOperationError,
97 WorkspaceFilePathEscapeError,
98 WorkspacePathNotFileError,
99 WorkspaceBinaryFileError,
100]);
101export type WorkspaceFileSystemError = typeof WorkspaceFileSystemError.Type;
102
49/** Service tag for workspace file operations. */103/** Service tag for workspace file operations. */
50export class WorkspaceFileSystem extends Context.Service<104export class WorkspaceFileSystem extends Context.Service<
51 WorkspaceFileSystem,105 WorkspaceFileSystem,
@@ -86,53 +140,123 @@ export const make = Effect.gen(function* () { · 34 unmodified lines
86 relativePath: input.relativePath,140 relativePath: input.relativePath,
87 });141 });
88 142
89 return yield* Effect.tryPromise({143 const realWorkspaceRoot = yield* Effect.tryPromise({
90 try: async () => {144 try: () => NodeFSP.realpath(input.cwd),
91 const [realWorkspaceRoot, realTargetPath] = await Promise.all([145 catch: (cause) =>
92 NodeFSP.realpath(input.cwd),146 new WorkspaceFileSystemOperationError({
93 NodeFSP.realpath(target.absolutePath),147 workspaceRoot: input.cwd,
94 ]);148 relativePath: input.relativePath,
95 const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath);149 resolvedPath: target.absolutePath,
96 if (150 operationPath: input.cwd,
97 relativeRealPath.startsWith(`..${path.sep}`) ||151 operation: "realpath-workspace-root",
98 relativeRealPath === ".." ||152 cause,
99 path.isAbsolute(relativeRealPath)153 }),
100 ) {154 });
101 throw new Error("Workspace file path resolves outside the project root.");155 const realTargetPath = yield* Effect.tryPromise({
102 }156 try: () => NodeFSP.realpath(target.absolutePath),
103 157 catch: (cause) =>
104 const handle = await NodeFSP.open(realTargetPath, "r");158 new WorkspaceFileSystemOperationError({
105 try {159 workspaceRoot: input.cwd,
106 const stat = await handle.stat();160 relativePath: input.relativePath,
161 resolvedPath: target.absolutePath,
162 operationPath: target.absolutePath,
163 operation: "realpath-target",
164 cause,
165 }),
166 });
167 const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath);
168 if (
169 relativeRealPath.startsWith(`..${path.sep}`) ||
170 relativeRealPath === ".." ||
171 path.isAbsolute(relativeRealPath)
172 ) {
173 return yield* new WorkspaceFilePathEscapeError({
174 workspaceRoot: input.cwd,
175 relativePath: input.relativePath,
176 resolvedWorkspaceRoot: realWorkspaceRoot,
177 resolvedPath: realTargetPath,
178 });
179 }
180
181 return yield* Effect.acquireUseRelease(
182 Effect.tryPromise({
183 try: () => NodeFSP.open(realTargetPath, "r"),
184 catch: (cause) =>
185 new WorkspaceFileSystemOperationError({
186 workspaceRoot: input.cwd,
187 relativePath: input.relativePath,
188 resolvedPath: realTargetPath,
189 operationPath: realTargetPath,
190 operation: "open",
191 cause,
192 }),
193 }),
194 (handle) =>
195 Effect.gen(function* () {
196 const stat = yield* Effect.tryPromise({
197 try: () => handle.stat(),
198 catch: (cause) =>
199 new WorkspaceFileSystemOperationError({
200 workspaceRoot: input.cwd,
201 relativePath: input.relativePath,
202 resolvedPath: realTargetPath,
203 operationPath: realTargetPath,
204 operation: "stat",
205 cause,
206 }),
207 });
107 if (!stat.isFile()) {208 if (!stat.isFile()) {
108 throw new Error("Workspace path is not a file.");209 return yield* new WorkspacePathNotFileError({
210 workspaceRoot: input.cwd,
211 relativePath: input.relativePath,
212 resolvedPath: realTargetPath,
213 });
109 }214 }
215
110 const bytesToRead = Math.min(stat.size, PROJECT_READ_FILE_MAX_BYTES);216 const bytesToRead = Math.min(stat.size, PROJECT_READ_FILE_MAX_BYTES);
111 const buffer = Buffer.alloc(bytesToRead);217 const buffer = Buffer.alloc(bytesToRead);
112 const { bytesRead } = await handle.read(buffer, 0, bytesToRead, 0);218 const { bytesRead } = yield* Effect.tryPromise({
219 try: () => handle.read(buffer, 0, bytesToRead, 0),
220 catch: (cause) =>
221 new WorkspaceFileSystemOperationError({
222 workspaceRoot: input.cwd,
223 relativePath: input.relativePath,
224 resolvedPath: realTargetPath,
225 operationPath: realTargetPath,
226 operation: "read",
227 cause,
228 }),
229 });
113 const fileBytes = buffer.subarray(0, bytesRead);230 const fileBytes = buffer.subarray(0, bytesRead);
114 if (fileBytes.includes(0)) {231 if (fileBytes.includes(0)) {
115 throw new Error("Binary files cannot be previewed as text.");232 return yield* new WorkspaceBinaryFileError({
233 workspaceRoot: input.cwd,
234 relativePath: input.relativePath,
235 resolvedPath: realTargetPath,
236 });
116 }237 }
117 const contents = new TextDecoder("utf-8").decode(fileBytes);238
118 return {239 return {
119 relativePath: target.relativePath,240 relativePath: target.relativePath,
120 contents,241 contents: new TextDecoder("utf-8").decode(fileBytes),
121 byteLength: stat.size,242 byteLength: stat.size,
122 truncated: stat.size > PROJECT_READ_FILE_MAX_BYTES,243 truncated: stat.size > PROJECT_READ_FILE_MAX_BYTES,
123 };244 };
124 } finally {
125 await handle.close();
126 }
127 },
128 catch: (cause) =>
129 new WorkspaceFileSystemError({
130 cwd: input.cwd,
131 relativePath: input.relativePath,
132 operation: "workspaceFileSystem.readFile",
133 cause,
134 }),245 }),
135 });246 (handle) =>
247 Effect.tryPromise({
248 try: () => handle.close(),
249 catch: (cause) =>
250 new WorkspaceFileSystemOperationError({
251 workspaceRoot: input.cwd,
252 relativePath: input.relativePath,
253 resolvedPath: realTargetPath,
254 operationPath: realTargetPath,
255 operation: "close",
256 cause,
257 }),
258 }),
259 );
136 });260 });
137 261
138 const writeFile: WorkspaceFileSystem["Service"]["writeFile"] = Effect.fn(262 const writeFile: WorkspaceFileSystem["Service"]["writeFile"] = Effect.fn(
@@ -146,10 +270,12 @@ export const make = Effect.gen(function* () { · 7 unmodified lines
146 yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe(270 yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe(
147 Effect.mapError(271 Effect.mapError(
148 (cause) =>272 (cause) =>
149 new WorkspaceFileSystemError({273 new WorkspaceFileSystemOperationError({
150 cwd: input.cwd,274 workspaceRoot: input.cwd,
151 relativePath: input.relativePath,275 relativePath: input.relativePath,
152 operation: "workspaceFileSystem.makeDirectory",276 resolvedPath: target.absolutePath,
277 operationPath: path.dirname(target.absolutePath),
278 operation: "make-directory",
153 cause,279 cause,
154 }),280 }),
155 ),281 ),
@@ -157,10 +283,12 @@ export const make = Effect.gen(function* () { · 1 unmodified line
157 yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe(283 yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe(
158 Effect.mapError(284 Effect.mapError(
159 (cause) =>285 (cause) =>
160 new WorkspaceFileSystemError({286 new WorkspaceFileSystemOperationError({
161 cwd: input.cwd,287 workspaceRoot: input.cwd,
162 relativePath: input.relativePath,288 relativePath: input.relativePath,
163 operation: "workspaceFileSystem.writeFile",289 resolvedPath: target.absolutePath,
290 operationPath: target.absolutePath,
291 operation: "write-file",
164 cause,292 cause,
165 }),293 }),
166 ),294 ),