Skip to content
Merged
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
93 changes: 73 additions & 20 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4430,7 +4430,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive),
);

it.effect("preserves workspace rpc failure messages", () =>
it.effect("preserves structured workspace rpc failures", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
Expand All @@ -4443,18 +4443,20 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
const outsideFile = path.join(outsideDir, "outside.txt");
yield* fs.writeFileString(outsideFile, "outside\n");
yield* fs.symlink(outsideFile, path.join(workspaceDir, "linked-outside.txt"));
const resolvedOutsideFile = yield* fs.realPath(outsideFile);

yield* buildAppUnderTest();

const invalidWorkspace = path.join(workspaceDir, "missing-workspace");
const missingBrowseParent = path.join(workspaceDir, "missing-browse");
const sensitiveQuery = "authorization: Bearer secret-token";
const wsUrl = yield* getWsServerUrl("/ws");
const results = yield* Effect.scoped(
withWsRpcClient(wsUrl, (client) =>
Effect.all({
search: client[WS_METHODS.projectsSearchEntries]({
cwd: invalidWorkspace,
query: "needle",
query: sensitiveQuery,
limit: 10,
}).pipe(Effect.result),
list: client[WS_METHODS.projectsListEntries]({ cwd: invalidWorkspace }).pipe(
Expand All @@ -4472,26 +4474,70 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
),
);

assertTrue(results.search._tag === "Failure");
assert.equal(
results.search.failure.message,
`Failed to search workspace entries: Workspace root does not exist: ${invalidWorkspace}`,
);
assertTrue(results.list._tag === "Failure");
if (
results.search._tag !== "Failure" ||
results.search.failure._tag !== "ProjectSearchEntriesError"
) {
assert.fail("Expected a ProjectSearchEntriesError");
}
const searchError = results.search.failure;
assert.equal(
results.list.failure.message,
`Failed to list workspace entries: Workspace root does not exist: ${invalidWorkspace}`,
searchError.message,
`Failed to search workspace entries in '${invalidWorkspace}'.`,
);
assertTrue(results.read._tag === "Failure");
assert.equal(searchError.cwd, invalidWorkspace);
assert.equal(searchError.queryLength, sensitiveQuery.length);
assert.notProperty(searchError, "query");
assert.notInclude(searchError.message, "Bearer");
assert.notInclude(searchError.message, "secret-token");
assert.equal(searchError.limit, 10);
assert.equal(searchError.failure, "workspace_root_not_found");
assert.equal(searchError.normalizedCwd, invalidWorkspace);
assert.isDefined(searchError.cause);

if (
results.list._tag !== "Failure" ||
results.list.failure._tag !== "ProjectListEntriesError"
) {
assert.fail("Expected a ProjectListEntriesError");
}
const listError = results.list.failure;
assert.equal(listError.message, `Failed to list workspace entries in '${invalidWorkspace}'.`);
assert.equal(listError.cwd, invalidWorkspace);
assert.equal(listError.failure, "workspace_root_not_found");
assert.equal(listError.normalizedCwd, invalidWorkspace);
assert.isDefined(listError.cause);

if (results.read._tag !== "Failure" || results.read.failure._tag !== "ProjectReadFileError") {
assert.fail("Expected a ProjectReadFileError");
}
const readError = results.read.failure;
assert.equal(
results.read.failure.message,
"Failed to read workspace file: Workspace file path resolves outside the project root.",
readError.message,
`Failed to read workspace file 'linked-outside.txt' in '${workspaceDir}'.`,
);
assertTrue(results.browse._tag === "Failure");
assert.equal(readError.cwd, workspaceDir);
assert.equal(readError.relativePath, "linked-outside.txt");
assert.equal(readError.failure, "resolved_path_outside_root");
assert.equal(readError.resolvedPath, resolvedOutsideFile);
assert.isDefined(readError.cause);

if (
results.browse._tag !== "Failure" ||
results.browse.failure._tag !== "FilesystemBrowseError"
) {
assert.fail("Expected a FilesystemBrowseError");
}
const browseError = results.browse.failure;
assert.equal(
results.browse.failure.message,
`Unable to browse '${missingBrowseParent}': ENOENT: no such file or directory, scandir '${missingBrowseParent}'`,
browseError.message,
`Failed to browse filesystem path './missing-browse/child' from '${workspaceDir}'.`,
);
assert.equal(browseError.cwd, workspaceDir);
assert.equal(browseError.partialPath, "./missing-browse/child");
assert.equal(browseError.failure, "read_directory_failed");
assert.equal(browseError.parentPath, missingBrowseParent);
assert.isDefined(browseError.cause);
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

Expand Down Expand Up @@ -4572,12 +4618,19 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
).pipe(Effect.result),
);

assertTrue(result._tag === "Failure");
assertTrue(result.failure._tag === "ProjectWriteFileError");
if (result._tag !== "Failure" || result.failure._tag !== "ProjectWriteFileError") {
assert.fail("Expected a ProjectWriteFileError");
}
const writeError = result.failure;
assert.equal(
result.failure.message,
"Workspace file path must stay within the project root.",
writeError.message,
`Failed to write workspace file '../escape.txt' in '${workspaceDir}'.`,
);
assert.equal(writeError.cwd, workspaceDir);
assert.equal(writeError.relativePath, "../escape.txt");
assert.equal(writeError.failure, "workspace_path_outside_root");
assert.isDefined(writeError.cause);
assert.notProperty(writeError, "contents");
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

Expand Down
87 changes: 81 additions & 6 deletions apps/server/src/workspace/WorkspaceFileSystem.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,14 +104,89 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i
const error = yield* workspaceFileSystem
.readFile({ cwd, relativePath: "linked-secret.txt" })
.pipe(Effect.flip);
const resolvedWorkspaceRoot = yield* fileSystem.realPath(cwd);
const resolvedPath = yield* fileSystem.realPath(path.join(outsideDir, "secret.txt"));

expect(error.message).toBe(
`Workspace file operation 'workspaceFileSystem.readFile' failed for 'linked-secret.txt' in '${cwd}'.`,
);
expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFilePathEscapeError);
expect(error).toMatchObject({
workspaceRoot: cwd,
relativePath: "linked-secret.txt",
resolvedWorkspaceRoot,
resolvedPath,
});
expect("cause" in error).toBe(false);
}),
);

