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";
66import * as FileSystem from "effect/FileSystem";
77import * as Layer from "effect/Layer";
88import * as Option from "effect/Option";
9import * as Schema from "effect/Schema";
910import * as TestClock from "effect/testing/TestClock";
1011import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http";
1112
......@@ -16,6 +17,8 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
1617import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
1718import type * as VcsDriver from "../vcs/VcsDriver.ts";
1819
20const isBitbucketRepositoryLocatorError = Schema.is(BitbucketApi.BitbucketRepositoryLocatorError);
21
1922const bitbucketPullRequest = {
2023 id: 42,
2124 title: "Add Bitbucket provider",
......@@ -99,7 +102,7 @@ function makeLayer(input: {
99102 } satisfies Partial<GitVcsDriver.GitVcsDriver["Service"]>;
100103
101104 const driver = {
102 listRemotes: () =>
105 listRemotes: vi.fn(() =>
103106 Effect.succeed({
104107 remotes: [
105108 {
......@@ -115,8 +118,26 @@ function makeLayer(input: {
115118 expiresAt: Option.none(),
116119 },
117120 }),
121 ),
118122 } 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
120141 const layer = BitbucketApi.layer.pipe(
121142 Layer.provide(
122143 Layer.succeed(
......@@ -126,21 +147,7 @@ function makeLayer(input: {
126147 ),
127148 Layer.provide(
128149 Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({
129 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 }),
150 resolve,
144151 }),
145152 ),
146153 Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)(git)),
......@@ -159,7 +166,7 @@ function makeLayer(input: {
159166 Layer.provideMerge(NodeServices.layer),
160167 );
161168
162 return { execute, git: gitMock, layer };
169 return { execute, git: gitMock, resolve, listRemotes: driver.listRemotes, layer };
163170}
164171
165172it.effect("parses pull request responses from the Bitbucket REST API", () => {
......@@ -333,6 +340,102 @@ it.effect("reads repository clone URLs and default branch", () => {
333340 }).pipe(Effect.provide(layer));
334341});
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
336439it.effect(
337440 "prefers the Bitbucket branching model development branch as the default PR target",
338441 () => {
apps/server/src/sourceControl/BitbucketApi.ts+7-5
......@@ -72,12 +72,14 @@ export class BitbucketRepositoryLocatorError extends Schema.TaggedError<Bitbucke
7272 repository: Schema.String,
7373 },
7474) {
75 static readonly detail = "Bitbucket repositories must be specified as workspace/repository.";
76
7577 get detail(): string {
76 return "Bitbucket repositories must be specified as workspace/repository.";
78 return BitbucketRepositoryLocatorError.detail;
7779 }
7880
7981 override get message(): string {
80 return `Bitbucket API failed in createRepository: ${this.detail}`;
82 return `Bitbucket API failed: ${this.detail}`;
8183 }
8284}
8385
......@@ -727,9 +729,9 @@ export const make = Effect.gen(function* () {
727729 readonly context?: SourceControlProvider.SourceControlProviderContext;
728730 readonly repository?: string;
729731 }) {
730 const fromRepository =
731 input.repository !== undefined ? parseBitbucketRepositorySlug(input.repository) : null;
732 if (fromRepository) return fromRepository;
732 if (input.repository !== undefined) {
733 return yield* requireRepositoryLocator(input.repository);
734 }
733735
734736 const fromContext =
735737 input.context?.provider.kind === "bitbucket"
apps/server/src/sourceControl/SourceControlRepositoryService.test.ts+111
......@@ -6,16 +6,21 @@ import * as FileSystem from "effect/FileSystem";
66import * as Path from "effect/Path";
77import * as Layer from "effect/Layer";
88import * as PlatformError from "effect/PlatformError";
9import * as Schema from "effect/Schema";
910import { ChildProcessSpawner } from "effect/process";
1011
1112import { GitCommandError, SourceControlProviderError } from "@t3tools/contracts";
1213
1314import * as ServerConfig from "../config.ts";
1415import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
16import * as BitbucketApi from "./BitbucketApi.ts";
17import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts";
1518import type * as SourceControlProvider from "./SourceControlProvider.ts";
1619import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
1720import * as SourceControlRepositoryService from "./SourceControlRepositoryService.ts";
1821
22const isSourceControlProviderError = Schema.is(SourceControlProviderError);
23
1924const CLONE_URLS = {
2025 nameWithOwner: "octocat/t3code",
2126 url: "https://github.com/octocat/t3code",
......@@ -152,6 +157,112 @@ it.effect("preserves provider failures without deriving the repository message f
152157 }).pipe(Effect.provide(layer({ provider })));
153158});
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
155266it.effect("clones a looked-up repository into the requested destination", () =>
156267 Effect.gen(function* () {
157268 const fs = yield* FileSystem.FileSystem;
apps/server/src/sourceControl/SourceControlRepositoryService.ts+10-1
......@@ -7,6 +7,7 @@ import * as Schedule from "effect/Schedule";
77import * as Schema from "effect/Schema";
88
99import {
10 SourceControlProviderError,
1011 SourceControlRepositoryError,
1112 type SourceControlCloneRepositoryInput,
1213 type SourceControlCloneRepositoryResult,
......@@ -26,8 +27,11 @@ import {
2627 type GitCloneProgressLine,
2728} from "../project/gitCloneProgress.ts";
2829import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
30import * as BitbucketApi from "./BitbucketApi.ts";
2931import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
3032const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError);
33const isSourceControlProviderError = Schema.is(SourceControlProviderError);
34const isBitbucketRepositoryLocatorError = Schema.is(BitbucketApi.BitbucketRepositoryLocatorError);
3135
3236export class SourceControlRepositoryService extends Context.Service<
3337 SourceControlRepositoryService,
......@@ -92,7 +96,12 @@ function mapRepositoryError(operation: string, provider: SourceControlProviderKi
9296 : new SourceControlRepositoryError({
9397 operation,
9498 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.",
96105 cause,
97106 }),
98107 );