| 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,6 +2,7 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; |
| 2 | import * as NodeSocket from "@effect/platform-node/NodeSocket"; | 2 | import * as NodeSocket from "@effect/platform-node/NodeSocket"; |
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | import * as NodeCrypto from "node:crypto"; | 4 | import * as NodeCrypto from "node:crypto"; |
| 5 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 5 | 6 | ||
| 6 | import { | 7 | import { |
| 7 | AuthAccessTokenType, | 8 | AuthAccessTokenType, |
| ... | @@ -4541,6 +4542,39 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -4541,6 +4542,39 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 4541 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 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 | it.effect("routes websocket rpc projects.writeFile", () => | 4578 | it.effect("routes websocket rpc projects.writeFile", () => |
| 4545 | Effect.gen(function* () { | 4579 | Effect.gen(function* () { |
| 4546 | const fs = yield* FileSystem.FileSystem; | 4580 | const fs = yield* FileSystem.FileSystem; |
apps/server/src/workspace/WorkspaceEntries.ts+1| ... | @@ -73,6 +73,7 @@ export type WorkspaceEntriesBrowseError = typeof WorkspaceEntriesBrowseError.Typ | ... | @@ -73,6 +73,7 @@ export type WorkspaceEntriesBrowseError = typeof WorkspaceEntriesBrowseError.Typ |
| 73 | export const WorkspaceEntriesError = Schema.Union([ | 73 | export const WorkspaceEntriesError = Schema.Union([ |
| 74 | WorkspacePaths.WorkspaceRootNotExistsError, | 74 | WorkspacePaths.WorkspaceRootNotExistsError, |
| 75 | WorkspacePaths.WorkspaceRootCreateFailedError, | 75 | WorkspacePaths.WorkspaceRootCreateFailedError, |
| 76 | WorkspacePaths.WorkspaceRootStatFailedError, | ||
| 76 | WorkspacePaths.WorkspaceRootNotDirectoryError, | 77 | WorkspacePaths.WorkspaceRootNotDirectoryError, |
| 77 | WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed, | 78 | WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed, |
| 78 | WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut, | 79 | WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut, |
apps/server/src/workspace/WorkspacePaths.test.ts+74| ... | @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; | ... | @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; | 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Layer from "effect/Layer"; | 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Path from "effect/Path"; | 6 | import * as Path from "effect/Path"; |
| 7 | import * as PlatformError from "effect/PlatformError"; | ||
| 7 | 8 | ||
| 8 | import * as WorkspacePaths from "./WorkspacePaths.ts"; | 9 | import * as WorkspacePaths from "./WorkspacePaths.ts"; |
| 9 | 10 | ||
| ... | @@ -91,6 +92,79 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => { | ... | @@ -91,6 +92,79 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => { |
| 91 | expect(error.message).toContain("Workspace root is not a directory:"); | 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 | describe("resolveRelativePathWithinRoot", () => { | 170 | describe("resolveRelativePathWithinRoot", () => { |
apps/server/src/workspace/WorkspacePaths.ts+52-7| ... | @@ -40,6 +40,20 @@ export class WorkspaceRootCreateFailedError extends Schema.TaggedErrorClass<Work | ... | @@ -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 | export class WorkspaceRootNotDirectoryError extends Schema.TaggedErrorClass<WorkspaceRootNotDirectoryError>()( | 57 | export class WorkspaceRootNotDirectoryError extends Schema.TaggedErrorClass<WorkspaceRootNotDirectoryError>()( |
| 44 | "WorkspaceRootNotDirectoryError", | 58 | "WorkspaceRootNotDirectoryError", |
| 45 | { | 59 | { |
| ... | @@ -67,6 +81,7 @@ export class WorkspacePathOutsideRootError extends Schema.TaggedErrorClass<Works | ... | @@ -67,6 +81,7 @@ export class WorkspacePathOutsideRootError extends Schema.TaggedErrorClass<Works |
| 67 | export const WorkspacePathsError = Schema.Union([ | 81 | export const WorkspacePathsError = Schema.Union([ |
| 68 | WorkspaceRootNotExistsError, | 82 | WorkspaceRootNotExistsError, |
| 69 | WorkspaceRootCreateFailedError, | 83 | WorkspaceRootCreateFailedError, |
| 84 | WorkspaceRootStatFailedError, | ||
| 70 | WorkspaceRootNotDirectoryError, | 85 | WorkspaceRootNotDirectoryError, |
| 71 | WorkspacePathOutsideRootError, | 86 | WorkspacePathOutsideRootError, |
| 72 | ]); | 87 | ]); |
| ... | @@ -82,7 +97,10 @@ export class WorkspacePaths extends Context.Service< | ... | @@ -82,7 +97,10 @@ export class WorkspacePaths extends Context.Service< |
| 82 | options?: { readonly createIfMissing?: boolean }, | 97 | options?: { readonly createIfMissing?: boolean }, |
| 83 | ) => Effect.Effect< | 98 | ) => Effect.Effect< |
| 84 | string, | 99 | string, |
| 85 | WorkspaceRootNotExistsError | WorkspaceRootCreateFailedError | WorkspaceRootNotDirectoryError | 100 | | WorkspaceRootNotExistsError |
| 101 | | WorkspaceRootCreateFailedError | ||
| 102 | | WorkspaceRootStatFailedError | ||
| 103 | | WorkspaceRootNotDirectoryError | ||
| 86 | >; | 104 | >; |
| 87 | /** | 105 | /** |
| 88 | * Resolve a relative path within a validated workspace root. | 106 | * Resolve a relative path within a validated workspace root. |
| ... | @@ -117,13 +135,38 @@ export const make = Effect.gen(function* () { | ... | @@ -117,13 +135,38 @@ export const make = Effect.gen(function* () { |
| 117 | const fileSystem = yield* FileSystem.FileSystem; | 135 | const fileSystem = yield* FileSystem.FileSystem; |
| 118 | const path = yield* Path.Path; | 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 | const normalizeWorkspaceRoot: WorkspacePaths["Service"]["normalizeWorkspaceRoot"] = Effect.fn( | 161 | const normalizeWorkspaceRoot: WorkspacePaths["Service"]["normalizeWorkspaceRoot"] = Effect.fn( |
| 121 | "WorkspacePaths.normalizeWorkspaceRoot", | 162 | "WorkspacePaths.normalizeWorkspaceRoot", |
| 122 | )(function* (workspaceRoot, options) { | 163 | )(function* (workspaceRoot, options) { |
| 123 | const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim(), path)); | 164 | const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim(), path)); |
| 124 | let workspaceStat = yield* fileSystem | 165 | let workspaceStat = yield* statWorkspaceRoot( |
| 125 | .stat(normalizedWorkspaceRoot) | 166 | workspaceRoot, |
| 126 | .pipe(Effect.orElseSucceed(() => null)); | 167 | normalizedWorkspaceRoot, |
| 168 | "validate-existing", | ||
| 169 | ); | ||
| 127 | if (!workspaceStat && options?.createIfMissing) { | 170 | if (!workspaceStat && options?.createIfMissing) { |
| 128 | yield* fileSystem.makeDirectory(normalizedWorkspaceRoot, { recursive: true }).pipe( | 171 | yield* fileSystem.makeDirectory(normalizedWorkspaceRoot, { recursive: true }).pipe( |
| 129 | Effect.mapError( | 172 | Effect.mapError( |
| ... | @@ -135,9 +178,11 @@ export const make = Effect.gen(function* () { | ... | @@ -135,9 +178,11 @@ export const make = Effect.gen(function* () { |
| 135 | }), | 178 | }), |
| 136 | ), | 179 | ), |
| 137 | ); | 180 | ); |
| 138 | workspaceStat = yield* fileSystem | 181 | workspaceStat = yield* statWorkspaceRoot( |
| 139 | .stat(normalizedWorkspaceRoot) | 182 | workspaceRoot, |
| 140 | .pipe(Effect.orElseSucceed(() => null)); | 183 | normalizedWorkspaceRoot, |
| 184 | "verify-created", | ||
| 185 | ); | ||
| 141 | } | 186 | } |
| 142 | if (!workspaceStat) { | 187 | if (!workspaceStat) { |
| 143 | return yield* new WorkspaceRootNotExistsError({ | 188 | return yield* new WorkspaceRootNotExistsError({ |
apps/server/src/ws.ts+6| ... | @@ -149,6 +149,12 @@ function projectEntriesFailureContext(error: WorkspaceEntries.WorkspaceEntriesEr | ... | @@ -149,6 +149,12 @@ function projectEntriesFailureContext(error: WorkspaceEntries.WorkspaceEntriesEr |
| 149 | failure: "workspace_root_create_failed", | 149 | failure: "workspace_root_create_failed", |
| 150 | normalizedCwd: error.normalizedWorkspaceRoot, | 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 | case "WorkspaceRootNotDirectoryError": | 158 | case "WorkspaceRootNotDirectoryError": |
| 153 | return { | 159 | return { |
| 154 | failure: "workspace_root_not_directory", | 160 | failure: "workspace_root_not_directory", |
packages/contracts/src/project.ts+1| ... | @@ -40,6 +40,7 @@ export type ProjectListEntriesResult = typeof ProjectListEntriesResult.Type; | ... | @@ -40,6 +40,7 @@ export type ProjectListEntriesResult = typeof ProjectListEntriesResult.Type; |
| 40 | export const ProjectEntriesFailure = Schema.Literals([ | 40 | export const ProjectEntriesFailure = Schema.Literals([ |
| 41 | "workspace_root_not_found", | 41 | "workspace_root_not_found", |
| 42 | "workspace_root_create_failed", | 42 | "workspace_root_create_failed", |
| 43 | "workspace_root_stat_failed", | ||
| 43 | "workspace_root_not_directory", | 44 | "workspace_root_not_directory", |
| 44 | "search_index_create_failed", | 45 | "search_index_create_failed", |
| 45 | "search_index_scan_timed_out", | 46 | "search_index_scan_timed_out", |