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
2727
2828 const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024;
2929
30−export class WorkspaceFileSystemError extends Schema.TaggedErrorClass<WorkspaceFileSystemError>()(
31− "WorkspaceFileSystemError",
30+export class WorkspaceFileSystemOperationError extends Schema.TaggedErrorClass<WorkspaceFileSystemOperationError>()(
31+ "WorkspaceFileSystemOperationError",
3232 {
33− cwd: Schema.String,
34− relativePath: Schema.optional(Schema.String),
33+ workspaceRoot: Schema.String,
34+ relativePath: Schema.String,
35+ resolvedPath: Schema.String,
36+ operationPath: Schema.String,
3537 operation: Schema.Literals([
36− "workspaceFileSystem.readFile",
37− "workspaceFileSystem.makeDirectory",
38− "workspaceFileSystem.writeFile",
38+ "realpath-workspace-root",
39+ "realpath-target",
40+ "open",
41+ "stat",
42+ "read",
43+ "close",
44+ "make-directory",
45+ "write-file",
3946 ]),
4047 cause: Schema.Defect(),
4148 },
4249 ) {
4350 override get message(): string {
44− const target = this.relativePath ? `'${this.relativePath}' in '${this.cwd}'` : `'${this.cwd}'`;
45− return `Workspace file operation '${this.operation}' failed for ${target}.`;
51+ return `Workspace file operation '${this.operation}' failed at '${this.operationPath}' for resolved path '${this.resolvedPath}' (requested as '${this.relativePath}' in '${this.workspaceRoot}').`;
4652 }
4753 }
4854
55+export 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+
69+export 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+
82+export 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+
95+export const WorkspaceFileSystemError = Schema.Union([
96+ WorkspaceFileSystemOperationError,
97+ WorkspaceFilePathEscapeError,
98+ WorkspacePathNotFileError,
99+ WorkspaceBinaryFileError,
100+]);
101+export type WorkspaceFileSystemError = typeof WorkspaceFileSystemError.Type;
102+
49103 /** Service tag for workspace file operations. */
50104 export class WorkspaceFileSystem extends Context.Service<
51105 WorkspaceFileSystem,
@@ -86,53 +140,123 @@ export const make = Effect.gen(function* () { · 34 unmodified lines
86140 relativePath: input.relativePath,
87141 });
88142
89− return yield* Effect.tryPromise({
90− try: async () => {
91− const [realWorkspaceRoot, realTargetPath] = await Promise.all([
92− NodeFSP.realpath(input.cwd),
93− NodeFSP.realpath(target.absolutePath),
94− ]);
95− const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath);
96− if (
97− relativeRealPath.startsWith(`..${path.sep}`) ||
98− relativeRealPath === ".." ||
99− path.isAbsolute(relativeRealPath)
100− ) {
101− throw new Error("Workspace file path resolves outside the project root.");
102− }
103−
104− const handle = await NodeFSP.open(realTargetPath, "r");
105− try {
106− const stat = await handle.stat();
143+ const realWorkspaceRoot = yield* Effect.tryPromise({
144+ try: () => NodeFSP.realpath(input.cwd),
145+ catch: (cause) =>
146+ new WorkspaceFileSystemOperationError({
147+ workspaceRoot: input.cwd,
148+ relativePath: input.relativePath,
149+ resolvedPath: target.absolutePath,
150+ operationPath: input.cwd,
151+ operation: "realpath-workspace-root",
152+ cause,
153+ }),
154+ });
155+ const realTargetPath = yield* Effect.tryPromise({
156+ try: () => NodeFSP.realpath(target.absolutePath),
157+ catch: (cause) =>
158+ new WorkspaceFileSystemOperationError({
159+ workspaceRoot: input.cwd,
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+ });
107208 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+ });
109214 }
215+
110216 const bytesToRead = Math.min(stat.size, PROJECT_READ_FILE_MAX_BYTES);
111217 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+ });
113230 const fileBytes = buffer.subarray(0, bytesRead);
114231 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+ });
116237 }
117− const contents = new TextDecoder("utf-8").decode(fileBytes);
238+
118239 return {
119240 relativePath: target.relativePath,
120− contents,
241+ contents: new TextDecoder("utf-8").decode(fileBytes),
121242 byteLength: stat.size,
122243 truncated: stat.size > PROJECT_READ_FILE_MAX_BYTES,
123244 };
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,
134245 }),
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+ );
136260 });
137261
138262 const writeFile: WorkspaceFileSystem["Service"]["writeFile"] = Effect.fn(
@@ -146,10 +270,12 @@ export const make = Effect.gen(function* () { · 7 unmodified lines
146270 yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe(
147271 Effect.mapError(
148272 (cause) =>
149− new WorkspaceFileSystemError({
150− cwd: input.cwd,
273+ new WorkspaceFileSystemOperationError({
274+ workspaceRoot: input.cwd,
151275 relativePath: input.relativePath,
152− operation: "workspaceFileSystem.makeDirectory",
276+ resolvedPath: target.absolutePath,
277+ operationPath: path.dirname(target.absolutePath),
278+ operation: "make-directory",
153279 cause,
154280 }),
155281 ),
@@ -157,10 +283,12 @@ export const make = Effect.gen(function* () { · 1 unmodified line
157283 yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe(
158284 Effect.mapError(
159285 (cause) =>
160− new WorkspaceFileSystemError({
161− cwd: input.cwd,
286+ new WorkspaceFileSystemOperationError({
287+ workspaceRoot: input.cwd,
162288 relativePath: input.relativePath,
163− operation: "workspaceFileSystem.writeFile",
289+ resolvedPath: target.absolutePath,
290+ operationPath: target.absolutePath,
291+ operation: "write-file",
164292 cause,
165293 }),
166294 ),