feat(route): domain matcher + bundled geosite DB search

Suffix/exact/keyword/regex matcher, normalizeDomain, and a hand-rolled geosite.dat parser (MIT v2fly) with category/domain search. Unit-tested.
This commit is contained in:
heaven 2026-06-16 16:03:56 +03:00
parent 8ed667d49e
commit e01b0c2a87
8 changed files with 24067 additions and 0 deletions

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018-2019 V2Ray
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.

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,48 @@
package chat.vojo.proxy.core.route
import java.net.IDN
// A label is 1..63 chars of [a-z0-9-], not starting/ending with '-'. Punycode
// labels (xn--…) and digits are covered by this set, so IDN input survives
// IDN.toASCII and still validates.
private val LABEL = Regex("^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
/**
* Normalises user-typed site input into a bare ASCII registrable host suitable for
* suffix matching, or null if it isn't a usable domain.
*
* Forgiving on purpose a user may paste a full URL. We strip scheme, userinfo,
* path/query/fragment and port, lowercase, drop a trailing dot, punycode-encode IDNs
* (IDN.toASCII), then validate the label structure. A bare TLD or single label is
* rejected: a one-label suffix would match an entire TLD, which is never intended.
*/
fun normalizeDomain(input: String): String? {
var s = input.trim().lowercase()
if (s.isEmpty()) return null
val scheme = s.indexOf("://")
if (scheme >= 0) s = s.substring(scheme + 3)
// Authority only: cut everything from the first path/query/fragment separator.
s = s.substringBefore('/').substringBefore('?').substringBefore('#')
if ('@' in s) s = s.substringAfterLast('@') // drop user:pass@
s = s.substringBefore(':') // drop :port
s = s.trim('.')
if (s.isEmpty()) return null
val ascii = try { IDN.toASCII(s) } catch (e: IllegalArgumentException) { return null }
val host = ascii.lowercase()
if (!isValidDomain(host)) return null
return host
}
/** A multi-label host (at least one dot) whose every label is a valid DNS label. */
private fun isValidDomain(host: String): Boolean {
if (host.length > 253) return false
val labels = host.split('.')
if (labels.size < 2) return false
// Reject IPv4 literals (and any all-numeric final label — no such TLD exists). The
// suffix matcher only ever sees domains; IP destinations bypass it (isDomain=false),
// so an IP entry would be a silently-dead rule. Rejecting it lets the UI flag it.
if (labels.last().all { it.isDigit() }) return false
return labels.all { LABEL.matches(it) }
}

View file

@ -0,0 +1,180 @@
package chat.vojo.proxy.core.route
/** A geosite category surfaced by search: its code plus how many rules it carries. */
data class GeositeHit(val category: String, val ruleCount: Int, val matchedByName: Boolean)
/**
* In-memory index of the bundled geosite database (MIT v2fly `geosite.dat`, a
* `GeoSiteList` protobuf of category domain rules). Built once from the asset bytes by
* [parse] a tiny hand-rolled protobuf reader, no protobuf-java dependency and shared
* process-wide (see data/GeositeRepository). Categories are keyed lowercased.
*
* geosite.dat schema (v2fly/v2ray-core routercommon):
* GeoSiteList { repeated GeoSite entry = 1 }
* GeoSite { string country_code = 1; repeated Domain domain = 2; ... }
* Domain { Type type = 1; string value = 2; ... } Type: 0 Plain(keyword),
* 1 Regex, 2 RootDomain(suffix), 3 Full(exact)
*/
class GeositeIndex private constructor(
private val byCategory: Map<String, List<DomainRule>>,
) : GeositeSource {
/** All category codes, lowercased, sorted — for browsing/suggestions. */
val categories: List<String> = byCategory.keys.sorted()
// Geosite regexp rules compiled ONCE (the DB has a few hundred) so a domain search
// doesn't recompile them on every keystroke; uncompilable patterns map to null.
private val regexCache: Map<String, Regex?> = buildMap {
for (rules in byCategory.values) for (r in rules) {
if (r is DomainRule.Regexp && r.value !in this) {
put(r.value, runCatching { Regex(r.value, RegexOption.IGNORE_CASE) }.getOrNull())
}
}
}
override fun rules(category: String): List<DomainRule> =
byCategory[category.trim().lowercase()] ?: emptyList()
private fun covers(rule: DomainRule, q: String): Boolean = when (rule) {
is DomainRule.Suffix -> q == rule.value || q.endsWith(".${rule.value}")
is DomainRule.Exact -> q == rule.value
is DomainRule.Keyword -> q.contains(rule.value)
is DomainRule.Regexp -> regexCache[rule.value]?.containsMatchIn(q) == true
}
/**
* Suggestions for [query]: categories whose code contains the query (name match,
* ranked first), plus when the query looks like a domain categories that actually
* cover it (domain match). The costly per-rule domain scan is skipped once [limit] hits
* are found; name matches (O(categories)) stay cheap. Run off the main thread + debounced.
*/
fun search(query: String, limit: Int = 40): List<GeositeHit> {
val q = query.trim().lowercase()
if (q.isEmpty()) return emptyList()
val domainish = '.' in q
val byName = ArrayList<GeositeHit>()
val byDomain = ArrayList<GeositeHit>()
for ((cat, rules) in byCategory) {
if (cat.contains(q)) {
byName.add(GeositeHit(cat, rules.size, true))
} else if (domainish && byName.size + byDomain.size < limit && rules.any { covers(it, q) }) {
byDomain.add(GeositeHit(cat, rules.size, false))
}
}
byName.sortBy { it.category }
byDomain.sortBy { it.category }
return (byName + byDomain).take(limit)
}
companion object {
/** Empty index — returned when the asset is missing/corrupt so callers never crash. */
val EMPTY = GeositeIndex(emptyMap())
fun parse(bytes: ByteArray): GeositeIndex {
val r = ProtoReader(bytes)
val map = HashMap<String, List<DomainRule>>()
while (r.hasMore()) {
val tag = r.readTag()
if (tag.field == 1 && tag.wire == 2) parseSite(r.readLengthDelimited(), map)
else r.skip(tag)
}
return GeositeIndex(map)
}
private fun parseSite(bytes: ByteArray, out: MutableMap<String, List<DomainRule>>) {
val r = ProtoReader(bytes)
var name = ""
val rules = ArrayList<DomainRule>()
while (r.hasMore()) {
val tag = r.readTag()
when {
tag.field == 1 && tag.wire == 2 -> name = r.readString()
tag.field == 2 && tag.wire == 2 -> parseDomain(r.readLengthDelimited())?.let { rules.add(it) }
else -> r.skip(tag)
}
}
if (name.isNotEmpty()) out[name.lowercase()] = rules
}
private fun parseDomain(bytes: ByteArray): DomainRule? {
val r = ProtoReader(bytes)
var type = 2 // default RootDomain (suffix)
var value = ""
while (r.hasMore()) {
val tag = r.readTag()
when {
tag.field == 1 && tag.wire == 0 -> type = r.readVarint().toInt()
tag.field == 2 && tag.wire == 2 -> value = r.readString()
else -> r.skip(tag)
}
}
if (value.isEmpty()) return null
return when (type) {
0 -> DomainRule.Keyword(value) // Plain
1 -> DomainRule.Regexp(value) // Regex
3 -> DomainRule.Exact(value) // Full
else -> DomainRule.Suffix(value) // RootDomain (2) and unknown
}
}
}
}
/**
* Minimal protobuf wire reader (varint + length-delimited only; enough for geosite.dat).
* Bounds-checked: a truncated/corrupt asset throws a clean [IndexOutOfBoundsException] which
* [GeositeRepository][chat.vojo.proxy.data.GeositeRepository] turns into an empty index rather
* than an AIOOBE that would crash the search coroutine.
*/
private class ProtoReader(private val buf: ByteArray) {
private var pos = 0
fun hasMore() = pos < buf.size
fun readVarint(): Long {
var shift = 0
var result = 0L
while (true) {
if (pos >= buf.size || shift >= 64) throw IndexOutOfBoundsException("varint past end")
val b = buf[pos++].toInt() and 0xff
result = result or ((b and 0x7f).toLong() shl shift)
if (b < 0x80) return result
shift += 7
}
}
fun readTag(): Tag {
val t = readVarint().toInt()
return Tag(t ushr 3, t and 0x7)
}
private fun take(len: Int): Int {
if (len < 0 || pos + len > buf.size) throw IndexOutOfBoundsException("length-delimited past end")
val start = pos
pos += len
return start
}
fun readLengthDelimited(): ByteArray {
val len = readVarint().toInt()
val start = take(len)
return buf.copyOfRange(start, start + len)
}
fun readString(): String {
val len = readVarint().toInt()
val start = take(len)
return String(buf, start, len, Charsets.UTF_8)
}
/** Skip an unexpected field by its wire type so parsing stays aligned. */
fun skip(tag: Tag) {
when (tag.wire) {
0 -> readVarint()
2 -> take(readVarint().toInt())
5 -> take(4)
1 -> take(8)
else -> pos = buf.size // unknown wire type — stop rather than spin
}
}
}
private data class Tag(val field: Int, val wire: Int)

View file

@ -0,0 +1,141 @@
package chat.vojo.proxy.core.route
import chat.vojo.proxy.core.RoutingMode
/** Prefix marking a [routedSites] entry as a bundled geosite category, e.g. `geosite:google`. */
const val GEOSITE_PREFIX = "geosite:"
/**
* A single domain-matching rule, mirroring the v2fly domain-list-community rule kinds.
* Values are already lowercased ASCII (user domains via [normalizeDomain]; geosite
* values are ASCII at the source).
*/
sealed interface DomainRule {
val value: String
/** Matches the domain itself and any subdomain (v2fly `domain:` / RootDomain). */
data class Suffix(override val value: String) : DomainRule
/** Matches the exact domain only (v2fly `full:`). */
data class Exact(override val value: String) : DomainRule
/** Substring match anywhere in the host (v2fly `keyword:`). */
data class Keyword(override val value: String) : DomainRule
/** Regular-expression match (v2fly `regexp:`). Rare; partial (containsMatchIn) like RE2. */
data class Regexp(override val value: String) : DomainRule
}
/** Resolves a geosite category name to its rules. Empty list for an unknown category. */
fun interface GeositeSource {
fun rules(category: String): List<DomainRule>
companion object {
/** A source with no categories — used when the geosite asset is unavailable or in tests. */
val EMPTY = GeositeSource { emptyList() }
}
}
/**
* Compiled domain matcher. Suffix/exact lookups are O(labels) hash probes; keyword and
* regexp are linear but rare (most geosite rules are suffix/exact). Built once per tunnel
* start and reused for every CONNECT, so matching never re-parses the rule set.
*/
class SiteMatcher private constructor(
private val suffixes: Set<String>,
private val exacts: Set<String>,
private val keywords: List<String>,
private val regexes: List<Regex>,
) {
/** True if [host] is selected by any rule. [host] is lowercased and de-dotted first. */
fun matches(host: String): Boolean {
if (isEmpty) return false
val h = host.lowercase().trim('.')
if (h.isEmpty()) return false
if (exacts.contains(h)) return true
// Walk progressive suffixes: a.b.example.com -> b.example.com -> example.com -> com.
var s = h
while (true) {
if (suffixes.contains(s)) return true
val dot = s.indexOf('.')
if (dot < 0) break
s = s.substring(dot + 1)
}
if (keywords.any { h.contains(it) }) return true
if (regexes.any { it.containsMatchIn(h) }) return true
return false
}
val isEmpty: Boolean
get() = suffixes.isEmpty() && exacts.isEmpty() && keywords.isEmpty() && regexes.isEmpty()
companion object {
val EMPTY = SiteMatcher(emptySet(), emptySet(), emptyList(), emptyList())
fun from(rules: Collection<DomainRule>): SiteMatcher {
if (rules.isEmpty()) return EMPTY
val suffixes = HashSet<String>()
val exacts = HashSet<String>()
val keywords = ArrayList<String>()
val regexes = ArrayList<Regex>()
for (r in rules) {
val v = r.value.lowercase().trim('.')
if (v.isEmpty()) continue
when (r) {
is DomainRule.Suffix -> suffixes.add(v)
is DomainRule.Exact -> exacts.add(v)
is DomainRule.Keyword -> keywords.add(v)
// Host is lowercased before matching, so compile case-insensitive — an
// upper-case char in a geosite regexp would otherwise never match.
is DomainRule.Regexp -> runCatching { Regex(r.value, RegexOption.IGNORE_CASE) }.getOrNull()?.let { regexes.add(it) }
}
}
return SiteMatcher(suffixes, exacts, keywords, regexes)
}
}
}
/** Where a flow should go once its destination is known. */
enum class RouteDecision { PROXY, DIRECT }
/**
* Decides proxy-vs-direct for a tunnelled flow. [mode] is the global default and the
* matched sites are the exceptions, per the "Сайты" tab semantics:
* - [RoutingMode.PROXY_ALL]: everything proxied; matched sites go DIRECT.
* - [RoutingMode.DIRECT_ALL]: everything direct; matched sites go through the PROXY.
*
* Only domain destinations can match IP-literal / IPv6 / non-DNS flows have no domain
* to test, so they always take the global default (the documented coverage gap).
*/
class SiteRouter(
val mode: RoutingMode,
private val matcher: SiteMatcher,
) {
fun decide(host: String, isDomain: Boolean): RouteDecision {
val matched = isDomain && matcher.matches(host)
return when (mode) {
RoutingMode.PROXY_ALL -> if (matched) RouteDecision.DIRECT else RouteDecision.PROXY
RoutingMode.DIRECT_ALL -> if (matched) RouteDecision.PROXY else RouteDecision.DIRECT
}
}
/** Rule count drives whether the matcher has anything to do; for journal/diagnostics. */
val hasRules: Boolean get() = !matcher.isEmpty
companion object {
/**
* Builds a router from persisted settings. Plain entries become suffix rules;
* `geosite:` entries expand via [geosite]. Invalid user entries are dropped.
*/
fun build(mode: RoutingMode, entries: Set<String>, geosite: GeositeSource): SiteRouter {
val rules = buildList {
for (entry in entries) {
val e = entry.trim()
if (e.startsWith(GEOSITE_PREFIX)) {
addAll(geosite.rules(e.removePrefix(GEOSITE_PREFIX).trim()))
} else {
normalizeDomain(e)?.let { add(DomainRule.Suffix(it)) }
}
}
}
return SiteRouter(mode, SiteMatcher.from(rules))
}
}
}

View file

@ -0,0 +1,44 @@
package chat.vojo.proxy.data
import android.content.Context
import chat.vojo.proxy.core.route.DomainRule
import chat.vojo.proxy.core.route.GeositeHit
import chat.vojo.proxy.core.route.GeositeIndex
import chat.vojo.proxy.core.route.GeositeSource
/**
* Process-wide, lazily-parsed view of the bundled geosite database
* (`assets/geosite/geosite.dat`, MIT v2fly). The 2.2 MB protobuf is parsed once on first
* use (~hundreds of ms call off the main thread) and cached for the process: both the
* UI search and the tunnel's [SiteRouter][chat.vojo.proxy.core.route.SiteRouter] build
* share the one index. The service and UI live in the same process, so this is one copy.
*/
object GeositeRepository {
@Volatile
private var index: GeositeIndex? = null
/**
* Returns the parsed index, building it from the asset on first call. A missing or corrupt
* asset yields [GeositeIndex.EMPTY] (cached, so we don't re-read/re-parse and re-throw
* on every keystroke), keeping both the tunnel build and the UI search crash-free.
*/
fun index(context: Context): GeositeIndex {
index?.let { return it }
return synchronized(this) {
index ?: runCatching {
val bytes = context.applicationContext.assets.open("geosite/geosite.dat")
.use { it.readBytes() }
GeositeIndex.parse(bytes)
}.getOrDefault(GeositeIndex.EMPTY).also { index = it }
}
}
fun search(context: Context, query: String, limit: Int = 40): List<GeositeHit> =
index(context).search(query, limit)
}
/** [GeositeSource] backed by [GeositeRepository]; resolves a `geosite:<category>` entry. */
class AssetGeositeSource(private val context: Context) : GeositeSource {
override fun rules(category: String): List<DomainRule> =
GeositeRepository.index(context).rules(category)
}

View file

@ -0,0 +1,205 @@
package chat.vojo.proxy.core.route
import chat.vojo.proxy.core.RoutingMode
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SiteRoutingTest {
// --- normalizeDomain ---
@Test fun normalize_plainDomain() {
assertEquals("example.com", normalizeDomain("example.com"))
assertEquals("example.com", normalizeDomain(" Example.COM "))
assertEquals("example.com", normalizeDomain("example.com."))
}
@Test fun normalize_stripsUrlParts() {
assertEquals("example.com", normalizeDomain("https://example.com/path?q=1#frag"))
assertEquals("example.com", normalizeDomain("http://user:pw@example.com:8443/x"))
assertEquals("sub.example.com", normalizeDomain("HTTPS://Sub.Example.com:443"))
}
@Test fun normalize_idnToPunycode() {
// пример.рф and a German umlaut domain both punycode-encode.
assertEquals("xn--e1afmkfd.xn--p1ai", normalizeDomain("пример.рф"))
assertEquals("xn--mnchen-3ya.de", normalizeDomain("münchen.de"))
}
@Test fun normalize_rejectsJunk() {
assertNull(normalizeDomain(""))
assertNull(normalizeDomain(" "))
assertNull(normalizeDomain("localhost")) // single label = whole TLD-ish, rejected
assertNull(normalizeDomain("com"))
assertNull(normalizeDomain("a b.com")) // space is not a valid label char
assertNull(normalizeDomain("-bad.com")) // label can't start with '-'
assertNull(normalizeDomain("bad-.com"))
}
@Test fun normalize_rejectsIpLiterals() {
// IPs can never match (IP destinations bypass the matcher), so reject them at entry.
assertNull(normalizeDomain("8.8.8.8"))
assertNull(normalizeDomain("192.168.0.1"))
assertNull(normalizeDomain("1.2.3.4.5"))
assertNull(normalizeDomain("2001:db8::1")) // IPv6 already rejected by ':' handling
}
// --- SiteMatcher ---
private fun matcher(vararg domains: String) =
SiteMatcher.from(domains.map { DomainRule.Suffix(it) })
@Test fun suffix_matchesSelfAndSubdomains() {
val m = matcher("example.com")
assertTrue(m.matches("example.com"))
assertTrue(m.matches("a.example.com"))
assertTrue(m.matches("a.b.example.com"))
assertTrue(m.matches("EXAMPLE.com")) // case-insensitive
assertTrue(m.matches("example.com.")) // trailing dot tolerated
}
@Test fun suffix_doesNotOvermatch() {
val m = matcher("example.com")
assertFalse(m.matches("notexample.com")) // not a label boundary
assertFalse(m.matches("example.com.evil.com"))
assertFalse(m.matches("example.org"))
assertFalse(m.matches("com"))
}
@Test fun exact_keyword_regexp() {
val m = SiteMatcher.from(
listOf(
DomainRule.Exact("only.example.com"),
DomainRule.Keyword("doubleclick"),
DomainRule.Regexp("""^ads[0-9]+\.net$"""),
)
)
assertTrue(m.matches("only.example.com"))
assertFalse(m.matches("sub.only.example.com")) // exact: no subdomains
assertTrue(m.matches("stats.g.doubleclick.net"))
assertTrue(m.matches("ads42.net"))
assertFalse(m.matches("ads.net"))
}
@Test fun emptyMatcher_matchesNothing() {
assertTrue(SiteMatcher.EMPTY.isEmpty)
assertFalse(SiteMatcher.EMPTY.matches("example.com"))
}
// --- decideRoute truth table ---
@Test fun proxyAll_listedGoDirect_restProxy() {
val r = SiteRouter(RoutingMode.PROXY_ALL, matcher("example.com"))
assertEquals(RouteDecision.DIRECT, r.decide("a.example.com", isDomain = true))
assertEquals(RouteDecision.PROXY, r.decide("other.com", isDomain = true))
// No domain (IP literal / IPv6) -> global default = PROXY.
assertEquals(RouteDecision.PROXY, r.decide("93.184.216.34", isDomain = false))
}
@Test fun directAll_listedGoProxy_restDirect() {
val r = SiteRouter(RoutingMode.DIRECT_ALL, matcher("example.com"))
assertEquals(RouteDecision.PROXY, r.decide("a.example.com", isDomain = true))
assertEquals(RouteDecision.DIRECT, r.decide("other.com", isDomain = true))
// No domain -> global default = DIRECT.
assertEquals(RouteDecision.DIRECT, r.decide("93.184.216.34", isDomain = false))
}
// --- SiteRouter.build (entries -> rules) ---
@Test fun build_mixesPlainAndGeositeEntries() {
val geosite = GeositeSource { cat ->
if (cat == "google") listOf(DomainRule.Suffix("google.com"), DomainRule.Suffix("youtube.com"))
else emptyList()
}
val r = SiteRouter.build(
RoutingMode.PROXY_ALL,
setOf("My.Site.org", "geosite:google", "garbage entry", "geosite:unknown"),
geosite,
)
assertTrue(r.hasRules)
assertEquals(RouteDecision.DIRECT, r.decide("my.site.org", isDomain = true))
assertEquals(RouteDecision.DIRECT, r.decide("www.google.com", isDomain = true))
assertEquals(RouteDecision.DIRECT, r.decide("m.youtube.com", isDomain = true))
assertEquals(RouteDecision.PROXY, r.decide("example.net", isDomain = true))
}
@Test fun build_emptyEntries_inert() {
val r = SiteRouter.build(RoutingMode.PROXY_ALL, emptySet(), GeositeSource.EMPTY)
assertFalse(r.hasRules)
assertEquals(RouteDecision.PROXY, r.decide("anything.com", isDomain = true))
}
// --- geosite.dat protobuf parsing + search ---
// Minimal protobuf encoders to build a synthetic GeoSiteList for the parser.
private fun varint(n: Int): ByteArray {
var x = n
val o = ArrayList<Byte>()
while (true) {
val b = x and 0x7f
x = x ushr 7
if (x != 0) o.add((b or 0x80).toByte()) else { o.add(b.toByte()); break }
}
return o.toByteArray()
}
private fun tag(field: Int, wire: Int) = varint((field shl 3) or wire)
private fun ld(field: Int, data: ByteArray) = tag(field, 2) + varint(data.size) + data
private fun vfield(field: Int, v: Int) = tag(field, 0) + varint(v)
private fun str(field: Int, s: String) = ld(field, s.toByteArray(Charsets.UTF_8))
private fun sampleDat(): ByteArray {
val dom1 = vfield(1, 2) + str(2, "example.com") // RootDomain -> suffix
val dom2 = vfield(1, 3) + str(2, "only.exact.com") // Full -> exact
val dom3 = vfield(1, 0) + str(2, "trackword") // Plain -> keyword
val site = str(1, "TESTCAT") + ld(2, dom1) + ld(2, dom2) + ld(2, dom3)
return ld(1, site)
}
@Test fun geositeIndex_parsesCategoriesAndRules() {
val idx = GeositeIndex.parse(sampleDat())
assertTrue("testcat" in idx.categories)
val m = SiteMatcher.from(idx.rules("TESTCAT")) // lookup is case-insensitive
assertTrue(m.matches("a.example.com")) // suffix
assertTrue(m.matches("example.com"))
assertTrue(m.matches("only.exact.com")) // exact
assertFalse(m.matches("sub.only.exact.com")) // exact: no subdomains
assertTrue(m.matches("xtrackwordy.net")) // keyword
assertFalse(m.matches("unrelated.org"))
}
@Test fun geositeIndex_searchByNameAndDomain() {
val idx = GeositeIndex.parse(sampleDat())
val byName = idx.search("testc")
assertEquals(1, byName.size)
assertEquals("testcat", byName[0].category)
assertTrue(byName[0].matchedByName)
// A domain query finds the category that covers it.
val byDomain = idx.search("www.example.com")
assertEquals(listOf("testcat"), byDomain.map { it.category })
assertFalse(byDomain[0].matchedByName)
assertTrue(idx.search("nothingmatches").isEmpty())
}
@Test fun geositeIndex_routerResolvesCategory() {
val idx = GeositeIndex.parse(sampleDat())
val r = SiteRouter.build(RoutingMode.PROXY_ALL, setOf("geosite:testcat"), idx)
assertTrue(r.hasRules)
assertEquals(RouteDecision.DIRECT, r.decide("a.example.com", isDomain = true))
assertEquals(RouteDecision.PROXY, r.decide("other.net", isDomain = true))
}
@Test(timeout = 5000) fun geositeIndex_truncatedInputIsBounded() {
// Every truncation of a valid blob must finish with either a partial index or a bounded
// exception (mirrors GeositeRepository's runCatching -> EMPTY) — never an infinite loop
// or an uncaught AIOOBE. The test timeout guards against a spin.
val full = sampleDat()
for (cut in 0..full.size) {
val idx = runCatching { GeositeIndex.parse(full.copyOfRange(0, cut)) }.getOrDefault(GeositeIndex.EMPTY)
idx.search("test") // must not throw
idx.rules("testcat") // must not throw
}
}
}

15
tools/fetch-geosite.sh Normal file
View file

@ -0,0 +1,15 @@
#!/bin/bash
# Refresh the bundled geosite database from the MIT-licensed v2fly release.
# dlc.dat == geosite.dat: a GeoSiteList protobuf (category -> domain rules), parsed
# on-device by core/route/GeositeIndex. ~2.2 MB raw; APK-compressed to a few hundred KB.
# License notice is vendored alongside at app/src/main/assets/geosite/LICENSE.
set -euo pipefail
DIR="$(cd "$(dirname "$0")/.." && pwd)/app/src/main/assets/geosite"
mkdir -p "$DIR"
curl -fsSL --max-time 60 \
"https://github.com/v2fly/domain-list-community/releases/latest/download/dlc.dat" \
-o "$DIR/geosite.dat"
curl -fsSL --max-time 30 \
"https://raw.githubusercontent.com/v2fly/domain-list-community/master/LICENSE" \
-o "$DIR/LICENSE"
echo "geosite.dat: $(wc -c < "$DIR/geosite.dat") bytes"