mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-04 10:12:05 +00:00
Validate the bracketed authority with a URI parser instead of a regex
Review feedback: the previous pattern hardcoded http, https and ftp and guessed at the address grammar. Candidates are now found by scheme and handed to java.net.URI, which accepts any scheme and rejects an authority that is not a real IPv6 literal, an unclosed bracket, or array syntax such as values[0]:1. Also drops the explanatory comments.
This commit is contained in:
+13
-14
@@ -16,6 +16,7 @@ import androidx.core.text.toSpannable
|
|||||||
import androidx.core.text.util.LinkifyCompat
|
import androidx.core.text.util.LinkifyCompat
|
||||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import java.net.URI
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper class to linkify text while preserving existing URL spans.
|
* Helper class to linkify text while preserving existing URL spans.
|
||||||
@@ -23,11 +24,7 @@ import timber.log.Timber
|
|||||||
* It also checks the linkified results to make sure URLs spans are not including trailing punctuation.
|
* It also checks the linkified results to make sure URLs spans are not including trailing punctuation.
|
||||||
*/
|
*/
|
||||||
object LinkifyHelper {
|
object LinkifyHelper {
|
||||||
/**
|
private val BRACKETED_AUTHORITY_URL_REGEX = Regex("""(?<![a-zA-Z0-9+.-])[a-zA-Z][a-zA-Z0-9+.-]*://\[\S*""")
|
||||||
* Matches a URL whose authority is a bracketed IPv6 address, i.e. `http://[2001:db8::1]:8008/path`.
|
|
||||||
* A scheme and the brackets are both required, so array syntax such as `values[0]:1` is not matched.
|
|
||||||
*/
|
|
||||||
private val IPV6_URL_REGEX = Regex("""(?i)\b(?:https?|ftp)://\[[0-9a-f:.]+(?:%25[0-9a-z._-]+)?](?::\d{1,5})?(?:/\S*)?""")
|
|
||||||
|
|
||||||
fun linkify(
|
fun linkify(
|
||||||
text: CharSequence,
|
text: CharSequence,
|
||||||
@@ -73,25 +70,22 @@ object LinkifyHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Patterns.WEB_URL, which LinkifyCompat uses, has no production for a bracketed IPv6
|
for (match in BRACKETED_AUTHORITY_URL_REGEX.findAll(spannable)) {
|
||||||
// authority, so such a URL is never linkified — and worse, the phone-number matcher claims
|
|
||||||
// the digit runs inside the address. Add the links here, after addLinks (which strips any
|
|
||||||
// span set before it) and before the old spans are restored, so real <a href> and pill
|
|
||||||
// spans still win. See https://github.com/element-hq/element-x-android/issues/2614
|
|
||||||
for (match in IPV6_URL_REGEX.findAll(spannable)) {
|
|
||||||
val start = match.range.first
|
val start = match.range.first
|
||||||
val end = match.range.last + 1
|
val end = match.range.last + 1
|
||||||
val newEnd = runCatchingExceptions {
|
val newEnd = runCatchingExceptions {
|
||||||
adjustLinkifiedUrlSpanEndIndex(spannable, start, end)
|
adjustLinkifiedUrlSpanEndIndex(spannable, start, end)
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
Timber.e(it, "Failed to adjust end index for IPv6 link span")
|
Timber.e(it, "Failed to adjust end index for link span")
|
||||||
}.getOrNull() ?: end
|
}.getOrNull() ?: end
|
||||||
|
|
||||||
// Evict whatever Linkify put inside the address, typically a tel: span.
|
val url = spannable.substring(start, newEnd)
|
||||||
|
if (!url.hasBracketedHost()) continue
|
||||||
|
|
||||||
for (span in spannable.getSpans<URLSpan>(start, newEnd)) {
|
for (span in spannable.getSpans<URLSpan>(start, newEnd)) {
|
||||||
spannable.removeSpan(span)
|
spannable.removeSpan(span)
|
||||||
}
|
}
|
||||||
spannable.setSpan(URLSpan(spannable.substring(start, newEnd)), start, newEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
spannable.setSpan(URLSpan(url), start, newEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore old spans, remove new ones if there is a conflict
|
// Restore old spans, remove new ones if there is a conflict
|
||||||
@@ -109,6 +103,11 @@ object LinkifyHelper {
|
|||||||
return spannable
|
return spannable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun String.hasBracketedHost(): Boolean {
|
||||||
|
val host = runCatchingExceptions { URI(this) }.getOrNull()?.host ?: return false
|
||||||
|
return host.startsWith('[') && host.endsWith(']')
|
||||||
|
}
|
||||||
|
|
||||||
private fun adjustLinkifiedUrlSpanEndIndex(spannable: Spannable, start: Int, end: Int): Int {
|
private fun adjustLinkifiedUrlSpanEndIndex(spannable: Spannable, start: Int, end: Int): Int {
|
||||||
var end = end
|
var end = end
|
||||||
|
|
||||||
|
|||||||
+17
@@ -65,6 +65,23 @@ class LinkifierHelperTest : RobolectricTest() {
|
|||||||
assertThat(urlSpans.first().url).isEqualTo("http://[::1]/x")
|
assertThat(urlSpans.first().url).isEqualTo("http://[::1]/x")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `linkification finds an ipv6 URL of any scheme`() {
|
||||||
|
val text = "A url ssh://[2001:db8::1]/path"
|
||||||
|
val result = LinkifyHelper.linkify(text)
|
||||||
|
val urlSpans = result.toSpannable().getSpans<URLSpan>()
|
||||||
|
assertThat(urlSpans.size).isEqualTo(1)
|
||||||
|
assertThat(urlSpans.first().url).isEqualTo("ssh://[2001:db8::1]/path")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `linkification ignores a bracketed authority which is not an address`() {
|
||||||
|
val text = "A url http://[not-an-address]/path"
|
||||||
|
val result = LinkifyHelper.linkify(text)
|
||||||
|
val urlSpans = result.toSpannable().getSpans<URLSpan>()
|
||||||
|
assertThat(urlSpans).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `linkification ignores bracketed text which is not a URL`() {
|
fun `linkification ignores bracketed text which is not a URL`() {
|
||||||
val text = "An array values[0]:1 and a bare [::1]"
|
val text = "An array values[0]:1 and a bare [::1]"
|
||||||
|
|||||||
Reference in New Issue
Block a user