Skip to content

Commit 4fce519

Browse files
authored
fix(bitbucket): bind cursors and task locations case-insensitively (#6883)
* fix(bitbucket): bind cursors and task locations case-insensitively Bitbucket resolves workspace and repository slugs case-insensitively but echoes the canonical lowercase form in `next` links, diff/diffstat redirect targets, and async merge task Locations. Binding those back with exact string equality meant a mixed-case slug succeeded on the first request and then failed on every follow-up — worst on a 202 merge, where the merge has already started when polling breaks. Repository file paths keep verbatim comparison; git treats those as case-sensitive. * 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 0b71717 commit 4fce519

4 files changed

Lines changed: 111 additions & 5 deletions

File tree

apps/sim/tools/bitbucket/merge_pull_request.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
bitbucketHeaders,
1212
bitbucketJson,
1313
bitbucketPullRequestPath,
14+
bitbucketRepositoryPathHasPrefix,
1415
normalizeBitbucketPullRequest,
1516
validateBitbucketOpaqueUrl,
1617
} from '@/tools/bitbucket/utils'
@@ -52,7 +53,7 @@ function mergeTaskLocation(
5253
)
5354
const parsed = new URL(taskUrl)
5455
const expectedPrefix = `/2.0${bitbucketPullRequestPath(params.workspaceSlug, params.repoSlug, params.prId)}/merge/task-status/`
55-
if (!parsed.pathname.startsWith(expectedPrefix)) {
56+
if (!bitbucketRepositoryPathHasPrefix(parsed.pathname, expectedPrefix)) {
5657
throw new Error('Bitbucket merge task Location did not match the requested pull request')
5758
}
5859
const taskId = decodeURIComponent(parsed.pathname.slice(expectedPrefix.length))

apps/sim/tools/bitbucket/pull-requests.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,20 @@ describe('Bitbucket merge lifecycle', () => {
505505
})
506506
})
507507

