refactor: improve URL detector performance and readability
- Replace 6 regex operations in Url.removeDotSegments() with simple string operations (startsWith/indexOf) using a when-expression - Remove regex-based dropLastSegment() in favor of StringBuilder with lastIndexOf - Extract duplicated hex/octal/decimal parsing in DomainNameReader into shared parseNumericLiteral() helper - Deduplicate scheme matching by unifying findValidScheme* methods into findSchemeSuffix() using regionMatches (avoids lowercase allocation) - Extract readPath() validity check into isPathValid() to remove duplicated 5-line condition - Extract trySchemeNoSlashesOrUserPass() from readScheme() to eliminate duplicated branch logic - Restructure readCurrent() with when-expression and extract resetDomainCounters() helper https://claude.ai/code/session_017oGieyaUiLCxehNJ5aMFDK
This commit is contained in:
@@ -245,95 +245,74 @@ class Url(
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes dot segments from the given path as stated in
|
||||
* ["RFC 3986, 5.2.4. Remove Dot Segments"](https://www.rfc-editor.org/rfc/rfc3986#section-5.2.4).
|
||||
*
|
||||
* @param path
|
||||
* The path from which dot segments are to be removed.
|
||||
*
|
||||
* @return
|
||||
* The path from which dot segments are removed.
|
||||
* Removes dot segments from the given path per
|
||||
* [RFC 3986 §5.2.4](https://www.rfc-editor.org/rfc/rfc3986#section-5.2.4).
|
||||
*/
|
||||
fun removeDotSegments(path: String): String {
|
||||
// Initialize the input with the no-appended path components and the output
|
||||
// with the empty string.
|
||||
var input = path
|
||||
var output = ""
|
||||
val output = StringBuilder()
|
||||
|
||||
// While the input is not empty, loop the following steps.
|
||||
while (input.isNotEmpty()) {
|
||||
// If the input begins with a prefix of "../" or "./", then
|
||||
// remove that prefix from the input;
|
||||
if (DOT_DOT_SLASH.find(input) != null) {
|
||||
input = DOT_DOT_SLASH.replaceFirst(input, "")
|
||||
continue
|
||||
}
|
||||
when {
|
||||
// A: Remove leading "../" or "./"
|
||||
input.startsWith("../") -> {
|
||||
input = input.substring(3)
|
||||
}
|
||||
|
||||
// If the input begins with a prefix of "/./" or "/.", where
|
||||
// "." is a complete path segment, then replace that prefix
|
||||
// with "/" in the input.
|
||||
if (SLASH_DOT_SLASH.find(input) != null) {
|
||||
input = SLASH_DOT_SLASH.replaceFirst(input, "/")
|
||||
continue
|
||||
}
|
||||
input.startsWith("./") -> {
|
||||
input = input.substring(2)
|
||||
}
|
||||
|
||||
// If the input begins with a prefix of "/../" or "/..",
|
||||
// where ".." is a complete path segment, then replace that
|
||||
// prefix with "/" in the input and remove the last segment
|
||||
// and its preceding "/" (if any) from the output.
|
||||
if (SLASH_DOT_DOT_SLASH.find(input) != null) {
|
||||
input = SLASH_DOT_DOT_SLASH.replaceFirst(input, "/")
|
||||
output = dropLastSegment(output, true)
|
||||
continue
|
||||
}
|
||||
// B: Replace leading "/./" or "/." (end) with "/"
|
||||
input.startsWith("/./") -> {
|
||||
input = "/" + input.substring(3)
|
||||
}
|
||||
|
||||
// If the input consists only of "." or "..", then remove
|
||||
// that from the input.
|
||||
if (DOT_OR_DOT_DOT.find(input) != null) {
|
||||
input = DOT_OR_DOT_DOT.replaceFirst(input, "")
|
||||
continue
|
||||
}
|
||||
input == "/." -> {
|
||||
input = "/"
|
||||
}
|
||||
|
||||
// Move the first path segment in the input buffer to the
|
||||
// end of the output, including the initial "/" character
|
||||
// (if any) and any subsequent characters up to, but not
|
||||
// including, the next "/" character or the end of the input.
|
||||
val matchResult = MOVE_REGEX.find(input)
|
||||
if (matchResult != null) {
|
||||
input = matchResult.groups["remaining"]!!.value
|
||||
output += matchResult.groups["firstsegment"]!!.value
|
||||
continue
|
||||
// C: Replace leading "/../" or "/.." (end) with "/" and drop last output segment
|
||||
input.startsWith("/../") -> {
|
||||
input = "/" + input.substring(4)
|
||||
dropLastSegment(output)
|
||||
}
|
||||
|
||||
input == "/.." -> {
|
||||
input = "/"
|
||||
dropLastSegment(output)
|
||||
}
|
||||
|
||||
// D: Input is just "." or ".."
|
||||
input == "." || input == ".." -> {
|
||||
input = ""
|
||||
}
|
||||
|
||||
// E: Move the first path segment to output
|
||||
else -> {
|
||||
val startIdx = if (input.startsWith("/")) 1 else 0
|
||||
val idx = input.indexOf('/', startIdx)
|
||||
val segEnd = if (idx == -1) input.length else idx
|
||||
output.append(input, 0, segEnd)
|
||||
input = input.substring(segEnd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
return output.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the last segment (= characters after the last slash) of a path and
|
||||
* optionally the last slash. If the path doesn't contain slash, an empty string
|
||||
* is returned.
|
||||
*
|
||||
* @param path
|
||||
* The path.
|
||||
*
|
||||
* @param dropLastSlash
|
||||
* Whether or not to drop the last slash if present.
|
||||
*
|
||||
* @return The path from which the last segment is removed.
|
||||
* Removes the last segment and its preceding "/" from the output buffer.
|
||||
* For example, "/a/b" becomes "/a" and "/a" becomes "".
|
||||
*/
|
||||
fun dropLastSegment(
|
||||
path: String,
|
||||
dropLastSlash: Boolean,
|
||||
): String {
|
||||
// The regular expression for the target.
|
||||
val m = if (dropLastSlash) DROP_LAST_SLASH_REGEX else DROP_LAST_SEGMENT_REGEX
|
||||
|
||||
// Find the target. (Any inputs matches the pattern.)
|
||||
m.find(path)
|
||||
|
||||
// Drop the target.
|
||||
return m.replaceFirst(path, "")
|
||||
private fun dropLastSegment(output: StringBuilder) {
|
||||
val lastSlash = output.lastIndexOf('/')
|
||||
if (lastSlash >= 0) {
|
||||
output.delete(lastSlash, output.length)
|
||||
} else {
|
||||
output.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -416,34 +395,6 @@ class Url(
|
||||
}
|
||||
|
||||
companion object {
|
||||
val DROP_LAST_SLASH_REGEX = Regex("\\/?[^/]*$")
|
||||
val DROP_LAST_SEGMENT_REGEX = Regex("[^/]*$")
|
||||
|
||||
// If the input begins with a prefix of "../" or "./", then
|
||||
// remove that prefix from the input;
|
||||
val DOT_DOT_SLASH = Regex("^\\.?\\./")
|
||||
|
||||
// If the input begins with a prefix of "/./" or "/.", where
|
||||
// "." is a complete path segment, then replace that prefix
|
||||
// with "/" in the input.
|
||||
val SLASH_DOT_SLASH = Regex("^\\/\\.(\\/|$)")
|
||||
|
||||
// If the input begins with a prefix of "/../" or "/..",
|
||||
// where ".." is a complete path segment, then replace that
|
||||
// prefix with "/" in the input and remove the last segment
|
||||
// and its preceding "/" (if any) from the output.
|
||||
val SLASH_DOT_DOT_SLASH = Regex("^\\/\\.\\.(\\/|$)")
|
||||
|
||||
// If the input consists only of "." or "..", then remove
|
||||
// that from the input.
|
||||
val DOT_OR_DOT_DOT = Regex("^\\.?\\.$")
|
||||
|
||||
// Move the first path segment in the input buffer to the
|
||||
// end of the output, including the initial "/" character
|
||||
// (if any) and any subsequent characters up to, but not
|
||||
// including, the next "/" character or the end of the input.
|
||||
val MOVE_REGEX = Regex("^(?<firstsegment>\\/?[^/]*)(?<remaining>.*)$")
|
||||
|
||||
private const val DEFAULT_SCHEME = "https"
|
||||
private val SCHEME_PORT_MAP: Map<String, Int> =
|
||||
mapOf(
|
||||
|
||||
+154
-195
@@ -146,128 +146,132 @@ class DomainNameReader(
|
||||
private set
|
||||
|
||||
/**
|
||||
* Reads and parses the current string to make sure the domain name started where it was supposed to,
|
||||
* and the current domain name is correct.
|
||||
* @return The next state to use after reading the current.
|
||||
* Validates the buffered domain-name prefix ([current]) that was accumulated before
|
||||
* the caller started reading from the stream. Updates label/dot counters, detects
|
||||
* hex-numeric IPs, brackets for IPv6, and ASCII/international char boundaries.
|
||||
*
|
||||
* If an invalid character is found mid-string the domain is restarted from that
|
||||
* position (e.g. `asdf%asdf.google.com` → restart at `asdf.google.com`).
|
||||
*
|
||||
* @return [ReaderNextState.ValidDomainName] when the prefix is acceptable,
|
||||
* [ReaderNextState.InvalidDomainName] otherwise.
|
||||
*/
|
||||
private fun readCurrent(): ReaderNextState {
|
||||
if (current != null) {
|
||||
// Handles the case where the string is ".hello"
|
||||
if (current.length == 1 && isDot(current[0])) {
|
||||
return ReaderNextState.InvalidDomainName
|
||||
} else if (current.length == 3 && current.isDotPercent()) {
|
||||
if (current == null) {
|
||||
startDomainName = buffer.length
|
||||
return ReaderNextState.ValidDomainName
|
||||
}
|
||||
|
||||
// A lone dot or percent-encoded dot is never a valid domain start.
|
||||
if ((current.length == 1 && isDot(current[0])) ||
|
||||
(current.length == 3 && current.isDotPercent())
|
||||
) {
|
||||
return ReaderNextState.InvalidDomainName
|
||||
}
|
||||
|
||||
startDomainName = buffer.length - current.length
|
||||
numeric = true
|
||||
|
||||
// Index into `current` where we'd restart the domain if we hit an invalid char.
|
||||
var newStart = 0
|
||||
|
||||
val chars = current.toCharArray()
|
||||
val length = chars.size
|
||||
|
||||
// Detect hex literal prefix (0x...)
|
||||
var isAllHexSoFar = length > 2 && chars[0] == '0' && (chars[1] == 'x' || chars[1] == 'X')
|
||||
var lastWasAscii = length > 0 && chars[0].code < INTERNATIONAL_CHAR_START
|
||||
|
||||
var index = if (isAllHexSoFar) 2 else 0
|
||||
|
||||
while (index < length) {
|
||||
val ch = chars[index]
|
||||
val isAscii = ch.code < INTERNATIONAL_CHAR_START
|
||||
|
||||
currentLabelLength++
|
||||
topLevelLength = currentLabelLength
|
||||
|
||||
if (currentLabelLength > MAX_LABEL_LENGTH) {
|
||||
return ReaderNextState.InvalidDomainName
|
||||
}
|
||||
|
||||
// The location where the domain name started.
|
||||
startDomainName = buffer.length - current.length
|
||||
|
||||
// flag that the domain is currently all numbers and/or dots.
|
||||
numeric = true
|
||||
|
||||
// If an invalid char is found, we can just restart the domain from there.
|
||||
var newStart = 0
|
||||
|
||||
val currArray = current.toCharArray()
|
||||
val length = currArray.size
|
||||
|
||||
// hex special case
|
||||
var isAllHexSoFar =
|
||||
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 done = false
|
||||
var isAscii = false
|
||||
|
||||
while (index < length && !done) {
|
||||
// get the current character and update length counts.
|
||||
val curr = currArray[index]
|
||||
isAscii = curr.code < INTERNATIONAL_CHAR_START
|
||||
|
||||
currentLabelLength++
|
||||
topLevelLength = currentLabelLength
|
||||
|
||||
// Is the length of the last part > 64 (plus one since we just incremented)
|
||||
if (currentLabelLength > MAX_LABEL_LENGTH) {
|
||||
return ReaderNextState.InvalidDomainName
|
||||
} else if (isDot(curr)) {
|
||||
// found a dot. Increment dot count, and reset last length
|
||||
when {
|
||||
isDot(ch) -> {
|
||||
dots++
|
||||
currentLabelLength = 0
|
||||
} else if (curr == '[') {
|
||||
}
|
||||
|
||||
ch == '[' -> {
|
||||
seenBracket = true
|
||||
numeric = false
|
||||
} else if (curr == '%' && index + 2 < length && isHex(currArray[index + 1]) && isHex(currArray[index + 2])) {
|
||||
// handle url encoded dot
|
||||
if (currArray[index + 1] == '2' && currArray[index + 2] == 'e') {
|
||||
}
|
||||
|
||||
ch == '%' && index + 2 < length && isHex(chars[index + 1]) && isHex(chars[index + 2]) -> {
|
||||
// Percent-encoded byte; check for encoded dot (%2e)
|
||||
if (chars[index + 1] == '2' && chars[index + 2] == 'e') {
|
||||
dots++
|
||||
currentLabelLength = 0
|
||||
} else {
|
||||
numeric = false
|
||||
}
|
||||
index += 2
|
||||
} else if (isAllHexSoFar) {
|
||||
// if it's a valid character in the domain that is not numeric
|
||||
if (!isHex(curr)) {
|
||||
numeric = false
|
||||
isAllHexSoFar = false
|
||||
index-- // backtrack to rerun last character knowing it isn't hex.
|
||||
}
|
||||
} 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.
|
||||
}
|
||||
|
||||
isAllHexSoFar && !isHex(ch) -> {
|
||||
// Thought it was hex but this char isn't — reprocess as non-hex
|
||||
numeric = false
|
||||
isAllHexSoFar = false
|
||||
index-- // backtrack to re-evaluate this char
|
||||
}
|
||||
|
||||
isAscii == lastWasAscii && (isAlpha(ch) || ch == '-' || !isAscii) -> {
|
||||
// Valid domain character (same script as previous)
|
||||
numeric = false
|
||||
lastWasAscii = isAscii
|
||||
} else if (isAscii != lastWasAscii) {
|
||||
// if its not _numeric and not alphabetical, then restart searching for a domain from this point.
|
||||
}
|
||||
|
||||
isAscii != lastWasAscii -> {
|
||||
// Script boundary (ASCII ↔ international) — restart domain from here
|
||||
newStart = index
|
||||
currentLabelLength = 0
|
||||
topLevelLength = 0
|
||||
numeric = true
|
||||
dots = 0
|
||||
// done = true
|
||||
|
||||
resetDomainCounters()
|
||||
lastWasAscii = isAscii
|
||||
} else if (index == 0) {
|
||||
if (curr in UrlDetector.CANNOT_BEGIN_URLS_WITH) {
|
||||
newStart = index + 1
|
||||
currentLabelLength = 0
|
||||
topLevelLength = 0
|
||||
numeric = true
|
||||
dots = 0
|
||||
}
|
||||
}
|
||||
index++
|
||||
}
|
||||
|
||||
// An invalid character for the domain was found somewhere in the current buffer.
|
||||
// cut the first part of the domain out. For example:
|
||||
// http://asdf%asdf.google.com <- asdf.google.com is still valid, so restart from the %
|
||||
if (newStart > 0) {
|
||||
// make sure the location is not at the end. Otherwise the thing is just invalid.
|
||||
|
||||
if (newStart < current.length) {
|
||||
buffer.clear()
|
||||
buffer.append(current.substring(newStart))
|
||||
|
||||
// cut out the previous part, so now the domain name has to be from here.
|
||||
startDomainName = 0
|
||||
}
|
||||
|
||||
// now after cutting if the buffer is just "." newStart > current (last character in current is invalid)
|
||||
if (newStart >= current.length || buffer.toString() == ".") {
|
||||
return ReaderNextState.InvalidDomainName
|
||||
index == 0 && ch in UrlDetector.CANNOT_BEGIN_URLS_WITH -> {
|
||||
// Invalid leading char — restart after it
|
||||
newStart = index + 1
|
||||
resetDomainCounters()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
startDomainName = buffer.length
|
||||
index++
|
||||
}
|
||||
|
||||
// If we found an invalid region, trim the buffer to start after it.
|
||||
if (newStart > 0) {
|
||||
if (newStart < current.length) {
|
||||
buffer.clear()
|
||||
buffer.append(current.substring(newStart))
|
||||
startDomainName = 0
|
||||
}
|
||||
|
||||
if (newStart >= current.length || buffer.toString() == ".") {
|
||||
return ReaderNextState.InvalidDomainName
|
||||
}
|
||||
}
|
||||
|
||||
// all else is good, return OK
|
||||
return ReaderNextState.ValidDomainName
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets domain tracking counters when the domain start is being moved forward.
|
||||
*/
|
||||
private fun resetDomainCounters() {
|
||||
currentLabelLength = 0
|
||||
topLevelLength = 0
|
||||
numeric = true
|
||||
dots = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the Dns and returns the next state the state machine should take in throwing this out, or continue processing
|
||||
* if this is a valid domain name.
|
||||
@@ -527,111 +531,66 @@ class DomainNameReader(
|
||||
* @return Returns true if it's a valid ipv4 address
|
||||
*/
|
||||
private fun isValidIpv4(testDomain: String): Boolean {
|
||||
var valid = false
|
||||
val length: Int = testDomain.length
|
||||
if (length > 0) {
|
||||
// handling format without dots. Ex: http://2123123123123/path/a, http://0x8242343/aksdjf
|
||||
if (dots == 0) {
|
||||
try {
|
||||
val value: Long
|
||||
if (length > 2 && testDomain[0] == '0' && testDomain[1] == 'x') { // hex
|
||||
// digit must be within ['0', '9'] or ['A', 'F'] or ['a', 'f']
|
||||
for (c in 2..<length) {
|
||||
val d: Char = testDomain[c]
|
||||
if ((d < '0' || (d in ':'..<'A') || (d in 'G'..<'a') || d > 'f')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
value = testDomain.substring(2).toLong(16)
|
||||
} else if (testDomain[0] == '0') { // octal
|
||||
// digit must be within ['0', '7']
|
||||
for (c in 1..<length) {
|
||||
val d: Char = testDomain[c]
|
||||
if (d !in '0'..'7') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
value = testDomain.substring(1).toLong(8)
|
||||
} else { // decimal
|
||||
// digit must be within ['0', '9']
|
||||
for (c in 0..<length) {
|
||||
val d: Char = testDomain[c]
|
||||
if (d !in '0'..'9') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
value = testDomain.toLong()
|
||||
}
|
||||
valid = value in MIN_NUMERIC_DOMAIN_VALUE..MAX_NUMERIC_DOMAIN_VALUE
|
||||
} catch (_: NumberFormatException) {
|
||||
valid = false
|
||||
}
|
||||
} else if (dots == 3) {
|
||||
// Dotted decimal/hex/octal format
|
||||
val parts: List<String> = splitByDot(testDomain)
|
||||
valid = true
|
||||
if (testDomain.isEmpty()) return false
|
||||
|
||||
// check each part of the ip and make sure its valid.
|
||||
var i = 0
|
||||
while (i < parts.size && valid) {
|
||||
val part = parts[i]
|
||||
val partLen: Int = part.length
|
||||
if (partLen > 0) {
|
||||
val parsedNum: String
|
||||
val base: Int
|
||||
if (partLen > 2 && part[0] == '0' && part[1] == 'x') { // dotted hex
|
||||
// digit must be within ['0', '9'] or ['A', 'F'] or ['a', 'f']
|
||||
for (c in 2..<partLen) {
|
||||
val d: Char = part[c]
|
||||
if ((d < '0' || (d in ':'..<'A') || (d in 'G'..<'a') || d > 'f')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
parsedNum = part.substring(2)
|
||||
base = 16
|
||||
} else if (part[0] == '0') { // dotted octal
|
||||
// digit must be within ['0', '7']
|
||||
for (c in 1..<partLen) {
|
||||
val d: Char = part[c]
|
||||
if (d !in '0'..'7') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
parsedNum = part.substring(1)
|
||||
base = 8
|
||||
} else { // dotted decimal
|
||||
// digit must be within ['0', '9']
|
||||
for (c in 0..<partLen) {
|
||||
val d: Char = part[c]
|
||||
if (d !in '0'..'9') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
parsedNum = part
|
||||
base = 10
|
||||
}
|
||||
// Dotless format: http://2123123123123/path, http://0x8242343/aksdjf
|
||||
if (dots == 0) {
|
||||
val value = parseNumericLiteral(testDomain) ?: return false
|
||||
return value in MIN_NUMERIC_DOMAIN_VALUE..MAX_NUMERIC_DOMAIN_VALUE
|
||||
}
|
||||
|
||||
val section =
|
||||
if (parsedNum.isEmpty()) {
|
||||
0
|
||||
} else {
|
||||
try {
|
||||
parsedNum.toInt(base)
|
||||
} catch (_: NumberFormatException) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (section !in MIN_IP_PART..MAX_IP_PART) {
|
||||
valid = false
|
||||
}
|
||||
} else {
|
||||
valid = false
|
||||
}
|
||||
i++
|
||||
}
|
||||
// Dotted format: must have exactly 4 parts (3 dots)
|
||||
if (dots != 3) return false
|
||||
|
||||
val parts = splitByDot(testDomain)
|
||||
for (part in parts) {
|
||||
if (part.isEmpty()) return false
|
||||
val section = parseNumericLiteral(part) ?: return false
|
||||
if (section !in MIN_IP_PART..MAX_IP_PART) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a numeric literal that may be decimal, hexadecimal (0x prefix), or octal (0 prefix).
|
||||
* Validates digit ranges before parsing to avoid exceptions.
|
||||
* @return The parsed value as a Long, or null if the string is not a valid numeric literal.
|
||||
*/
|
||||
private fun parseNumericLiteral(s: String): Long? {
|
||||
if (s.isEmpty()) return 0L
|
||||
|
||||
val digits: String
|
||||
val base: Int
|
||||
|
||||
if (s.length > 2 && s[0] == '0' && (s[1] == 'x' || s[1] == 'X')) {
|
||||
// Hexadecimal
|
||||
digits = s.substring(2)
|
||||
base = 16
|
||||
for (c in digits) {
|
||||
if (!isHex(c)) return null
|
||||
}
|
||||
} else if (s[0] == '0') {
|
||||
// Octal
|
||||
digits = s.substring(1)
|
||||
base = 8
|
||||
for (c in digits) {
|
||||
if (c !in '0'..'7') return null
|
||||
}
|
||||
} else {
|
||||
// Decimal
|
||||
digits = s
|
||||
base = 10
|
||||
for (c in digits) {
|
||||
if (c !in '0'..'9') return null
|
||||
}
|
||||
}
|
||||
return valid
|
||||
|
||||
if (digits.isEmpty()) return 0L
|
||||
return try {
|
||||
digits.toLong(base)
|
||||
} catch (_: NumberFormatException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+59
-89
@@ -316,81 +316,75 @@ class UrlDetector(
|
||||
while (!reader.eof()) {
|
||||
val curr = reader.read()
|
||||
|
||||
// if we match a slash, look for a second one.
|
||||
if (curr == '/') {
|
||||
buffer.append(curr)
|
||||
if (numSlashes == 1) {
|
||||
// return only if its an approved protocol. This can be expanded to allow others
|
||||
val schemeStartIndex: Int = findValidSchemeStartIndex(buffer.toString())
|
||||
// Two slashes found — check for a valid scheme like "http://"
|
||||
val schemeStartIndex = findValidSchemeStartIndex(buffer.toString())
|
||||
if (schemeStartIndex >= 0) {
|
||||
buffer.deleteRange(0, schemeStartIndex)
|
||||
currentUrlMarker.setIndex(UrlPart.SCHEME, 0)
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
numSlashes++
|
||||
} else if (curr == ' ') {
|
||||
// if we find a space or end of input, then nothing found.
|
||||
buffer.append(curr)
|
||||
return false
|
||||
} else if (curr == '[') { // if we're starting to see an ipv6 address
|
||||
reader.goBack() // unread the '[', so that we can start looking for ipv6
|
||||
return false
|
||||
} else if (originalLength > 0 && numSlashes == 0 && CharUtils.isAlpha(curr)) {
|
||||
// If we had already read something before the : and we are matching regardless of slashes, assume it's a scheme
|
||||
|
||||
// Add the slashes to the end of the scheme so it matches what's in the scheme list
|
||||
val schemeStartIndex = findValidSchemeNoSlashesStartIndex(buffer.toString())
|
||||
if (schemeStartIndex >= 0) {
|
||||
if (schemeStartIndex > 0) {
|
||||
buffer.deleteRange(0, schemeStartIndex)
|
||||
}
|
||||
currentUrlMarker.setIndex(UrlPart.SCHEME, 0)
|
||||
reader.goBack()
|
||||
return true
|
||||
} else {
|
||||
reader.goBack()
|
||||
return readUserPass(0)
|
||||
}
|
||||
// If this didn't match a defined scheme, continue processing as usual
|
||||
} else if (originalLength > 0 || numSlashes > 0 || !CharUtils.isAlpha(curr)) {
|
||||
// if it's not a character a-z or A-Z then assume we aren't matching scheme, but instead
|
||||
// matching username and password.
|
||||
// Add the slashes to the end of the scheme so it matches what's in the scheme list
|
||||
val schemeStartIndex = findValidSchemeNoSlashesStartIndex(buffer.toString())
|
||||
if (schemeStartIndex >= 0) {
|
||||
if (schemeStartIndex > 0) {
|
||||
buffer.deleteRange(0, schemeStartIndex)
|
||||
}
|
||||
currentUrlMarker.setIndex(UrlPart.SCHEME, 0)
|
||||
reader.goBack()
|
||||
return true
|
||||
}
|
||||
|
||||
} else if (curr == '[') {
|
||||
// Start of IPv6 — unread and let the caller handle it
|
||||
reader.goBack()
|
||||
return readUserPass(0)
|
||||
return false
|
||||
} else if (originalLength > 0 || numSlashes > 0 || !CharUtils.isAlpha(curr)) {
|
||||
// Not a plain alpha char continuing a potential scheme name, or we already
|
||||
// had content before the colon / had slashes. Try matching a scheme without
|
||||
// slashes (e.g. "nostr:npub1...") then fall back to username:password.
|
||||
return trySchemeNoSlashesOrUserPass()
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private fun findValidSchemeStartIndex(optionalScheme: String): Int {
|
||||
val optionalSchemeLowercase = optionalScheme.lowercase()
|
||||
return VALID_SCHEMES
|
||||
.filter(optionalSchemeLowercase::endsWith)
|
||||
.map(optionalSchemeLowercase::lastIndexOf)
|
||||
.firstOrNull() ?: -1
|
||||
/**
|
||||
* Attempts to match the buffer as a scheme without slashes (e.g. "nostr:").
|
||||
* If that fails, treats the content as a potential username:password.
|
||||
*/
|
||||
private fun trySchemeNoSlashesOrUserPass(): Boolean {
|
||||
val schemeStartIndex = findValidSchemeNoSlashesStartIndex(buffer.toString())
|
||||
if (schemeStartIndex >= 0) {
|
||||
if (schemeStartIndex > 0) {
|
||||
buffer.deleteRange(0, schemeStartIndex)
|
||||
}
|
||||
currentUrlMarker.setIndex(UrlPart.SCHEME, 0)
|
||||
reader.goBack()
|
||||
return true
|
||||
}
|
||||
reader.goBack()
|
||||
return readUserPass(0)
|
||||
}
|
||||
|
||||
private fun findValidSchemeNoSlashesStartIndex(optionalScheme: String): Int {
|
||||
val optionalSchemeLowercase = optionalScheme.lowercase()
|
||||
return VALID_SCHEMES_NO_SLASHES
|
||||
.filter(optionalSchemeLowercase::endsWith)
|
||||
.map(optionalSchemeLowercase::lastIndexOf)
|
||||
.firstOrNull() ?: -1
|
||||
private fun findValidSchemeStartIndex(optionalScheme: String): Int = findSchemeSuffix(optionalScheme, VALID_SCHEMES)
|
||||
|
||||
private fun findValidSchemeNoSlashesStartIndex(optionalScheme: String): Int = findSchemeSuffix(optionalScheme, VALID_SCHEMES_NO_SLASHES)
|
||||
|
||||
/**
|
||||
* Checks if [buffer] ends with any of the [schemes] (case-insensitive)
|
||||
* and returns the start index of the match, or -1 if none match.
|
||||
*/
|
||||
private fun findSchemeSuffix(
|
||||
buffer: String,
|
||||
schemes: List<String>,
|
||||
): Int {
|
||||
val len = buffer.length
|
||||
for (scheme in schemes) {
|
||||
val schemeLen = scheme.length
|
||||
if (len >= schemeLen && buffer.regionMatches(len - schemeLen, scheme, 0, schemeLen, ignoreCase = true)) {
|
||||
return len - schemeLen
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -619,57 +613,33 @@ class UrlDetector(
|
||||
var endsOnASlash = true
|
||||
|
||||
while (!reader.eof()) {
|
||||
// read the next char
|
||||
val curr = reader.read()
|
||||
|
||||
if (curr == ' ') {
|
||||
// if end of state and we got here, then the url is valid
|
||||
// if it is not just a word/word
|
||||
if (
|
||||
currentUrlMarker.hasScheme() ||
|
||||
currentUrlMarker.hasPort() ||
|
||||
currentUrlMarker.hasUsernamePassword() ||
|
||||
!isSingleLevelLabel ||
|
||||
endsOnASlash
|
||||
) {
|
||||
return readEnd(ReadEndState.ValidUrl)
|
||||
} else {
|
||||
return readEnd(ReadEndState.InvalidUrl)
|
||||
}
|
||||
return readEnd(if (isPathValid(endsOnASlash)) ReadEndState.ValidUrl else ReadEndState.InvalidUrl)
|
||||
}
|
||||
|
||||
// append the char
|
||||
buffer.append(curr)
|
||||
|
||||
// now see if we move to another state.
|
||||
if (curr == '?') {
|
||||
// if ? read query string
|
||||
return readQueryString()
|
||||
} else if (curr == '#') {
|
||||
// if # read the fragment
|
||||
return readFragment()
|
||||
}
|
||||
if (curr == '?') return readQueryString()
|
||||
if (curr == '#') return readFragment()
|
||||
|
||||
endsOnASlash = curr == '/'
|
||||
}
|
||||
|
||||
// end of input then this url is good.
|
||||
// if end of state and we got here, then the url is valid
|
||||
// if it is not just a word/word
|
||||
// no need to check for query and fragments
|
||||
// here we accept urls that end in /
|
||||
if (
|
||||
currentUrlMarker.hasScheme() ||
|
||||
return readEnd(if (isPathValid(endsOnASlash)) ReadEndState.ValidUrl else ReadEndState.InvalidUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* A path is valid if the URL has additional context (scheme, port, credentials)
|
||||
* or if it's not an ambiguous single-level label like "word/word".
|
||||
*/
|
||||
private fun isPathValid(endsOnASlash: Boolean): Boolean =
|
||||
currentUrlMarker.hasScheme() ||
|
||||
currentUrlMarker.hasPort() ||
|
||||
currentUrlMarker.hasUsernamePassword() ||
|
||||
!isSingleLevelLabel ||
|
||||
endsOnASlash
|
||||
) {
|
||||
return readEnd(ReadEndState.ValidUrl)
|
||||
} else {
|
||||
return readEnd(ReadEndState.InvalidUrl)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The url has been read to here. Remember the url if its valid, and reset state.
|
||||
|
||||
Reference in New Issue
Block a user