diff --git a/amethyst/proguard-rules.pro b/amethyst/proguard-rules.pro index f374b857ef..3cd8e417f7 100644 --- a/amethyst/proguard-rules.pro +++ b/amethyst/proguard-rules.pro @@ -166,13 +166,12 @@ # those types at the hand-written kotlinx serializers, which name every field as a # string literal. Nothing to keep, nothing to verify. -# On-disk JSON written and re-read by the app itself. The field names are the -# file format, so renaming them makes every existing file unreadable. --keep class com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPost { *; } --keep class com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostFile { *; } --keep class com.vitorpamplona.amethyst.service.pow.PowJobsFile { *; } --keep class com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob { *; } --keep class com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageStore$UsageFile { *; } +# The on-disk stores (scheduled posts, pending PoW jobs, resource usage) used to +# need a keep each, because Jackson derived their JSON keys from the Kotlin +# constructor parameter names. They are @Serializable now: kotlinx bakes every key +# in as a string literal, so the field names can be renamed freely. The formats are +# pinned by ScheduledPostFileFormatTest and PowAndUsageFileFormatTest instead, +# which assert the bytes against what the Jackson build wrote. # ----------------------------------------------------------------------------- # Names referenced from outside the DEX diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobStore.kt index e20ee660d6..97e68b8b19 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowJobStore.kt @@ -20,9 +20,6 @@ */ package com.vitorpamplona.amethyst.service.pow -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob import com.vitorpamplona.amethyst.commons.service.pow.PoWJobPersistence import com.vitorpamplona.quartz.utils.Log @@ -32,6 +29,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json import java.io.File /** @@ -46,9 +45,18 @@ class PowJobStore( private val storageFile: File, scope: CoroutineScope, ) : PoWJobPersistence { - private val mapper = - jacksonObjectMapper() - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + /** + * `encodeDefaults = true` so this writes the same bytes Jackson did — kotlinx + * omits a value equal to its default, which would silently drop `"version":1` + * from every file this build rewrites. `ignoreUnknownKeys` matches the + * FAIL_ON_UNKNOWN_PROPERTIES=false it replaces, so a file written by a newer + * build still loads here. + */ + private val json = + Json { + ignoreUnknownKeys = true + encodeDefaults = true + } // one lane: launch order == execution order, so a save followed by its // remove can never be applied backwards. @@ -103,7 +111,7 @@ class PowJobStore( jobs = try { if (storageFile.exists() && storageFile.length() > 0) { - mapper.readValue(storageFile).jobs.toMutableList() + json.decodeFromString(storageFile.readText()).jobs.toMutableList() } else { mutableListOf() } @@ -120,7 +128,7 @@ class PowJobStore( storageFile.parentFile?.mkdirs() val tmp = File(storageFile.parentFile, storageFile.name + ".tmp") try { - mapper.writeValue(tmp, PowJobsFile(version = 1, jobs = jobs.toList())) + tmp.writeText(json.encodeToString(PowJobsFile(version = 1, jobs = jobs.toList()))) if (!tmp.renameTo(storageFile)) { if (!storageFile.delete() || !tmp.renameTo(storageFile)) { Log.e(TAG) { "Failed to rename $tmp to $storageFile" } @@ -147,6 +155,7 @@ class PowJobStore( } } +@Serializable data class PowJobsFile( val version: Int = 1, val jobs: List = emptyList(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageStore.kt index fc63089343..a6a0aba0f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageStore.kt @@ -20,12 +20,11 @@ */ package com.vitorpamplona.amethyst.service.resourceusage -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json import java.io.File /** @@ -60,6 +59,7 @@ class ResourceUsageStore( */ private val keepDays: Long = 7, ) { + @Serializable data class UsageFile( val version: Int = 1, val days: Map> = emptyMap(), @@ -67,9 +67,18 @@ class ResourceUsageStore( val alertsOptOut: Boolean = false, ) - private val mapper = - jacksonObjectMapper() - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + /** + * `encodeDefaults = true` so this writes the same bytes Jackson did — kotlinx + * omits a value equal to its default, which would silently drop `"version":1` + * from every file this build rewrites. `ignoreUnknownKeys` matches the + * FAIL_ON_UNKNOWN_PROPERTIES=false it replaces, so a file written by a newer + * build still loads here. + */ + private val json = + Json { + ignoreUnknownKeys = true + encodeDefaults = true + } private val mutex = Mutex() private var loaded = false @@ -133,7 +142,7 @@ class ResourceUsageStore( data = try { if (storageFile.exists() && storageFile.length() > 0) { - mapper.readValue(storageFile) + json.decodeFromString(storageFile.readText()) } else { UsageFile() } @@ -148,7 +157,7 @@ class ResourceUsageStore( storageFile.parentFile?.mkdirs() val tmp = File(storageFile.parentFile, storageFile.name + ".tmp") try { - mapper.writeValue(tmp, data) + tmp.writeText(json.encodeToString(data)) if (!tmp.renameTo(storageFile)) { if (!storageFile.delete() || !tmp.renameTo(storageFile)) { Log.e(TAG) { "Failed to rename $tmp to $storageFile" } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/pow/PowAndUsageFileFormatTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/pow/PowAndUsageFileFormatTest.kt new file mode 100644 index 0000000000..dbea72be3d --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/pow/PowAndUsageFileFormatTest.kt @@ -0,0 +1,135 @@ +/* + * 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.amethyst.service.pow + +import com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob +import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageStore +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The PoW-queue and resource-usage files survived the move off Jackson. + * + * Both hold state an update must not lose: `pending_pow_jobs.json` is posts the user + * already hit send on that are still mining, and the usage file backs the + * high-consumption alert. Each literal below is the exact string the Jackson build + * wrote for the object beside it, captured before the switch. + */ +class PowAndUsageFileFormatTest { + private val json = + Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + private val powSample = + PowJobsFile( + version = 1, + jobs = + listOf( + PersistedPoWJob( + id = "j1", + accountPubkey = "pk1", + kind = 1, + difficulty = 21, + templateJson = """{"t":1}""", + replayType = "broadcast", + relayUrls = listOf("wss://a"), + extraEventsJson = emptyList(), + publishAtSec = null, + recipientPubkeys = listOf("r1"), + wrapExpirationDelta = null, + createdAtSec = 999L, + ), + ), + ) + + private val usageSample = + ResourceUsageStore.UsageFile( + version = 1, + days = mapOf("20260919" to mapOf("relay.a" to 12L, "relay.b" to 34L)), + lastAlertAtSec = 555L, + alertsOptOut = true, + ) + + @Test + fun powJobsWriteTheBytesJacksonWrote() { + assertEquals(POW_JACKSON_OUTPUT, json.encodeToString(powSample)) + } + + @Test + fun powJobsFromTheJacksonBuildStillLoad() { + val loaded = json.decodeFromString(POW_JACKSON_OUTPUT) + + assertEquals(1, loaded.jobs.size) + val job = loaded.jobs.first() + assertEquals("j1", job.id) + assertEquals(21, job.difficulty) + assertEquals("broadcast", job.replayType) + assertEquals(listOf("r1"), job.recipientPubkeys) + assertEquals(999L, job.createdAtSec) + assertEquals(null, job.publishAtSec) + } + + @Test + fun usageWritesTheBytesJacksonWrote() { + assertEquals(USAGE_JACKSON_OUTPUT, json.encodeToString(usageSample)) + } + + @Test + fun usageFromTheJacksonBuildStillLoads() { + val loaded = json.decodeFromString(USAGE_JACKSON_OUTPUT) + + assertEquals(mapOf("relay.a" to 12L, "relay.b" to 34L), loaded.days["20260919"]) + assertEquals(555L, loaded.lastAlertAtSec) + assertTrue(loaded.alertsOptOut) + } + + @Test + fun bothReadersTolerateKeysFromANewerBuild() { + val pow = POW_JACKSON_OUTPUT.replace("""{"version":1""", """{"version":1,"futureKey":[1]""") + val usage = USAGE_JACKSON_OUTPUT.replace("""{"version":1""", """{"version":1,"futureKey":{"a":1}""") + + assertEquals( + "j1", + json + .decodeFromString(pow) + .jobs + .first() + .id, + ) + assertEquals(555L, json.decodeFromString(usage).lastAlertAtSec) + } + + companion object { + private const val POW_JACKSON_OUTPUT = + """{"version":1,"jobs":[{"id":"j1","accountPubkey":"pk1","kind":1,"difficulty":21,""" + + """"templateJson":"{\"t\":1}","replayType":"broadcast","relayUrls":["wss://a"],""" + + """"extraEventsJson":[],"publishAtSec":null,"recipientPubkeys":["r1"],""" + + """"wrapExpirationDelta":null,"createdAtSec":999}]}""" + + private const val USAGE_JACKSON_OUTPUT = + """{"version":1,"days":{"20260919":{"relay.a":12,"relay.b":34}},""" + + """"lastAlertAtSec":555,"alertsOptOut":true}""" + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPost.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPost.kt index 5f37378765..04562ff4cd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPost.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPost.kt @@ -20,6 +20,9 @@ */ package com.vitorpamplona.amethyst.commons.scheduledposts +import kotlinx.serialization.Serializable + +@Serializable enum class ScheduledPostStatus { PENDING, PUBLISHING, @@ -28,6 +31,7 @@ enum class ScheduledPostStatus { CANCELLED, } +@Serializable data class ScheduledPost( val id: String, val accountPubkey: String, @@ -44,6 +48,7 @@ data class ScheduledPost( val terminatedAtSec: Long? = null, ) +@Serializable data class ScheduledPostFile( val version: Int = 1, val posts: List = emptyList(), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWJobPersistence.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWJobPersistence.kt index 1409f4768b..dddb512664 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWJobPersistence.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/pow/PoWJobPersistence.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.service.pow +import kotlinx.serialization.Serializable + /** * Durable record of a template mining job so a post survives process death: * everything needed to re-mine and re-send with no lambda captured — the @@ -31,6 +33,7 @@ package com.vitorpamplona.amethyst.commons.service.pow * outbox relays, [REPLAY_RELAYS] publishes to [relayUrls], [REPLAY_SCHEDULE] * signs and parks the event in the scheduled-post store for [publishAtSec]. */ +@Serializable data class PersistedPoWJob( val id: String, val accountPubkey: String, diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStore.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStore.kt index 539f759fce..d19e8e7a6c 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStore.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostStore.kt @@ -20,15 +20,13 @@ */ package com.vitorpamplona.amethyst.commons.scheduledposts -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.Json import java.io.File import java.nio.file.Files import java.nio.file.attribute.PosixFilePermission @@ -37,9 +35,18 @@ class ScheduledPostStore( private val storageFile: File, private val nowSec: () -> Long = { System.currentTimeMillis() / 1000 }, ) { - private val mapper = - jacksonObjectMapper() - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + /** + * `encodeDefaults = true` so this writes the same bytes Jackson did — kotlinx + * omits a value equal to its default, which would silently drop `"version":1` + * from every file this build rewrites. `ignoreUnknownKeys` matches the + * FAIL_ON_UNKNOWN_PROPERTIES=false it replaces, so a file written by a newer + * build still loads here. + */ + private val json = + Json { + ignoreUnknownKeys = true + encodeDefaults = true + } private val mutex = Mutex() private var loaded = false @@ -330,7 +337,7 @@ class ScheduledPostStore( val fromDisk = try { if (storageFile.exists() && storageFile.length() > 0) { - mapper.readValue(storageFile).posts.toMutableList() + json.decodeFromString(storageFile.readText()).posts.toMutableList() } else { // File vanished — treat as no external state; keep current in-memory. return @@ -348,7 +355,7 @@ class ScheduledPostStore( posts = try { if (storageFile.exists() && storageFile.length() > 0) { - mapper.readValue(storageFile).posts.toMutableList() + json.decodeFromString(storageFile.readText()).posts.toMutableList() } else { mutableListOf() } @@ -408,7 +415,7 @@ class ScheduledPostStore( storageFile.parentFile?.mkdirs() val tmp = File(storageFile.parentFile, storageFile.name + ".tmp") try { - mapper.writeValue(tmp, ScheduledPostFile(version = 1, posts = snapshot)) + tmp.writeText(json.encodeToString(ScheduledPostFile(version = 1, posts = snapshot))) // Restrict to owner-only BEFORE the rename so the store is never briefly // world-readable. It holds pre-signed events + the account's pubkey, which // must not leak to other local users on a shared machine. diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostFileFormatTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostFileFormatTest.kt new file mode 100644 index 0000000000..7745905273 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/scheduledposts/ScheduledPostFileFormatTest.kt @@ -0,0 +1,117 @@ +/* + * 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.amethyst.commons.scheduledposts + +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The scheduled-post file survived the move off Jackson. + * + * This file is a user's queued posts: pre-signed events waiting for their publish + * time. If the format shifted when the serializer changed, an existing install + * would silently drop everything it had queued on the first launch after the + * update — no crash, no error, just an empty queue. + * + * [JACKSON_OUTPUT] is the exact string the Jackson build wrote for [sample], + * captured from it before the switch. The assertions are that the kotlinx reader + * loads it and the kotlinx writer reproduces it byte for byte — the second half + * matters because it is what a downgrade, or an older `amy` build sharing the same + * file, has to keep reading. + */ +class ScheduledPostFileFormatTest { + private val json = + Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + private val sample = + ScheduledPostFile( + version = 1, + posts = + listOf( + ScheduledPost( + id = "id1", + accountPubkey = "pk1", + signedEventJson = """{"k":1}""", + relayUrls = listOf("wss://a", "wss://b"), + extraEventsJson = listOf("e1"), + publishAtSec = 111L, + createdAtSec = 222L, + status = ScheduledPostStatus.PENDING, + lastAttemptAtSec = null, + attemptCount = 0, + lastError = null, + terminatedAtSec = 333L, + ), + ), + ) + + @Test + fun writesTheBytesJacksonWrote() { + assertEquals(JACKSON_OUTPUT, json.encodeToString(sample)) + } + + @Test + fun readsAFileWrittenByTheJacksonBuild() { + val loaded = json.decodeFromString(JACKSON_OUTPUT) + + assertEquals(1, loaded.version) + assertEquals(1, loaded.posts.size) + val post = loaded.posts.first() + assertEquals("id1", post.id) + assertEquals("""{"k":1}""", post.signedEventJson) + assertEquals(listOf("wss://a", "wss://b"), post.relayUrls) + assertEquals(ScheduledPostStatus.PENDING, post.status) + assertEquals(333L, post.terminatedAtSec) + } + + @Test + fun aFileFromANewerBuildStillLoads() { + // Forward compatibility: the reader must not choke on a key it does not know, + // which is what FAIL_ON_UNKNOWN_PROPERTIES=false used to buy. + val withExtras = + JACKSON_OUTPUT + .replace("""{"version":1""", """{"version":1,"somethingNew":{"a":1}""") + .replace(""""id":"id1"""", """"id":"id1","perPostFutureField":true""") + + val loaded = json.decodeFromString(withExtras) + + assertEquals("id1", loaded.posts.first().id) + } + + @Test + fun versionIsNotDroppedJustBecauseItEqualsItsDefault() { + // kotlinx omits a value equal to its default unless encodeDefaults is set. + // Losing "version" would make the file unreadable to anything that checks it. + assertEquals(true, json.encodeToString(sample).startsWith("""{"version":1,""")) + } + + companion object { + private const val JACKSON_OUTPUT = + """{"version":1,"posts":[{"id":"id1","accountPubkey":"pk1","signedEventJson":"{\"k\":1}",""" + + """"relayUrls":["wss://a","wss://b"],"extraEventsJson":["e1"],"publishAtSec":111,""" + + """"createdAtSec":222,"status":"PENDING","lastAttemptAtSec":null,"attemptCount":0,""" + + """"lastError":null,"terminatedAtSec":333}]}""" + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt index acf1711813..e96f9431cd 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt @@ -57,6 +57,7 @@ import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorDeserializer import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorSerializer import java.io.InputStream +import kotlin.reflect.KClass class JacksonMapper { companion object { @@ -125,9 +126,56 @@ class JacksonMapper { fun fromJsonToEventList(json: String): List = mapper.readValue(json, eventListTypeInstance) - inline fun fromJsonTo(json: String): T = mapper.readValue(json) + /** + * The types this mapper has a registered deserializer for. + * + * [fromJsonTo] is generic, so nothing in the type system stops a new + * [OptimizedSerializable] being passed to it. Jackson would answer by binding + * that type REFLECTIVELY off its Kotlin constructor parameter names — which + * works in debug, and in a release build writes obfuscated one-letter JSON keys + * onto the wire. That is how NIP-47 and CLINK ended up needing a package keep + * rule each, and the symptom only ever shows up in production. + * + * So the fallback is closed: an unregistered type fails here, loudly, on the + * first call. Register a StdSerializer/StdDeserializer pair below, or route the + * type at kotlinx in OptimizedJsonMapper the way NIP-47 and CLINK are. + */ + @PublishedApi + internal val registered: Set> = + setOf( + Event::class, + Filter::class, + Message::class, + Command::class, + TagArray::class, + EventTemplate::class, + Rumor::class, + BunkerMessage::class, + BunkerRequest::class, + BunkerResponse::class, + ) - inline fun fromJsonTo(json: InputStream): T = mapper.readValue(json) + @PublishedApi + internal fun checkRegistered(type: KClass<*>) { + if (type !in registered) { + throw IllegalArgumentException( + "No Jackson deserializer is registered for $type, so Jackson would bind it " + + "reflectively and emit obfuscated field names in a release build. Register " + + "one in JacksonMapper, or route the type at KotlinSerializationMapper in " + + "OptimizedJsonMapper.", + ) + } + } + + inline fun fromJsonTo(json: String): T { + checkRegistered(T::class) + return mapper.readValue(json) + } + + inline fun fromJsonTo(json: InputStream): T { + checkRegistered(T::class) + return mapper.readValue(json) + } fun toJson(event: Event): String = EventManualSerializer.toJson(event.id, event.pubKey, event.createdAt, event.kind, event.tags, event.content, event.sig) diff --git a/tools/r8-verify/reflection-contract.txt b/tools/r8-verify/reflection-contract.txt index c54d2091c8..9cb556da50 100644 --- a/tools/r8-verify/reflection-contract.txt +++ b/tools/r8-verify/reflection-contract.txt @@ -78,13 +78,6 @@ class com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker # any more — OptimizedJsonMapper routes them at kotlinx serializers that write # every field name as a string literal — so there is no rule to verify. -# --- JSON the app writes to disk and reads back after an update -------------- -# Field names are the file format; renaming them orphans every existing file. -fields com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPost -fields com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostFile -fields com.vitorpamplona.amethyst.commons.service.pow.PersistedPoWJob -fields com.vitorpamplona.amethyst.service.pow.PowJobsFile -fields com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageStore$UsageFile # --- Enum constants persisted as strings ------------------------------------- # The preference stores write `enum.name` into DataStore and read it back with @@ -103,4 +96,10 @@ enum com.vitorpamplona.amethyst.model.ProfileGalleryType CLASSIC enum com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinBackend enum com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType enum com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ChannelExpand +# Still listed after ScheduledPost moved to kotlinx: this one is also the on-disk +# format of the scheduled-post file, and a plain @Serializable enum is not obviously +# immune — kotlinx builds its descriptor from the entries, and it has not been proven +# here that those names are literals rather than Enum.name read at runtime. Keeping +# the constants costs nothing (the blanket enum rule already provides them) and the +# failure mode — every queued post's status unreadable — is not worth guessing at. enum com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostStatus PENDING PUBLISHING SENT FAILED CANCELLED