fix(blossom-bridge): only the last path segment is the blob hash

BUD-01 defines a Blossom URL as `<server>/<sha256>[.<ext>]` — the blob hash is
always the last path segment. Walking the path right-to-left for any hex
match was too permissive: a non-Blossom URL like `https://example.com/<sha>/avatar.jpg`
(sha appears in an intermediate segment) would get incorrectly bridged.

Both parsers now look at the last path segment only and skip the URL entirely
when it isn't a sha256. The earlier hex-prefix case (share.yabu.me's
`<cache-prefix>/<blob>.ext`) still works because the blob is still the last
segment; the prefix flows into `xs` via the existing `buildServerBase` /
`extractServerBase` logic. Adds negative tests covering the
sha-in-non-last-segment case in both modules.
This commit is contained in:
Claude
2026-05-13 10:27:26 +00:00
parent 74c2bd2fa8
commit 3cc9ac8b7c
4 changed files with 48 additions and 19 deletions
@@ -72,18 +72,18 @@ class LocalBlossomCacheRedirectInterceptor(
} }
private fun findSha256AndExtensionInPath(url: HttpUrl): Triple<Int, String, String>? { private fun findSha256AndExtensionInPath(url: HttpUrl): Triple<Int, String, String>? {
// Walk segments right-to-left so CDNs that put a cache prefix (itself a // Per Blossom (BUD-01) the blob is always the last path segment. If the
// 64-char hex segment) ahead of the blob hash — e.g. // last segment isn't a sha256, this isn't a Blossom URL and the bridge
// `https://share.yabu.me/<prefix>/<sha>.webp` — still resolve to the // must leave it alone — even if an earlier path segment happens to be
// blob and leave the prefix in `xs`. // a 64-char hex (e.g. a per-user cache prefix). The prefix segments
for (index in url.pathSegments.indices.reversed()) { // are preserved verbatim via `buildServerBase`.
val segment = url.pathSegments[index] val lastIndex = url.pathSegments.lastIndex
val match = SHA256_SEGMENT_REGEX.find(segment) ?: continue if (lastIndex < 0) return null
val segment = url.pathSegments[lastIndex]
val match = SHA256_SEGMENT_REGEX.find(segment) ?: return null
val sha = match.value.lowercase() val sha = match.value.lowercase()
val ext = guessExtensionFrom(segment, sha) ?: "bin" val ext = guessExtensionFrom(segment, sha) ?: "bin"
return Triple(index, sha, ext) return Triple(lastIndex, sha, ext)
}
return null
} }
/** /**
@@ -133,7 +133,7 @@ class LocalBlossomCacheRedirectInterceptorTest {
} }
@Test @Test
fun bridgeOnPicksRightmostShaWhenPathHasTwoHashes() { fun bridgeOnRewritesShaInLastPathSegmentWithHexPrefix() {
// share.yabu.me layout: <cache-prefix-sha>/<blob-sha>.<ext> // share.yabu.me layout: <cache-prefix-sha>/<blob-sha>.<ext>
val interceptor = LocalBlossomCacheRedirectInterceptor { true } val interceptor = LocalBlossomCacheRedirectInterceptor { true }
val captured = mutableListOf<String>() val captured = mutableListOf<String>()
@@ -151,6 +151,18 @@ class LocalBlossomCacheRedirectInterceptorTest {
response.close() response.close()
} }
@Test
fun bridgeOnSkipsWhenLastSegmentIsNotSha() {
// Per BUD-01 the last segment is the blob; if it isn't a sha256, the
// URL isn't a Blossom blob even if an earlier segment is hex.
val interceptor = LocalBlossomCacheRedirectInterceptor { true }
val captured = mutableListOf<String>()
val url = "https://example.com/$sha/avatar.jpg"
val response = interceptor.intercept(fakeChain(url, captured))
assertEquals(url, captured.single())
response.close()
}
private fun fakeChain( private fun fakeChain(
url: String, url: String,
captured: MutableList<String>, captured: MutableList<String>,
@@ -144,11 +144,13 @@ private fun percentEncode(input: String): String {
} }
private fun extractSha256FromUrlPath(url: String): String? { private fun extractSha256FromUrlPath(url: String): String? {
// Per Blossom (BUD-01) the blob is always the last path segment. If the
// last segment isn't a sha256, this isn't a Blossom URL and the bridge
// must leave it alone — even if an earlier path segment happens to be
// a 64-char hex (e.g. a per-user cache prefix).
val pathPart = url.substringBefore('?').substringBefore('#') val pathPart = url.substringBefore('?').substringBefore('#')
// Prefer the rightmost 64-char hex segment so CDNs that put a cache prefix val lastSegment = pathPart.substringAfterLast('/')
// (itself a 64-char hex) ahead of the blob hash — e.g. val match = sha256InPathRegex.find(lastSegment) ?: return null
// `https://share.yabu.me/<prefix>/<sha>.webp` — still resolve to the blob.
val match = sha256InPathRegex.findAll(pathPart).lastOrNull() ?: return null
return match.value.lowercase() return match.value.lowercase()
} }
@@ -175,7 +175,7 @@ class MediaUrlContentExtTest {
} }
@Test @Test
fun bridgeOnPicksRightmostShaWhenPathHasTwoHashes() { fun bridgeOnRewritesShaInLastPathSegmentWithHexPrefix() {
// share.yabu.me layout: <cache-prefix-sha>/<blob-sha>.<ext> // share.yabu.me layout: <cache-prefix-sha>/<blob-sha>.<ext>
val image = val image =
MediaUrlImage( MediaUrlImage(
@@ -189,7 +189,7 @@ class MediaUrlContentExtTest {
} }
@Test @Test
fun bridgeProfilePictureUrlPicksRightmostShaWhenPathHasTwoHashes() { fun bridgeProfilePictureUrlRewritesShaInLastPathSegmentWithHexPrefix() {
assertEquals( assertEquals(
"http://127.0.0.1:24242/28fa4d999af6ae3e4e11bfc2727130ef1b3a13cc0f981e5a93c3996cb2f524e5.webp?xs=https%3A%2F%2Fshare.yabu.me%2F84b0c46ab699ac35eb2ca286470b85e081db2087cdef63932236c397417782f5", "http://127.0.0.1:24242/28fa4d999af6ae3e4e11bfc2727130ef1b3a13cc0f981e5a93c3996cb2f524e5.webp?xs=https%3A%2F%2Fshare.yabu.me%2F84b0c46ab699ac35eb2ca286470b85e081db2087cdef63932236c397417782f5",
bridgeProfilePictureUrl( bridgeProfilePictureUrl(
@@ -198,4 +198,19 @@ class MediaUrlContentExtTest {
), ),
) )
} }
@Test
fun bridgeOnSkipsWhenLastSegmentIsNotSha() {
// Per BUD-01 the last segment is the blob; if it isn't a sha256, the
// URL isn't a Blossom blob even if an earlier segment is hex.
val url = "https://example.com/$sha/avatar.jpg"
val image = MediaUrlImage(url = url, hash = null)
assertEquals(url, image.toCoilModel(useLocalBlossomBridge = true))
}
@Test
fun bridgeProfilePictureUrlSkipsWhenLastSegmentIsNotSha() {
val url = "https://example.com/$sha/avatar.jpg"
assertEquals(url, bridgeProfilePictureUrl(url, useBridge = true))
}
} }