blocker: an exception reads like a block, so make it behave like one

`youtube.com` on the block list catches `music.youtube.com` — a plain entry
covers its subtree. `youtube.com` on the *allow* list caught only itself,
because exceptions were glob-matched whatever they looked like and a plain name
has no metacharacters. The same text in two boxes of the same form meant a
subtree in one and a single hostname in the other, with nothing in the UI
saying so.

A plain exception now covers its subdomains; a masked one is glob-matched
exactly as before, which is still how you spare something narrower than a whole
subtree (`docs.*`, `*.docs.google.com`).

Three enforcers decide this and all three had to move together, or an exception
would work in the browser and not at the resolver: `blocker_service.is_allowed`,
`:core`'s `isAllowed`, and the desktop DNS stub's own allow check, which was
reusing the generic glob matcher. Parity tests on both sides, plus the two
sentences in the web UI and the README that promised the old behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
timka123andClaude Opus 5 committed 2026-10-22 17:24:17 +03:00
1 parent b9c0762f23
commit 5facf41f10
7 files changed
+120 -9

No files matched your search

@@ -85,9 +85,20 @@ fun expandEntry(raw: String, labels: List<String>, wildcard: Boolean = false): P
return listOf(entry, "www.$entry", "m.$entry") to null
}
/** Whether an allow-list entry — itself maskable — covers this hostname. */
fun isAllowed(host: String, allowMasks: List<String>): Boolean =
allowMasks.any { globMatches(it, host) }
/**
* Whether an allow-list entry — itself maskable — covers this hostname.
*
* A plain entry covers itself and its subdomains, matching what a plain entry does on
* the block side (`youtube.com` blocked catches `music.youtube.com`). An exception used
* to be glob-matched whatever it looked like, so the two halves of one list read
* identically and behaved differently. A masked entry is glob-matched as before, which
* is still how you allow something narrower than a whole subtree.
*
* Parity with `blocker_service.is_allowed`; `BlockerTest` pins it.
*/
fun isAllowed(host: String, allowMasks: List<String>): Boolean = allowMasks.any { entry ->
if (isMask(entry)) globMatches(entry, host) else host == entry || host.endsWith(".$entry")
}
/** Normalised, de-duplicated allow masks, the way `expand_domains()` prepares them. */
fun allowMasksOf(rules: BlockerRules): List<String> = rules.allowedDomains
@@ -275,4 +275,32 @@ class BlockerTest {
assertEquals(ZoneId.systemDefault(), zoneOrDefault(null))
assertEquals(ZoneId.systemDefault(), zoneOrDefault(""))
}
@Test
fun `a plain exception covers its subdomains, like a plain block does`() {
// Parity with `blocker_service.is_allowed`. `youtube.com` blocked catches
// `music.youtube.com`; `youtube.com` allowed now spares it, instead of sparing
// only the literal string while reading exactly like the block entry.
assertTrue(isAllowed("music.youtube.com", listOf("youtube.com")))
assertTrue(isAllowed("youtube.com", listOf("youtube.com")))
assertFalse(isAllowed("notyoutube.com", listOf("youtube.com")))
}
@Test
fun `a masked exception is still a glob`() {
assertTrue(isAllowed("docs.google.com", listOf("docs.*")))
assertTrue(isAllowed("a.docs.google.com", listOf("*.docs.google.com")))
assertFalse(isAllowed("sheets.google.com", listOf("docs.*")))
}
@Test
fun `an exception subtracts its subtree from the expanded list`() {
val rules = BlockerRules(
blockedDomains = listOf("*.youtube.com", "reddit.com"),
allowedDomains = listOf("youtube.com"),
subdomainExpansions = listOf("www", "m", "music"),
)
assertEquals(listOf("reddit.com", "www.reddit.com", "m.reddit.com"), expandDomains(rules))
}
}