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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user