| author | |
| committer | |
| log | be56fa43b0aaad6ab537526e2129501038bbb6a8 |
| tree | d38c10043f111ad0941a37245ef3b5bba8e18468 |
| parent | eb5eb0d9fa99364aa3b5091c62d6a187f5eef375 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>6 files changed, 168 insertions(+), 7 deletions(-)
apps/server/src/server.test.ts+34| ... | ... | @@ -2,6 +2,7 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; |
| 2 | 2 | import * as NodeSocket from "@effect/platform-node/NodeSocket"; |
| 3 | 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | 4 | import * as NodeCrypto from "node:crypto"; |
| 5 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 5 | 6 | |
| 6 | 7 | import { |
| 7 | 8 | AuthAccessTokenType, |
| ... | ... | @@ -4541,6 +4542,39 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 4541 | 4542 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 4542 | 4543 | ); |
| 4543 | 4544 | |
| 4545 | it.effect("reports workspace root stat failures without relabeling them as missing", () => | |
| 4546 | Effect.gen(function* () { | |
| 4547 | if ((yield* HostProcessPlatform) === "win32") return; | |
| 4548 | ||
| 4549 | const fs = yield* FileSystem.FileSystem; | |
| 4550 | const path = yield* Path.Path; | |
| 4551 | const blockedRoot = yield* fs.makeTempDirectoryScoped({ | |
| 4552 | prefix: "t3-ws-workspace-stat-error-", | |
| 4553 | }); | |
| 4554 | const workspaceRoot = path.join(blockedRoot, "workspace"); | |
| 4555 | yield* fs.makeDirectory(workspaceRoot); | |
| 4556 | yield* fs.chmod(blockedRoot, 0o000); | |
| 4557 | ||
| 4558 | const result = yield* Effect.gen(function* () { | |
| 4559 | yield* buildAppUnderTest(); | |
| 4560 | const wsUrl = yield* getWsServerUrl("/ws"); | |
| 4561 | return yield* Effect.scoped( | |
| 4562 | withWsRpcClient(wsUrl, (client) => | |
| 4563 | client[WS_METHODS.projectsListEntries]({ cwd: workspaceRoot }).pipe(Effect.result), | |
| 4564 | ), | |
| 4565 | ); | |
| 4566 | }).pipe(Effect.ensuring(fs.chmod(blockedRoot, 0o700).pipe(Effect.ignore))); | |
| 4567 | ||
| 4568 | if (result._tag !== "Failure" || result.failure._tag !== "ProjectListEntriesError") { | |
| 4569 | assert.fail("Expected a ProjectListEntriesError"); | |
| 4570 | } | |
| 4571 | const error = result.failure; | |
| 4572 | assert.equal(error.failure, "workspace_root_stat_failed"); | |
| 4573 | assert.equal(error.normalizedCwd, workspaceRoot); | |
| 4574 | assert.equal(error.detail, "validate-existing"); | |
| 4575 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 4576 | ); | |
| 4577 | ||
| 4544 | 4578 | it.effect("routes websocket rpc projects.writeFile", () => |
| 4545 | 4579 | Effect.gen(function* () { |
| 4546 | 4580 | const fs = yield* FileSystem.FileSystem; |
apps/server/src/workspace/WorkspaceEntries.ts+1| ... | ... | @@ -73,6 +73,7 @@ export type WorkspaceEntriesBrowseError = typeof WorkspaceEntriesBrowseError.Typ |
| 73 | 73 | export const WorkspaceEntriesError = Schema.Union([ |
| 74 | 74 | WorkspacePaths.WorkspaceRootNotExistsError, |
| 75 | 75 | WorkspacePaths.WorkspaceRootCreateFailedError, |
| 76 | WorkspacePaths.WorkspaceRootStatFailedError, | |
| 76 | 77 | WorkspacePaths.WorkspaceRootNotDirectoryError, |
| 77 | 78 | WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed, |
| 78 | 79 | WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut, |
apps/server/src/workspace/WorkspacePaths.test.ts+74| ... | ... | @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; |
| 4 | 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | 5 | import * as Layer from "effect/Layer"; |
| 6 | 6 | import * as Path from "effect/Path"; |
| 7 | import * as PlatformError from "effect/PlatformError"; | |
| 7 | 8 | |
| 8 | 9 | import * as WorkspacePaths from "./WorkspacePaths.ts"; |
| 9 | 10 | |
| ... | ... | @@ -91,6 +92,79 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => { |
| 91 | 92 | expect(error.message).toContain("Workspace root is not a directory:"); |
| 92 | 93 | }), |
| 93 | 94 | ); |
| 95 | ||
| 96 | it.effect("preserves non-NotFound stat failures while validating the root", () => | |
| 97 | Effect.gen(function* () { | |
| 98 | const fileSystem = yield* FileSystem.FileSystem; | |
| 99 | const workspacePaths = yield* WorkspacePaths.make.pipe( | |
| 100 | Effect.provideService(FileSystem.FileSystem, { | |
| 101 | ...fileSystem, | |
| 102 | stat: (path) => | |
| 103 | Effect.fail( | |
| 104 | PlatformError.systemError({ | |
| 105 | _tag: "PermissionDenied", | |
| 106 | module: "FileSystem", | |
| 107 | method: "stat", | |
| 108 | pathOrDescriptor: String(path), | |
| 109 | description: "Test PermissionDenied stat failure.", | |
| 110 | }), | |
| 111 | ), | |
| 112 | }), | |
| 113 | ); | |
| 114 | const path = yield* Path.Path; | |
| 115 | const workspaceRoot = " ./permission-denied "; | |
| 116 | const normalizedWorkspaceRoot = path.resolve(workspaceRoot.trim()); | |
| 117 | ||
| 118 | const error = yield* workspacePaths.normalizeWorkspaceRoot(workspaceRoot).pipe(Effect.flip); | |
| 119 | ||
| 120 | expect(error).toBeInstanceOf(WorkspacePaths.WorkspaceRootStatFailedError); | |
| 121 | expect(error).toMatchObject({ | |
| 122 | workspaceRoot, | |
| 123 | normalizedWorkspaceRoot, | |
| 124 | phase: "validate-existing", | |
| 125 | }); | |
| 126 | }), | |
| 127 | ); | |
| 128 | ||
| 129 | it.effect("preserves stat failures while verifying a newly created root", () => | |
| 130 | Effect.gen(function* () { | |
| 131 | const fileSystem = yield* FileSystem.FileSystem; | |
| 132 | let statCalls = 0; | |
| 133 | const workspacePaths = yield* WorkspacePaths.make.pipe( | |
| 134 | Effect.provideService(FileSystem.FileSystem, { | |
| 135 | ...fileSystem, | |
| 136 | stat: (path) => { | |
| 137 | statCalls += 1; | |
| 138 | const reason = statCalls === 1 ? "NotFound" : "PermissionDenied"; | |
| 139 | return Effect.fail( | |
| 140 | PlatformError.systemError({ | |
| 141 | _tag: reason, | |
| 142 | module: "FileSystem", | |
| 143 | method: "stat", | |
| 144 | pathOrDescriptor: String(path), | |
| 145 | description: `Test ${reason} stat failure.`, | |
| 146 | }), | |
| 147 | ); | |
| 148 | }, | |
| 149 | makeDirectory: () => Effect.void, | |
| 150 | }), | |
| 151 | ); | |
| 152 | const path = yield* Path.Path; | |
| 153 | const workspaceRoot = " ./created-then-unreadable "; | |
| 154 | const normalizedWorkspaceRoot = path.resolve(workspaceRoot.trim()); | |
| 155 | ||
| 156 | const error = yield* workspacePaths | |
| 157 | .normalizeWorkspaceRoot(workspaceRoot, { createIfMissing: true }) | |
| 158 | .pipe(Effect.flip); | |
| 159 | ||
| 160 | expect(error).toBeInstanceOf(WorkspacePaths.WorkspaceRootStatFailedError); | |
| 161 | expect(error).toMatchObject({ | |
| 162 | workspaceRoot, | |
| 163 | normalizedWorkspaceRoot, | |
| 164 | phase: "verify-created", | |
| 165 | }); | |
| 166 | }), | |
| 167 | ); | |
| 94 | 168 | }); |
| 95 | 169 | |
| 96 | 170 | describe("resolveRelativePathWithinRoot", () => { |
apps/server/src/workspace/WorkspacePaths.ts+52-7| ... | ... | @@ -40,6 +40,20 @@ export class WorkspaceRootCreateFailedError extends Schema.TaggedErrorClass<Work |
| 40 | 40 | } |
| 41 | 41 | } |
| 42 | 42 | |
| 43 | export class WorkspaceRootStatFailedError extends Schema.TaggedErrorClass<WorkspaceRootStatFailedError>()( | |
| 44 | "WorkspaceRootStatFailedError", | |
| 45 | { | |
| 46 | workspaceRoot: Schema.String, | |
| 47 | normalizedWorkspaceRoot: Schema.String, | |
| 48 | phase: Schema.Literals(["validate-existing", "verify-created"]), | |
| 49 | cause: Schema.Defect(), | |
| 50 | }, | |
| 51 | ) { | |
| 52 | override get message(): string { | |
| 53 | return `Failed to stat workspace root '${this.normalizedWorkspaceRoot}' during '${this.phase}'.`; | |
| 54 | } | |
| 55 | } | |
| 56 | ||
| 43 | 57 | export class WorkspaceRootNotDirectoryError extends Schema.TaggedErrorClass<WorkspaceRootNotDirectoryError>()( |
| 44 | 58 | "WorkspaceRootNotDirectoryError", |
| 45 | 59 | { |
| ... | ... | @@ -67,6 +81,7 @@ export class WorkspacePathOutsideRootError extends Schema.TaggedErrorClass<Works |
| 67 | 81 | export const WorkspacePathsError = Schema.Union([ |
| 68 | 82 | WorkspaceRootNotExistsError, |
| 69 | 83 | WorkspaceRootCreateFailedError, |
| 84 | WorkspaceRootStatFailedError, | |
| 70 | 85 | WorkspaceRootNotDirectoryError, |
| 71 | 86 | WorkspacePathOutsideRootError, |
| 72 | 87 | ]); |
| ... | ... | @@ -82,7 +97,10 @@ export class WorkspacePaths extends Context.Service< |
| 82 | 97 | options?: { readonly createIfMissing?: boolean }, |
| 83 | 98 | ) => Effect.Effect< |
| 84 | 99 | string, |
| 85 | WorkspaceRootNotExistsError | WorkspaceRootCreateFailedError | WorkspaceRootNotDirectoryError | |
| 100 | | WorkspaceRootNotExistsError | |
| 101 | | WorkspaceRootCreateFailedError | |
| 102 | | WorkspaceRootStatFailedError | |
| 103 | | WorkspaceRootNotDirectoryError | |
| 86 | 104 | >; |
| 87 | 105 | /** |
| 88 | 106 | * Resolve a relative path within a validated workspace root. |
| ... | ... | @@ -117,13 +135,38 @@ export const make = Effect.gen(function* () { |
| 117 | 135 | const fileSystem = yield* FileSystem.FileSystem; |
| 118 | 136 | const path = yield* Path.Path; |
| 119 | 137 | |
| 138 | const statWorkspaceRoot = Effect.fn("WorkspacePaths.statWorkspaceRoot")(function* ( | |
| 139 | workspaceRoot: string, | |
| 140 | normalizedWorkspaceRoot: string, | |
| 141 | phase: WorkspaceRootStatFailedError["phase"], | |
| 142 | ) { | |
| 143 | return yield* fileSystem.stat(normalizedWorkspaceRoot).pipe( | |
| 144 | Effect.matchEffect({ | |
| 145 | onFailure: (cause) => | |
| 146 | cause.reason._tag === "NotFound" | |
| 147 | ? Effect.succeed(null) | |
| 148 | : Effect.fail( | |
| 149 | new WorkspaceRootStatFailedError({ | |
| 150 | workspaceRoot, | |
| 151 | normalizedWorkspaceRoot, | |
| 152 | phase, | |
| 153 | cause, | |
| 154 | }), | |
| 155 | ), | |
| 156 | onSuccess: Effect.succeed, | |
| 157 | }), | |
| 158 | ); | |
| 159 | }); | |
| 160 | ||
| 120 | 161 | const normalizeWorkspaceRoot: WorkspacePaths["Service"]["normalizeWorkspaceRoot"] = Effect.fn( |
| 121 | 162 | "WorkspacePaths.normalizeWorkspaceRoot", |
| 122 | 163 | )(function* (workspaceRoot, options) { |
| 123 | 164 | const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim(), path)); |
| 124 | let workspaceStat = yield* fileSystem | |
| 125 | .stat(normalizedWorkspaceRoot) | |
| 126 | .pipe(Effect.orElseSucceed(() => null)); | |
| 165 | let workspaceStat = yield* statWorkspaceRoot( | |
| 166 | workspaceRoot, | |
| 167 | normalizedWorkspaceRoot, | |
| 168 | "validate-existing", | |
| 169 | ); | |
| 127 | 170 | if (!workspaceStat && options?.createIfMissing) { |
| 128 | 171 | yield* fileSystem.makeDirectory(normalizedWorkspaceRoot, { recursive: true }).pipe( |
| 129 | 172 | Effect.mapError( |
| ... | ... | @@ -135,9 +178,11 @@ export const make = Effect.gen(function* () { |
| 135 | 178 | }), |
| 136 | 179 | ), |
| 137 | 180 | ); |
| 138 | workspaceStat = yield* fileSystem | |
| 139 | .stat(normalizedWorkspaceRoot) | |
| 140 | .pipe(Effect.orElseSucceed(() => null)); | |
| 181 | workspaceStat = yield* statWorkspaceRoot( | |
| 182 | workspaceRoot, | |
| 183 | normalizedWorkspaceRoot, | |
| 184 | "verify-created", | |
| 185 | ); | |
| 141 | 186 | } |
| 142 | 187 | if (!workspaceStat) { |
| 143 | 188 | return yield* new WorkspaceRootNotExistsError({ |
apps/server/src/ws.ts+6| ... | ... | @@ -149,6 +149,12 @@ function projectEntriesFailureContext(error: WorkspaceEntries.WorkspaceEntriesEr |
| 149 | 149 | failure: "workspace_root_create_failed", |
| 150 | 150 | normalizedCwd: error.normalizedWorkspaceRoot, |
| 151 | 151 | }; |
| 152 | case "WorkspaceRootStatFailedError": | |
| 153 | return { | |
| 154 | failure: "workspace_root_stat_failed", | |
| 155 | normalizedCwd: error.normalizedWorkspaceRoot, | |
| 156 | detail: error.phase, | |
| 157 | }; | |
| 152 | 158 | case "WorkspaceRootNotDirectoryError": |
| 153 | 159 | return { |
| 154 | 160 | failure: "workspace_root_not_directory", |
packages/contracts/src/project.ts+1| ... | ... | @@ -40,6 +40,7 @@ export type ProjectListEntriesResult = typeof ProjectListEntriesResult.Type; |
| 40 | 40 | export const ProjectEntriesFailure = Schema.Literals([ |
| 41 | 41 | "workspace_root_not_found", |
| 42 | 42 | "workspace_root_create_failed", |
| 43 | "workspace_root_stat_failed", | |
| 43 | 44 | "workspace_root_not_directory", |
| 44 | 45 | "search_index_create_failed", |
| 45 | 46 | "search_index_scan_timed_out", |