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
34 changes: 34 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
import * as NodeSocket from "@effect/platform-node/NodeSocket";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as NodeCrypto from "node:crypto";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";

import {
AuthAccessTokenType,
Expand Down Expand Up @@ -4541,6 +4542,39 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("reports workspace root stat failures without relabeling them as missing", () =>
Effect.gen(function* () {
if ((yield* HostProcessPlatform) === "win32") return;

const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const blockedRoot = yield* fs.makeTempDirectoryScoped({
prefix: "t3-ws-workspace-stat-error-",
});
const workspaceRoot = path.join(blockedRoot, "workspace");
yield* fs.makeDirectory(workspaceRoot);
yield* fs.chmod(blockedRoot, 0o000);

const result = yield* Effect.gen(function* () {
yield* buildAppUnderTest();
const wsUrl = yield* getWsServerUrl("/ws");
return yield* Effect.scoped(
withWsRpcClient(wsUrl, (client) =>
client[WS_METHODS.projectsListEntries]({ cwd: workspaceRoot }).pipe(Effect.result),
),
);
}).pipe(Effect.ensuring(fs.chmod(blockedRoot, 0o700).pipe(Effect.ignore)));

if (result._tag !== "Failure" || result.failure._tag !== "ProjectListEntriesError") {
assert.fail("Expected a ProjectListEntriesError");
}
const error = result.failure;
assert.equal(error.failure, "workspace_root_stat_failed");
assert.equal(error.normalizedCwd, workspaceRoot);
assert.equal(error.detail, "validate-existing");
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("routes websocket rpc projects.writeFile", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/workspace/WorkspaceEntries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export type WorkspaceEntriesBrowseError = typeof WorkspaceEntriesBrowseError.Typ
export const WorkspaceEntriesError = Schema.Union([
WorkspacePaths.WorkspaceRootNotExistsError,
WorkspacePaths.WorkspaceRootCreateFailedError,
WorkspacePaths.WorkspaceRootStatFailedError,
WorkspacePaths.WorkspaceRootNotDirectoryError,
WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed,
WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut,
Expand Down
74 changes: 74 additions & 0 deletions apps/server/src/workspace/WorkspacePaths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as PlatformError from "effect/PlatformError";

import * as WorkspacePaths from "./WorkspacePaths.ts";

Expand Down Expand Up @@ -91,6 +92,79 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => {
expect(error.message).toContain("Workspace root is not a directory:");
}),
);

it.effect("preserves non-NotFound stat failures while validating the root", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const workspacePaths = yield* WorkspacePaths.make.pipe(
Effect.provideService(FileSystem.FileSystem, {
...fileSystem,
stat: (path) =>
Effect.fail(
PlatformError.systemError({
_tag: "PermissionDenied",
module: "FileSystem",
method: "stat",
pathOrDescriptor: String(path),
description: "Test PermissionDenied stat failure.",
}),
),
}),
);
const path = yield* Path.Path;
const workspaceRoot = " ./permission-denied ";
const normalizedWorkspaceRoot = path.resolve(workspaceRoot.trim());

const error = yield* workspacePaths.normalizeWorkspaceRoot(workspaceRoot).pipe(Effect.flip);

expect(error).toBeInstanceOf(WorkspacePaths.WorkspaceRootStatFailedError);
expect(error).toMatchObject({
workspaceRoot,
normalizedWorkspaceRoot,
phase: "validate-existing",
});
}),
);

it.effect("preserves stat failures while verifying a newly created root", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
let statCalls = 0;
const workspacePaths = yield* WorkspacePaths.make.pipe(
Effect.provideService(FileSystem.FileSystem, {
...fileSystem,
stat: (path) => {
statCalls += 1;
const reason = statCalls === 1 ? "NotFound" : "PermissionDenied";
return Effect.fail(
PlatformError.systemError({
_tag: reason,
module: "FileSystem",
method: "stat",
pathOrDescriptor: String(path),
description: `Test ${reason} stat failure.`,
}),
);
},
makeDirectory: () => Effect.void,
}),
);
const path = yield* Path.Path;
const workspaceRoot = " ./created-then-unreadable ";
const normalizedWorkspaceRoot = path.resolve(workspaceRoot.trim());

const error = yield* workspacePaths
.normalizeWorkspaceRoot(workspaceRoot, { createIfMissing: true })
.pipe(Effect.flip);

expect(error).toBeInstanceOf(WorkspacePaths.WorkspaceRootStatFailedError);
expect(error).toMatchObject({
workspaceRoot,
normalizedWorkspaceRoot,
phase: "verify-created",
});
}),
);
});

