diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index aa2dabb3337..5c1b694ea0c 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { it, describe, expect } from "@effect/vitest"; +import { assert, it, describe } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -12,6 +12,8 @@ import * as WorkspaceEntries from "./WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./WorkspaceFileSystem.ts"; import * as WorkspacePaths from "./WorkspacePaths.ts"; +const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; + const ProjectLayer = WorkspaceFileSystem.layer.pipe( Layer.provide(WorkspacePaths.layer), Layer.provide(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))), @@ -51,6 +53,20 @@ const writeTextFile = Effect.fn("writeTextFile")(function* ( yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie); }); +const writeBinaryFile = Effect.fn("writeBinaryFile")(function* ( + cwd: string, + relativePath: string, + contents: Uint8Array, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const absolutePath = path.join(cwd, relativePath); + yield* fileSystem + .makeDirectory(path.dirname(absolutePath), { recursive: true }) + .pipe(Effect.orDie); + yield* fileSystem.writeFile(absolutePath, contents).pipe(Effect.orDie); +}); + it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (it) => { describe("readFile", () => { it.effect("reads UTF-8 files relative to the workspace root", () => @@ -64,7 +80,7 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i relativePath: "src/index.ts", }); - expect(result).toEqual({ + assert.deepStrictEqual(result, { relativePath: "src/index.ts", contents: "export const answer = 42;\n", byteLength: 26, @@ -82,8 +98,52 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i .readFile({ cwd, relativePath: "../escape.md" }) .pipe(Effect.flip); - expect(error.message).toContain( - "Workspace file path must be relative to the project root: ../escape.md", + assert.match( + error.message, + /Workspace file path must be relative to the project root: \.\.\/escape\.md/, + ); + }), + ); + + it.effect("truncates text files larger than the preview byte limit", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "large.txt", `${"a".repeat(PROJECT_READ_FILE_MAX_BYTES)}tail`); + + const result = yield* workspaceFileSystem.readFile({ + cwd, + relativePath: "large.txt", + }); + + assert.strictEqual(result.byteLength, PROJECT_READ_FILE_MAX_BYTES + 4); + assert.strictEqual(result.contents.length, PROJECT_READ_FILE_MAX_BYTES); + assert.strictEqual(result.contents, "a".repeat(PROJECT_READ_FILE_MAX_BYTES)); + assert.strictEqual(result.truncated, true); + }), + ); + + it.effect("rejects binary files before decoding them as text", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const cwd = yield* makeTempDir; + yield* writeBinaryFile(cwd, "image.bin", new Uint8Array([0x66, 0x6f, 0x00, 0x6f])); + + const error = yield* workspaceFileSystem + .readFile({ cwd, relativePath: "image.bin" }) + .pipe(Effect.flip); + + assert.strictEqual( + error.message, + `Workspace file operation 'workspaceFileSystem.readFile' failed for 'image.bin' in '${cwd}'.`, + ); + assert.strictEqual( + (error.cause as { readonly _tag?: string })._tag, + "WorkspaceReadFileBinaryFileError", + ); + assert.strictEqual( + (error.cause as { readonly message?: string }).message, + "Binary files cannot be previewed as text.", ); }), ); @@ -105,11 +165,16 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i .readFile({ cwd, relativePath: "linked-secret.txt" }) .pipe(Effect.flip); - expect(error.message).toBe( + assert.strictEqual( + error.message, `Workspace file operation 'workspaceFileSystem.readFile' failed for 'linked-secret.txt' in '${cwd}'.`, ); - expect(error.cause).toBeInstanceOf(Error); - expect((error.cause as Error).message).toBe( + assert.strictEqual( + (error.cause as { readonly _tag?: string })._tag, + "WorkspaceReadFileResolvedOutsideRootError", + ); + assert.strictEqual( + (error.cause as { readonly message?: string }).message, "Workspace file path resolves outside the project root.", ); }), @@ -132,8 +197,8 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i .readFileString(path.join(cwd, "plans/effect-rpc.md")) .pipe(Effect.orDie); - expect(result).toEqual({ relativePath: "plans/effect-rpc.md" }); - expect(saved).toBe("# Plan\n"); + assert.deepStrictEqual(result, { relativePath: "plans/effect-rpc.md" }); + assert.strictEqual(saved, "# Plan\n"); }), ); @@ -145,7 +210,8 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i yield* writeTextFile(cwd, "src/existing.ts", "export {};\n"); const beforeWrite = yield* workspaceEntries.list({ cwd }); - expect(beforeWrite.entries.some((entry) => entry.path === "plans/effect-rpc.md")).toBe( + assert.strictEqual( + beforeWrite.entries.some((entry) => entry.path === "plans/effect-rpc.md"), false, ); @@ -156,10 +222,11 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }); const afterWrite = yield* workspaceEntries.list({ cwd }); - expect(afterWrite.entries).toEqual( - expect.arrayContaining([expect.objectContaining({ path: "plans/effect-rpc.md" })]), + assert.strictEqual( + afterWrite.entries.some((entry) => entry.path === "plans/effect-rpc.md"), + true, ); - expect(afterWrite.truncated).toBe(false); + assert.strictEqual(afterWrite.truncated, false); }), ); @@ -178,15 +245,14 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }) .pipe(Effect.flip); - expect(error.message).toContain( - "Workspace file path must be relative to the project root: ../escape.md", + assert.match( + error.message, + /Workspace file path must be relative to the project root: \.\.\/escape\.md/, ); const escapedPath = path.resolve(cwd, "..", "escape.md"); - const escapedStat = yield* fileSystem - .stat(escapedPath) - .pipe(Effect.orElseSucceed(() => null)); - expect(escapedStat).toBeNull(); + const escapedExists = yield* fileSystem.exists(escapedPath); + assert.strictEqual(escapedExists, false); }), ); }); diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index 8cd176db3dd..8f52c1985e5 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -1,4 +1,3 @@ -// @effect-diagnostics nodeBuiltinImport:off /** * WorkspaceFileSystem - Effect service contract for workspace file mutations. * @@ -7,8 +6,6 @@ * * @module WorkspaceFileSystem */ -import * as NodeFSP from "node:fs/promises"; - import type { ProjectReadFileInput, ProjectReadFileResult, @@ -27,6 +24,46 @@ import * as WorkspacePaths from "./WorkspacePaths.ts"; const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; +class WorkspaceReadFileResolvedOutsideRootError extends Schema.TaggedErrorClass()( + "WorkspaceReadFileResolvedOutsideRootError", + { + cwd: Schema.String, + relativePath: Schema.String, + realWorkspaceRoot: Schema.String, + realTargetPath: Schema.String, + }, +) { + override get message(): string { + return "Workspace file path resolves outside the project root."; + } +} + +class WorkspaceReadFileNotFileError extends Schema.TaggedErrorClass()( + "WorkspaceReadFileNotFileError", + { + cwd: Schema.String, + relativePath: Schema.String, + fileType: Schema.String, + }, +) { + override get message(): string { + return "Workspace path is not a file."; + } +} + +class WorkspaceReadFileBinaryFileError extends Schema.TaggedErrorClass()( + "WorkspaceReadFileBinaryFileError", + { + cwd: Schema.String, + relativePath: Schema.String, + nulByteOffset: Schema.Number, + }, +) { + override get message(): string { + return "Binary files cannot be previewed as text."; + } +} + export class WorkspaceFileSystemError extends Schema.TaggedErrorClass()( "WorkspaceFileSystemError", { @@ -86,11 +123,11 @@ export const make = Effect.gen(function* () { relativePath: input.relativePath, }); - return yield* Effect.tryPromise({ - try: async () => { - const [realWorkspaceRoot, realTargetPath] = await Promise.all([ - NodeFSP.realpath(input.cwd), - NodeFSP.realpath(target.absolutePath), + return yield* Effect.scoped( + Effect.gen(function* () { + const [realWorkspaceRoot, realTargetPath] = yield* Effect.all([ + fileSystem.realPath(input.cwd), + fileSystem.realPath(target.absolutePath), ]); const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath); if ( @@ -98,41 +135,55 @@ export const make = Effect.gen(function* () { relativeRealPath === ".." || path.isAbsolute(relativeRealPath) ) { - throw new Error("Workspace file path resolves outside the project root."); + return yield* new WorkspaceReadFileResolvedOutsideRootError({ + cwd: input.cwd, + relativePath: target.relativePath, + realWorkspaceRoot, + realTargetPath, + }); } - const handle = await NodeFSP.open(realTargetPath, "r"); - try { - const stat = await handle.stat(); - if (!stat.isFile()) { - throw new Error("Workspace path is not a file."); - } - const bytesToRead = Math.min(stat.size, PROJECT_READ_FILE_MAX_BYTES); - const buffer = Buffer.alloc(bytesToRead); - const { bytesRead } = await handle.read(buffer, 0, bytesToRead, 0); - const fileBytes = buffer.subarray(0, bytesRead); - if (fileBytes.includes(0)) { - throw new Error("Binary files cannot be previewed as text."); - } - const contents = new TextDecoder("utf-8").decode(fileBytes); - return { + const handle = yield* fileSystem.open(realTargetPath, { flag: "r" }); + const stat = yield* handle.stat; + if (stat.type !== "File") { + return yield* new WorkspaceReadFileNotFileError({ + cwd: input.cwd, relativePath: target.relativePath, - contents, - byteLength: stat.size, - truncated: stat.size > PROJECT_READ_FILE_MAX_BYTES, - }; - } finally { - await handle.close(); + fileType: stat.type, + }); } - }, - catch: (cause) => - new WorkspaceFileSystemError({ - cwd: input.cwd, - relativePath: input.relativePath, - operation: "workspaceFileSystem.readFile", - cause, - }), - }); + const byteLength = Number(stat.size); + const bytesToRead = Math.min(byteLength, PROJECT_READ_FILE_MAX_BYTES); + const buffer = new Uint8Array(bytesToRead); + const bytesRead = yield* handle.read(buffer); + const fileBytes = buffer.subarray(0, Number(bytesRead)); + const nulByteOffset = fileBytes.indexOf(0); + if (nulByteOffset !== -1) { + return yield* new WorkspaceReadFileBinaryFileError({ + cwd: input.cwd, + relativePath: target.relativePath, + nulByteOffset, + }); + } + const contents = new TextDecoder("utf-8").decode(fileBytes); + return { + relativePath: target.relativePath, + contents, + byteLength, + truncated: stat.size > BigInt(PROJECT_READ_FILE_MAX_BYTES), + }; + }), + ).pipe( + Effect.mapError( + (cause) => + new WorkspaceFileSystemError({ + cwd: input.cwd, + relativePath: input.relativePath, + operation: "workspaceFileSystem.readFile", + cause, + }), + ), + ); }); const writeFile: WorkspaceFileSystem["Service"]["writeFile"] = Effect.fn(