Merge pull request #2707 from mstrofnone/feat/namecoin-import-nip05-only
feat(namecoin): resolve `import` items per ifa-0001 (NIP-05 only)
This commit is contained in:
+276
@@ -0,0 +1,276 @@
|
||||
/*
|
||||
* 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.nip05DnsIdentifiers.namecoin
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
|
||||
/**
|
||||
* Resolves the [`import`][ifa-0001] item of a Namecoin Domain Name Object,
|
||||
* recursively merging values from the imported names into the importing
|
||||
* object before the caller extracts fields like `nostr`.
|
||||
*
|
||||
* Per [ifa-0001](https://github.com/namecoin/proposals/blob/master/ifa-0001.md)
|
||||
* §"import":
|
||||
*
|
||||
* - The importing object's items take precedence over the imported items.
|
||||
* `null` items in the importer are still considered "present" and so
|
||||
* nullify the corresponding imported item (semantic suppression).
|
||||
* - The `"import"` value is an array of arrays. Each inner array has at
|
||||
* least one element, the name to import (e.g. `"d/example2"`), and an
|
||||
* optional second element, a Subdomain Selector (DNS-format, dotted).
|
||||
* Selector labels are resolved via the imported value's `map` tree
|
||||
* before merging.
|
||||
* - Three short-hand value forms are accepted alongside the canonical
|
||||
* array-of-arrays. Real-world Namecoin records frequently write the
|
||||
* bare-string form, so we MUST handle it:
|
||||
* - `"import": "d/foo"` ↔ `[["d/foo"]]`
|
||||
* - `"import": ["d/foo"]` ↔ `[["d/foo"]]`
|
||||
* - `"import": ["d/foo","sub"]` ↔ `[["d/foo","sub"]]`
|
||||
* - Recursion: the spec mandates that implementations support **at least
|
||||
* a recursion depth of four**. We default to that limit; deeper chains
|
||||
* are silently truncated (the importing object's own items still apply).
|
||||
* - Cycles are broken by a visited-set keyed on `name|selector`.
|
||||
* - Maps are merged shallow-per-spec: a key in the importing object
|
||||
* replaces the imported value for that key wholesale; there is no
|
||||
* recursive merge of nested objects (none of the items consumed by
|
||||
* Quartz today require it).
|
||||
* - A failed lookup (name not found, malformed JSON, network error)
|
||||
* MAY cause the whole importing record to fail per spec. To preserve
|
||||
* Quartz's existing best-effort namecoin behaviour (where transient
|
||||
* ElectrumX hiccups don't kill resolution outright), this
|
||||
* implementation treats a failed import as if the imported value
|
||||
* were the empty object `{}`. The importing object's own items still
|
||||
* apply.
|
||||
*
|
||||
* Use [expandImports] in any flow that has a parsed root [JsonObject]
|
||||
* and needs the post-import view before extracting record-specific fields.
|
||||
*/
|
||||
internal object NamecoinImportResolver {
|
||||
/** The minimum recursion depth ifa-0001 requires implementations to support. */
|
||||
const val DEFAULT_MAX_DEPTH: Int = 4
|
||||
|
||||
private val SHARED_JSON =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Async name lookup callback. Returns the raw value JSON string of the
|
||||
* named record, or `null` if the name does not exist / is expired /
|
||||
* could not be fetched. Failures are absorbed: the returned object is
|
||||
* always usable.
|
||||
*
|
||||
* The callback is called once per `import` target (deduplicated within
|
||||
* a single recursive expansion path).
|
||||
*/
|
||||
typealias NameValueFetcher = suspend (namecoinName: String) -> String?
|
||||
|
||||
/**
|
||||
* Expand all `import` items in [root] (and recursively in imported
|
||||
* objects) up to [maxDepth] levels deep, returning a single merged
|
||||
* [JsonObject] with no `import` key.
|
||||
*
|
||||
* The merged object preserves the importing object's items unchanged;
|
||||
* imported items only fill in keys the importing object did not declare
|
||||
* (including keys whose value is `null` — those remain suppressed).
|
||||
*
|
||||
* If [root] has no `import` key, it is returned unchanged.
|
||||
*/
|
||||
suspend fun expandImports(
|
||||
root: JsonObject,
|
||||
maxDepth: Int = DEFAULT_MAX_DEPTH,
|
||||
fetcher: NameValueFetcher,
|
||||
): JsonObject = expandRecursive(root, fetcher, maxDepth, mutableSetOf())
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
private suspend fun expandRecursive(
|
||||
obj: JsonObject,
|
||||
fetcher: NameValueFetcher,
|
||||
budgetRemaining: Int,
|
||||
visited: MutableSet<String>,
|
||||
): JsonObject {
|
||||
val importItem = obj["import"] ?: return obj
|
||||
val operations = parseImportItem(importItem) ?: return removeImportKey(obj)
|
||||
if (operations.isEmpty() || budgetRemaining <= 0) return removeImportKey(obj)
|
||||
|
||||
// Walk imports left-to-right. Spec is silent on multiple-import
|
||||
// precedence; we follow the common-sense rule that LATER imports
|
||||
// override EARLIER ones in the same array (otherwise listing two
|
||||
// libraries would silently ignore the second). The whole accumulator
|
||||
// still loses to the importing object on top of all of it.
|
||||
var accumulator: JsonObject = JsonObject(emptyMap())
|
||||
for (op in operations) {
|
||||
val visitKey = visitKeyFor(op)
|
||||
if (!visited.add(visitKey)) continue // cycle / duplicate within this chain
|
||||
try {
|
||||
val importedRaw = fetcher(op.name) ?: continue
|
||||
val importedRoot = tryParseObject(importedRaw) ?: continue
|
||||
val selectorView = applySelector(importedRoot, op.selector) ?: continue
|
||||
val expanded =
|
||||
expandRecursive(
|
||||
selectorView,
|
||||
fetcher,
|
||||
budgetRemaining - 1,
|
||||
visited,
|
||||
)
|
||||
accumulator = mergeImporterWins(importer = expanded, imported = accumulator)
|
||||
} finally {
|
||||
visited.remove(visitKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Finally merge the importing object on top, removing its `import` key.
|
||||
val withoutImport = removeImportKey(obj)
|
||||
return mergeImporterWins(importer = withoutImport, imported = accumulator)
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two objects with importer-wins semantics: every key in [importer]
|
||||
* stays as-is (including `null` values, which suppress the imported
|
||||
* counterpart per ifa-0001); keys present only in [imported] are added.
|
||||
*/
|
||||
private fun mergeImporterWins(
|
||||
importer: JsonObject,
|
||||
imported: JsonObject,
|
||||
): JsonObject {
|
||||
if (imported.isEmpty()) return importer
|
||||
if (importer.isEmpty()) return imported
|
||||
val out = LinkedHashMap<String, JsonElement>(importer.size + imported.size)
|
||||
// Imported first so we can overwrite with importer.
|
||||
for ((k, v) in imported) out[k] = v
|
||||
for ((k, v) in importer) out[k] = v
|
||||
return JsonObject(out)
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the imported object's `map` tree to the node addressed by
|
||||
* [selector] (DNS dotted, e.g. `relay`, `a.b.c`). Empty selector
|
||||
* returns [root] unchanged.
|
||||
*
|
||||
* Resolution rules per ifa-0001 §"map":
|
||||
* - Exact label match wins.
|
||||
* - Wildcard `*` matches any single label.
|
||||
* - Empty key `""` is the default for the current level when no other
|
||||
* match applies.
|
||||
* - A non-object child terminates the walk with `null`.
|
||||
*/
|
||||
private fun applySelector(
|
||||
root: JsonObject,
|
||||
selector: String,
|
||||
): JsonObject? {
|
||||
if (selector.isEmpty()) return root
|
||||
// Selector is DNS-dotted: leftmost label is the most-specific. The
|
||||
// `map` tree is rooted at the parent and nests inwards toward the
|
||||
// leaf, so we walk labels right-to-left (the rightmost label is
|
||||
// the immediate child of the parent's `map`).
|
||||
val labels =
|
||||
selector
|
||||
.split('.')
|
||||
.filter { it.isNotEmpty() }
|
||||
.asReversed()
|
||||
if (labels.isEmpty()) return root
|
||||
|
||||
var current: JsonObject = root
|
||||
for (label in labels) {
|
||||
val map = current["map"] as? JsonObject ?: return null
|
||||
val child =
|
||||
(map[label] as? JsonObject)
|
||||
?: (map["*"] as? JsonObject)
|
||||
?: (map[""] as? JsonObject)
|
||||
?: return null
|
||||
current = child
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
private fun tryParseObject(rawJson: String): JsonObject? = runCatching { SHARED_JSON.parseToJsonElement(rawJson).jsonObject }.getOrNull()
|
||||
|
||||
private fun removeImportKey(obj: JsonObject): JsonObject {
|
||||
if (!obj.containsKey("import")) return obj
|
||||
val out = LinkedHashMap<String, JsonElement>(obj.size - 1)
|
||||
for ((k, v) in obj) if (k != "import") out[k] = v
|
||||
return JsonObject(out)
|
||||
}
|
||||
|
||||
private fun visitKeyFor(op: ImportOp): String = "${op.name}|${op.selector}"
|
||||
|
||||
/**
|
||||
* Parse the value of an `import` item into a flat list of [ImportOp]
|
||||
* descriptors. Returns `null` if the value is malformed.
|
||||
*
|
||||
* Accepted shapes (in order of preference):
|
||||
* - canonical: `[ ["d/foo"], ["d/bar","sub"] ]`
|
||||
* - shorthand string: `"d/foo"` → one op with no selector
|
||||
* - shorthand single-array: `["d/foo"]` → one op with no selector
|
||||
* - shorthand pair-array: `["d/foo","sub"]` → one op with selector
|
||||
*
|
||||
* Anything else is treated as malformed and the import is skipped.
|
||||
*/
|
||||
private fun parseImportItem(item: JsonElement): List<ImportOp>? {
|
||||
// Shorthand: bare string.
|
||||
if (item is JsonPrimitive && item.isString) {
|
||||
return listOf(ImportOp(item.content, ""))
|
||||
}
|
||||
// Array shapes.
|
||||
val arr = (item as? JsonArray) ?: return null
|
||||
if (arr.isEmpty()) return emptyList()
|
||||
|
||||
// Distinguish: array-of-arrays (canonical) vs array-of-strings (shorthand).
|
||||
val firstIsArray = arr.first() is JsonArray
|
||||
if (firstIsArray) {
|
||||
return arr.mapNotNull { entry ->
|
||||
val inner = (entry as? JsonArray) ?: return@mapNotNull null
|
||||
opFromArray(inner)
|
||||
}
|
||||
}
|
||||
// Shorthand: ["name"] or ["name","selector"]. All elements must be
|
||||
// strings; anything else makes the whole item malformed.
|
||||
return listOfNotNull(opFromArray(arr))
|
||||
}
|
||||
|
||||
private fun opFromArray(arr: JsonArray): ImportOp? {
|
||||
if (arr.isEmpty()) return null
|
||||
val name = (arr[0] as? JsonPrimitive)?.takeIf { it.isString }?.content?.trim() ?: return null
|
||||
if (name.isEmpty()) return null
|
||||
val selector =
|
||||
if (arr.size >= 2) {
|
||||
(arr[1] as? JsonPrimitive)?.takeIf { it.isString }?.content?.trim() ?: ""
|
||||
} else {
|
||||
""
|
||||
}
|
||||
// Trailing dot is forbidden by spec; treat as malformed → no selector.
|
||||
if (selector.endsWith('.')) return null
|
||||
return ImportOp(name = name, selector = selector)
|
||||
}
|
||||
|
||||
private data class ImportOp(
|
||||
val name: String,
|
||||
/** DNS dotted, may be empty. Preserved as written. */
|
||||
val selector: String,
|
||||
)
|
||||
}
|
||||
+31
-4
@@ -210,10 +210,11 @@ class NamecoinNameResolver(
|
||||
private suspend fun performLookup(parsed: ParsedIdentifier): NamecoinNostrResult? {
|
||||
val nameResult = electrumxClient.nameShowWithFallback(parsed.namecoinName, serverListProvider()) ?: return null
|
||||
val valueJson = tryParseJson(nameResult.value) ?: return null
|
||||
val merged = expandImportsIfPresent(valueJson)
|
||||
|
||||
return when (parsed.namespace) {
|
||||
Namespace.DOMAIN -> extractFromDomainValue(valueJson, parsed)
|
||||
Namespace.IDENTITY -> extractFromIdentityValue(valueJson, parsed)
|
||||
Namespace.DOMAIN -> extractFromDomainValue(merged, parsed)
|
||||
Namespace.IDENTITY -> extractFromIdentityValue(merged, parsed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,11 +237,12 @@ class NamecoinNameResolver(
|
||||
val valueJson =
|
||||
tryParseJson(nameResult.value)
|
||||
?: return NamecoinResolveOutcome.NoNostrField(parsed.namecoinName)
|
||||
val merged = expandImportsIfPresent(valueJson)
|
||||
|
||||
val nostrResult =
|
||||
when (parsed.namespace) {
|
||||
Namespace.DOMAIN -> extractFromDomainValue(valueJson, parsed)
|
||||
Namespace.IDENTITY -> extractFromIdentityValue(valueJson, parsed)
|
||||
Namespace.DOMAIN -> extractFromDomainValue(merged, parsed)
|
||||
Namespace.IDENTITY -> extractFromIdentityValue(merged, parsed)
|
||||
}
|
||||
|
||||
return if (nostrResult != null) {
|
||||
@@ -250,6 +252,31 @@ class NamecoinNameResolver(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand any ifa-0001 `import` items in [root] into a single merged
|
||||
* object, fetching imported names through this resolver's ElectrumX
|
||||
* client. Records without an `import` key are returned unchanged with
|
||||
* zero extra I/O.
|
||||
*
|
||||
* Failures (name not found, malformed JSON, network errors) are
|
||||
* absorbed: the corresponding import contributes nothing and the
|
||||
* importing record's own items still apply. This keeps resolution
|
||||
* best-effort, in line with the rest of the namecoin path.
|
||||
*/
|
||||
private suspend fun expandImportsIfPresent(root: JsonObject): JsonObject {
|
||||
if (!root.containsKey("import")) return root
|
||||
return NamecoinImportResolver.expandImports(root) { name ->
|
||||
try {
|
||||
electrumxClient.nameShowWithFallback(name, serverListProvider())?.value
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} catch (e: NamecoinLookupException) {
|
||||
// Best-effort: missing/expired/unreachable → contribute nothing.
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Nostr data from a `d/` domain value.
|
||||
*
|
||||
|
||||
+533
@@ -0,0 +1,533 @@
|
||||
/*
|
||||
* 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.nip05.namecoin
|
||||
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.IElectrumXClient
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NameShowResult
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinImportResolver
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
|
||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Hermetic tests for [NamecoinImportResolver] and the import-aware
|
||||
* NIP-05 resolution path through [NamecoinNameResolver].
|
||||
*
|
||||
* Tests do NOT touch the network: every "imported" name is served by
|
||||
* an in-memory map keyed by Namecoin name.
|
||||
*/
|
||||
class NamecoinImportTest {
|
||||
private val json =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
// ── Pure unit tests (NamecoinImportResolver only) ─────────────────────
|
||||
|
||||
@Test
|
||||
fun `no import key returns object unchanged`() =
|
||||
runTest {
|
||||
val obj = parse("""{"ip":"1.2.3.4"}""")
|
||||
val expanded = NamecoinImportResolver.expandImports(obj) { error("should not be called") }
|
||||
assertEquals(obj, expanded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `string shorthand import merges imported items into importer`() =
|
||||
runTest {
|
||||
// ifa-0001 §"import" canonical form is array-of-arrays, but the
|
||||
// string form `"import": "d/foo"` is widely used in practice; we
|
||||
// accept it as shorthand for `[["d/foo"]]`.
|
||||
val obj = parse("""{"import":"d/lib","ip":"1.1.1.1"}""")
|
||||
val expanded =
|
||||
NamecoinImportResolver.expandImports(obj) { name ->
|
||||
when (name) {
|
||||
"d/lib" -> """{"ip":"9.9.9.9","nostr":{"names":{"_":"abc"}}}"""
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
// importer wins on `ip`, imports fill in `nostr.names`.
|
||||
assertEquals("1.1.1.1", expanded["ip"]?.jsonPrimitive?.content)
|
||||
assertEquals(
|
||||
"abc",
|
||||
expanded["nostr"]
|
||||
?.jsonObject
|
||||
?.get("names")
|
||||
?.jsonObject
|
||||
?.get("_")
|
||||
?.jsonPrimitive
|
||||
?.content,
|
||||
)
|
||||
assertFalse("import key must not survive expansion", expanded.containsKey("import"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canonical array of arrays form processes each in order`() =
|
||||
runTest {
|
||||
val obj = parse("""{"import":[["d/a"],["d/b"]]}""")
|
||||
val expanded =
|
||||
NamecoinImportResolver.expandImports(obj) { name ->
|
||||
when (name) {
|
||||
"d/a" -> """{"ip":"10.0.0.1","tag":"from-a"}"""
|
||||
"d/b" -> """{"ip":"10.0.0.2","extra":"from-b"}"""
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
// d/b is processed AFTER d/a, so its `ip` (10.0.0.2) overrides d/a's.
|
||||
// The importer itself has no `ip`, so the last imported one wins.
|
||||
assertEquals("10.0.0.2", expanded["ip"]?.jsonPrimitive?.content)
|
||||
assertEquals("from-a", expanded["tag"]?.jsonPrimitive?.content)
|
||||
assertEquals("from-b", expanded["extra"]?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pair-array shorthand uses subdomain selector`() =
|
||||
runTest {
|
||||
val obj = parse("""{"import":["d/lib","relay"]}""")
|
||||
val expanded =
|
||||
NamecoinImportResolver.expandImports(obj) { name ->
|
||||
when (name) {
|
||||
"d/lib" -> {
|
||||
"""
|
||||
{"ip":"1.1.1.1",
|
||||
"map":{"relay":{"ip":"7.7.7.7","tag":"selected"}}}
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
// We selected `map.relay` from d/lib, so its contents (ip=7.7.7.7,
|
||||
// tag=selected) are merged at the top level of the importer.
|
||||
// d/lib's top-level ip (1.1.1.1) is NOT seen because we descended.
|
||||
assertEquals("7.7.7.7", expanded["ip"]?.jsonPrimitive?.content)
|
||||
assertEquals("selected", expanded["tag"]?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `importer items take precedence over imported items`() =
|
||||
runTest {
|
||||
val obj = parse("""{"import":"d/lib","ip":"1.1.1.1","extra":"local"}""")
|
||||
val expanded =
|
||||
NamecoinImportResolver.expandImports(obj) { name ->
|
||||
when (name) {
|
||||
"d/lib" -> """{"ip":"9.9.9.9","extra":"remote","only-imported":"yes"}"""
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
assertEquals("1.1.1.1", expanded["ip"]?.jsonPrimitive?.content)
|
||||
assertEquals("local", expanded["extra"]?.jsonPrimitive?.content)
|
||||
assertEquals("yes", expanded["only-imported"]?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `null in importer suppresses imported value`() =
|
||||
runTest {
|
||||
// ifa-0001: null is "present for precedence" — semantic
|
||||
// suppression. The importer says ip=null, so imported ip is gone.
|
||||
val obj = parse("""{"import":"d/lib","ip":null}""")
|
||||
val expanded =
|
||||
NamecoinImportResolver.expandImports(obj) { name ->
|
||||
when (name) {
|
||||
"d/lib" -> """{"ip":"9.9.9.9","other":"keep"}"""
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
// The merged object still has `ip` as a JsonNull, not removed.
|
||||
// Downstream parsers ignore null as if absent (same outcome).
|
||||
assertTrue(expanded.containsKey("ip"))
|
||||
assertEquals(
|
||||
kotlinx.serialization.json.JsonNull,
|
||||
expanded["ip"],
|
||||
)
|
||||
assertEquals("keep", expanded["other"]?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recursion depth four is supported`() =
|
||||
runTest {
|
||||
// ifa-0001 mandates implementations support a recursion degree
|
||||
// of at least 4. Test pins the 4-deep happy path.
|
||||
val obj = parse("""{"import":"d/a"}""")
|
||||
val expanded =
|
||||
NamecoinImportResolver.expandImports(obj) { name ->
|
||||
when (name) {
|
||||
"d/a" -> """{"import":"d/b","layer":"a"}"""
|
||||
"d/b" -> """{"import":"d/c","layer":"b"}"""
|
||||
"d/c" -> """{"import":"d/d","layer":"c"}"""
|
||||
"d/d" -> """{"layer":"d","deep":"reached"}"""
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
// Each layer overrides "layer" so the importer sees "a".
|
||||
// "deep" only exists on d/d and survives to the top.
|
||||
assertEquals("a", expanded["layer"]?.jsonPrimitive?.content)
|
||||
assertEquals("reached", expanded["deep"]?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recursion deeper than max-depth is silently truncated`() =
|
||||
runTest {
|
||||
// Anything past the depth limit is dropped, but the importing
|
||||
// record's own items still apply.
|
||||
val obj = parse("""{"import":"d/a","local":"keep"}""")
|
||||
val expanded =
|
||||
NamecoinImportResolver.expandImports(
|
||||
root = obj,
|
||||
maxDepth = 1, // only one level of imports
|
||||
) { name ->
|
||||
when (name) {
|
||||
"d/a" -> """{"import":"d/b","tag":"from-a"}"""
|
||||
"d/b" -> """{"tag":"from-b","leaf":"won't-show"}"""
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
assertEquals("from-a", expanded["tag"]?.jsonPrimitive?.content)
|
||||
assertEquals("keep", expanded["local"]?.jsonPrimitive?.content)
|
||||
// d/b was never expanded so its keys are NOT present.
|
||||
assertNull(expanded["leaf"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed import lookup is treated as empty object`() =
|
||||
runTest {
|
||||
// Per our docs: spec says a failed import MAY fail the whole
|
||||
// record; we choose the more lenient "empty object" semantics
|
||||
// so transient ElectrumX hiccups don't kill resolution.
|
||||
val obj = parse("""{"import":"d/missing","local":"survives"}""")
|
||||
val expanded =
|
||||
NamecoinImportResolver.expandImports(obj) { _ ->
|
||||
null // simulate name not found
|
||||
}
|
||||
assertEquals("survives", expanded["local"]?.jsonPrimitive?.content)
|
||||
assertFalse(expanded.containsKey("import"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cycle in imports is broken without infinite recursion`() =
|
||||
runTest {
|
||||
// d/a imports d/b which imports d/a. A naive resolver would
|
||||
// hang. The visited-set guard breaks the loop on the second
|
||||
// appearance of d/a; importer's own items still apply.
|
||||
val obj = parse("""{"import":"d/a","local":"top"}""")
|
||||
val expanded =
|
||||
NamecoinImportResolver.expandImports(obj) { name ->
|
||||
when (name) {
|
||||
"d/a" -> """{"import":"d/b","fromA":"yes"}"""
|
||||
"d/b" -> """{"import":"d/a","fromB":"yes"}"""
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
assertEquals("top", expanded["local"]?.jsonPrimitive?.content)
|
||||
// At least one of fromA/fromB should have made it through —
|
||||
// we don't pin which because the cycle break point is an
|
||||
// implementation detail, but the call MUST terminate.
|
||||
assertTrue(
|
||||
expanded.containsKey("fromA") || expanded.containsKey("fromB"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed import value is skipped without throwing`() =
|
||||
runTest {
|
||||
val obj = parse("""{"import":42,"local":"keep"}""")
|
||||
val expanded = NamecoinImportResolver.expandImports(obj) { _ -> null }
|
||||
assertEquals("keep", expanded["local"]?.jsonPrimitive?.content)
|
||||
assertFalse(expanded.containsKey("import"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `import target with malformed JSON is skipped`() =
|
||||
runTest {
|
||||
val obj = parse("""{"import":"d/broken","local":"keep"}""")
|
||||
val expanded =
|
||||
NamecoinImportResolver.expandImports(obj) { name ->
|
||||
when (name) {
|
||||
"d/broken" -> """not valid json {{{"""
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
assertEquals("keep", expanded["local"]?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selector with multiple labels descends in DNS order`() =
|
||||
runTest {
|
||||
// selector "a.b" means: descend map.b, then map.a (DNS-rightmost
|
||||
// first). The empty-key and "*" wildcard rules apply too.
|
||||
val obj = parse("""{"import":[["d/lib","a.b"]]}""")
|
||||
val expanded =
|
||||
NamecoinImportResolver.expandImports(obj) { name ->
|
||||
when (name) {
|
||||
"d/lib" -> {
|
||||
"""
|
||||
{"map":{"b":{"map":{"a":{"value":"deep"}}}}}
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals("deep", expanded["value"]?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selector falls back to wildcard star when exact label is missing`() =
|
||||
runTest {
|
||||
val obj = parse("""{"import":["d/lib","ghost"]}""")
|
||||
val expanded =
|
||||
NamecoinImportResolver.expandImports(obj) { name ->
|
||||
when (name) {
|
||||
"d/lib" -> """{"map":{"*":{"value":"wildcard"}}}"""
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
assertEquals("wildcard", expanded["value"]?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
// ── Integration: NamecoinNameResolver (NIP-05) follows import ──────────
|
||||
|
||||
@Test
|
||||
fun `NIP-05 lookup follows import for shared nostr names block`() =
|
||||
runTest {
|
||||
// The real-world `testls.bit` deployment: the apex record at
|
||||
// `d/testls` is up against the 520-byte per-name limit and
|
||||
// delegates its `nostr.names` block to a sibling name via
|
||||
// `"import":"dd/testls"`. Without import support, NIP-05
|
||||
// resolution sees no `nostr` field at d/testls and fails.
|
||||
val client =
|
||||
FakeElectrumXClient().apply {
|
||||
register(
|
||||
"d/testls",
|
||||
"""{"import":"dd/testls","ip":"107.152.38.155"}""",
|
||||
)
|
||||
register(
|
||||
"dd/testls",
|
||||
"""
|
||||
{"nostr":{"names":{
|
||||
"_":"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
|
||||
"m":"6cdebccabda1dfa058ab85352a79509b592b2bdfa0370325e28ec1cb4f18667d"
|
||||
}}}
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
val resolver = NamecoinNameResolver(client, lookupTimeoutMs = 1_000L)
|
||||
|
||||
// Bare `.bit` resolves to root entry.
|
||||
val rootResult = resolver.resolve("testls.bit")
|
||||
assertNotNull("bare testls.bit should resolve via import", rootResult)
|
||||
assertEquals(
|
||||
"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
|
||||
rootResult?.pubkey,
|
||||
)
|
||||
|
||||
// Named identity `m@testls.bit` resolves through the same import.
|
||||
val mResult = resolver.resolve("m@testls.bit")
|
||||
assertNotNull("m@testls.bit should resolve via import", mResult)
|
||||
assertEquals(
|
||||
"6cdebccabda1dfa058ab85352a79509b592b2bdfa0370325e28ec1cb4f18667d",
|
||||
mResult?.pubkey,
|
||||
)
|
||||
|
||||
// Both names must have been queried (parent + import target).
|
||||
assertTrue("d/testls" in client.queriedNames)
|
||||
assertTrue("dd/testls" in client.queriedNames)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolveDetailed returns Success when import supplies the names block`() =
|
||||
runTest {
|
||||
val client =
|
||||
FakeElectrumXClient().apply {
|
||||
register(
|
||||
"d/testls",
|
||||
"""{"import":"dd/testls"}""",
|
||||
)
|
||||
register(
|
||||
"dd/testls",
|
||||
"""
|
||||
{"nostr":{"names":{
|
||||
"m":"6cdebccabda1dfa058ab85352a79509b592b2bdfa0370325e28ec1cb4f18667d"
|
||||
}}}
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
val resolver = NamecoinNameResolver(client, lookupTimeoutMs = 1_000L)
|
||||
val outcome = resolver.resolveDetailed("m@testls.bit")
|
||||
assertTrue("expected Success, got $outcome", outcome is NamecoinResolveOutcome.Success)
|
||||
outcome as NamecoinResolveOutcome.Success
|
||||
assertEquals(
|
||||
"6cdebccabda1dfa058ab85352a79509b592b2bdfa0370325e28ec1cb4f18667d",
|
||||
outcome.result.pubkey,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolveDetailed returns NoNostrField when import target lacks nostr`() =
|
||||
runTest {
|
||||
val client =
|
||||
FakeElectrumXClient().apply {
|
||||
register(
|
||||
"d/testls",
|
||||
"""{"import":"dd/testls"}""",
|
||||
)
|
||||
register(
|
||||
"dd/testls",
|
||||
"""{"ip":"1.2.3.4"}""", // no nostr field even after merge
|
||||
)
|
||||
}
|
||||
val resolver = NamecoinNameResolver(client, lookupTimeoutMs = 1_000L)
|
||||
val outcome = resolver.resolveDetailed("testls.bit")
|
||||
assertTrue(
|
||||
"expected NoNostrField, got $outcome",
|
||||
outcome is NamecoinResolveOutcome.NoNostrField,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `record without import key skips import resolver entirely`() =
|
||||
runTest {
|
||||
// Pure regression guard: ensure non-import records pay zero
|
||||
// I/O cost (no extra ElectrumX queries beyond the parent).
|
||||
val client =
|
||||
FakeElectrumXClient().apply {
|
||||
register(
|
||||
"d/plain",
|
||||
"""
|
||||
{"nostr":{"names":{
|
||||
"_":"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
|
||||
}}}
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
val resolver = NamecoinNameResolver(client, lookupTimeoutMs = 1_000L)
|
||||
val result = resolver.resolve("plain.bit")
|
||||
assertNotNull(result)
|
||||
assertEquals(
|
||||
"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
|
||||
result?.pubkey,
|
||||
)
|
||||
// Exactly one query: d/plain. No import means no extra fetches.
|
||||
assertEquals(listOf("d/plain"), client.queriedNames)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `importer wins for nostr names so apex can override a named entry`() =
|
||||
runTest {
|
||||
// Importer declares its own `nostr.names.m`; imported value
|
||||
// declares a different one. Importer wins on the whole `nostr`
|
||||
// key (shallow merge per spec).
|
||||
val client =
|
||||
FakeElectrumXClient().apply {
|
||||
register(
|
||||
"d/testls",
|
||||
"""
|
||||
{"import":"dd/testls",
|
||||
"nostr":{"names":{"m":"aaaa000000000000000000000000000000000000000000000000000000000001"}}}
|
||||
""".trimIndent(),
|
||||
)
|
||||
register(
|
||||
"dd/testls",
|
||||
"""
|
||||
{"nostr":{"names":{"m":"bbbb000000000000000000000000000000000000000000000000000000000002"}}}
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
val resolver = NamecoinNameResolver(client, lookupTimeoutMs = 1_000L)
|
||||
val result = resolver.resolve("m@testls.bit")
|
||||
assertNotNull(result)
|
||||
assertEquals(
|
||||
"aaaa000000000000000000000000000000000000000000000000000000000001",
|
||||
result?.pubkey,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed import does not break NIP-05 if names are local`() =
|
||||
runTest {
|
||||
// Importer has its own `nostr.names`; the imported
|
||||
// boilerplate happens to be unreachable. Resolution still
|
||||
// succeeds from the importer's own data.
|
||||
val client =
|
||||
FakeElectrumXClient().apply {
|
||||
register(
|
||||
"d/testls",
|
||||
"""
|
||||
{"import":"dd/missing",
|
||||
"nostr":{"names":{"_":"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"}}}
|
||||
""".trimIndent(),
|
||||
)
|
||||
// dd/missing is intentionally NOT registered.
|
||||
}
|
||||
val resolver = NamecoinNameResolver(client, lookupTimeoutMs = 1_000L)
|
||||
val result = resolver.resolve("testls.bit")
|
||||
assertNotNull(result)
|
||||
assertEquals(
|
||||
"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
|
||||
result?.pubkey,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private fun parse(s: String): JsonObject = json.parseToJsonElement(s).jsonObject
|
||||
|
||||
/**
|
||||
* In-memory ElectrumX double. Lets each test register the records its
|
||||
* lookup will see and asserts on the names actually queried.
|
||||
*/
|
||||
private class FakeElectrumXClient : IElectrumXClient {
|
||||
private val records = mutableMapOf<String, String>()
|
||||
val queriedNames: MutableList<String> = mutableListOf()
|
||||
|
||||
fun register(
|
||||
name: String,
|
||||
value: String,
|
||||
) {
|
||||
records[name] = value
|
||||
}
|
||||
|
||||
override suspend fun nameShowWithFallback(
|
||||
identifier: String,
|
||||
servers: List<ElectrumxServer>,
|
||||
): NameShowResult? {
|
||||
queriedNames += identifier
|
||||
val value = records[identifier] ?: return null
|
||||
return NameShowResult(name = identifier, value = value)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user