mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
feat(privacy): BlockedRouteException — typed fail-closed signal
Replaces the inline IOException thrown by DualHttpClientManager.getHttpClient when a hidden-service URL is requested while its matching daemon is off. The new exception extends IOException so existing HTTP error paths (Coil, OkHttp call factories, etc.) propagate it unchanged, but carries the BlockReason so future UI work can surface a clear "Enable Tor / I2P to view this content" hint instead of just a broken-image placeholder. DualHttpClientManager.blockedException(...) now returns BlockedRouteException rather than a bare IOException; the messages move into the typed exception's companion. Tests cover the message wording (mentions Tor/.onion and I2P/.i2p) and pin the factory's return type so the typed information can't be silently widened back to a bare IOException without breaking the test. Surfacing this to the UI (snackbar on image-load fail, etc.) is left to a follow-up — that work is cross-cutting (Coil event listeners, NIP-05 error paths, etc.) and orthogonal to the routing decision itself.
This commit is contained in:
+48
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.okhttp
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.privacy.BlockReason
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Thrown when a request targets a hidden-service hostname whose matching
|
||||
* privacy daemon is disabled. The fail-closed semantics chosen by the user —
|
||||
* we never silently fall back to a clearnet route for `.onion` / `.i2p` URLs.
|
||||
*
|
||||
* Subclass of [IOException] so the existing HTTP error paths in Coil, OkHttp
|
||||
* call factories, etc. propagate it without changes. Callers that want to
|
||||
* present a clearer UI ("enable Tor/I2P to view this content") can
|
||||
* pattern-match on the type / reason instead of inspecting the message.
|
||||
*/
|
||||
class BlockedRouteException(
|
||||
val reason: BlockReason,
|
||||
) : IOException(messageFor(reason)) {
|
||||
companion object {
|
||||
fun messageFor(reason: BlockReason): String =
|
||||
when (reason) {
|
||||
BlockReason.ONION_REQUIRES_TOR ->
|
||||
"Cannot reach .onion address: Tor is disabled. Enable Tor in Privacy Options."
|
||||
BlockReason.I2P_REQUIRES_I2P ->
|
||||
"Cannot reach .i2p address: I2P is disabled. Enable I2P in Privacy Options."
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-10
@@ -32,7 +32,6 @@ import kotlinx.coroutines.flow.stateIn
|
||||
import okhttp3.Call
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.IOException
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
|
||||
@@ -126,15 +125,7 @@ class DualHttpClientManager(
|
||||
fun getDynamicCallFactory(useProxy: Boolean) = DynamicCallFactory(useProxy, this)
|
||||
|
||||
companion object {
|
||||
fun blockedException(reason: BlockReason): IOException =
|
||||
IOException(
|
||||
when (reason) {
|
||||
BlockReason.ONION_REQUIRES_TOR ->
|
||||
"Cannot reach .onion address: Tor is disabled. Enable Tor in Privacy Options."
|
||||
BlockReason.I2P_REQUIRES_I2P ->
|
||||
"Cannot reach .i2p address: I2P is disabled. Enable I2P in Privacy Options."
|
||||
},
|
||||
)
|
||||
fun blockedException(reason: BlockReason): BlockedRouteException = BlockedRouteException(reason)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.okhttp
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.privacy.BlockReason
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class BlockedRouteExceptionTest {
|
||||
@Test
|
||||
fun onionMessage_mentionsTorAndOnion() {
|
||||
val e = BlockedRouteException(BlockReason.ONION_REQUIRES_TOR)
|
||||
assertSame(BlockReason.ONION_REQUIRES_TOR, e.reason)
|
||||
assertTrue("message should mention Tor: ${e.message}", e.message!!.contains("Tor", ignoreCase = true))
|
||||
assertTrue("message should mention .onion: ${e.message}", e.message!!.contains(".onion"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun i2pMessage_mentionsI2pAndDotI2p() {
|
||||
val e = BlockedRouteException(BlockReason.I2P_REQUIRES_I2P)
|
||||
assertSame(BlockReason.I2P_REQUIRES_I2P, e.reason)
|
||||
assertTrue("message should mention I2P: ${e.message}", e.message!!.contains("I2P"))
|
||||
assertTrue("message should mention .i2p: ${e.message}", e.message!!.contains(".i2p"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dualHttpClientManagerCompanion_returnsBlockedRouteException_withReason() {
|
||||
// Pins the factory return type so callers can pattern-match without losing the reason.
|
||||
val e: BlockedRouteException = DualHttpClientManager.blockedException(BlockReason.ONION_REQUIRES_TOR)
|
||||
assertEquals(BlockReason.ONION_REQUIRES_TOR, e.reason)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user