@@ -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+
326360export 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+
330371export type BitbucketPullRequestRedirectKind = 'diff' | 'diffstat'
331372
332373export 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