Skip to content

Commit ecb5ba8

Browse files
committed
fix(bitbucket): fold only the slug segments Bitbucket canonicalizes
Segment-wise comparison replaces whole-path case folding, so a cursor that recases a fixed endpoint literal (repositories, commits, pullrequests) fails locally again instead of being deferred to Bitbucket. Only the workspace and repository segments of a /2.0/repositories path fold; file paths and literals stay verbatim.
1 parent 13f260d commit ecb5ba8

3 files changed

Lines changed: 58 additions & 12 deletions

File tree

apps/sim/tools/bitbucket/merge_pull_request.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
bitbucketHeaders,
1212
bitbucketJson,
1313
bitbucketPullRequestPath,
14-
equalsIgnoreCase,
14+
bitbucketRepositoryPathHasPrefix,
1515
normalizeBitbucketPullRequest,
1616
validateBitbucketOpaqueUrl,
1717
} from '@/tools/bitbucket/utils'
@@ -53,7 +53,7 @@ function mergeTaskLocation(
5353
)
5454
const parsed = new URL(taskUrl)
5555
const expectedPrefix = `/2.0${bitbucketPullRequestPath(params.workspaceSlug, params.repoSlug, params.prId)}/merge/task-status/`
56-
if (!equalsIgnoreCase(parsed.pathname.slice(0, expectedPrefix.length), expectedPrefix)) {
56+
if (!bitbucketRepositoryPathHasPrefix(parsed.pathname, expectedPrefix)) {
5757
throw new Error('Bitbucket merge task Location did not match the requested pull request')
5858
}
5959
const taskId = decodeURIComponent(parsed.pathname.slice(expectedPrefix.length))

apps/sim/tools/bitbucket/utils.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,16 @@ describe('Bitbucket path and pagination safety', () => {
117117
})
118118
).toBe(`https://api.bitbucket.org/2.0/repositories/acme/demo/src/${revision}/src/dir?page=2`)
119119

120+
for (const recased of [
121+
'https://api.bitbucket.org/2.0/Repositories/acme/demo/commits?page=2',
122+
'https://api.bitbucket.org/2.0/repositories/acme/demo/Commits?page=2',
123+
]) {
124+
expect(
125+
() => bitbucketApiUrl('/repositories/ACME/Demo/commits', { nextUrl: recased }),
126+
recased
127+
).toThrow(/does not belong to this Bitbucket list endpoint/)
128+
}
129+
120130
expect(() =>
121131
bitbucketApiUrl(`/repositories/acme/demo/src/${revision}/src/Dir`, {
122132
nextUrl: `https://api.bitbucket.org/2.0/repositories/acme/demo/src/${revision}/src/dir?page=2`,

apps/sim/tools/bitbucket/utils.ts

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -320,13 +320,54 @@ export function validateBitbucketOpaqueUrl(value: string): string {
320320
* Bitbucket resolves workspace and repository slugs case-insensitively but echoes the canonical
321321
* lowercase form in `next` links, redirect `Location` headers, and merge task URLs. Binding those
322322
* back to the caller's slug must therefore ignore case, or a mixed-case slug that Bitbucket just
323-
* accepted fails on the follow-up request. Repository file paths are compared verbatim — git
324-
* treats those as case-sensitive.
323+
* accepted fails on the follow-up request.
324+
*
325+
* Only those two segments are canonicalized, so only those two are folded. Fixed API literals and
326+
* repository file paths compare verbatim, keeping a malformed path a deterministic local failure
327+
* rather than one deferred to Bitbucket. Hex revisions are folded by their own dedicated check.
325328
*/
329+
function isBitbucketSlugSegment(segments: string[], index: number): boolean {
330+
return segments[0] === '2.0' && segments[1] === 'repositories' && (index === 2 || index === 3)
331+
}
332+
333+
function bitbucketSegmentsMatch(candidate: string[], expected: string[]): boolean {
334+
if (candidate.length !== expected.length) return false
335+
return expected.every(
336+
(segment, index) =>
337+
candidate[index] === segment ||
338+
(isBitbucketSlugSegment(expected, index) && equalsIgnoreCase(candidate[index], segment))
339+
)
340+
}
341+
342+
/** Compares two absolute API paths segment-wise under the slug-only case rule above. */
343+
function bitbucketPathsMatch(candidatePath: string, expectedPath: string): boolean {
344+
return bitbucketSegmentsMatch(
345+
candidatePath.replace(/^\//, '').split('/'),
346+
expectedPath.replace(/^\//, '').split('/')
347+
)
348+
}
349+
350+
/** True when `candidatePath` begins with every segment of `expectedPrefix`, same case rule. */
351+
function bitbucketPathHasPrefix(candidatePath: string, expectedPrefix: string): boolean {
352+
const expected = expectedPrefix.replace(/^\//, '').replace(/\/$/, '').split('/')
353+
const candidate = candidatePath.replace(/^\//, '').split('/')
354+
return (
355+
candidate.length > expected.length &&
356+
bitbucketSegmentsMatch(candidate.slice(0, expected.length), expected)
357+
)
358+
}
359+
326360
export function equalsIgnoreCase(a: string, b: string): boolean {
327361
return a.toLowerCase() === b.toLowerCase()
328362
}
329363

364+
export function bitbucketRepositoryPathHasPrefix(
365+
candidatePath: string,
366+
expectedPrefix: string
367+
): boolean {
368+
return bitbucketPathHasPrefix(candidatePath, expectedPrefix)
369+
}
370+
330371
export type BitbucketPullRequestRedirectKind = 'diff' | 'diffstat'
331372

332373
export function validateBitbucketPullRequestRedirect(
@@ -338,10 +379,7 @@ export function validateBitbucketPullRequestRedirect(
338379
const validated = validateBitbucketOpaqueUrl(value)
339380
const parsed = new URL(validated)
340381
const expectedPrefix = `/2.0${bitbucketRepositoryPath(workspaceSlug, repoSlug)}/${kind}/`
341-
const encodedSpec = equalsIgnoreCase(
342-
parsed.pathname.slice(0, expectedPrefix.length),
343-
expectedPrefix
344-
)
382+
const encodedSpec = bitbucketPathHasPrefix(parsed.pathname, expectedPrefix)
345383
? parsed.pathname.slice(expectedPrefix.length)
346384
: ''
347385
if (!encodedSpec) {
@@ -431,9 +469,7 @@ export function bitbucketApiUrl(
431469
const prefixSegments = decodePath(prefix)
432470
if (
433471
candidateSegments.length <= prefixSegments.length ||
434-
!prefixSegments.every((segment, index) =>
435-
equalsIgnoreCase(candidateSegments[index], segment)
436-
)
472+
!bitbucketSegmentsMatch(candidateSegments.slice(0, prefixSegments.length), prefixSegments)
437473
) {
438474
throw new Error('nextUrl does not belong to this Bitbucket list endpoint')
439475
}
@@ -452,7 +488,7 @@ export function bitbucketApiUrl(
452488
) {
453489
throw new Error('nextUrl does not preserve the requested Bitbucket directory path')
454490
}
455-
} else if (!equalsIgnoreCase(candidatePath.replace(/\/$/, ''), exactPath)) {
491+
} else if (!bitbucketPathsMatch(candidatePath.replace(/\/$/, ''), exactPath)) {
456492
throw new Error('nextUrl does not belong to this Bitbucket list endpoint')
457493
}
458494
return validated

0 commit comments

Comments
 (0)