test(nip29): cover weird apostrophe placements + guard relay-URL possessives

Reject a single-character group id (except the default `_`) so a possessive
glued to a bare relay URL — `wss://relay.damus.io's uptime` — no longer
linkifies group "s". Real ids (relay29/Wisp/0xchat) are all longer.

Adds coverage proving only genuine ws/wss relay URLs are peeked: apostrophes
after http, nostr:, blossom:, email and bech32 tokens never become group links;
plus ws:// (insecure), second-apostrophe boundary, and multi-link cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
This commit is contained in:
Claude
2026-07-08 17:21:15 +00:00
parent 9b7d2f7e45
commit 2b11dbd7d9
3 changed files with 103 additions and 1 deletions
@@ -99,4 +99,70 @@ class RichTextParserGroupLinkTest {
assertTrue(links.groupLinks.isEmpty())
assertNull(groupSegmentsOf("odd wss://relay.damus.io' end").firstOrNull())
}
// ---- weird apostrophe placements after non-relay URLs: none may become a group link ----
@Test
fun possessiveAfterRelayUrlIsNotAGroupLink() {
// "wss://relay.damus.io's uptime" — the possessive `'s` must not linkify group "s".
val text = "how is wss://relay.damus.io's uptime today"
val links = UrlParser().parseValidUrls(text)
assertTrue(links.groupLinks.isEmpty())
assertTrue(links.relayUrls.contains("wss://relay.damus.io"))
assertTrue(groupSegmentsOf(text).isEmpty())
}
@Test
fun apostropheAfterHttpUrlIsNotAGroupLink() {
val links = UrlParser().parseValidUrls("see https://example.com'abc123 here")
assertTrue(links.groupLinks.isEmpty())
assertTrue(links.withScheme.contains("https://example.com"))
assertTrue(groupSegmentsOf("see https://example.com'abc123 here").isEmpty())
}
@Test
fun apostropheAfterNostrUriIsNotAGroupLink() {
val text = "ping nostr:npub1sn0wdenkukak0d9dfczzeacvhkrgz92ak56egt7vdgzn8pv2wfqqhrjdv9'abc there"
val links = UrlParser().parseValidUrls(text)
assertTrue(links.groupLinks.isEmpty())
}
@Test
fun apostropheAfterBlossomUriIsNotAGroupLink() {
val text = "file blossom://b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553'abc"
assertTrue(UrlParser().parseValidUrls(text).groupLinks.isEmpty())
}
@Test
fun apostropheAfterEmailIsNotAGroupLink() {
val links = UrlParser().parseValidUrls("mail vitor@example.com'abc please")
assertTrue(links.groupLinks.isEmpty())
}
@Test
fun insecureWsSchemeAlsoLinkifies() {
// `ws://` (not just `wss://`) is a relay scheme and must peek the same way.
val links = UrlParser().parseValidUrls("dev ws://relay.example.com'abc123 test")
assertEquals(setOf("ws://relay.example.com'abc123"), links.groupLinks)
}
@Test
fun secondApostropheEndsTheGroupId() {
// `wss://relay.example.com'abc'def` — the group id is `abc`; `'def` is left as text.
val segs = groupSegmentsOf("go wss://relay.example.com'abc'def now")
assertEquals(1, segs.size)
assertEquals("wss://relay.example.com'abc", segs.first().segmentText)
}
@Test
fun multipleGroupLinksInOnePostAreAllDetected() {
val links =
UrlParser().parseValidUrls(
"two groups wss://groups.0xchat.com'aaaa and wss://chat.wisp.talk'bbbb here",
)
assertEquals(
setOf("wss://groups.0xchat.com'aaaa", "wss://chat.wisp.talk'bbbb"),
links.groupLinks,
)
}
}
@@ -47,6 +47,16 @@ data class GroupInviteLink(
// swallowing trailing prose punctuation into the link.
private fun isIdChar(c: Char): Boolean = c in 'a'..'z' || c in 'A'..'Z' || c in '0'..'9' || c == '-' || c == '_'
/**
* A single-character group id is only accepted when it is the relay-wide default
* group `_`. Every real group id from the reference clients (relay29 random ids,
* Wisp/0xchat 12+ chars) is longer, whereas a lone char after an apostrophe is
* almost always an English possessive/contraction glued to a bare relay URL
* (`wss://relay.damus.io's uptime`). Rejecting it kills that false positive without
* dropping any real link.
*/
private fun isAcceptableGroupId(id: String): Boolean = id.length >= 2 || id == "_"
private const val CODE_PREFIX = "?code="
/**
@@ -64,6 +74,7 @@ data class GroupInviteLink(
var i = from
while (i < content.length && isIdChar(content[i])) i++
if (i == from) return 0 // empty group id — not a link
if (!isAcceptableGroupId(content.substring(from, i))) return 0 // possessive/contraction guard
// Optional `?code=<code>`; only extend the span when a non-empty code follows.
if (content.startsWith(CODE_PREFIX, i)) {
@@ -89,7 +100,7 @@ data class GroupInviteLink(
val rest = link.substring(apos + 1)
val queryIdx = rest.indexOf('?')
val groupId = if (queryIdx < 0) rest else rest.substring(0, queryIdx)
if (groupId.isEmpty() || !groupId.all { isIdChar(it) }) return null
if (!groupId.all { isIdChar(it) } || !isAcceptableGroupId(groupId)) return null
val code =
if (queryIdx >= 0) {
@@ -77,6 +77,31 @@ class GroupInviteLinkTest {
assertNull(GroupInviteLink.parse("'abc123"))
}
@Test
fun rejectsSingleCharPossessive() {
// `wss://relay.damus.io's uptime` — the `'s` is a possessive, not a group id.
assertNull(GroupInviteLink.parse("wss://relay.damus.io's"))
}
@Test
fun acceptsUnderscoreDefaultGroupButNotOtherSingleChars() {
// `_` is the relay-wide default group and is a legitimate 1-char id; other lone
// chars are rejected as contractions.
assertEquals("_", GroupInviteLink.parse("wss://relay.example.com'_")?.groupId)
assertNull(GroupInviteLink.parse("wss://relay.example.com'x"))
}
@Test
fun suffixLengthRejectsPossessiveS() {
// "wss://r.com's uptime" — apostrophe at 11, `s` at 12 → not a link.
assertEquals(0, GroupInviteLink.suffixLength("wss://r.com's uptime", 12))
}
@Test
fun suffixLengthAcceptsUnderscore() {
assertEquals(1, GroupInviteLink.suffixLength("wss://r.com'_ hi", 12))
}
@Test
fun suffixLengthMeasuresGroupIdOnly() {
// "wss://r.com'abc def" — apostrophe at index 11, group id starts at 12.