Adds treatment for the transition between ASCII and non-ASCII in the middle of urls

This commit is contained in:
Vitor Pamplona
2026-03-07 15:19:24 -05:00
parent 5e1a193f78
commit 030921c963
7 changed files with 78 additions and 36 deletions
@@ -20,7 +20,6 @@
*/ */
package com.vitorpamplona.amethyst.commons.richtext package com.vitorpamplona.amethyst.commons.richtext
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.noProtocolUrlValidator
import com.vitorpamplona.quartz.utils.urldetector.Url import com.vitorpamplona.quartz.utils.urldetector.Url
import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector import com.vitorpamplona.quartz.utils.urldetector.detection.UrlDetector
@@ -48,7 +47,12 @@ class UrlParser {
fun Url.wroteWithSchema(): Boolean = originalUrl.startsWith(scheme) fun Url.wroteWithSchema(): Boolean = originalUrl.startsWith(scheme)
fun Url.isEmail(): Boolean = originalUrl.contains('@') && path == "/" && query.isEmpty() && fragment.isEmpty() fun Url.isEmail(): Boolean =
urlMarker.hasUsernamePassword() &&
!urlMarker.hasQuery() &&
!urlMarker.hasFragment() &&
originalUrl.contains('@') &&
path == "/"
fun Char.isValidLastHostnameChar(): Boolean = (this in 'a'..'z' || this in 'A'..'Z' || this in '0'..'9') fun Char.isValidLastHostnameChar(): Boolean = (this in 'a'..'z' || this in 'A'..'Z' || this in '0'..'9')
@@ -85,12 +89,7 @@ class UrlParser {
emails.add(it.value) emails.add(it.value)
} }
} else { } else {
noProtocolUrlValidator.findAll(it.originalUrl).forEach { components -> urlsWithoutScheme.add(it.originalUrl)
val url = components.groups[1]?.value
if (url != null) {
urlsWithoutScheme.add(url)
}
}
} }
} }
} }
@@ -35,6 +35,7 @@ package com.vitorpamplona.quartz.utils.urldetector
*/ */
class Url( class Url(
val urlMarker: UrlMarker, val urlMarker: UrlMarker,
val originalUrl: String,
) { ) {
private var _scheme: String? = null private var _scheme: String? = null
private var _username: String? = null private var _username: String? = null
@@ -44,7 +45,6 @@ class Url(
private var rawPath: String? = null private var rawPath: String? = null
private var _query: String? = null private var _query: String? = null
private var _fragment: String? = null private var _fragment: String? = null
val originalUrl: String = urlMarker.originalUrl
override fun toString(): String = this.fullUrl override fun toString(): String = this.fullUrl
@@ -28,14 +28,17 @@ class UrlMarker {
private var pathIndex = -1 private var pathIndex = -1
private var queryIndex = -1 private var queryIndex = -1
private var fragmentIndex = -1 private var fragmentIndex = -1
var originalUrl: String = ""
fun createUrl(): Url = Url(this) var hasChanged: Boolean = false
private set
fun createUrl(originalUrl: String): Url = Url(this, originalUrl)
fun setIndex( fun setIndex(
urlPart: UrlPart, urlPart: UrlPart,
index: Int, index: Int,
) { ) {
hasChanged = true
when (urlPart) { when (urlPart) {
UrlPart.SCHEME -> schemeIndex = index UrlPart.SCHEME -> schemeIndex = index
UrlPart.USERNAME_PASSWORD -> usernamePasswordIndex = index UrlPart.USERNAME_PASSWORD -> usernamePasswordIndex = index
@@ -53,6 +56,10 @@ class UrlMarker {
fun hasUsernamePassword() = usernamePasswordIndex >= 0 fun hasUsernamePassword() = usernamePasswordIndex >= 0
fun hasQuery() = queryIndex >= 0
fun hasFragment() = fragmentIndex >= 0
/** /**
* @param urlPart The part you want the index of * @param urlPart The part you want the index of
* @return Returns the index of the part * @return Returns the index of the part
@@ -166,12 +166,17 @@ class DomainNameReader(
var isAllHexSoFar = var isAllHexSoFar =
length > 2 && (currArray[0] == '0' && (currArray[1] == 'x' || currArray[1] == 'X')) length > 2 && (currArray[0] == '0' && (currArray[1] == 'x' || currArray[1] == 'X'))
var lastWasAscii = length > 0 && currArray[0].code < INTERNATIONAL_CHAR_START
var index = if (isAllHexSoFar) 2 else 0 var index = if (isAllHexSoFar) 2 else 0
var done = false var done = false
var isAscii = false
while (index < length && !done) { while (index < length && !done) {
// get the current character and update length counts. // get the current character and update length counts.
val curr = currArray[index] val curr = currArray[index]
isAscii = curr.code < INTERNATIONAL_CHAR_START
currentLabelLength++ currentLabelLength++
topLevelLength = currentLabelLength topLevelLength = currentLabelLength
@@ -185,9 +190,7 @@ class DomainNameReader(
} else if (curr == '[') { } else if (curr == '[') {
seenBracket = true seenBracket = true
numeric = false numeric = false
} else if (curr == '%' && index + 2 < length && isHex(currArray[index + 1]) && } else if (curr == '%' && index + 2 < length && isHex(currArray[index + 1]) && isHex(currArray[index + 2])) {
isHex(currArray[index + 2])
) {
// handle url encoded dot // handle url encoded dot
if (currArray[index + 1] == '2' && currArray[index + 2] == 'e') { if (currArray[index + 1] == '2' && currArray[index + 2] == 'e') {
dots++ dots++
@@ -203,8 +206,20 @@ class DomainNameReader(
isAllHexSoFar = false isAllHexSoFar = false
index-- // backtrack to rerun last character knowing it isn't hex. index-- // backtrack to rerun last character knowing it isn't hex.
} }
} else if (isAlpha(curr) || curr == '-' || curr.code >= INTERNATIONAL_CHAR_START) { } else if (isAscii == lastWasAscii && (isAlpha(curr) || curr == '-' || !isAscii)) {
// we don't allow mixed domains: doesn't come here if it changed form ascii to not ascii.
numeric = false numeric = false
lastWasAscii = isAscii
} else if (isAscii != lastWasAscii) {
// if its not _numeric and not alphabetical, then restart searching for a domain from this point.
newStart = index
currentLabelLength = 0
topLevelLength = 0
numeric = true
dots = 0
// done = true
lastWasAscii = isAscii
} }
index++ index++
} }
@@ -216,7 +231,9 @@ class DomainNameReader(
// make sure the location is not at the end. Otherwise the thing is just invalid. // make sure the location is not at the end. Otherwise the thing is just invalid.
if (newStart < current.length) { if (newStart < current.length) {
buffer.replaceRange(0, buffer.length, current.substring(newStart)) buffer.clear()
buffer.append(current.substring(newStart))
// cut out the previous part, so now the domain name has to be from here. // cut out the previous part, so now the domain name has to be from here.
startDomainName = 0 startDomainName = 0
@@ -265,8 +282,16 @@ class DomainNameReader(
topLevelLength = currentLabelLength topLevelLength = currentLabelLength
} }
var lastWasAscii: Boolean? = null
var isAscii = false
while (!done && !reader.eof()) { while (!done && !reader.eof()) {
val curr: Char = reader.read() val curr: Char = reader.read()
isAscii = curr.code < INTERNATIONAL_CHAR_START
if (lastWasAscii == null) {
lastWasAscii = isAscii
}
if (curr == '/') { if (curr == '/') {
// continue by reading the path // continue by reading the path
@@ -314,9 +339,7 @@ class DomainNameReader(
return ReaderNextState.InvalidDomainName return ReaderNextState.InvalidDomainName
} }
} }
} else if (seenBracket && (isHex(curr) || curr == ':' || curr == '[' || curr == ']' || curr == '%') && } else if (seenBracket && (isHex(curr) || curr == ':' || curr == '[' || curr == ']' || curr == '%') && !seenCompleteBracketSet) { // if this is an ipv6 address.
!seenCompleteBracketSet
) { // if this is an ipv6 address.
when (curr) { when (curr) {
':' -> { ':' -> {
currentLabelLength = 0 currentLabelLength = 0
@@ -359,12 +382,18 @@ class DomainNameReader(
if (!isAllHexSoFar && !isNumeric(curr)) { if (!isAllHexSoFar && !isNumeric(curr)) {
numeric = false numeric = false
} }
if (isAscii != lastWasAscii) {
// append to the states. reader.goBack()
buffer.append(curr) done = true
currentLabelLength++ } else {
topLevelLength = currentLabelLength // append to the states.
buffer.append(curr)
currentLabelLength++
topLevelLength = currentLabelLength
}
} }
lastWasAscii = isAscii
} else if (curr == '[' && !seenBracket) { } else if (curr == '[' && !seenBracket) {
seenBracket = true seenBracket = true
numeric = false numeric = false
@@ -372,9 +401,7 @@ class DomainNameReader(
} else if (curr == '[' && seenCompleteBracketSet) { // Case where [::][ ... } else if (curr == '[' && seenCompleteBracketSet) { // Case where [::][ ...
reader.goBack() reader.goBack()
done = true done = true
} else if (curr == '%' && reader.canReadChars(2) && isHex(reader.peekChar(0)) && } else if (curr == '%' && reader.canReadChars(2) && isHex(reader.peekChar(0)) && isHex(reader.peekChar(1))) {
isHex(reader.peekChar(1))
) {
// append to the states. // append to the states.
buffer.append(curr) buffer.append(curr)
buffer.append(reader.read()) buffer.append(reader.read())
@@ -95,11 +95,12 @@ class UrlDetector(
val domain = buffer.substring(length) val domain = buffer.substring(length)
if (!readDomainName(domain)) { if (!readDomainName(domain)) {
readEnd(ReadEndState.InvalidUrl) readEnd(ReadEndState.InvalidUrl)
} else {
readEnd(ReadEndState.InvalidUrl)
} }
} else {
readEnd(ReadEndState.InvalidUrl)
} }
buffer.append(curr)
readEnd(ReadEndState.InvalidUrl)
length = 0 length = 0
} }
@@ -649,17 +650,19 @@ class UrlDetector(
if (state == ReadEndState.ValidUrl && buffer.isNotEmpty()) { if (state == ReadEndState.ValidUrl && buffer.isNotEmpty()) {
// Add the url to the list of good urls. // Add the url to the list of good urls.
if (buffer.isNotEmpty()) { if (buffer.isNotEmpty()) {
currentUrlMarker.originalUrl = buffer.toString() urlList.add(currentUrlMarker.createUrl(buffer.toString()))
urlList.add(currentUrlMarker.createUrl())
} }
} }
// clear out the buffer. // clear out the buffer.
buffer.deleteRange(0, buffer.length) buffer.clear()
// reset the state of internal objects. // reset the state of internal objects.
hasScheme = false hasScheme = false
currentUrlMarker = UrlMarker() isSingleLevelLabel = false
if (currentUrlMarker.hasChanged) {
currentUrlMarker = UrlMarker()
}
// return true if valid. // return true if valid.
return state == ReadEndState.ValidUrl return state == ReadEndState.ValidUrl
@@ -37,9 +37,8 @@ class UrlMarkerTest {
indices: IntArray, indices: IntArray,
) { ) {
val urlMarker = UrlMarker() val urlMarker = UrlMarker()
urlMarker.originalUrl = testString
urlMarker.setIndices(indices) urlMarker.setIndices(indices)
val url = urlMarker.createUrl() val url = urlMarker.createUrl(testString)
assertEquals(url.host, host, "host, " + testString) assertEquals(url.host, host, "host, " + testString)
assertEquals(url.path, path, "path, " + testString) assertEquals(url.path, path, "path, " + testString)
assertEquals(url.scheme, scheme, "scheme, " + testString) assertEquals(url.scheme, scheme, "scheme, " + testString)
@@ -735,6 +735,13 @@ class UriDetectionTest {
runTest("I saw this on http://[::1]:3000 I think it is really cool", "http://[::1]:3000") runTest("I saw this on http://[::1]:3000 I think it is really cool", "http://[::1]:3000")
} }
@Test
fun testNoSchemaUrlMultibyteAscii() {
runTest("ほtest.com", "test.com")
runTest("test.comほ", "test.com")
runTest("ほtest.comほ", "test.com")
}
private fun runTest( private fun runTest(
text: String, text: String,
vararg expected: String?, vararg expected: String?,