diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/LimitsPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/LimitsPolicy.kt index e711734d5b..3aaa3e3f32 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/LimitsPolicy.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/policies/LimitsPolicy.kt @@ -72,9 +72,13 @@ class LimitsPolicy( return PolicyResult.Accepted(if (clamped === cmd.filters) cmd else ReqCmd(cmd.subId, clamped)) } + /** + * A COUNT is clamped by [RelayLimits.maxLimit] but NEVER given + * [RelayLimits.defaultLimit] — see [capLimits]. + */ override fun accept(cmd: CountCmd): PolicyResult { subscriptionRejection(cmd.queryId, cmd.filters)?.let { return PolicyResult.Rejected(it) } - val clamped = clampLimits(cmd.filters) + val clamped = capLimits(cmd.filters) return PolicyResult.Accepted(if (clamped === cmd.filters) cmd else CountCmd(cmd.queryId, clamped)) } @@ -106,6 +110,28 @@ class LimitsPolicy( return filters.map { it.copy(limit = targetLimit(it.limit)) } } + /** + * Cap what a COUNT asks for, without inventing a page size for it. + * + * `defaultLimit` answers "how many events should a REQ return when the + * client names no limit". A COUNT returns no events, so that question has + * no meaning for it — and applying the answer anyway turns every unbounded + * COUNT into `min(matches, defaultLimit)`. + * + * Silently: a relay holding 12,289,614 profiles replied `{"count":500}`, + * which is a plausible-looking number, so a client cannot tell it from the + * truth. The kinds that happened to fall under the default were correct, + * which is what made it survive. + * + * `maxLimit` still applies, because a client that explicitly asks to count + * at most N is asking a question this relay may bound. + */ + private fun capLimits(filters: List): List { + val max = limits.maxLimit ?: return filters + if (filters.none { it.limit != null && it.limit!! > max }) return filters + return filters.map { if (it.limit != null && it.limit!! > max) it.copy(limit = max) else it } + } + private fun targetLimit(current: Int?): Int? = when { current != null && limits.maxLimit != null && current > limits.maxLimit -> limits.maxLimit diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayLimitsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayLimitsTest.kt index 4fa12f80d7..55f6aa7139 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayLimitsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/server/RelayLimitsTest.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RelayLimits import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue class RelayLimitsTest { @@ -157,6 +158,39 @@ class RelayLimitsTest { ) } + @Test + fun countNeverGetsTheDefaultLimit() { + // A COUNT returns no events, so "how many should a REQ return by + // default" is not a question it asked. Applying the answer anyway turns + // every unbounded COUNT into min(matches, defaultLimit) — a relay + // holding 12,289,614 profiles replied {"count":500}, which is plausible + // enough that no client can tell it from the truth. + val policy = LimitsPolicy(RelayLimits(defaultLimit = 50, maxLimit = 100)) + + val result = policy.accept(CountCmd("q", listOf(Filter(kinds = listOf(1))))) as PolicyResult.Accepted + + assertNull( + result.cmd.filters + .single() + .limit, + ) + } + + @Test + fun countStillHonoursAnExplicitMaxLimit() { + // Asking to count at most N is a question the relay may bound. + val policy = LimitsPolicy(RelayLimits(defaultLimit = 50, maxLimit = 100)) + + val result = policy.accept(CountCmd("q", listOf(Filter(kinds = listOf(1), limit = 900)))) as PolicyResult.Accepted + + assertEquals( + 100, + result.cmd.filters + .single() + .limit, + ) + } + @Test fun leavesAcceptableRequestUnchanged() { val policy = LimitsPolicy(RelayLimits(maxLimit = 100))