Merge pull request #3254 from vitorpamplona/claude/beautiful-hawking-qfcq4w

Fix lone surrogates in truncated strings (emoji safety)
This commit is contained in:
Vitor Pamplona
2026-06-17 18:15:37 -04:00
committed by GitHub
5 changed files with 170 additions and 2 deletions
@@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.takeKeepingSurrogatePairs
/**
* High-level CLINK Offers payer.
@@ -73,7 +74,9 @@ class OfferClient(
expires_in_seconds = expiresInSeconds,
// The spec caps the invoice description at 100 chars; trim so an over-long
// value doesn't get the whole request rejected by the service.
description = description?.take(100),
// Surrogate-aware so the trim never leaves half an emoji (a lone
// surrogate is unencodable as UTF-8 and would corrupt the payload).
description = description?.takeKeepingSurrogatePairs(100),
)
return OfferEvent.createRequest(request, servicePubKey, signer, createdAt)
}
@@ -50,6 +50,7 @@ import com.vitorpamplona.quartz.nip19Bech32.pubKeys
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.takeKeepingSurrogatePairs
@Immutable
class TextNoteEvent(
@@ -127,7 +128,10 @@ class TextNoteEvent(
private fun shortedMessageForAlt(msg: String): String {
if (msg.length < 50) return ALT + msg
return ALT + msg.take(50) + "..."
// takeKeepingSurrogatePairs avoids leaving a lone surrogate (e.g. half
// of an emoji) in the alt tag, which would be unencodable as UTF-8 and
// corrupt the event id on the wire.
return ALT + msg.takeKeepingSurrogatePairs(50) + "..."
}
fun build(
@@ -21,3 +21,22 @@
package com.vitorpamplona.quartz.utils
expect fun String.internIfPossible(): String
/**
* Truncates to at most [maxUnits] UTF-16 code units **without splitting a
* surrogate pair**.
*
* A plain [take]/[substring] counts UTF-16 code units, so a cut can land
* between the two halves of an astral character (e.g. an emoji) and leave a
* lone surrogate. A lone surrogate is unencodable as UTF-8: it survives an
* in-memory event-id hash but is replaced by '?' the moment the event is
* serialized to a relay, so the relay recomputes a different id and rejects the
* event. Any truncation whose result ends up inside a signed event (alt,
* summary, description, … tags) must go through here.
*/
fun String.takeKeepingSurrogatePairs(maxUnits: Int): String {
if (maxUnits <= 0) return ""
if (length <= maxUnits) return this
val end = if (this[maxUnits - 1].isHighSurrogate()) maxUnits - 1 else maxUnits
return substring(0, end)
}
@@ -0,0 +1,67 @@
/*
* 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.nip10Notes
import com.vitorpamplona.quartz.nip31Alts.AltTag
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class AltTagSurrogateTest {
// The summarizer truncates the note to 50 UTF-16 units for the NIP-31 "alt"
// tag. In this note the 50th unit falls between the two halves of the
// saluting-face emoji (🫡, U+1FAE1 = surrogate pair D83E DEE1), so a naive
// take(50) used to leave a lone high surrogate at the end of the alt tag.
//
// A lone surrogate is unencodable as UTF-8: it is kept in memory while the
// event id is hashed (so the signature is over that id), but it is replaced
// by '?' the moment the event is serialized to the relay. Relays therefore
// recompute a different id and reject the event as having an invalid id.
private val note =
"I had to toggle a setting in fit 👍 That's slick 🫡\n" +
"https://haven.downisontheup.ca/b598bd967080491578db15cb861ebacdfd056a6ba9e0701190444b183380933b.jpg"
private fun altOf(note: String): String = TextNoteEvent.build(note).tags.firstNotNullOfOrNull(AltTag::parse)!!
@Test
fun altTagHasNoLoneSurrogate() {
val alt = altOf(note)
val loneSurrogate =
alt.indices.any { i ->
val c = alt[i]
when {
c.isHighSurrogate() -> i + 1 >= alt.length || !alt[i + 1].isLowSurrogate()
c.isLowSurrogate() -> i == 0 || !alt[i - 1].isHighSurrogate()
else -> false
}
}
assertTrue(!loneSurrogate, "alt tag must not contain a lone surrogate: <$alt>")
}
@Test
fun altTagSurvivesUtf8WireRoundTrip() {
// Models the UTF-8 encode the relay transport performs. A lone surrogate
// would be replaced (and the round-trip would differ), which is exactly
// what corrupted the event id.
val alt = altOf(note)
assertEquals(alt, alt.encodeToByteArray().decodeToString())
}
}
@@ -0,0 +1,75 @@
/*
* 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.utils
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class TakeKeepingSurrogatePairsTest {
private val salute = "🫡" // 🫡 U+1FAE1
@Test
fun shorterThanLimitReturnsWhole() {
assertEquals("hello", "hello".takeKeepingSurrogatePairs(50))
}
@Test
fun cutOnAsciiBoundaryIsExact() {
assertEquals("abcde", "abcdefghij".takeKeepingSurrogatePairs(5))
}
@Test
fun cutThatWouldSplitSurrogateBacksOff() {
// "ab" + 🫡; limit 3 would keep the high surrogate only -> back off to "ab".
val result = ("ab" + salute).takeKeepingSurrogatePairs(3)
assertEquals("ab", result)
assertNoLoneSurrogate(result)
}
@Test
fun cutRightAfterSurrogatePairKeepsWholeEmoji() {
val result = ("ab" + salute).takeKeepingSurrogatePairs(4)
assertEquals("ab" + salute, result)
assertNoLoneSurrogate(result)
}
@Test
fun resultAlwaysSurvivesUtf8RoundTrip() {
for (n in 0..6) {
val result = ("ab" + salute + "cd").takeKeepingSurrogatePairs(n)
assertEquals(result, result.encodeToByteArray().decodeToString(), "n=$n")
}
}
private fun assertNoLoneSurrogate(s: String) {
val lone =
s.indices.any { i ->
val c = s[i]
when {
c.isHighSurrogate() -> i + 1 >= s.length || !s[i + 1].isLowSurrogate()
c.isLowSurrogate() -> i == 0 || !s[i - 1].isHighSurrogate()
else -> false
}
}
assertTrue(!lone, "unexpected lone surrogate in <$s>")
}
}