Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 85 additions & 19 deletions apps/server/src/workspace/WorkspaceFileSystem.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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))),
Expand Down Expand Up @@ -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", () =>
Expand All @@ -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,
Expand All @@ -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.",
);
}),
);
Expand All @@ -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.",
);
}),
Expand All @@ -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");
}),
);

Expand All @@ -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,
);

Expand All @@ -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);
}),
);

Expand All @@ -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);
}),
);
});
Expand Down
129 changes: 90 additions & 39 deletions apps/server/src/workspace/WorkspaceFileSystem.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// @effect-diagnostics nodeBuiltinImport:off
/**
* WorkspaceFileSystem - Effect service contract for workspace file mutations.
*
Expand All @@ -7,8 +6,6 @@
*
* @module WorkspaceFileSystem
*/
import * as NodeFSP from "node:fs/promises";

import type {
ProjectReadFileInput,
ProjectReadFileResult,
Expand All @@ -27,6 +24,46 @@ import * as WorkspacePaths from "./WorkspacePaths.ts";

const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024;

class WorkspaceReadFileResolvedOutsideRootError extends Schema.TaggedErrorClass<WorkspaceReadFileResolvedOutsideRootError>()(
"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>()(
"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>()(
"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>()(
"WorkspaceFileSystemError",
{
Expand Down Expand Up @@ -86,53 +123,67 @@ 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 (
relativeRealPath.startsWith(`..${path.sep}`) ||
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(
Expand Down
Loading