feat(nipB7Blossom): add BUD-10 Blossom URI parser

Implements a pure-Kotlin (commonMain) parser for the BUD-10 blossom: URI
scheme, which allows sharing blob references with discovery hints.

Format: blossom:<sha256>.<ext>[?xs=<server>&as=<pubkey>&sz=<bytes>]

- BlossomUri data class holds sha256, extension, servers (xs), authors (as), size (sz)
- BlossomUri.parse() returns null for non-blossom URIs or invalid SHA-256
- Supports repeated xs/as params (multiple servers and authors)
- Percent-encodes server URLs on serialisation; decodes on parse
- BlossomUri.toUriString() round-trips back to a canonical URI
- Includes commonTest coverage for all parsing cases

https://claude.ai/code/session_01T4jyLbkNWd3f7i6MwsBcD4
This commit is contained in:
Claude
2026-03-11 19:16:45 +00:00
parent 3bfd3f4314
commit ac69a0fa74
2 changed files with 302 additions and 0 deletions
@@ -0,0 +1,169 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package com.vitorpamplona.quartz.nipB7Blossom
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* Parsed representation of a BUD-10 Blossom URI.
*
* Format: `blossom:<sha256>.<ext>[?parameters]`
*
* @param sha256 64-character lowercase hex hash of the blob.
* @param extension File extension (e.g. "jpg", "mp4"). Defaults to "bin" when unknown.
* @param servers Server base-URL hints where the blob may exist (`xs` params, tried first).
* @param authors Hex pubkeys of blob uploaders used for BUD-03 server-list lookup (`as` params).
* @param size Blob size in bytes for verification and progress display (`sz` param).
*/
data class BlossomUri(
val sha256: HexKey,
val extension: String,
val servers: List<String>,
val authors: List<HexKey>,
val size: Long?,
) {
/**
* Serialises back to a canonical `blossom:` URI string.
* Server URLs are percent-encoded so that `&`, `=`, and `#` inside them
* do not break the query string.
*/
fun toUriString(): String =
buildString {
append("blossom:")
append(sha256)
append('.')
append(extension)
val params =
buildList {
servers.forEach { add("xs=${percentEncodeQueryValue(it)}") }
authors.forEach { add("as=$it") }
size?.let { add("sz=$it") }
}
if (params.isNotEmpty()) {
append('?')
append(params.joinToString("&"))
}
}
companion object {
private const val SCHEME = "blossom:"
private val SHA256_REGEX = Regex("^[0-9a-f]{64}$")
/**
* Parses a BUD-10 URI string into a [BlossomUri], or returns `null` if the
* input is not a valid blossom URI (wrong scheme or malformed SHA-256).
*
* `xs` and `as` parameters may appear multiple times; all values are collected.
*/
fun parse(uri: String): BlossomUri? {
if (!uri.startsWith(SCHEME, ignoreCase = true)) return null
val rest = uri.substring(SCHEME.length)
val queryIndex = rest.indexOf('?')
val pathPart = if (queryIndex >= 0) rest.substring(0, queryIndex) else rest
val queryPart = if (queryIndex >= 0) rest.substring(queryIndex + 1) else ""
// Separate sha256 from extension at the last dot.
val dotIndex = pathPart.lastIndexOf('.')
val sha256: String
val extension: String
if (dotIndex > 0) {
sha256 = pathPart.substring(0, dotIndex).lowercase()
extension = pathPart.substring(dotIndex + 1).ifEmpty { "bin" }
} else {
sha256 = pathPart.lowercase()
extension = "bin"
}
if (!SHA256_REGEX.matches(sha256)) return null
// Collect repeated query parameters.
val servers = mutableListOf<String>()
val authors = mutableListOf<HexKey>()
var size: Long? = null
if (queryPart.isNotEmpty()) {
for (param in queryPart.split('&')) {
val eqIndex = param.indexOf('=')
if (eqIndex < 0) continue
val key = param.substring(0, eqIndex)
val value = percentDecode(param.substring(eqIndex + 1))
when (key) {
"xs" -> if (value.isNotEmpty()) servers.add(value)
"as" -> if (value.isNotEmpty()) authors.add(value)
"sz" -> value.toLongOrNull()?.let { size = it }
}
}
}
return BlossomUri(
sha256 = sha256,
extension = extension,
servers = servers,
authors = authors,
size = size,
)
}
/** Decodes percent-encoded sequences (e.g. `%3A` → `:`). */
private fun percentDecode(input: String): String {
if ('%' !in input) return input
val sb = StringBuilder(input.length)
var i = 0
while (i < input.length) {
val c = input[i]
if (c == '%' && i + 2 < input.length) {
val hex = input.substring(i + 1, i + 3)
val code = hex.toIntOrNull(16)
if (code != null) {
sb.append(code.toChar())
i += 3
continue
}
}
sb.append(c)
i++
}
return sb.toString()
}
/**
* Percent-encodes characters that are not safe inside a query-string value.
* Letters, digits, and unreserved chars (`-._~`) pass through unchanged.
* Everything else (notably `&`, `=`, `#`, and non-ASCII) is encoded.
*/
private fun percentEncodeQueryValue(input: String): String {
val sb = StringBuilder(input.length)
for (c in input) {
if (c.isLetterOrDigit() || c in "-._~:/?@!$'()*+,;") {
sb.append(c)
} else {
for (b in c.toString().encodeToByteArray()) {
sb.append('%')
sb.append((b.toInt() and 0xFF).toString(16).padStart(2, '0').uppercase())
}
}
}
return sb.toString()
}
}
}
@@ -0,0 +1,133 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package com.vitorpamplona.quartz.nipB7Blossom
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class BlossomUriTest {
private val sha256 = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"
private val authorPubkey = "a8f3721a0dc1b4d5c12f4cc7c54ae14071eb9c1b4f9b2cf0d4ab22c0e9f0c7e"
@Test
fun parsesMinimalUri() {
val uri = "blossom:${sha256}.jpg"
val result = BlossomUri.parse(uri)
assertNotNull(result)
assertEquals(sha256, result.sha256)
assertEquals("jpg", result.extension)
assertTrue(result.servers.isEmpty())
assertTrue(result.authors.isEmpty())
assertNull(result.size)
}
@Test
fun parsesFullUri() {
val uri = "blossom:${sha256}.mp4" +
"?xs=https%3A%2F%2Fcdn.example.com" +
"&xs=https%3A%2F%2Fbackup.example.com" +
"&as=$authorPubkey" +
"&sz=1048576"
val result = BlossomUri.parse(uri)
assertNotNull(result)
assertEquals(sha256, result.sha256)
assertEquals("mp4", result.extension)
assertEquals(listOf("https://cdn.example.com", "https://backup.example.com"), result.servers)
assertEquals(listOf(authorPubkey), result.authors)
assertEquals(1048576L, result.size)
}
@Test
fun parsesUriWithUnencodedServerUrls() {
// xs values with unencoded colons/slashes should still parse
val uri = "blossom:${sha256}.bin?xs=https://cdn.example.com&sz=512"
val result = BlossomUri.parse(uri)
assertNotNull(result)
assertEquals(listOf("https://cdn.example.com"), result.servers)
assertEquals(512L, result.size)
}
@Test
fun defaultsExtensionToBinWhenMissing() {
val uri = "blossom:$sha256"
val result = BlossomUri.parse(uri)
assertNotNull(result)
assertEquals("bin", result.extension)
}
@Test
fun isCaseInsensitiveForScheme() {
val result = BlossomUri.parse("BLOSSOM:${sha256}.png")
assertNotNull(result)
assertEquals(sha256, result.sha256)
}
@Test
fun normalisesUpperCaseSha256ToLowercase() {
val result = BlossomUri.parse("blossom:${sha256.uppercase()}.jpg")
assertNotNull(result)
assertEquals(sha256, result.sha256)
}
@Test
fun returnsNullForWrongScheme() {
assertNull(BlossomUri.parse("magnet:?xt=urn:btih:$sha256"))
assertNull(BlossomUri.parse("https://example.com/$sha256"))
}
@Test
fun returnsNullForMalformedSha256() {
assertNull(BlossomUri.parse("blossom:tooshort.jpg"))
assertNull(BlossomUri.parse("blossom:gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg.jpg"))
}
@Test
fun roundTrip() {
val original = BlossomUri(
sha256 = sha256,
extension = "jpg",
servers = listOf("https://cdn.example.com", "https://backup.example.com"),
authors = listOf(authorPubkey),
size = 204800L,
)
val reparsed = BlossomUri.parse(original.toUriString())
assertEquals(original, reparsed)
}
@Test
fun handlesMultipleAuthors() {
val author2 = "b8f3721a0dc1b4d5c12f4cc7c54ae14071eb9c1b4f9b2cf0d4ab22c0e9f0c7f"
val uri = "blossom:${sha256}.jpg?as=$authorPubkey&as=$author2"
val result = BlossomUri.parse(uri)
assertNotNull(result)
assertEquals(listOf(authorPubkey, author2), result.authors)
}
}