From 39531b85fba4ff9530d99e1f744dc7493d1f22d3 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 7 Jun 2026 23:04:13 +0200 Subject: [PATCH 1/2] fix(metadata): tolerate non-spec birthday so it can't drop the profile --- .../metadata/BirthdayTolerantSerializer.kt | 82 ++++++++++++ .../quartz/nip01Core/metadata/UserMetadata.kt | 2 + .../BirthdayTolerantSerializerTest.kt | 120 ++++++++++++++++++ 3 files changed, 204 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/BirthdayTolerantSerializer.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/BirthdayTolerantSerializerTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/BirthdayTolerantSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/BirthdayTolerantSerializer.kt new file mode 100644 index 0000000000..df549128e4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/BirthdayTolerantSerializer.kt @@ -0,0 +1,82 @@ +/* + * 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.nip01Core.metadata + +import com.vitorpamplona.quartz.utils.Log +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonObject + +/** + * Tolerant serializer for the kind-0 `birthday` field. + * + * NIP-24 defines `birthday` as an object `{ "year", "month", "day" }` (each field + * optional). Some clients (e.g. Ditto / divine.video) instead write a string such + * as `"10-24"`, which is not spec-compliant. With the default serializer that type + * mismatch throws, and because [MetadataEvent.contactMetaData] turns any parse + * exception into `null`, a single malformed `birthday` would discard the **entire** + * profile (name, picture, about…). + * + * This serializer parses the spec object form and treats anything else as absent + * (`null`) rather than failing, so one non-conformant field can no longer break + * profile rendering. The string form is intentionally not "recovered": the spec + * has no string format, and a bare `"10-24"` is ambiguous (MM-DD vs DD-MM). + * + * Modelled on [com.vitorpamplona.quartz.nip11RelayInfo.FlexibleIntListSerializer]. + */ +object BirthdayTolerantSerializer : KSerializer { + private val delegate = Birthday.serializer() + + override val descriptor: SerialDescriptor = delegate.descriptor + + override fun deserialize(decoder: Decoder): Birthday? { + require(decoder is JsonDecoder) { "This serializer can only be used with Json format" } + + val element = decoder.decodeJsonElement() + if (element !is JsonObject) { + // Non-spec birthday (e.g. Ditto's "10-24" string). Ignore it rather than + // failing the whole profile parse. + Log.w("BirthdayTolerantSerializer") { "Ignoring non-object birthday: $element" } + return null + } + + return try { + decoder.json.decodeFromJsonElement(delegate, element) + } catch (e: Exception) { + Log.w("BirthdayTolerantSerializer") { "Ignoring malformed birthday object: ${e.message}" } + null + } + } + + override fun serialize( + encoder: Encoder, + value: Birthday?, + ) { + if (value == null) { + encoder.encodeNull() + } else { + delegate.serialize(encoder, value) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt index 71362b9d79..6ebd2ad25c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/UserMetadata.kt @@ -48,6 +48,8 @@ class UserMetadata { var about: String? = null var bot: Boolean? = null var pronouns: String? = null + + @Serializable(with = BirthdayTolerantSerializer::class) var birthday: Birthday? = null var nip05: String? = null var domain: String? = null diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/BirthdayTolerantSerializerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/BirthdayTolerantSerializerTest.kt new file mode 100644 index 0000000000..1495b3262f --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/BirthdayTolerantSerializerTest.kt @@ -0,0 +1,120 @@ +/* + * 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.nip01Core.metadata + +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import com.vitorpamplona.quartz.utils.EventFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class BirthdayTolerantSerializerTest { + private fun metaWith(content: String): MetadataEvent = + EventFactory.create( + id = "ed269c23907649461da4b0fe109eed689ed1a562d33873b97ed01496dd02b87c", + pubKey = "932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d", + createdAt = 1L, + kind = MetadataEvent.KIND, + tags = emptyArray(), + content = content, + sig = "00".repeat(64), + ) as MetadataEvent + + /** + * Regression for the Ditto / divine.video profile (npub1jvnpg4c…, "MK Fain") + * whose `birthday` is the non-spec string "10-24". Before the tolerant + * serializer this threw and [MetadataEvent.contactMetaData] returned null, + * dropping the whole profile. + */ + @Test + fun stringBirthdayDoesNotDropTheProfile() { + val meta = + metaWith( + """{"name":"MK Fain","about":"Team Soapbox","picture":"https://blossom.ditto.pub/x.jpg","nip05":"mk@ditto.pub","birthday":"10-24"}""", + ).contactMetaData() + + assertIs(meta, "profile must still parse despite the malformed birthday") + assertEquals("MK Fain", meta.name) + assertEquals("https://blossom.ditto.pub/x.jpg", meta.picture) + assertEquals("mk@ditto.pub", meta.nip05) + assertNull(meta.birthday, "non-object birthday must be ignored, not fatal") + } + + @Test + fun otherNonObjectBirthdaysAreIgnored() { + // number, array, and JSON null are all non-spec for `birthday`. + listOf( + """{"name":"A","birthday":1024}""", + """{"name":"A","birthday":[10,24]}""", + """{"name":"A","birthday":null}""", + ).forEach { json -> + val meta = metaWith(json).contactMetaData() + assertIs(meta, "profile must survive birthday=$json") + assertEquals("A", meta.name) + assertNull(meta.birthday) + } + } + + @Test + fun specObjectBirthdayStillParses() { + val meta = metaWith("""{"name":"A","birthday":{"year":1990,"month":6,"day":15}}""").contactMetaData() + assertIs(meta) + val birthday = meta.birthday + assertIs(birthday) + assertEquals(1990, birthday.year) + assertEquals(6, birthday.month) + assertEquals(15, birthday.day) + } + + @Test + fun partialObjectBirthdayStillParses() { + val meta = metaWith("""{"name":"A","birthday":{"month":6,"day":15}}""").contactMetaData() + assertIs(meta) + val birthday = meta.birthday + assertIs(birthday) + assertNull(birthday.year) + assertEquals(6, birthday.month) + assertEquals(15, birthday.day) + } + + @Test + fun objectBirthdayRoundTrips() { + val meta = metaWith("""{"name":"A","birthday":{"year":1990,"month":6,"day":15}}""").contactMetaData() + assertIs(meta) + val serialized = JsonMapper.toJson(meta) + val reparsed = JsonMapper.fromJson(serialized) + val birthday = reparsed.birthday + assertIs(birthday) + assertEquals(1990, birthday.year) + assertEquals(6, birthday.month) + assertEquals(15, birthday.day) + } + + @Test + fun nullBirthdayIsOmittedOnSerialization() { + val meta = metaWith("""{"name":"A","birthday":"10-24"}""").contactMetaData() + assertIs(meta) + val serialized = JsonMapper.toJson(meta) + assertTrue("birthday" !in serialized, "a null birthday should not be serialized back out: $serialized") + } +} From 0107808ef6b312aef9de99d22ac05bfec0e2dcba Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 7 Jun 2026 23:10:16 +0200 Subject: [PATCH 2/2] Code review: - Expose a nullable descriptor - Log the JSON element kind instead of the raw, network-sourced value. - drop birthday happy-path tests duplicated by UpdateMetadataTest --- .../metadata/BirthdayTolerantSerializer.kt | 10 +++-- .../BirthdayTolerantSerializerTest.kt | 41 +++---------------- 2 files changed, 13 insertions(+), 38 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/BirthdayTolerantSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/BirthdayTolerantSerializer.kt index df549128e4..5e04867275 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/BirthdayTolerantSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/BirthdayTolerantSerializer.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip01Core.metadata import com.vitorpamplona.quartz.utils.Log import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.nullable import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.JsonDecoder @@ -48,7 +49,9 @@ import kotlinx.serialization.json.JsonObject object BirthdayTolerantSerializer : KSerializer { private val delegate = Birthday.serializer() - override val descriptor: SerialDescriptor = delegate.descriptor + // Nullable serializer ⇒ nullable descriptor, so the framework's metadata stays + // honest even on code paths that consult it (e.g. coerceInputValues). + override val descriptor: SerialDescriptor = delegate.descriptor.nullable override fun deserialize(decoder: Decoder): Birthday? { require(decoder is JsonDecoder) { "This serializer can only be used with Json format" } @@ -56,8 +59,9 @@ object BirthdayTolerantSerializer : KSerializer { val element = decoder.decodeJsonElement() if (element !is JsonObject) { // Non-spec birthday (e.g. Ditto's "10-24" string). Ignore it rather than - // failing the whole profile parse. - Log.w("BirthdayTolerantSerializer") { "Ignoring non-object birthday: $element" } + // failing the whole profile parse. Log the JSON kind only, not the raw + // (untrusted, network-sourced) value. + Log.w("BirthdayTolerantSerializer") { "Ignoring non-object birthday (${element::class.simpleName})" } return null } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/BirthdayTolerantSerializerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/BirthdayTolerantSerializerTest.kt index 1495b3262f..839545b344 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/BirthdayTolerantSerializerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/BirthdayTolerantSerializerTest.kt @@ -75,41 +75,12 @@ class BirthdayTolerantSerializerTest { } } - @Test - fun specObjectBirthdayStillParses() { - val meta = metaWith("""{"name":"A","birthday":{"year":1990,"month":6,"day":15}}""").contactMetaData() - assertIs(meta) - val birthday = meta.birthday - assertIs(birthday) - assertEquals(1990, birthday.year) - assertEquals(6, birthday.month) - assertEquals(15, birthday.day) - } - - @Test - fun partialObjectBirthdayStillParses() { - val meta = metaWith("""{"name":"A","birthday":{"month":6,"day":15}}""").contactMetaData() - assertIs(meta) - val birthday = meta.birthday - assertIs(birthday) - assertNull(birthday.year) - assertEquals(6, birthday.month) - assertEquals(15, birthday.day) - } - - @Test - fun objectBirthdayRoundTrips() { - val meta = metaWith("""{"name":"A","birthday":{"year":1990,"month":6,"day":15}}""").contactMetaData() - assertIs(meta) - val serialized = JsonMapper.toJson(meta) - val reparsed = JsonMapper.fromJson(serialized) - val birthday = reparsed.birthday - assertIs(birthday) - assertEquals(1990, birthday.year) - assertEquals(6, birthday.month) - assertEquals(15, birthday.day) - } - + /** + * End-to-end check that a dropped birthday does not leak back into the + * serialized profile. The omission itself is the decoder's default-null + * suppression (encodeDefaults stays false on JsonMapper), not the serializer — + * this just pins the real-world JsonMapper output. + */ @Test fun nullBirthdayIsOmittedOnSerialization() { val meta = metaWith("""{"name":"A","birthday":"10-24"}""").contactMetaData()