authorgravatar for aravhawk@gmail.comArav Jain <aravhawk@gmail.com> 2026-10-06 04:28:50-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 04:28:50-05:00
log9ae9f2ba1f30e5fc14f69ba6dff6b08c7bfb401c
treeacbec4d648cfcad7d65d13951d28f664b7aaa79d
parent4980ba282eb313b3be87a6b7df28f3b42dcb4ce9
signature Signed by PGP key B5690EEEBB952194

fix(server): reject invalid explicit Bitbucket repositories (#15876)


4 files changed, 248 insertions(+), 23 deletions(-)

apps/server/src/sourceControl/BitbucketApi.test.ts+120-17
...@@ -6,6 +6,7 @@ import * as Effect from "effect/Effect";...@@ -6,6 +6,7 @@ import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";7import * as Layer from "effect/Layer";
8import * as Option from "effect/Option";8import * as Option from "effect/Option";
9import * as Schema from "effect/Schema";
9import * as TestClock from "effect/testing/TestClock";10import * as TestClock from "effect/testing/TestClock";
10import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http";11import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http";
1112
...@@ -16,6 +17,8 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";...@@ -16,6 +17,8 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
16import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";17import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
17import type * as VcsDriver from "../vcs/VcsDriver.ts";18import type * as VcsDriver from "../vcs/VcsDriver.ts";
1819
20const isBitbucketRepositoryLocatorError = Schema.is(BitbucketApi.BitbucketRepositoryLocatorError);
21
19const bitbucketPullRequest = {22const bitbucketPullRequest = {
20 id: 42,23 id: 42,
21 title: "Add Bitbucket provider",24 title: "Add Bitbucket provider",
...@@ -99,7 +102,7 @@ function makeLayer(input: {...@@ -99,7 +102,7 @@ function makeLayer(input: {
99 } satisfies Partial<GitVcsDriver.GitVcsDriver["Service"]>;102 } satisfies Partial<GitVcsDriver.GitVcsDriver["Service"]>;
100103
101 const driver = {104 const driver = {
102 listRemotes: () =>105 listRemotes: vi.fn(() =>
103 Effect.succeed({106 Effect.succeed({
104 remotes: [107 remotes: [
105 {108 {
...@@ -115,8 +118,26 @@ function makeLayer(input: {...@@ -115,8 +118,26 @@ function makeLayer(input: {
115 expiresAt: Option.none(),118 expiresAt: Option.none(),
116 },119 },
117 }),120 }),
121 ),
118 } satisfies Partial<VcsDriver.VcsDriver["Service"]>;122 } satisfies Partial<VcsDriver.VcsDriver["Service"]>;
119123
124 const resolve = vi.fn(() =>
125 Effect.succeed({
126 kind: "git" as const,
127 repository: {
128 kind: "git" as const,
129 rootPath: "/repo",
130 metadataPath: null,
131 freshness: {
132 source: "live-local" as const,
133 observedAt: DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"),
134 expiresAt: Option.none(),
135 },
136 },
137 driver: driver as unknown as VcsDriver.VcsDriver["Service"],
138 }),
139 );
140
120 const layer = BitbucketApi.layer.pipe(141 const layer = BitbucketApi.layer.pipe(
121 Layer.provide(142 Layer.provide(
122 Layer.succeed(143 Layer.succeed(
...@@ -126,21 +147,7 @@ function makeLayer(input: {...@@ -126,21 +147,7 @@ function makeLayer(input: {
126 ),147 ),
127 Layer.provide(148 Layer.provide(
128 Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({149 Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({
129 resolve: () =>150 resolve,
130 Effect.succeed({
131 kind: "git",
132 repository: {
133 kind: "git",
134 rootPath: "/repo",
135 metadataPath: null,
136 freshness: {
137 source: "live-local" as const,
138 observedAt: DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"),
139 expiresAt: Option.none(),
140 },
141 },
142 driver: driver as unknown as VcsDriver.VcsDriver["Service"],
143 }),
144 }),151 }),
145 ),152 ),
146 Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)(git)),153 Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)(git)),
...@@ -159,7 +166,7 @@ function makeLayer(input: {...@@ -159,7 +166,7 @@ function makeLayer(input: {
159 Layer.provideMerge(NodeServices.layer),166 Layer.provideMerge(NodeServices.layer),
160 );167 );
161168
162 return { execute, git: gitMock, layer };169 return { execute, git: gitMock, resolve, listRemotes: driver.listRemotes, layer };
163}170}
164171
165it.effect("parses pull request responses from the Bitbucket REST API", () => {172it.effect("parses pull request responses from the Bitbucket REST API", () => {
...@@ -333,6 +340,102 @@ it.effect("reads repository clone URLs and default branch", () => {...@@ -333,6 +340,102 @@ it.effect("reads repository clone URLs and default branch", () => {
333 }).pipe(Effect.provide(layer));340 }).pipe(Effect.provide(layer));
334});341});
335342
343it.effect.each([false, true])(
344 "rejects invalid explicit repositories before fallback, context=%s",
345 (withContext) => {
346 const { execute, resolve, listRemotes, layer } = makeLayer({
347 response: () => Response.json(repositoryJson),
348 });
349
350 return Effect.gen(function* () {
351 const bitbucket = yield* BitbucketApi.BitbucketApi;
352 for (const repository of ["t3code", "", " \t "]) {
353 const error = yield* bitbucket
354 .getRepositoryCloneUrls({
355 cwd: "/repo",
356 repository,
357 ...(withContext
358 ? {
359 context: {
360 provider: {
361 kind: "bitbucket" as const,
362 name: "Bitbucket",
363 baseUrl: "https://bitbucket.org",
364 },
365 remoteName: "origin",
366 remoteUrl: "git@bitbucket.org:pingdotgg/t3code.git",
367 },
368 }
369 : {}),
370 })
371 .pipe(Effect.match({ onFailure: (error) => error, onSuccess: () => null }));
372
373 assert.instanceOf(error, BitbucketApi.BitbucketRepositoryLocatorError);
374 assert.strictEqual(
375 isBitbucketRepositoryLocatorError(error) ? error.repository : null,
376 repository,
377 );
378 assert.strictEqual(resolve.mock.calls.length, 0);
379 assert.strictEqual(listRemotes.mock.calls.length, 0);
380 assert.strictEqual(execute.mock.calls.length, 0);
381 }
382 }).pipe(Effect.provide(layer));
383 },
384);
385
386it.effect("prefers an explicit repository and uses context when the repository is omitted", () => {
387 const { execute, resolve, listRemotes, layer } = makeLayer({
388 response: (request) =>
389 Response.json(request.url.endsWith("/branching-model") ? {} : repositoryJson),
390 });
391
392 return Effect.gen(function* () {
393 const bitbucket = yield* BitbucketApi.BitbucketApi;
394 const context = {
395 provider: { kind: "bitbucket" as const, name: "Bitbucket", baseUrl: "https://bitbucket.org" },
396 remoteName: "origin",
397 remoteUrl: "git@bitbucket.org:another/context.git",
398 };
399 for (const repository of ["pingdotgg/t3code", "https://bitbucket.org/pingdotgg/t3code.git"]) {
400 yield* bitbucket.getRepositoryCloneUrls({ cwd: "/repo", context, repository });
401 }
402 yield* bitbucket.getDefaultBranch({ cwd: "/repo", context });
403
404 assert.deepStrictEqual(
405 execute.mock.calls.map(([request]) => request.url).toSorted(),
406 [
407 "https://api.test.local/2.0/repositories/pingdotgg/t3code",
408 "https://api.test.local/2.0/repositories/pingdotgg/t3code",
409 "https://api.test.local/2.0/repositories/another/context",
410 "https://api.test.local/2.0/repositories/another/context/branching-model",
411 ].toSorted(),
412 );
413 assert.strictEqual(resolve.mock.calls.length, 0);
414 assert.strictEqual(listRemotes.mock.calls.length, 0);
415 }).pipe(Effect.provide(layer));
416});
417
418it.effect("discovers the repository from cwd remotes when the repository is omitted", () => {
419 const { execute, resolve, listRemotes, layer } = makeLayer({
420 response: (request) =>
421 Response.json(request.url.endsWith("/branching-model") ? {} : repositoryJson),
422 });
423
424 return Effect.gen(function* () {
425 const bitbucket = yield* BitbucketApi.BitbucketApi;
426 const branch = yield* bitbucket.getDefaultBranch({ cwd: "/repo" });
427
428 assert.strictEqual(branch, "main");
429 assert.deepStrictEqual(resolve.mock.calls, [[{ cwd: "/repo" }]]);
430 assert.deepStrictEqual(listRemotes.mock.calls, [["/repo"]]);
431 assert.isTrue(
432 execute.mock.calls.every(([request]) =>
433 request.url.startsWith("https://api.test.local/2.0/repositories/pingdotgg/t3code"),
434 ),
435 );
436 }).pipe(Effect.provide(layer));
437});
438
336it.effect(439it.effect(
337 "prefers the Bitbucket branching model development branch as the default PR target",440 "prefers the Bitbucket branching model development branch as the default PR target",
338 () => {441 () => {
apps/server/src/sourceControl/BitbucketApi.ts+7-5
...@@ -72,12 +72,14 @@ export class BitbucketRepositoryLocatorError extends Schema.TaggedError<Bitbucke...@@ -72,12 +72,14 @@ export class BitbucketRepositoryLocatorError extends Schema.TaggedError<Bitbucke
72 repository: Schema.String,72 repository: Schema.String,
73 },73 },
74) {74) {
75 static readonly detail = "Bitbucket repositories must be specified as workspace/repository.";
76
75 get detail(): string {77 get detail(): string {
76 return "Bitbucket repositories must be specified as workspace/repository.";78 return BitbucketRepositoryLocatorError.detail;
77 }79 }
7880
79 override get message(): string {81 override get message(): string {
80 return `Bitbucket API failed in createRepository: ${this.detail}`;82 return `Bitbucket API failed: ${this.detail}`;
81 }83 }
82}84}
8385
...@@ -727,9 +729,9 @@ export const make = Effect.gen(function* () {...@@ -727,9 +729,9 @@ export const make = Effect.gen(function* () {
727 readonly context?: SourceControlProvider.SourceControlProviderContext;729 readonly context?: SourceControlProvider.SourceControlProviderContext;
728 readonly repository?: string;730 readonly repository?: string;
729 }) {731 }) {
730 const fromRepository =732 if (input.repository !== undefined) {
731 input.repository !== undefined ? parseBitbucketRepositorySlug(input.repository) : null;733 return yield* requireRepositoryLocator(input.repository);
732 if (fromRepository) return fromRepository;734 }
733735
734 const fromContext =736 const fromContext =
735 input.context?.provider.kind === "bitbucket"737 input.context?.provider.kind === "bitbucket"
apps/server/src/sourceControl/SourceControlRepositoryService.test.ts+111
...@@ -6,16 +6,21 @@ import * as FileSystem from "effect/FileSystem";...@@ -6,16 +6,21 @@ import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";6import * as Path from "effect/Path";
7import * as Layer from "effect/Layer";7import * as Layer from "effect/Layer";
8import * as PlatformError from "effect/PlatformError";8import * as PlatformError from "effect/PlatformError";
9import * as Schema from "effect/Schema";
9import { ChildProcessSpawner } from "effect/process";10import { ChildProcessSpawner } from "effect/process";
1011
11import { GitCommandError, SourceControlProviderError } from "@t3tools/contracts";12import { GitCommandError, SourceControlProviderError } from "@t3tools/contracts";
1213
13import * as ServerConfig from "../config.ts";14import * as ServerConfig from "../config.ts";
14import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";15import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
16import * as BitbucketApi from "./BitbucketApi.ts";
17import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts";
15import type * as SourceControlProvider from "./SourceControlProvider.ts";18import type * as SourceControlProvider from "./SourceControlProvider.ts";
16import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";19import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
17import * as SourceControlRepositoryService from "./SourceControlRepositoryService.ts";20import * as SourceControlRepositoryService from "./SourceControlRepositoryService.ts";
1821
22const isSourceControlProviderError = Schema.is(SourceControlProviderError);
23
19const CLONE_URLS = {24const CLONE_URLS = {
20 nameWithOwner: "octocat/t3code",25 nameWithOwner: "octocat/t3code",
21 url: "https://github.com/octocat/t3code",26 url: "https://github.com/octocat/t3code",
...@@ -152,6 +157,112 @@ it.effect("preserves provider failures without deriving the repository message f...@@ -152,6 +157,112 @@ it.effect("preserves provider failures without deriving the repository message f
152 }).pipe(Effect.provide(layer({ provider })));157 }).pipe(Effect.provide(layer({ provider })));
153});158});
154159
160it.effect(
161 "reports the safe Bitbucket locator detail through repository lookup and publishing",
162 () =>
163 Effect.gen(function* () {
164 const locatorCause = new BitbucketApi.BitbucketRepositoryLocatorError({
165 repository: "credential-token-abc123",
166 });
167 const provider = yield* BitbucketSourceControlProvider.make.pipe(
168 Effect.provide(
169 Layer.mock(BitbucketApi.BitbucketApi)({
170 getRepositoryCloneUrls: () => Effect.fail(locatorCause),
171 createRepository: () => Effect.fail(locatorCause),
172 }),
173 ),
174 );
175
176 yield* Effect.gen(function* () {
177 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
178 const input = {
179 provider: "bitbucket" as const,
180 repository: locatorCause.repository,
181 cwd: "/workspace",
182 };
183 for (const operation of [
184 service.lookupRepository(input).pipe(Effect.asVoid),
185 service.publishRepository({ ...input, visibility: "private" }).pipe(Effect.asVoid),
186 ]) {
187 const error = yield* Effect.flip(operation);
188
189 assert.strictEqual(error.provider, "bitbucket");
190 assert.strictEqual(
191 error.detail,
192 "Bitbucket repositories must be specified as workspace/repository.",
193 );
194 assert.notInclude(error.message, locatorCause.repository);
195 assert.instanceOf(error.cause, SourceControlProviderError);
196 assert.strictEqual(
197 isSourceControlProviderError(error.cause) ? error.cause.cause : null,
198 locatorCause,
199 );
200 }
201 }).pipe(Effect.provide(layer({ provider })));
202 }),
203);
204
205it.effect("keeps a plain Bitbucket locator lookalike out of the repository message", () => {
206 const provider = makeProvider({
207 kind: "bitbucket",
208 getRepositoryCloneUrls: () =>
209 Effect.fail(
210 new SourceControlProviderError({
211 provider: "bitbucket",
212 operation: "getRepositoryCloneUrls",
213 cwd: "/workspace",
214 detail: "credential token abc123 was rejected",
215 cause: {
216 _tag: "BitbucketRepositoryLocatorError",
217 repository: "t3code",
218 detail: "credential token abc123 was rejected",
219 },
220 }),
221 ),
222 });
223
224 return Effect.gen(function* () {
225 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
226 const error = yield* Effect.flip(
227 service.lookupRepository({ provider: "bitbucket", repository: "t3code" }),
228 );
229
230 assert.strictEqual(error.detail, "The source control operation could not be completed.");
231 assert.notInclude(error.message, "abc123");
232 }).pipe(Effect.provide(layer({ provider })));
233});
234
235it.effect("keeps arbitrary Bitbucket request failures out of the repository message", () =>
236 Effect.gen(function* () {
237 const cause = new BitbucketApi.BitbucketRequestError({
238 operation: "getRepository",
239 cause: new Error("credential token abc123 was rejected"),
240 });
241 const provider = yield* BitbucketSourceControlProvider.make.pipe(
242 Effect.provide(
243 Layer.mock(BitbucketApi.BitbucketApi)({
244 getRepositoryCloneUrls: () => Effect.fail(cause),
245 }),
246 ),
247 );
248
249 yield* Effect.gen(function* () {
250 const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
251 const error = yield* Effect.flip(
252 service.lookupRepository({
253 provider: "bitbucket",
254 repository: "pingdotgg/t3code",
255 cwd: "/workspace",
256 }),
257 );
258
259 assert.strictEqual(error.detail, "The source control operation could not be completed.");
260 assert.notInclude(error.message, "abc123");
261 assert.notInclude(error.message, cause.detail);
262 }).pipe(Effect.provide(layer({ provider })));
263 }),
264);
265
155it.effect("clones a looked-up repository into the requested destination", () =>266it.effect("clones a looked-up repository into the requested destination", () =>
156 Effect.gen(function* () {267 Effect.gen(function* () {
157 const fs = yield* FileSystem.FileSystem;268 const fs = yield* FileSystem.FileSystem;
apps/server/src/sourceControl/SourceControlRepositoryService.ts+10-1
...@@ -7,6 +7,7 @@ import * as Schedule from "effect/Schedule";...@@ -7,6 +7,7 @@ import * as Schedule from "effect/Schedule";
7import * as Schema from "effect/Schema";7import * as Schema from "effect/Schema";
88
9import {9import {
10 SourceControlProviderError,
10 SourceControlRepositoryError,11 SourceControlRepositoryError,
11 type SourceControlCloneRepositoryInput,12 type SourceControlCloneRepositoryInput,
12 type SourceControlCloneRepositoryResult,13 type SourceControlCloneRepositoryResult,
...@@ -26,8 +27,11 @@ import {...@@ -26,8 +27,11 @@ import {
26 type GitCloneProgressLine,27 type GitCloneProgressLine,
27} from "../project/gitCloneProgress.ts";28} from "../project/gitCloneProgress.ts";
28import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";29import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
30import * as BitbucketApi from "./BitbucketApi.ts";
29import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";31import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
30const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError);32const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError);
33const isSourceControlProviderError = Schema.is(SourceControlProviderError);
34const isBitbucketRepositoryLocatorError = Schema.is(BitbucketApi.BitbucketRepositoryLocatorError);
3135
32export class SourceControlRepositoryService extends Context.Service<36export class SourceControlRepositoryService extends Context.Service<
33 SourceControlRepositoryService,37 SourceControlRepositoryService,
...@@ -92,7 +96,12 @@ function mapRepositoryError(operation: string, provider: SourceControlProviderKi...@@ -92,7 +96,12 @@ function mapRepositoryError(operation: string, provider: SourceControlProviderKi
92 : new SourceControlRepositoryError({96 : new SourceControlRepositoryError({
93 operation,97 operation,
94 provider,98 provider,
95 detail: "The source control operation could not be completed.",99 detail:
100 isSourceControlProviderError(cause) &&
101 cause.provider === "bitbucket" &&
102 isBitbucketRepositoryLocatorError(cause.cause)
103 ? BitbucketApi.BitbucketRepositoryLocatorError.detail
104 : "The source control operation could not be completed.",
96 cause,105 cause,
97 }),106 }),
98 );107 );