feat(namecoin): distinguish malformed-JSON values from missing-field

When a Namecoin record's value isn't valid JSON (a real failure mode
when an operator hand-builds the value and miscounts braces), the
NIP-05 path used to silently swallow the parser exception and surface
a misleading "no nostr field" message. That sends the publisher
chasing a phantom missing field when the actual problem is the value
itself.

Concrete case that triggered this: a `name_update` published a
474-byte d/testls value with one closing brace short of balanced. The
string parses up to the missing brace, after which kotlinx.serialization
throws "Unfinished JSON term at EOF at line 1, column 474". That error
was previously dropped, leaving the operator to debug "no nostr field"
without ever seeing the underlying JSON parse failure.

Changes:

- New NamecoinResolveOutcome.MalformedRecord(name, error). Distinct
  from NoNostrField. The `error` field is the parser's own diagnostic
  (e.g. "Unfinished JSON term at EOF at line 1, column 474") so the
  publisher can locate the broken byte without spelunking.
- NamecoinNameResolver.performLookupDetailed: parse via a new
  parseValueOrError helper and surface MalformedRecord instead of
  collapsing into NoNostrField. Also rejects non-object top-level
  values (arrays, primitives, null) with a useful diagnostic
  ("top-level value is JsonArray, expected JSON object").
- DesktopSearchScreen handles the new outcome by surfacing the parser
  error verbatim in the Namecoin status banner, so the column number
  reaches the publisher's screen.

Tests (commonTest / NamecoinImportTest):

- "NIP-05 lookup surfaces MalformedRecord with parser detail when
  value is broken JSON": a deliberately one-brace-short value yields
  MalformedRecord with a non-empty diagnostic.
- "NIP-05 lookup surfaces MalformedRecord when top-level value is a
  JSON array": ensures non-object top-level values are rejected with
  a useful "expected JSON object" message rather than silently
  parsing as something unusable.

Tests don't pin the exact parser wording (kotlinx.serialization can
change it across versions); they only pin that the message is
attributed to JSON parsing rather than to a missing field.
This commit is contained in:
m
2026-05-09 10:02:26 +10:00
parent 8b8c0a10e9
commit 3383b60315
3 changed files with 113 additions and 2 deletions
@@ -43,6 +43,20 @@ sealed class NamecoinResolveOutcome {
val name: String,
) : NamecoinResolveOutcome()
/**
* The name exists but its value is not parseable as a Namecoin Domain
* Name Object (i.e. valid JSON of the right shape). Distinct from
* [NoNostrField], which means valid JSON but no `nostr` data.
*
* `error` is the underlying parser message (kotlinx.serialization);
* useful for surfacing back to the publisher of the broken record
* (e.g. "Unfinished JSON term at EOF at line 1, column 474").
*/
data class MalformedRecord(
val name: String,
val error: String,
) : NamecoinResolveOutcome()
/** All ElectrumX servers were unreachable. */
data class ServersUnreachable(
val message: String,
@@ -234,9 +248,23 @@ class NamecoinNameResolver(
)
}
// Distinguish "valid JSON, no `nostr` field" from "value is
// unparseable JSON". Both used to collapse into NoNostrField, which
// sent operators chasing a missing field that wasn't actually the
// problem (the Namecoin record itself was malformed). Surfacing
// the parser's column number lets a publisher see WHERE the value
// is bad without spelunking through the whole serialised form.
val valueJson =
tryParseJson(nameResult.value)
?: return NamecoinResolveOutcome.NoNostrField(parsed.namecoinName)
parseValueOrError(nameResult.value)
.fold(
onSuccess = { it },
onFailure = { err ->
return NamecoinResolveOutcome.MalformedRecord(
name = parsed.namecoinName,
error = err.message ?: "unparseable JSON value",
)
},
)
val merged = expandImportsIfPresent(valueJson)
val nostrResult =
@@ -473,5 +501,25 @@ class NamecoinNameResolver(
null
}
/**
* Parse [raw] into a [JsonObject], surfacing the underlying parser
* error message instead of swallowing it. The result is used by
* [performLookupDetailed] to distinguish [NamecoinResolveOutcome.MalformedRecord]
* (the value itself is broken) from [NamecoinResolveOutcome.NoNostrField]
* (valid JSON, just missing the `nostr` block).
*
* Returns [Result.failure] with the parser's diagnostic message; the
* caller is expected to forward that text into a UI / log surface so
* the publisher of the broken record can fix it.
*/
private fun parseValueOrError(raw: String): Result<JsonObject> =
runCatching {
val element = json.parseToJsonElement(raw)
element as? JsonObject
?: throw IllegalArgumentException(
"top-level value is ${element::class.simpleName}, expected JSON object",
)
}
private fun isValidPubkey(s: String): Boolean = HEX_PUBKEY_REGEX.matches(s)
}
@@ -502,6 +502,62 @@ class NamecoinImportTest {
)
}
@Test
fun `NIP-05 lookup surfaces MalformedRecord with parser detail when value is broken JSON`() =
runTest {
// Real-world failure mode: a hand-built `name_update` with one
// closing brace short of balanced ends up published as broken
// JSON. The publisher then sees "name not found / no nostr
// field" and chases a phantom bug. Surface the parser's
// diagnostic so they know the value itself is the problem.
val truncated =
"""{"ip":"1.2.3.4","nostr":{"names":{"_":"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"}}""".trimIndent()
val client =
FakeElectrumXClient().apply {
register("d/broken", truncated)
}
val resolver = NamecoinNameResolver(client, lookupTimeoutMs = 1_000L)
val outcome = resolver.resolveDetailed("_@broken.bit")
assertTrue(
"expected MalformedRecord, got $outcome",
outcome is NamecoinResolveOutcome.MalformedRecord,
)
outcome as NamecoinResolveOutcome.MalformedRecord
assertEquals("d/broken", outcome.name)
// Don't pin the exact parser text — different kotlinx.serialization
// versions phrase it differently. Just require it's a non-empty
// diagnostic.
assertTrue(
"error must be a non-empty diagnostic: ${outcome.error}",
outcome.error.isNotBlank(),
)
}
@Test
fun `NIP-05 lookup surfaces MalformedRecord when top-level value is a JSON array`() =
runTest {
// Top-level non-object values (arrays, primitives, null) are
// not valid Domain Name Objects per ifa-0001. They should be
// rejected with a useful diagnostic, not collapsed into
// NoNostrField (which implies the JSON parsed fine).
val client =
FakeElectrumXClient().apply {
register("d/arr", """["not","an","object"]""")
}
val resolver = NamecoinNameResolver(client, lookupTimeoutMs = 1_000L)
val outcome = resolver.resolveDetailed("_@arr.bit")
assertTrue(
"expected MalformedRecord, got $outcome",
outcome is NamecoinResolveOutcome.MalformedRecord,
)
outcome as NamecoinResolveOutcome.MalformedRecord
assertEquals("d/arr", outcome.name)
assertTrue(
"error must mention the unexpected top-level shape: ${outcome.error}",
outcome.error.contains("expected JSON object", ignoreCase = true),
)
}
// ── Helpers ──────────────────────────────────────────────────────────
private fun parse(s: String): JsonObject = json.parseToJsonElement(s).jsonObject