diff --git a/apps/sim/tools/bitbucket/merge_pull_request.ts b/apps/sim/tools/bitbucket/merge_pull_request.ts index d0ad3d065e9..f631ecc220c 100644 --- a/apps/sim/tools/bitbucket/merge_pull_request.ts +++ b/apps/sim/tools/bitbucket/merge_pull_request.ts @@ -11,6 +11,7 @@ import { bitbucketHeaders, bitbucketJson, bitbucketPullRequestPath, + bitbucketRepositoryPathHasPrefix, normalizeBitbucketPullRequest, validateBitbucketOpaqueUrl, } from '@/tools/bitbucket/utils' @@ -52,7 +53,7 @@ function mergeTaskLocation( ) const parsed = new URL(taskUrl) const expectedPrefix = `/2.0${bitbucketPullRequestPath(params.workspaceSlug, params.repoSlug, params.prId)}/merge/task-status/` - if (!parsed.pathname.startsWith(expectedPrefix)) { + if (!bitbucketRepositoryPathHasPrefix(parsed.pathname, expectedPrefix)) { throw new Error('Bitbucket merge task Location did not match the requested pull request') } const taskId = decodeURIComponent(parsed.pathname.slice(expectedPrefix.length)) diff --git a/apps/sim/tools/bitbucket/pull-requests.test.ts b/apps/sim/tools/bitbucket/pull-requests.test.ts index 7490d3e7cb6..4e189981b0d 100644 --- a/apps/sim/tools/bitbucket/pull-requests.test.ts +++ b/apps/sim/tools/bitbucket/pull-requests.test.ts @@ -505,6 +505,20 @@ describe('Bitbucket merge lifecycle', () => { }) }) + it('accepts a canonical-cased merge task Location for a mixed-case slug', async () => { + const result = await bitbucketMergePullRequestTool.transformResponse!( + new Response(null, { + status: 202, + headers: { + Location: + 'https://api.bitbucket.org/2.0/repositories/acme%20team/sdk%2Fcore/pullrequests/7/merge/task-status/task-1', + }, + }), + { ...PULL_REQUEST_PARAMS, workspaceSlug: 'ACME Team', repoSlug: 'SDK/Core' } + ) + expect(result.output).toMatchObject({ status: 'pending', taskId: 'task-1' }) + }) + it('rejects missing, cross-origin, and wrong-pull-request task Locations', async () => { await expect( bitbucketMergePullRequestTool.transformResponse!( diff --git a/apps/sim/tools/bitbucket/utils.test.ts b/apps/sim/tools/bitbucket/utils.test.ts index f9ee13a14c6..9ba136f7af8 100644 --- a/apps/sim/tools/bitbucket/utils.test.ts +++ b/apps/sim/tools/bitbucket/utils.test.ts @@ -101,6 +101,42 @@ describe('Bitbucket path and pagination safety', () => { } }) + it('accepts a canonical-cased cursor for a mixed-case slug but not a re-cased file path', () => { + const canonical = 'https://api.bitbucket.org/2.0/repositories/acme/demo/commits?page=2' + expect(bitbucketApiUrl('/repositories/ACME/Demo/commits', { nextUrl: canonical })).toBe( + canonical + ) + + const revision = '0123456789abcdef0123456789abcdef01234567' + expect( + bitbucketApiUrl(`/repositories/ACME/Demo/src/${revision.toUpperCase()}/src/dir`, { + nextUrl: `https://api.bitbucket.org/2.0/repositories/acme/demo/src/${revision}/src/dir?page=2`, + nextPathPrefix: '/repositories/ACME/Demo/src', + nextPathSuffix: 'src/dir', + nextRevision: revision.toUpperCase(), + }) + ).toBe(`https://api.bitbucket.org/2.0/repositories/acme/demo/src/${revision}/src/dir?page=2`) + + for (const recased of [ + 'https://api.bitbucket.org/2.0/Repositories/acme/demo/commits?page=2', + 'https://api.bitbucket.org/2.0/repositories/acme/demo/Commits?page=2', + ]) { + expect( + () => bitbucketApiUrl('/repositories/ACME/Demo/commits', { nextUrl: recased }), + recased + ).toThrow(/does not belong to this Bitbucket list endpoint/) + } + + expect(() => + bitbucketApiUrl(`/repositories/acme/demo/src/${revision}/src/Dir`, { + nextUrl: `https://api.bitbucket.org/2.0/repositories/acme/demo/src/${revision}/src/dir?page=2`, + nextPathPrefix: '/repositories/acme/demo/src', + nextPathSuffix: 'src/Dir', + nextRevision: revision, + }) + ).toThrow(/does not preserve the requested Bitbucket directory path/) + }) + it('binds directory cursors to the selected repository path', () => { const revision = '0123456789abcdef0123456789abcdef01234567' const next = `https://api.bitbucket.org/2.0/repositories/acme/demo/src/${revision}/src/my%20dir?page=2` diff --git a/apps/sim/tools/bitbucket/utils.ts b/apps/sim/tools/bitbucket/utils.ts index c6066c98ebd..bb7639d5706 100644 --- a/apps/sim/tools/bitbucket/utils.ts +++ b/apps/sim/tools/bitbucket/utils.ts @@ -316,6 +316,58 @@ export function validateBitbucketOpaqueUrl(value: string): string { return parsed.toString() } +/** + * Bitbucket resolves workspace and repository slugs case-insensitively but echoes the canonical + * lowercase form in `next` links, redirect `Location` headers, and merge task URLs. Binding those + * back to the caller's slug must therefore ignore case, or a mixed-case slug that Bitbucket just + * accepted fails on the follow-up request. + * + * Only those two segments are canonicalized, so only those two are folded. Fixed API literals and + * repository file paths compare verbatim, keeping a malformed path a deterministic local failure + * rather than one deferred to Bitbucket. Hex revisions are folded by their own dedicated check. + */ +function isBitbucketSlugSegment(segments: string[], index: number): boolean { + return segments[0] === '2.0' && segments[1] === 'repositories' && (index === 2 || index === 3) +} + +function bitbucketSegmentsMatch(candidate: string[], expected: string[]): boolean { + if (candidate.length !== expected.length) return false + return expected.every( + (segment, index) => + candidate[index] === segment || + (isBitbucketSlugSegment(expected, index) && equalsIgnoreCase(candidate[index], segment)) + ) +} + +/** Compares two absolute API paths segment-wise under the slug-only case rule above. */ +function bitbucketPathsMatch(candidatePath: string, expectedPath: string): boolean { + return bitbucketSegmentsMatch( + candidatePath.replace(/^\//, '').split('/'), + expectedPath.replace(/^\//, '').split('/') + ) +} + +/** True when `candidatePath` begins with every segment of `expectedPrefix`, same case rule. */ +function bitbucketPathHasPrefix(candidatePath: string, expectedPrefix: string): boolean { + const expected = expectedPrefix.replace(/^\//, '').replace(/\/$/, '').split('/') + const candidate = candidatePath.replace(/^\//, '').split('/') + return ( + candidate.length > expected.length && + bitbucketSegmentsMatch(candidate.slice(0, expected.length), expected) + ) +} + +export function equalsIgnoreCase(a: string, b: string): boolean { + return a.toLowerCase() === b.toLowerCase() +} + +export function bitbucketRepositoryPathHasPrefix( + candidatePath: string, + expectedPrefix: string +): boolean { + return bitbucketPathHasPrefix(candidatePath, expectedPrefix) +} + export type BitbucketPullRequestRedirectKind = 'diff' | 'diffstat' export function validateBitbucketPullRequestRedirect( @@ -327,7 +379,7 @@ export function validateBitbucketPullRequestRedirect( const validated = validateBitbucketOpaqueUrl(value) const parsed = new URL(validated) const expectedPrefix = `/2.0${bitbucketRepositoryPath(workspaceSlug, repoSlug)}/${kind}/` - const encodedSpec = parsed.pathname.startsWith(expectedPrefix) + const encodedSpec = bitbucketPathHasPrefix(parsed.pathname, expectedPrefix) ? parsed.pathname.slice(expectedPrefix.length) : '' if (!encodedSpec) { @@ -417,12 +469,15 @@ export function bitbucketApiUrl( const prefixSegments = decodePath(prefix) if ( candidateSegments.length <= prefixSegments.length || - !prefixSegments.every((segment, index) => candidateSegments[index] === segment) + !bitbucketSegmentsMatch(candidateSegments.slice(0, prefixSegments.length), prefixSegments) ) { throw new Error('nextUrl does not belong to this Bitbucket list endpoint') } const revisionAndPath = candidateSegments.slice(prefixSegments.length) - if (options.nextRevision === undefined || revisionAndPath[0] !== options.nextRevision) { + if ( + options.nextRevision === undefined || + !equalsIgnoreCase(revisionAndPath[0], options.nextRevision) + ) { throw new Error('nextUrl does not preserve the requested Bitbucket revision') } const expectedSuffix = decodePath(options.nextPathSuffix ?? '') @@ -433,7 +488,7 @@ export function bitbucketApiUrl( ) { throw new Error('nextUrl does not preserve the requested Bitbucket directory path') } - } else if (candidatePath.replace(/\/$/, '') !== exactPath) { + } else if (!bitbucketPathsMatch(candidatePath.replace(/\/$/, ''), exactPath)) { throw new Error('nextUrl does not belong to this Bitbucket list endpoint') } return validated