describe("resolveRelativePathWithinRoot", () => {
Expand Down
59 changes: 52 additions & 7 deletions apps/server/src/workspace/WorkspacePaths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,20 @@ export class WorkspaceRootCreateFailedError extends Schema.TaggedErrorClass<Work
}
}

export class WorkspaceRootStatFailedError extends Schema.TaggedErrorClass<WorkspaceRootStatFailedError>()(
"WorkspaceRootStatFailedError",
{
workspaceRoot: Schema.String,
normalizedWorkspaceRoot: Schema.String,
phase: Schema.Literals(["validate-existing", "verify-created"]),
cause: Schema.Defect(),
},
) {
override get message(): string {
return `Failed to stat workspace root '${this.normalizedWorkspaceRoot}' during '${this.phase}'.`;
}
}

export class WorkspaceRootNotDirectoryError extends Schema.TaggedErrorClass<WorkspaceRootNotDirectoryError>()(
"WorkspaceRootNotDirectoryError",
{
Expand Down Expand Up @@ -67,6 +81,7 @@ export class WorkspacePathOutsideRootError extends Schema.TaggedErrorClass<Works
export const WorkspacePathsError = Schema.Union([
WorkspaceRootNotExistsError,
WorkspaceRootCreateFailedError,
WorkspaceRootStatFailedError,
WorkspaceRootNotDirectoryError,
WorkspacePathOutsideRootError,
]);
Expand All @@ -82,7 +97,10 @@ export class WorkspacePaths extends Context.Service<
options?: { readonly createIfMissing?: boolean },
) => Effect.Effect<
string,
WorkspaceRootNotExistsError | WorkspaceRootCreateFailedError | WorkspaceRootNotDirectoryError
| WorkspaceRootNotExistsError
| WorkspaceRootCreateFailedError
| WorkspaceRootStatFailedError
| WorkspaceRootNotDirectoryError
>;
/**
* Resolve a relative path within a validated workspace root.
Expand Down Expand Up @@ -117,13 +135,38 @@ export const make = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;

const statWorkspaceRoot = Effect.fn("WorkspacePaths.statWorkspaceRoot")(function* (
workspaceRoot: string,
normalizedWorkspaceRoot: string,
phase: WorkspaceRootStatFailedError["phase"],
) {
return yield* fileSystem.stat(normalizedWorkspaceRoot).pipe(
Effect.matchEffect({
onFailure: (cause) =>
cause.reason._tag === "NotFound"
? Effect.succeed(null)
: Effect.fail(
new WorkspaceRootStatFailedError({
workspaceRoot,
normalizedWorkspaceRoot,
phase,
cause,
}),
),
onSuccess: Effect.succeed,
}),
);
});

const normalizeWorkspaceRoot: WorkspacePaths["Service"]["normalizeWorkspaceRoot"] = Effect.fn(
"WorkspacePaths.normalizeWorkspaceRoot",
)(function* (workspaceRoot, options) {
const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim(), path));
let workspaceStat = yield* fileSystem
.stat(normalizedWorkspaceRoot)
.pipe(Effect.orElseSucceed(() => null));
let workspaceStat = yield* statWorkspaceRoot(
workspaceRoot,
normalizedWorkspaceRoot,
"validate-existing",
);
if (!workspaceStat && options?.createIfMissing) {
yield* fileSystem.makeDirectory(normalizedWorkspaceRoot, { recursive: true }).pipe(
Effect.mapError(
Expand All @@ -135,9 +178,11 @@ export const make = Effect.gen(function* () {
}),
),
);
workspaceStat = yield* fileSystem
.stat(normalizedWorkspaceRoot)
.pipe(Effect.orElseSucceed(() => null));
workspaceStat = yield* statWorkspaceRoot(
workspaceRoot,
normalizedWorkspaceRoot,
"verify-created",
);
}
if (!workspaceStat) {
return yield* new WorkspaceRootNotExistsError({
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,12 @@ function projectEntriesFailureContext(error: WorkspaceEntries.WorkspaceEntriesEr
failure: "workspace_root_create_failed",
normalizedCwd: error.normalizedWorkspaceRoot,
};
case "WorkspaceRootStatFailedError":
return {
failure: "workspace_root_stat_failed",
normalizedCwd: error.normalizedWorkspaceRoot,
detail: error.phase,
};
case "WorkspaceRootNotDirectoryError":
return {
failure: "workspace_root_not_directory",
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export type ProjectListEntriesResult = typeof ProjectListEntriesResult.Type;
export const ProjectEntriesFailure = Schema.Literals([
"workspace_root_not_found",
"workspace_root_create_failed",
"workspace_root_stat_failed",
"workspace_root_not_directory",
"search_index_create_failed",
"search_index_scan_timed_out",
Expand Down
Loading