508+
it('accepts a canonical-cased merge task Location for a mixed-case slug', async () => {
509+
const result = await bitbucketMergePullRequestTool.transformResponse!(
510+
new Response(null, {
511+
status: 202,
512+
headers: {
513+
Location:
514+
'https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/pullrequests/7/merge/task-status/task-1',
515+
},
516+
}),
517+
{ ...PULL_REQUEST_PARAMS, workspaceSlug: 'ACME Team', repoSlug: 'SDK/Core' }
518+
)
519+
expect(result.output).toMatchObject({ status: 'pending', taskId: 'task-1' })
520+
})
521+
508522
it('rejects missing, cross-origin, and wrong-pull-request task Locations', async () => {
509523
await expect(
510524
bitbucketMergePullRequestTool.transformResponse!(

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,42 @@ describe('Bitbucket path and pagination safety', () => {
101101
}
102102
})
103103

104+
it('accepts a canonical-cased cursor for a mixed-case slug but not a re-cased file path', () => {
105+
const canonical = 'https://api.bitbucket.org/2.0/repositories/acme/demo/commits?page=2'
106+
expect(bitbucketApiUrl('/repositories/ACME/Demo/commits', { nextUrl: canonical })).toBe(
107+
canonical
108+
)
109+
110+
const revision = '0123456789abcdef0123456789abcdef01234567'
111+
expect(
112+
bitbucketApiUrl(`/repositories/ACME/Demo/src/${revision.toUpperCase()}/src/dir`, {
113+
nextUrl: `https://api.bitbucket.org/2.0/repositories/acme/demo/src/${revision}/src/dir?page=2`,
114+
nextPathPrefix: '/repositories/ACME/Demo/src',
115+
nextPathSuffix: 'src/dir',
116+
nextRevision: revision.toUpperCase(),
117+
})
118+
).toBe(`https://api.bitbucket.org/2.0/repositories/acme/demo/src/${revision}/src/dir?page=2`)
119+
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+
130+
expect(() =>
131+
bitbucketApiUrl(`/repositories/acme/demo/src/${revision}/src/Dir`, {
132+
nextUrl: `https://api.bitbucket.org/2.0/repositories/acme/demo/src/${revision}/src/dir?page=2`,
133+
nextPathPrefix: '/repositories/acme/demo/src',
134+
nextPathSuffix: 'src/Dir',
135+
nextRevision: revision,
136+
})
137+
).toThrow(/does not preserve the requested Bitbucket directory path/)
138+
})
139+
104140
it('binds directory cursors to the selected repository path', () => {
105141
const revision = '0123456789abcdef0123456789abcdef01234567'
106142
const next = `https://api.bitbucket.org/2.0/repositories/acme/demo/src/${revision}/src/my%20dir?page=2`

apps/sim/tools/bitbucket/utils.ts

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,58 @@ export function validateBitbucketOpaqueUrl(value: string): string {
316316
return parsed.toString()
317317
}
318318

319+
/**
320+
* Bitbucket resolves workspace and repository slugs case-insensitively but echoes the canonical
321+
* lowercase form in `next` links, redirect `Location` headers, and merge task URLs. Binding those
322+
* 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.
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.
328+
*/
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+
360+
export function equalsIgnoreCase(a: string, b: string): boolean {
361+
return a.toLowerCase() === b.toLowerCase()
362+
}
363+
364+
export function bitbucketRepositoryPathHasPrefix(
365+
candidatePath: string,
366+
expectedPrefix: string
367+
): boolean {
368+
return bitbucketPathHasPrefix(candidatePath, expectedPrefix)
369+
}
370+
319371
export type BitbucketPullRequestRedirectKind = 'diff' | 'diffstat'
320372

321373
export function validateBitbucketPullRequestRedirect(
@@ -327,7 +379,7 @@ export function validateBitbucketPullRequestRedirect(
327379
const validated = validateBitbucketOpaqueUrl(value)
328380
const parsed = new URL(validated)
329381
const expectedPrefix = `/2.0${bitbucketRepositoryPath(workspaceSlug, repoSlug)}/${kind}/`
330-
const encodedSpec = parsed.pathname.startsWith(expectedPrefix)
382+
const encodedSpec = bitbucketPathHasPrefix(parsed.pathname, expectedPrefix)
331383
? parsed.pathname.slice(expectedPrefix.length)
332384
: ''
333385
if (!encodedSpec) {
@@ -417,12 +469,15 @@ export function bitbucketApiUrl(
417469
const prefixSegments = decodePath(prefix)
418470
if (
419471
candidateSegments.length <= prefixSegments.length ||
420-
!prefixSegments.every((segment, index) => candidateSegments[index] === segment)
472+
!bitbucketSegmentsMatch(candidateSegments.slice(0, prefixSegments.length), prefixSegments)
421473
) {
422474
throw new Error('nextUrl does not belong to this Bitbucket list endpoint')
423475
}
424476
const revisionAndPath = candidateSegments.slice(prefixSegments.length)
425-
if (options.nextRevision === undefined || revisionAndPath[0] !== options.nextRevision) {
477+
if (
478+
options.nextRevision === undefined ||
479+
!equalsIgnoreCase(revisionAndPath[0], options.nextRevision)
480+
) {
426481
throw new Error('nextUrl does not preserve the requested Bitbucket revision')
427482
}
428483
const expectedSuffix = decodePath(options.nextPathSuffix ?? '')
@@ -433,7 +488,7 @@ export function bitbucketApiUrl(
433488
) {
434489
throw new Error('nextUrl does not preserve the requested Bitbucket directory path')
435490
}
436-
} else if (candidatePath.replace(/\/$/, '') !== exactPath) {
491+
} else if (!bitbucketPathsMatch(candidatePath.replace(/\/$/, ''), exactPath)) {
437492
throw new Error('nextUrl does not belong to this Bitbucket list endpoint')
438493
}
439494
return validated

0 commit comments

Comments
 (0)