it.effect("rejects directories without manufacturing an I/O cause", () =>
Effect.gen(function* () {
const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const cwd = yield* makeTempDir;
yield* fileSystem.makeDirectory(path.join(cwd, "src"));

const error = yield* workspaceFileSystem
.readFile({ cwd, relativePath: "src" })
.pipe(Effect.flip);
const resolvedPath = yield* fileSystem.realPath(path.join(cwd, "src"));

expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspacePathNotFileError);
expect(error).toMatchObject({
workspaceRoot: cwd,
relativePath: "src",
resolvedPath,
});
expect("cause" in error).toBe(false);
}),
);

it.effect("rejects binary files without leaking their contents into the error", () =>
Effect.gen(function* () {
const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const cwd = yield* makeTempDir;
const absolutePath = path.join(cwd, "asset.bin");
yield* fileSystem.writeFile(absolutePath, Uint8Array.from([0x61, 0, 0x62]));

const error = yield* workspaceFileSystem
.readFile({ cwd, relativePath: "asset.bin" })
.pipe(Effect.flip);
const resolvedPath = yield* fileSystem.realPath(absolutePath);

expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceBinaryFileError);
expect(error).toMatchObject({
workspaceRoot: cwd,
relativePath: "asset.bin",
resolvedPath,
});
expect("cause" in error).toBe(false);
expect("contents" in error).toBe(false);
}),
);

it.effect("preserves the real cause and path for I/O failures", () =>
Effect.gen(function* () {
const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
const path = yield* Path.Path;
const cwd = yield* makeTempDir;
const resolvedPath = path.join(cwd, "missing.txt");

const error = yield* workspaceFileSystem
.readFile({ cwd, relativePath: "missing.txt" })
.pipe(Effect.flip);

expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFileSystemOperationError);
expect(error).toMatchObject({
workspaceRoot: cwd,
relativePath: "missing.txt",
resolvedPath,
operationPath: resolvedPath,
operation: "realpath-target",
});
expect(error.cause).toBeInstanceOf(Error);
expect((error.cause as Error).message).toBe(
"Workspace file path resolves outside the project root.",
);
expect((error.cause as NodeJS.ErrnoException).code).toBe("ENOENT");
}),
);
});
Expand Down
Loading
Loading