Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions app/src/main/java/com/bitchat/android/sync/GCSFilter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ import kotlin.math.ln
* - Bitstream is packed MSB-first in each byte.
*/
object GCSFilter {
/**
* Highest Golomb-Rice parameter accepted from the wire. P maps to an FPR of
* ~1/2^P; beyond 32 the remainder width exceeds any practical filter, and
* the shifts in decode silently wrap (Kotlin shifts use the low 6 bits of
* the count) into garbage values.
*/
const val MAX_P = 32

data class Params(
val p: Int, // Golomb-Rice parameter (>= 1)
val m: Long, // Range M = N * 2^P
Expand Down Expand Up @@ -70,6 +78,11 @@ object GCSFilter {
}

fun decodeToSortedSet(p: Int, m: Long, data: ByteArray): LongArray {
// p and m arrive off the wire. Reject out-of-range parameters rather
// than decoding garbage: callers read the result as "peer has nothing"
// and fall back to sending the data, which is the safe direction.
// Matches the iOS guard in GCSFilter.decodeToSortedSet.
if (p < 1 || p > MAX_P || m <= 1L) return LongArray(0)
val values = ArrayList<Long>()
val reader = BitReader(data)
var acc = 0L
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.bitchat.android.sync

import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.Random

/**
* `p` and `m` arrive off the wire — a REQUEST_SYNC carries P as a uint8 — and
* the decoded set decides which packets a peer is told it already has. Decoding
* garbage from an out-of-range parameter therefore withholds real packets, so
* out-of-range parameters have to decode to "peer has nothing" instead.
*/
class GCSFilterParameterTest {

private fun ids(n: Int): List<ByteArray> {
val random = Random(42)
return List(n) {
val bytes = ByteArray(16)
random.nextBytes(bytes)
bytes
}
}

@Test
fun `a filter round-trips with the parameters it was built with`() {
val params = GCSFilter.buildFilter(ids(20), maxBytes = 400, targetFpr = 0.01)
val decoded = GCSFilter.decodeToSortedSet(params.p, params.m, params.data)

assertTrue("expected a non-empty decode", decoded.isNotEmpty())
assertTrue("values must stay in range", decoded.all { it in 1 until params.m })
assertEquals(decoded.toList(), decoded.sorted())
}

@Test
fun `an out-of-range p decodes to nothing rather than to garbage`() {
val params = GCSFilter.buildFilter(ids(20), maxBytes = 400, targetFpr = 0.01)

// 64 and above wrap Kotlin's shift operators; 255 is what the byte allows.
for (p in listOf(0, 33, 64, 200, 255)) {
val decoded = GCSFilter.decodeToSortedSet(p, params.m, params.data)
assertTrue("p=$p should decode to nothing, got ${decoded.size} values", decoded.isEmpty())
}
}

@Test
fun `a degenerate m decodes to nothing`() {
val params = GCSFilter.buildFilter(ids(20), maxBytes = 400, targetFpr = 0.01)

assertTrue(GCSFilter.decodeToSortedSet(params.p, 0L, params.data).isEmpty())
assertTrue(GCSFilter.decodeToSortedSet(params.p, 1L, params.data).isEmpty())
}
}