From 44aa2623639e13b6cdffb3e77b8b21ddebd50cb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 20:05:00 +0000 Subject: [PATCH] fix(commons): tighter Base64Image contract + pin serializer wire format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-up cleanups from the audit. Base64Image.parse: when the regex matched but the data capture group was missing, the migrated version returned an empty ByteArray. The original threw NPE (java.util.Base64.getDecoder().decode(null)). Both behaviors are accidents — restore the intended contract: throw the existing "Unable to convert base64 to image" Exception explicitly. FeedDefinitionSerializerTest gains a serializesToExpectedWireFormat test that pins the byte-exact JSON output for a representative multi-field feed. The legacy-Jackson migration claimed byte-identity but only round-trip and reverse-compat were covered. Any future change to field ordering / null handling / number formatting now fails this test loudly, protecting users who have saved feeds on disk and any downstream consumer expecting the stable order. --- .../amethyst/commons/richtext/Base64Image.kt | 12 +++--- .../custom/FeedDefinitionSerializerTest.kt | 38 +++++++++++++++++++ 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/Base64Image.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/Base64Image.kt index f64d8872df..09a67302dc 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/Base64Image.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/Base64Image.kt @@ -30,12 +30,10 @@ object Base64Image { @OptIn(ExperimentalEncodingApi::class) fun parse(content: String): ByteArray { - val matcher = pattern.find(content) - if (matcher != null) { - val base64String = matcher.groups[2]?.value ?: "" - return Base64.decode(base64String) - } - - throw Exception("Unable to convert base64 to image $content") + val match = pattern.find(content) ?: throw Exception("Unable to convert base64 to image $content") + val base64String = + match.groups[2]?.value + ?: throw Exception("Unable to convert base64 to image $content") + return Base64.decode(base64String) } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializerTest.kt index b82cce5228..afbbfb5379 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/feeds/custom/FeedDefinitionSerializerTest.kt @@ -212,6 +212,44 @@ class FeedDefinitionSerializerTest { assertEquals(emptyList(), FeedDefinitionSerializer.deserializeList(" ")) } + @Test + fun serializesToExpectedWireFormat() { + // Pins the byte-exact JSON output so any future change to the + // serializer (field order, null handling, number formatting) is + // caught here. Users have feeds saved on disk in this exact format + // and may share JSON between app versions; downstream consumers + // depend on the field order being stable. + val feed = + FeedDefinition( + id = "test-1", + name = "Bitcoin", + emoji = "₿", + pinned = true, + pinOrder = 0, + source = + FeedSource.Filter( + hashtags = persistentListOf("bitcoin", "btc"), + authors = persistentListOf("abc123"), + relays = persistentListOf("wss://relay.damus.io"), + excludeAuthors = persistentListOf("spammer"), + excludeKeywords = persistentListOf("scam"), + kinds = persistentListOf(1, 6), + ), + refreshMode = RefreshMode.LIVE_STREAM, + createdAt = 1000L, + ) + + val expected = + """[{"id":"test-1","name":"Bitcoin","emoji":"₿","pinned":true,""" + + """"pinOrder":0,"refreshMode":"LIVE_STREAM","createdAt":1000,""" + + """"source":{"type":"filter","hashtags":["bitcoin","btc"],""" + + """"authors":["abc123"],"relays":["wss://relay.damus.io"],""" + + """"excludeAuthors":["spammer"],"excludeKeywords":["scam"],""" + + """"kinds":[1,6]}}]""" + + assertEquals(expected, FeedDefinitionSerializer.serializeList(listOf(feed))) + } + @Test fun parsesLegacyJacksonOutput() { // Wire format previously emitted by ObjectMapper. Users have feed