mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
test: add client rewrite contract suite
This commit is contained in:
parent
f27c47dae2
commit
8c46da71fa
6
.gitignore
vendored
6
.gitignore
vendored
@ -7,6 +7,7 @@ build/
|
||||
!*/build/intermediates/
|
||||
local.properties
|
||||
.gradle/
|
||||
.kotlin/
|
||||
captures/
|
||||
.externalNativeBuild/
|
||||
debug_keystore/
|
||||
@ -40,6 +41,11 @@ dependency-reduced-pom.xml
|
||||
# Linters
|
||||
.lint/
|
||||
|
||||
# Python test tooling
|
||||
**/__pycache__/
|
||||
*.py[cod]
|
||||
release-gate-results/
|
||||
|
||||
# Other
|
||||
*.log
|
||||
.cxx/
|
||||
|
||||
@ -0,0 +1,301 @@
|
||||
package com.bitchat.android.contracts
|
||||
|
||||
import com.bitchat.android.model.BitchatFilePacket
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.FragmentPayload
|
||||
import com.bitchat.android.model.IdentityAnnouncement
|
||||
import com.bitchat.android.model.NoisePayload
|
||||
import com.bitchat.android.model.NoisePayloadType
|
||||
import com.bitchat.android.model.PeerCapabilities
|
||||
import com.bitchat.android.model.PrivateMessagePacket
|
||||
import com.bitchat.android.model.RequestSyncPacket
|
||||
import com.bitchat.android.model.UnknownAnnouncementTLV
|
||||
import com.bitchat.android.protocol.BinaryProtocol
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* Golden wire vectors for formats that a from-scratch client must reproduce.
|
||||
*
|
||||
* These assertions deliberately compare literal bytes rather than relying only
|
||||
* on encode/decode round trips, which can hide matching bugs in both methods.
|
||||
*/
|
||||
class ClientRewriteWireContractTest {
|
||||
|
||||
@Test
|
||||
fun `v1 packet matches canonical unpadded bytes`() {
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.MESSAGE.value,
|
||||
senderID = hex("1011121314151617"),
|
||||
recipientID = null,
|
||||
timestamp = 0x0102030405060708uL,
|
||||
payload = hex("aabbcc"),
|
||||
signature = null,
|
||||
ttl = 7u
|
||||
)
|
||||
|
||||
val encoded = BinaryProtocol.encode(packet, padding = false)
|
||||
|
||||
assertArrayEquals(
|
||||
hex("01020701020304050607080000031011121314151617aabbcc"),
|
||||
encoded
|
||||
)
|
||||
assertEquals(packet, BinaryProtocol.decode(encoded!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `v2 routed signed packet matches canonical section order`() {
|
||||
val signature = ByteArray(64) { 0x5a }
|
||||
val packet = BitchatPacket(
|
||||
version = 2u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = hex("0102030405060708"),
|
||||
recipientID = hex("1112131415161718"),
|
||||
timestamp = 42uL,
|
||||
payload = hex("dead"),
|
||||
signature = signature,
|
||||
ttl = 5u,
|
||||
route = listOf(
|
||||
hex("2122232425262728"),
|
||||
hex("3132333435363738")
|
||||
)
|
||||
)
|
||||
|
||||
val encoded = BinaryProtocol.encode(packet, padding = false)!!
|
||||
val expectedPrefix = hex(
|
||||
"021105000000000000002a0b00000002" +
|
||||
"0102030405060708" +
|
||||
"1112131415161718" +
|
||||
"02" +
|
||||
"2122232425262728" +
|
||||
"3132333435363738" +
|
||||
"dead"
|
||||
)
|
||||
|
||||
assertArrayEquals(expectedPrefix + signature, encoded)
|
||||
assertEquals(packet, BinaryProtocol.decode(encoded))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `minimal chat message matches canonical binary payload`() {
|
||||
val message = BitchatMessage(
|
||||
id = "id",
|
||||
sender = "bob",
|
||||
content = "hi",
|
||||
timestamp = Date(0x0102030405060708L)
|
||||
)
|
||||
|
||||
val encoded = message.toBinaryPayload()
|
||||
|
||||
assertArrayEquals(
|
||||
hex("00010203040506070802696403626f6200026869"),
|
||||
encoded
|
||||
)
|
||||
assertEquals(message, BitchatMessage.fromBinaryPayload(encoded!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chat message optional fields use flags and UTF-8 byte lengths`() {
|
||||
val message = BitchatMessage(
|
||||
id = "m",
|
||||
sender = "é",
|
||||
content = "hello",
|
||||
timestamp = Date(42L),
|
||||
isRelay = true,
|
||||
originalSender = "o",
|
||||
isPrivate = true,
|
||||
recipientNickname = "r",
|
||||
senderPeerID = "p",
|
||||
mentions = listOf("a", "β"),
|
||||
channel = "c"
|
||||
)
|
||||
|
||||
val encoded = message.toBinaryPayload()!!
|
||||
|
||||
assertArrayEquals(
|
||||
hex(
|
||||
"7f000000000000002a" +
|
||||
"016d" +
|
||||
"02c3a9" +
|
||||
"000568656c6c6f" +
|
||||
"016f" +
|
||||
"0172" +
|
||||
"0170" +
|
||||
"02" +
|
||||
"0161" +
|
||||
"02ceb2" +
|
||||
"0163"
|
||||
),
|
||||
encoded
|
||||
)
|
||||
assertEquals(message, BitchatMessage.fromBinaryPayload(encoded))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encrypted chat payload carries ciphertext instead of placeholder content`() {
|
||||
val message = BitchatMessage(
|
||||
id = "e",
|
||||
sender = "alice",
|
||||
content = "must-not-be-on-wire",
|
||||
timestamp = Date(1L),
|
||||
encryptedContent = hex("000102ff"),
|
||||
isEncrypted = true,
|
||||
isPrivate = true
|
||||
)
|
||||
|
||||
val decoded = BitchatMessage.fromBinaryPayload(message.toBinaryPayload()!!)!!
|
||||
|
||||
assertEquals("", decoded.content)
|
||||
assertArrayEquals(hex("000102ff"), decoded.encryptedContent)
|
||||
assertTrue(decoded.isEncrypted)
|
||||
assertTrue(decoded.isPrivate)
|
||||
assertFalse(message.toBinaryPayload()!!.toString(Charsets.ISO_8859_1).contains("must-not-be-on-wire"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `private message and Noise envelopes match deployed type bytes`() {
|
||||
val privateMessage = PrivateMessagePacket(messageID = "m1", content = "hi")
|
||||
val privateMessageBytes = hex("00026d3101026869")
|
||||
|
||||
assertArrayEquals(privateMessageBytes, privateMessage.encode())
|
||||
assertEquals(privateMessage, PrivateMessagePacket.decode(privateMessageBytes))
|
||||
assertArrayEquals(
|
||||
hex("0100026d3101026869"),
|
||||
NoisePayload(NoisePayloadType.PRIVATE_MESSAGE, privateMessageBytes).encode()
|
||||
)
|
||||
assertEquals(
|
||||
NoisePayloadType.FILE_TRANSFER,
|
||||
NoisePayload.decode(hex("09cafe"))?.type
|
||||
)
|
||||
assertArrayEquals(
|
||||
hex("20cafe"),
|
||||
NoisePayload.decode(hex("09cafe"))!!.encode()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fragment payload matches the thirteen byte iOS header`() {
|
||||
val fragment = FragmentPayload(
|
||||
fragmentID = hex("0001020304050607"),
|
||||
index = 1,
|
||||
total = 3,
|
||||
originalType = MessageType.MESSAGE.value,
|
||||
data = hex("aabb")
|
||||
)
|
||||
val wire = hex("00010203040506070001000302aabb")
|
||||
|
||||
assertArrayEquals(wire, fragment.encode())
|
||||
assertEquals(fragment, FragmentPayload.decode(wire))
|
||||
assertTrue(fragment.isValid())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync request matches canonical TLV bytes and skips extensions`() {
|
||||
val request = RequestSyncPacket(
|
||||
p = 19,
|
||||
m = 0x01020304L,
|
||||
data = hex("aabb")
|
||||
)
|
||||
val wire = hex("0100011302000401020304030002aabb")
|
||||
|
||||
assertArrayEquals(wire, request.encode())
|
||||
assertSyncRequestEquals(request, RequestSyncPacket.decode(wire))
|
||||
|
||||
val withExtension = hex("7f0002cafe") + wire
|
||||
assertSyncRequestEquals(request, RequestSyncPacket.decode(withExtension))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `identity announcement matches canonical TLV order and preserves extensions`() {
|
||||
val announcement = IdentityAnnouncement(
|
||||
nickname = "bob",
|
||||
noisePublicKey = ByteArray(32) { 0x11 },
|
||||
signingPublicKey = ByteArray(32) { 0x22 },
|
||||
capabilities = PeerCapabilities.PRIVATE_MEDIA,
|
||||
unknownTLVs = listOf(UnknownAnnouncementTLV(0x7f, hex("cafe")))
|
||||
)
|
||||
val expected =
|
||||
hex("0103626f620220") +
|
||||
ByteArray(32) { 0x11 } +
|
||||
hex("0320") +
|
||||
ByteArray(32) { 0x22 } +
|
||||
hex("050200017f02cafe")
|
||||
|
||||
val encoded = announcement.encode()
|
||||
|
||||
assertArrayEquals(expected, encoded)
|
||||
assertEquals(announcement, IdentityAnnouncement.decode(encoded!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `file transfer matches deployed mixed-width TLV vector`() {
|
||||
val packet = BitchatFilePacket(
|
||||
fileName = "a",
|
||||
fileSize = 2,
|
||||
mimeType = "m",
|
||||
content = hex("dead")
|
||||
)
|
||||
val wire = hex("01000161020004000000020300016d0400000002dead")
|
||||
|
||||
assertArrayEquals(wire, packet.encode())
|
||||
|
||||
val decoded = BitchatFilePacket.decode(wire)
|
||||
assertNotNull(decoded)
|
||||
assertEquals(packet.fileName, decoded!!.fileName)
|
||||
assertEquals(packet.fileSize, decoded.fileSize)
|
||||
assertEquals(packet.mimeType, decoded.mimeType)
|
||||
assertArrayEquals(packet.content, decoded.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `required message prefixes reject every truncation`() {
|
||||
val wire = BitchatMessage(
|
||||
id = "id",
|
||||
sender = "bob",
|
||||
content = "hello",
|
||||
timestamp = Date(1L)
|
||||
).toBinaryPayload()!!
|
||||
|
||||
for (length in 0 until wire.size) {
|
||||
assertNull(
|
||||
"Accepted required message prefix of $length/${wire.size} bytes",
|
||||
BitchatMessage.fromBinaryPayload(wire.copyOf(length))
|
||||
)
|
||||
}
|
||||
assertNotNull(BitchatMessage.fromBinaryPayload(wire))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `TLV decoders reject missing required fields and truncated values`() {
|
||||
assertNull(PrivateMessagePacket.decode(hex("00026d31")))
|
||||
assertNull(PrivateMessagePacket.decode(hex("00026d3101036869")))
|
||||
assertNull(RequestSyncPacket.decode(hex("0100011302000401020304")))
|
||||
assertNull(IdentityAnnouncement.decode(hex("0103626f62022011")))
|
||||
assertNull(BitchatFilePacket.decode(hex("010001610400000002de")))
|
||||
assertNull(FragmentPayload.decode(ByteArray(FragmentPayload.HEADER_SIZE - 1)))
|
||||
}
|
||||
|
||||
private fun hex(value: String): ByteArray {
|
||||
require(value.length % 2 == 0)
|
||||
return value.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
}
|
||||
|
||||
private fun assertSyncRequestEquals(
|
||||
expected: RequestSyncPacket,
|
||||
actual: RequestSyncPacket?
|
||||
) {
|
||||
assertNotNull(actual)
|
||||
assertEquals(expected.p, actual!!.p)
|
||||
assertEquals(expected.m, actual.m)
|
||||
assertArrayEquals(expected.data, actual.data)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,261 @@
|
||||
package com.bitchat.android.noise
|
||||
|
||||
import com.bitchat.android.noise.southernstorm.protocol.CipherState
|
||||
import com.bitchat.android.noise.southernstorm.protocol.HandshakeState
|
||||
import com.bitchat.android.noise.southernstorm.protocol.Noise
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Cacophony/Noise-C vector for Noise_XX_25519_ChaChaPoly_SHA256.
|
||||
*
|
||||
* This exercises the vendored Noise state machine directly, independent of managers, Android
|
||||
* storage, and generated keys.
|
||||
*/
|
||||
class NoiseExternalVectorTest {
|
||||
private val messages = listOf(
|
||||
VectorMessage(
|
||||
"4c756477696720766f6e204d69736573",
|
||||
"ca35def5ae56cec33dc2036731ab14896bc4c75dbb07a61f879f8e3afa4c7944" +
|
||||
"4c756477696720766f6e204d69736573"
|
||||
),
|
||||
VectorMessage(
|
||||
"4d757272617920526f746862617264",
|
||||
"95ebc60d2b1fa672c1f46a8aa265ef51bfe38e7ccb39ec5be34069f144808843" +
|
||||
"81cbad1f276e038c48378ffce2b65285e08d6b68aaa3629a5a8639392490e5b9" +
|
||||
"bd5269c2f1e4f488ed8831161f19b7815528f8982ffe09be9b5c412f8a0db50f" +
|
||||
"8814c7194e83f23dbd8d162c9326ad"
|
||||
),
|
||||
VectorMessage(
|
||||
"462e20412e20486179656b",
|
||||
"c7195ffacac1307ff99046f219750fc47693e23c3cb08b89c2af808b444850a8" +
|
||||
"0ae475b9df0f169ae80a89be0865b57f58c9fea0d4ec82a286427402f113e4b6" +
|
||||
"ae769a1d95941d49b25030"
|
||||
),
|
||||
VectorMessage(
|
||||
"4361726c204d656e676572",
|
||||
"96763ed773f8e47bb3712f0e29b3060ffc956ffc146cee53d5e1df"
|
||||
),
|
||||
VectorMessage(
|
||||
"4a65616e2d426170746973746520536179",
|
||||
"3e40f15f6f3a46ae446b253bf8b1d9ffb6ed9b174d272328ff91a7e2e5c79c07f5"
|
||||
),
|
||||
VectorMessage(
|
||||
"457567656e2042f6686d20766f6e2042617765726b",
|
||||
"eb3f3515110702e047a6c9da4478b6ead94873c11c0f2d710ddb3f09fce024b3" +
|
||||
"a58502ae3f"
|
||||
)
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `Noise-C XX transcript matches every handshake and transport byte`() {
|
||||
val initiator = vectorState(HandshakeState.INITIATOR)
|
||||
val responder = vectorState(HandshakeState.RESPONDER)
|
||||
try {
|
||||
val states = listOf(
|
||||
initiator to responder,
|
||||
responder to initiator,
|
||||
initiator to responder
|
||||
)
|
||||
messages.take(3).zip(states).forEach { (message, peers) ->
|
||||
assertHandshakeMessage(peers.first, peers.second, message)
|
||||
}
|
||||
|
||||
assertEquals(HandshakeState.SPLIT, initiator.action)
|
||||
assertEquals(HandshakeState.SPLIT, responder.action)
|
||||
assertArrayEquals(initiator.handshakeHash, responder.handshakeHash)
|
||||
|
||||
val initiatorCiphers = initiator.split()
|
||||
val responderCiphers = responder.split()
|
||||
assertTransportMessage(
|
||||
responderCiphers.sender,
|
||||
initiatorCiphers.receiver,
|
||||
messages[3]
|
||||
)
|
||||
assertTransportMessage(
|
||||
initiatorCiphers.sender,
|
||||
responderCiphers.receiver,
|
||||
messages[4]
|
||||
)
|
||||
assertTransportMessage(
|
||||
responderCiphers.sender,
|
||||
initiatorCiphers.receiver,
|
||||
messages[5]
|
||||
)
|
||||
initiatorCiphers.sender.destroy()
|
||||
initiatorCiphers.receiver.destroy()
|
||||
responderCiphers.sender.destroy()
|
||||
responderCiphers.receiver.destroy()
|
||||
} finally {
|
||||
initiator.destroy()
|
||||
responder.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Noise state machine rejects invalid actions and a tampered handshake tag`() {
|
||||
val initiator = vectorState(HandshakeState.INITIATOR)
|
||||
val responder = vectorState(HandshakeState.RESPONDER)
|
||||
try {
|
||||
assertThrows(IllegalStateException::class.java) { initiator.start() }
|
||||
assertThrows(IllegalStateException::class.java) {
|
||||
responder.writeMessage(ByteArray(256), 0, null, 0, 0)
|
||||
}
|
||||
|
||||
assertHandshakeMessage(initiator, responder, messages[0])
|
||||
val message2 = write(responder, messages[1].payload)
|
||||
val tampered = message2.copyOf()
|
||||
tampered[tampered.lastIndex] = (tampered.last().toInt() xor 1).toByte()
|
||||
|
||||
assertThrows(Exception::class.java) {
|
||||
initiator.readMessage(tampered, 0, tampered.size, ByteArray(256), 0)
|
||||
}
|
||||
assertEquals(HandshakeState.FAILED, initiator.action)
|
||||
} finally {
|
||||
initiator.destroy()
|
||||
responder.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ChaChaPoly authentication binds nonce ciphertext tag and associated data`() {
|
||||
val key = ByteArray(32) { it.toByte() }
|
||||
val plaintext = "associated".toByteArray()
|
||||
val associatedData = "header".toByteArray()
|
||||
val sender = Noise.createCipher("ChaChaPoly")
|
||||
val receiver = Noise.createCipher("ChaChaPoly")
|
||||
val wrongAdReceiver = Noise.createCipher("ChaChaPoly")
|
||||
try {
|
||||
sender.initializeKey(key, 0)
|
||||
receiver.initializeKey(key, 0)
|
||||
wrongAdReceiver.initializeKey(key, 0)
|
||||
sender.setNonce(7)
|
||||
receiver.setNonce(7)
|
||||
wrongAdReceiver.setNonce(7)
|
||||
val ciphertext = ByteArray(plaintext.size + sender.macLength)
|
||||
val length = sender.encryptWithAd(
|
||||
associatedData,
|
||||
plaintext,
|
||||
0,
|
||||
ciphertext,
|
||||
0,
|
||||
plaintext.size
|
||||
)
|
||||
|
||||
assertThrows(Exception::class.java) {
|
||||
wrongAdReceiver.decryptWithAd(
|
||||
"wrong".toByteArray(),
|
||||
ciphertext,
|
||||
0,
|
||||
ByteArray(length),
|
||||
0,
|
||||
length
|
||||
)
|
||||
}
|
||||
val output = ByteArray(length)
|
||||
val outputLength = receiver.decryptWithAd(
|
||||
associatedData,
|
||||
ciphertext,
|
||||
0,
|
||||
output,
|
||||
0,
|
||||
length
|
||||
)
|
||||
assertArrayEquals(plaintext, output.copyOf(outputLength))
|
||||
} finally {
|
||||
sender.destroy()
|
||||
receiver.destroy()
|
||||
wrongAdReceiver.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
private fun vectorState(role: Int): HandshakeState {
|
||||
val state = HandshakeState(PROTOCOL, role)
|
||||
val prologue = hex("4a6f686e2047616c74")
|
||||
state.setPrologue(prologue, 0, prologue.size)
|
||||
val staticPrivate = if (role == HandshakeState.INITIATOR) {
|
||||
hex("e61ef9919cde45dd5f82166404bd08e38bceb5dfdfded0a34c8df7ed542214d1")
|
||||
} else {
|
||||
hex("4a3acbfdb163dec651dfa3194dece676d437029c62a408b4c5ea9114246e4893")
|
||||
}
|
||||
val ephemeralPrivate = if (role == HandshakeState.INITIATOR) {
|
||||
hex("893e28b9dc6ca8d611ab664754b8ceb7bac5117349a4439a6b0569da977c464a")
|
||||
} else {
|
||||
hex("bbdb4cdbd309f1a1f2e1456967fe288cadd6f712d65dc7b7793d5e63da6b375b")
|
||||
}
|
||||
state.localKeyPair.setPrivateKey(staticPrivate, 0)
|
||||
state.fixedEphemeralKey.setPrivateKey(ephemeralPrivate, 0)
|
||||
state.start()
|
||||
return state
|
||||
}
|
||||
|
||||
private fun assertHandshakeMessage(
|
||||
writer: HandshakeState,
|
||||
reader: HandshakeState,
|
||||
message: VectorMessage
|
||||
) {
|
||||
val actualCiphertext = write(writer, message.payload)
|
||||
assertArrayEquals(message.ciphertext, actualCiphertext)
|
||||
|
||||
val plaintext = ByteArray(256)
|
||||
val length = reader.readMessage(
|
||||
actualCiphertext,
|
||||
0,
|
||||
actualCiphertext.size,
|
||||
plaintext,
|
||||
0
|
||||
)
|
||||
assertArrayEquals(message.payload, plaintext.copyOf(length))
|
||||
}
|
||||
|
||||
private fun write(state: HandshakeState, payload: ByteArray): ByteArray {
|
||||
val output = ByteArray(512)
|
||||
val length = state.writeMessage(output, 0, payload, 0, payload.size)
|
||||
return output.copyOf(length)
|
||||
}
|
||||
|
||||
private fun assertTransportMessage(
|
||||
sender: CipherState,
|
||||
receiver: CipherState,
|
||||
message: VectorMessage
|
||||
) {
|
||||
val encrypted = ByteArray(message.payload.size + sender.macLength)
|
||||
val encryptedLength = sender.encryptWithAd(
|
||||
null,
|
||||
message.payload,
|
||||
0,
|
||||
encrypted,
|
||||
0,
|
||||
message.payload.size
|
||||
)
|
||||
assertArrayEquals(message.ciphertext, encrypted.copyOf(encryptedLength))
|
||||
|
||||
val decrypted = ByteArray(encryptedLength)
|
||||
val decryptedLength = receiver.decryptWithAd(
|
||||
null,
|
||||
encrypted,
|
||||
0,
|
||||
decrypted,
|
||||
0,
|
||||
encryptedLength
|
||||
)
|
||||
assertArrayEquals(message.payload, decrypted.copyOf(decryptedLength))
|
||||
}
|
||||
|
||||
private data class VectorMessage(
|
||||
private val payloadHex: String,
|
||||
private val ciphertextHex: String
|
||||
) {
|
||||
val payload: ByteArray get() = hex(payloadHex)
|
||||
val ciphertext: ByteArray get() = hex(ciphertextHex)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PROTOCOL = "Noise_XX_25519_ChaChaPoly_SHA256"
|
||||
|
||||
private fun hex(value: String): ByteArray =
|
||||
value.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
}
|
||||
}
|
||||
@ -330,6 +330,51 @@ class NoiseSessionManagerIdentityBindingTest {
|
||||
assertArrayEquals(plaintext, bobManager.decrypt(ciphertext, alice.peerID))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `simultaneous handshake collision matrix has one deterministic winner`() {
|
||||
val identities = listOf(
|
||||
identity("e61ef9919cde45dd5f82166404bd08e38bceb5dfdfded0a34c8df7ed542214d1"),
|
||||
identity("4a3acbfdb163dec651dfa3194dece676d437029c62a408b4c5ea9114246e4893"),
|
||||
identity("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a"),
|
||||
identity("5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb")
|
||||
)
|
||||
|
||||
identities.indices.forEach { leftIndex ->
|
||||
((leftIndex + 1) until identities.size).forEach { rightIndex ->
|
||||
val left = identities[leftIndex]
|
||||
val right = identities[rightIndex]
|
||||
val leftManager = manager(left)
|
||||
val rightManager = manager(right)
|
||||
val leftMessage1 = leftManager.initiateHandshake(right.peerID)!!
|
||||
val rightMessage1 = rightManager.initiateHandshake(left.peerID)!!
|
||||
|
||||
val leftResponse = leftManager.processHandshakeMessage(right.peerID, rightMessage1)
|
||||
val rightResponse = rightManager.processHandshakeMessage(left.peerID, leftMessage1)
|
||||
|
||||
if (left.peerID < right.peerID) {
|
||||
assertNull(leftResponse)
|
||||
val message3 = leftManager.processHandshakeMessage(right.peerID, rightResponse!!)!!
|
||||
assertNull(rightManager.processHandshakeMessage(left.peerID, message3))
|
||||
} else {
|
||||
assertNull(rightResponse)
|
||||
val message3 = rightManager.processHandshakeMessage(left.peerID, leftResponse!!)!!
|
||||
assertNull(leftManager.processHandshakeMessage(right.peerID, message3))
|
||||
}
|
||||
|
||||
assertTrue(leftManager.hasEstablishedSession(right.peerID))
|
||||
assertTrue(rightManager.hasEstablishedSession(left.peerID))
|
||||
val payload = "matrix-$leftIndex-$rightIndex".toByteArray()
|
||||
assertArrayEquals(
|
||||
payload,
|
||||
rightManager.decrypt(
|
||||
leftManager.encrypt(payload, right.peerID),
|
||||
left.peerID
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `peer ID derivation rejects malformed keys and non-wire claims`() {
|
||||
val peer = identity()
|
||||
@ -381,4 +426,19 @@ class NoiseSessionManagerIdentityBindingTest {
|
||||
dh.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
private fun identity(privateKeyHex: String): TestIdentity {
|
||||
val privateKey = privateKeyHex.chunked(2)
|
||||
.map { it.toInt(16).toByte() }
|
||||
.toByteArray()
|
||||
val dh = Noise.createDH("25519")
|
||||
return try {
|
||||
dh.setPrivateKey(privateKey, 0)
|
||||
val publicKey = ByteArray(32)
|
||||
dh.getPublicKey(publicKey, 0)
|
||||
TestIdentity(privateKey, publicKey, NoisePeerIdentity.derivePeerID(publicKey)!!)
|
||||
} finally {
|
||||
dh.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,51 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class NostrRelayManagerLifecycleSmokeTest {
|
||||
@Test
|
||||
fun `disconnected manager maintains subscription and empty publish invariants locally`() {
|
||||
val manager = NostrRelayManager.shared
|
||||
manager.disconnect()
|
||||
manager.clearAllSubscriptions()
|
||||
|
||||
val id = manager.subscribe(
|
||||
filter = NostrFilter(kinds = listOf(NostrKind.TEXT_NOTE)),
|
||||
id = "local-contract",
|
||||
handler = {},
|
||||
targetRelayUrls = emptyList()
|
||||
)
|
||||
|
||||
assertEquals("local-contract", id)
|
||||
assertEquals(1, manager.getActiveSubscriptionCount())
|
||||
assertTrue(manager.getActiveSubscriptions().containsKey(id))
|
||||
assertTrue(manager.validateSubscriptionConsistency().isConsistent)
|
||||
manager.sendEvent(signedEvent(), relayUrls = emptyList())
|
||||
manager.retryConnection("wss://not-configured.example")
|
||||
|
||||
manager.unsubscribe(id)
|
||||
assertEquals(0, manager.getActiveSubscriptionCount())
|
||||
assertFalse(manager.isConnected.value)
|
||||
assertTrue(manager.getRelayStatuses().none { it.isConnected })
|
||||
|
||||
manager.disconnect()
|
||||
assertFalse(manager.isConnected.value)
|
||||
}
|
||||
|
||||
private fun signedEvent(): NostrEvent {
|
||||
val privateKey = "0".repeat(63) + "1"
|
||||
return NostrEvent(
|
||||
pubkey = NostrCrypto.derivePublicKey(privateKey),
|
||||
createdAt = 1,
|
||||
kind = NostrKind.TEXT_NOTE,
|
||||
tags = emptyList(),
|
||||
content = "local"
|
||||
).sign(privateKey)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,87 @@
|
||||
package com.bitchat.android.onboarding
|
||||
|
||||
import android.app.Application
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.content.Context
|
||||
import android.location.LocationManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.Robolectric
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35], application = Application::class)
|
||||
class SystemStateManagerContractTest {
|
||||
@Test
|
||||
fun `Bluetooth disabled and enabled states are observable without throwing`() {
|
||||
val app = ApplicationProvider.getApplicationContext<Application>()
|
||||
val controller = Robolectric.buildActivity(ComponentActivity::class.java).create()
|
||||
val adapter = app.getSystemService(BluetoothManager::class.java).adapter
|
||||
val manager = BluetoothStatusManager(
|
||||
activity = controller.get(),
|
||||
context = app,
|
||||
onBluetoothEnabled = {},
|
||||
onBluetoothDisabled = {}
|
||||
)
|
||||
|
||||
shadowOf(adapter).setEnabled(false)
|
||||
assertEquals(BluetoothStatus.DISABLED, manager.checkBluetoothStatus())
|
||||
shadowOf(adapter).setEnabled(true)
|
||||
assertEquals(BluetoothStatus.ENABLED, manager.checkBluetoothStatus())
|
||||
controller.destroy()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `location disabled and enabled states are observable and receiver is cleaned up`() {
|
||||
val app = ApplicationProvider.getApplicationContext<Application>()
|
||||
val controller = Robolectric.buildActivity(ComponentActivity::class.java).create()
|
||||
val locationManager = app.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
val manager = LocationStatusManager(
|
||||
activity = controller.get(),
|
||||
context = app,
|
||||
onLocationEnabled = {},
|
||||
onLocationDisabled = {}
|
||||
)
|
||||
|
||||
shadowOf(locationManager).setLocationEnabled(false)
|
||||
assertEquals(LocationStatus.DISABLED, manager.checkLocationStatus())
|
||||
shadowOf(locationManager).setLocationEnabled(true)
|
||||
assertEquals(LocationStatus.ENABLED, manager.checkLocationStatus())
|
||||
|
||||
manager.cleanup()
|
||||
manager.cleanup()
|
||||
controller.destroy()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `location status routing and recovery messages remain exact`() {
|
||||
val app = ApplicationProvider.getApplicationContext<Application>()
|
||||
val controller = Robolectric.buildActivity(ComponentActivity::class.java).create()
|
||||
var enabled = 0
|
||||
val disabled = mutableListOf<String>()
|
||||
val manager = LocationStatusManager(
|
||||
activity = controller.get(),
|
||||
context = app,
|
||||
onLocationEnabled = { enabled++ },
|
||||
onLocationDisabled = disabled::add
|
||||
)
|
||||
|
||||
manager.handleLocationStatus(LocationStatus.ENABLED)
|
||||
manager.handleLocationStatus(LocationStatus.NOT_AVAILABLE)
|
||||
|
||||
assertEquals(1, enabled)
|
||||
assertEquals(
|
||||
listOf("Location services are not available on this device."),
|
||||
disabled
|
||||
)
|
||||
assertTrue(manager.getStatusMessage(LocationStatus.DISABLED).contains("Bluetooth scanning"))
|
||||
manager.cleanup()
|
||||
controller.destroy()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,141 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.mockito.kotlin.any
|
||||
import org.mockito.kotlin.eq
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.times
|
||||
import org.mockito.kotlin.verify
|
||||
import java.util.Date
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class MeshDelegateHandlerStateContractTest {
|
||||
private lateinit var state: ChatState
|
||||
private lateinit var messages: MessageManager
|
||||
private lateinit var channels: ChannelManager
|
||||
private lateinit var privateChats: PrivateChatManager
|
||||
private lateinit var notifications: NotificationManager
|
||||
private lateinit var mesh: MeshService
|
||||
private lateinit var handler: MeshDelegateHandler
|
||||
private lateinit var haptics: AtomicInteger
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val scope = TestScope(UnconfinedTestDispatcher())
|
||||
state = ChatState(scope)
|
||||
state.setNickname("Résumé")
|
||||
messages = MessageManager(state)
|
||||
channels = mock()
|
||||
privateChats = mock()
|
||||
notifications = mock()
|
||||
mesh = mock()
|
||||
haptics = AtomicInteger()
|
||||
handler = MeshDelegateHandler(
|
||||
state = state,
|
||||
messageManager = messages,
|
||||
channelManager = channels,
|
||||
privateChatManager = privateChats,
|
||||
notificationManager = notifications,
|
||||
coroutineScope = scope,
|
||||
onHapticFeedback = { haptics.incrementAndGet() },
|
||||
getMyPeerID = { "self" },
|
||||
getMeshService = { mesh }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `peer arrival deduplicates list and final departure restores disconnected state`() {
|
||||
handler.didUpdatePeerList(listOf("peer-a", "peer-a", "peer-b"))
|
||||
|
||||
assertEquals(listOf("peer-a", "peer-b"), state.connectedPeers.value)
|
||||
assertTrue(state.isConnected.value)
|
||||
verify(notifications).showActiveUserNotification(listOf("peer-a", "peer-b"))
|
||||
verify(channels).cleanupDisconnectedMembers(listOf("peer-a", "peer-b"), "self")
|
||||
|
||||
handler.didUpdatePeerList(emptyList())
|
||||
|
||||
assertTrue(state.connectedPeers.value.isEmpty())
|
||||
assertFalse(state.isConnected.value)
|
||||
verify(notifications).showActiveUserNotification(emptyList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delivery and read callbacks advance visible status monotonically`() {
|
||||
val outgoing = message(
|
||||
id = "outgoing",
|
||||
sender = "me",
|
||||
deliveryStatus = DeliveryStatus.Sending
|
||||
)
|
||||
state.setMessages(listOf(outgoing))
|
||||
|
||||
handler.didReceiveDeliveryAck("outgoing", "peer-a")
|
||||
assertTrue(state.messages.value.single().deliveryStatus is DeliveryStatus.Delivered)
|
||||
|
||||
handler.didReceiveReadReceipt("outgoing", "peer-a")
|
||||
assertTrue(state.messages.value.single().deliveryStatus is DeliveryStatus.Read)
|
||||
|
||||
handler.didReceiveDeliveryAck("outgoing", "peer-a")
|
||||
assertTrue(state.messages.value.single().deliveryStatus is DeliveryStatus.Read)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unicode mention notifies once and duplicate transport delivery is suppressed`() {
|
||||
val incoming = message(
|
||||
id = "incoming",
|
||||
sender = "alice",
|
||||
content = "hello @résumé",
|
||||
senderPeerID = "peer-a"
|
||||
)
|
||||
|
||||
handler.didReceiveMessage(incoming)
|
||||
handler.didReceiveMessage(incoming)
|
||||
|
||||
assertEquals(1, haptics.get())
|
||||
verify(notifications, times(1)).showMeshMentionNotification(
|
||||
senderNickname = eq("alice"),
|
||||
messageContent = eq("hello @résumé"),
|
||||
senderPeerID = eq("peer-a")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `channel inbound increments unread only when conversation is not focused`() {
|
||||
state.setJoinedChannels(setOf("#room"))
|
||||
val incoming = message(id = "channel-1", channel = "#room")
|
||||
|
||||
handler.didReceiveMessage(incoming)
|
||||
assertEquals(1, state.unreadChannelMessages.value["#room"])
|
||||
|
||||
state.setCurrentChannel("#room")
|
||||
handler.didReceiveMessage(incoming.copy(id = "channel-2"))
|
||||
assertEquals(1, state.unreadChannelMessages.value["#room"])
|
||||
}
|
||||
|
||||
private fun message(
|
||||
id: String,
|
||||
sender: String = "alice",
|
||||
content: String = id,
|
||||
senderPeerID: String? = null,
|
||||
channel: String? = null,
|
||||
deliveryStatus: DeliveryStatus? = null
|
||||
) = BitchatMessage(
|
||||
id = id,
|
||||
sender = sender,
|
||||
content = content,
|
||||
timestamp = Date(1),
|
||||
senderPeerID = senderPeerID,
|
||||
channel = channel,
|
||||
deliveryStatus = deliveryStatus
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,151 @@
|
||||
package com.bitchat.android.wifiaware
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.mockito.kotlin.doReturn
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.verify
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.DataInputStream
|
||||
import java.io.DataOutputStream
|
||||
import java.io.FilterInputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.Socket
|
||||
import java.util.Collections
|
||||
|
||||
class SyncedSocketContractTest {
|
||||
@Test
|
||||
fun `write emits big-endian length payload and empty keepalive frames`() {
|
||||
val output = ByteArrayOutputStream()
|
||||
val raw = socket(input = ByteArrayInputStream(byteArrayOf()), output = output)
|
||||
val synced = SyncedSocket(raw, readTimeoutMs = 1_234)
|
||||
|
||||
synced.write(byteArrayOf(1, 2, 3))
|
||||
synced.write(ByteArray(0))
|
||||
|
||||
assertArrayEquals(
|
||||
byteArrayOf(0, 0, 0, 3, 1, 2, 3, 0, 0, 0, 0),
|
||||
output.toByteArray()
|
||||
)
|
||||
verify(raw).soTimeout = 1_234
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readFully reconstructs one-byte partial reads and keepalives`() {
|
||||
val wire = framed(byteArrayOf(1, 2, 3, 4)) + framed(ByteArray(0))
|
||||
val partial = object : FilterInputStream(ByteArrayInputStream(wire)) {
|
||||
override fun read(buffer: ByteArray, offset: Int, length: Int): Int =
|
||||
super.read(buffer, offset, minOf(1, length))
|
||||
}
|
||||
val synced = SyncedSocket(socket(partial, ByteArrayOutputStream()))
|
||||
|
||||
assertArrayEquals(byteArrayOf(1, 2, 3, 4), synced.read())
|
||||
assertArrayEquals(ByteArray(0), synced.read())
|
||||
assertNull(synced.read())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `EOF truncated invalid and oversized frames fail closed`() {
|
||||
val cases = listOf(
|
||||
ByteArray(0),
|
||||
byteArrayOf(0, 0),
|
||||
byteArrayOf(0, 0, 0, 4, 1, 2),
|
||||
intPrefix(-1),
|
||||
intPrefix(65_537)
|
||||
)
|
||||
|
||||
cases.forEach { wire ->
|
||||
val synced = SyncedSocket(
|
||||
socket(ByteArrayInputStream(wire), ByteArrayOutputStream())
|
||||
)
|
||||
assertNull(synced.read())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `write exceptions propagate and do not create a partial success`() {
|
||||
val failingOutput = object : OutputStream() {
|
||||
override fun write(value: Int) {
|
||||
throw IOException("scripted write failure")
|
||||
}
|
||||
}
|
||||
val synced = SyncedSocket(socket(ByteArrayInputStream(byteArrayOf()), failingOutput))
|
||||
|
||||
assertThrows(IOException::class.java) {
|
||||
synced.write(byteArrayOf(1))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `concurrent writers produce complete non-interleaved frames`() {
|
||||
val output = ByteArrayOutputStream()
|
||||
val synced = SyncedSocket(socket(ByteArrayInputStream(byteArrayOf()), output))
|
||||
val payloads = (0 until 16).map { index ->
|
||||
ByteArray(index + 1) { index.toByte() }
|
||||
}
|
||||
val failures = Collections.synchronizedList(mutableListOf<Throwable>())
|
||||
val threads = payloads.map { payload ->
|
||||
Thread {
|
||||
runCatching { synced.write(payload) }
|
||||
.exceptionOrNull()
|
||||
?.let(failures::add)
|
||||
}.also(Thread::start)
|
||||
}
|
||||
threads.forEach { thread ->
|
||||
thread.join(2_000)
|
||||
assertFalse("Writer thread did not complete", thread.isAlive)
|
||||
}
|
||||
assertTrue(failures.isEmpty())
|
||||
|
||||
val input = DataInputStream(ByteArrayInputStream(output.toByteArray()))
|
||||
val decoded = mutableListOf<ByteArray>()
|
||||
while (input.available() > 0) {
|
||||
val length = input.readInt()
|
||||
decoded += ByteArray(length).also(input::readFully)
|
||||
}
|
||||
assertEquals(
|
||||
payloads.map(ByteArray::toList).toSet(),
|
||||
decoded.map(ByteArray::toList).toSet()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `close and raw socket status are exposed`() {
|
||||
val raw = socket(ByteArrayInputStream(byteArrayOf()), ByteArrayOutputStream())
|
||||
org.mockito.kotlin.whenever(raw.isClosed).thenReturn(false, true)
|
||||
org.mockito.kotlin.whenever(raw.isConnected).thenReturn(true)
|
||||
val synced = SyncedSocket(raw)
|
||||
|
||||
assertFalse(synced.isClosed())
|
||||
assertTrue(synced.isConnected())
|
||||
synced.close()
|
||||
verify(raw).close()
|
||||
assertTrue(synced.isClosed())
|
||||
}
|
||||
|
||||
private fun socket(input: InputStream, output: OutputStream): Socket = mock<Socket> {
|
||||
on { getInputStream() } doReturn input
|
||||
on { getOutputStream() } doReturn output
|
||||
}
|
||||
|
||||
private fun framed(payload: ByteArray): ByteArray =
|
||||
ByteArrayOutputStream().also { output ->
|
||||
DataOutputStream(output).use { data ->
|
||||
data.writeInt(payload.size)
|
||||
data.write(payload)
|
||||
}
|
||||
}.toByteArray()
|
||||
|
||||
private fun intPrefix(value: Int): ByteArray =
|
||||
ByteArrayOutputStream().also { output ->
|
||||
DataOutputStream(output).use { it.writeInt(value) }
|
||||
}.toByteArray()
|
||||
}
|
||||
@ -10,3 +10,9 @@ tasks.whenTaskAdded {
|
||||
enabled = false
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("clientRewriteContractTest") {
|
||||
group = "verification"
|
||||
description = "Runs the complete compatibility gate for a from-scratch client rewrite."
|
||||
dependsOn(":app:testDebugUnitTest")
|
||||
}
|
||||
|
||||
63
docs/client-rewrite-contracts.md
Normal file
63
docs/client-rewrite-contracts.md
Normal file
@ -0,0 +1,63 @@
|
||||
# Client rewrite compatibility contracts
|
||||
|
||||
This document defines the behavior a from-scratch BitChat client must preserve.
|
||||
The executable source of truth is the JVM test suite under
|
||||
`app/src/test/**/contracts`, together with the pre-existing protocol, security,
|
||||
mesh, and state tests.
|
||||
|
||||
The remaining implementation work and milestone progress are tracked in
|
||||
[test-implementation-plan.md](test-implementation-plan.md).
|
||||
|
||||
## Required contract layers
|
||||
|
||||
| Layer | Compatibility promise | Primary tests |
|
||||
|---|---|---|
|
||||
| Outer mesh packet | v1/v2 header widths, big-endian fields, flags, section order, route placement, signature placement, padding, compression, signing bytes | `BinaryProtocolTest`, `ClientRewriteWireContractTest` |
|
||||
| Chat payload | Flag bits, millisecond timestamp, UTF-8 byte lengths, encrypted-content substitution, optional-field order | `ClientRewriteWireContractTest` |
|
||||
| Inner payloads | Noise type bytes, private-message TLVs, peer-state TLVs, file-transfer TLVs, fragment header, sync request TLVs | `ClientRewriteWireContractTest`, `AuthenticatedPeerStateTest`, `PrivateMediaTransferPreparerTest`, `FragmentManagerTest` |
|
||||
| Identity/security | Announcement extensions, capability bitfield endianness, Noise static-key binding, handshake identity binding, signatures | `IdentityAnnouncementTest`, `NoiseSessionManagerIdentityBindingTest`, `ClientRewritePrimitiveContractTest` |
|
||||
| Sync/routing | Stable packet IDs, GCS bitstream, replay collapse, TTL handling, relay choice, confirmed graph edges | `ClientRewritePrimitiveContractTest`, `GCSFilterTest`, `PacketRelayManagerTest`, `MeshGraphServiceTest`, `TransportBridgeServiceTest` |
|
||||
| Nostr | Bech32, secp256k1 key derivation, NIP-01 event IDs/signatures, NIP-44 authenticated encryption, NIP-13 PoW, authenticated NIP-17 seals | `ClientRewriteNostrContractTest`, `NostrProtocolTest` |
|
||||
| Application state | Peer unions, canonical private conversations, chronological history, delivery/read behavior, media migration policy | `AppStateStoreTest`, `PrivateChatManagerTest`, `MediaSendingManagerMigrationTest` |
|
||||
|
||||
## Golden-vector policy
|
||||
|
||||
Golden vectors compare literal externally visible bytes or hashes. Do not update
|
||||
them merely because an implementation changed. Update a vector only when the
|
||||
wire protocol is intentionally versioned and interoperating clients are updated
|
||||
together.
|
||||
|
||||
Round-trip tests remain useful but are not sufficient on their own: an encoder
|
||||
and decoder can share the same defect. Each critical wire format therefore has
|
||||
at least one literal vector.
|
||||
|
||||
## Rewrite acceptance gate
|
||||
|
||||
From a configured Android development environment, run:
|
||||
|
||||
```sh
|
||||
./gradlew clientRewriteContractTest
|
||||
```
|
||||
|
||||
The task runs the new golden vectors and the complete existing unit suite. A
|
||||
rewrite is compatible only when this gate passes. Tests should be ported
|
||||
unchanged when package boundaries change; adapter façades are preferable to
|
||||
weakening assertions.
|
||||
|
||||
## Device-only acceptance
|
||||
|
||||
Local JVM tests cannot prove Android radio and lifecycle behavior. Before
|
||||
shipping a rewrite, run the following on at least two physical devices:
|
||||
|
||||
1. BLE discovery, connection, disconnect, reconnect, and multi-hop relay.
|
||||
2. Runtime permission denial/retry for Bluetooth, location, notifications, and
|
||||
microphone.
|
||||
3. Foreground-service survival with the screen off and after process recreation.
|
||||
4. Cross-client Android/iOS exchange for announce, public/private text, delivery
|
||||
and read receipts, image/audio/file transfer, sync replay, and Nostr fallback.
|
||||
5. Corrupt, duplicated, reordered, delayed, and partially delivered fragments.
|
||||
6. Identity rotation, verification continuity, downgrade rejection, and recovery
|
||||
after stale Noise sessions.
|
||||
|
||||
Those scenarios belong in instrumented tests or a two-device interoperability
|
||||
harness; they must not be represented as passing JVM mocks.
|
||||
113
docs/device-transport-test-matrix.md
Normal file
113
docs/device-transport-test-matrix.md
Normal file
@ -0,0 +1,113 @@
|
||||
# Physical transport validation matrix
|
||||
|
||||
This is the device-only companion to the deterministic Milestone 4 transport
|
||||
suite. It validates that the fake adapters, Robolectric behavior, and pure
|
||||
state machines match Android framework behavior. It is also consumed by the
|
||||
Milestone 10 release gate.
|
||||
|
||||
## Required device set
|
||||
|
||||
- Two physical Android devices from different manufacturers.
|
||||
- At least one Android 13+ device for `NEARBY_WIFI_DEVICES`.
|
||||
- At least one device that supports Wi-Fi Aware.
|
||||
- Bluetooth LE central and peripheral support on both devices.
|
||||
- A build from the exact commit under test installed on both devices.
|
||||
- Clean app data before the first run; retain a second run for restart tests.
|
||||
|
||||
Record device models, API levels, build commit, negotiated MTUs, and timestamps
|
||||
in the release artifact. Do not record user names, device serials, Bluetooth
|
||||
addresses, IP addresses, peer IDs, message contents, or other identifying
|
||||
values.
|
||||
|
||||
## BLE discovery and recovery
|
||||
|
||||
- [ ] Start both clients and confirm each begins scanning and advertising.
|
||||
- [ ] Stop and restart the foreground service; confirm exactly one scanner and
|
||||
advertiser generation remains active.
|
||||
- [ ] Toggle Bluetooth off during scanning, then on; confirm scanning,
|
||||
advertising, announcements, and peer discovery recover without process
|
||||
restart.
|
||||
- [ ] Disable and re-enable the BLE debug transport; confirm the same service
|
||||
instance can recover without duplicate callbacks.
|
||||
- [ ] Rotate the observed BLE address by restarting advertising; confirm the
|
||||
canonical peer remains singular.
|
||||
- [ ] Trigger a transient scan failure or Android Bluetooth process restart;
|
||||
confirm bounded retry and watchdog recovery.
|
||||
- [ ] Confirm permission denial reports unavailable state without a crash,
|
||||
prompt loop, or active radio work.
|
||||
|
||||
## GATT setup and teardown
|
||||
|
||||
- [ ] Connect in both directions simultaneously and confirm one canonical link
|
||||
survives.
|
||||
- [ ] Record the negotiated MTU and repeat at 23, 247, and 517 where the device
|
||||
or test peripheral allows it.
|
||||
- [ ] Remove the service, characteristic, or CCCD in a test peripheral and
|
||||
confirm setup fails closed.
|
||||
- [ ] Reject notification registration and descriptor writes; confirm the peer
|
||||
is never published ready.
|
||||
- [ ] Disconnect during MTU negotiation, service discovery, subscription,
|
||||
client write, and server notification; confirm no stale ready callback.
|
||||
- [ ] Leave setup incomplete for more than 30 seconds; confirm timeout and
|
||||
resource closure.
|
||||
- [ ] Connect beyond configured client, server, and total limits; confirm
|
||||
deterministic oldest-link eviction.
|
||||
|
||||
## Packet delivery and fragmentation
|
||||
|
||||
- [ ] Send directed and broadcast packets over client and server roles.
|
||||
- [ ] Saturate each link faster than radio completion callbacks; confirm one
|
||||
outstanding operation, bounded backpressure, and no reordered frames.
|
||||
- [ ] Inject a failed `onCharacteristicWrite` and `onNotificationSent`; confirm
|
||||
queued work is discarded and the failed generation is cleaned up.
|
||||
- [ ] Transfer payloads immediately below and above the fragmentation boundary.
|
||||
- [ ] Transfer a maximum admitted private-media payload at negotiated MTU 517.
|
||||
- [ ] At MTU 247 and 23, confirm oversized frames are rejected rather than
|
||||
partially sent. Adaptive per-link fragmentation remains tracked as
|
||||
`TDB-023`.
|
||||
- [ ] Disconnect and reconnect halfway through a fragmented transfer; confirm
|
||||
incomplete state expires and a fresh transfer can finish.
|
||||
- [ ] Cancel a queued transfer and stop the service during another; confirm no
|
||||
later fragments or progress callbacks.
|
||||
|
||||
## Wi-Fi Aware
|
||||
|
||||
- [ ] Confirm unsupported hardware and temporarily unavailable radio states are
|
||||
distinct.
|
||||
- [ ] Deny and grant `NEARBY_WIFI_DEVICES`; confirm publish/subscribe work only
|
||||
after grant.
|
||||
- [ ] Start and stop publish and subscribe sessions repeatedly; confirm no
|
||||
duplicate discovery callbacks.
|
||||
- [ ] Authenticate a provisional socket and promote it to the canonical peer.
|
||||
- [ ] Replace a socket while authentication is in flight; confirm the stale
|
||||
socket cannot promote or deliver.
|
||||
- [ ] Toggle Wi-Fi, location, and airplane mode; confirm rediscovery and bounded
|
||||
reconnect after availability returns.
|
||||
- [ ] Stop the service with active sockets, server sockets, and network
|
||||
callbacks; confirm all are closed or unregistered.
|
||||
|
||||
## Unified transport and failover
|
||||
|
||||
- [ ] Connect the same peer over BLE and Wi-Fi Aware; confirm one peer-list row.
|
||||
- [ ] Send with both transports active; confirm the preferred transport is used.
|
||||
- [ ] Drop the preferred transport during a transfer and confirm defined
|
||||
failover behavior without duplicate application delivery.
|
||||
- [ ] Relay between transports and confirm TTL decreases once per hop.
|
||||
- [ ] Reflect a bridged packet back over the other transport; confirm loop and
|
||||
duplicate suppression.
|
||||
- [ ] Stop the foreground service; confirm scans, advertisements, sessions,
|
||||
sockets, operation queues, transfer jobs, and callbacks all terminate.
|
||||
|
||||
## Evidence template
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Commit | |
|
||||
| Device/API classes | |
|
||||
| BLE central/peripheral | Pass / Fail |
|
||||
| MTU cases | Pass / Fail / Unsupported |
|
||||
| BLE recovery | Pass / Fail |
|
||||
| Wi-Fi Aware lifecycle | Pass / Fail / Unsupported |
|
||||
| Cross-transport failover | Pass / Fail |
|
||||
| Shutdown leak check | Pass / Fail |
|
||||
| Bugs filed | |
|
||||
244
docs/release-gate-runbook.md
Normal file
244
docs/release-gate-runbook.md
Normal file
@ -0,0 +1,244 @@
|
||||
# Physical-device and cross-client release gate
|
||||
|
||||
This runbook turns Milestone 10 into a repeatable release procedure. The gate
|
||||
uses a host-side CLI and USB/ADB as its control channel, so control traffic
|
||||
never shares BLE, Wi-Fi Aware, Nostr, or Tor with the system under test.
|
||||
|
||||
The gate cannot pass without the required physical devices and counterpart
|
||||
clients. A pending or blocked result is useful diagnostic evidence, but it is
|
||||
not release approval.
|
||||
|
||||
## Safety and privacy rules
|
||||
|
||||
- Use only disposable lab app data, identities, nicknames, messages, and files.
|
||||
- Never use a personal Nostr account or a production relay.
|
||||
- Do not put device serials, UDIDs, Bluetooth/MAC/IP addresses, peer IDs,
|
||||
usernames, email addresses, local home paths, or message contents in a
|
||||
result, trace, filename, issue, commit, or release artifact.
|
||||
- Device selectors may be supplied to ADB commands as ephemeral inputs. The
|
||||
tooling emits only logical aliases such as `android-current`.
|
||||
- Models, manufacturer classes, Android API levels, negotiated MTU classes,
|
||||
client versions, commit hashes, aggregate counts, durations, and stable
|
||||
failure reason codes are allowed.
|
||||
- Do not archive raw logcat. Convert observations to the structured,
|
||||
privacy-checked trace format, and keep any raw diagnostic capture local until
|
||||
it has been reviewed and sanitized.
|
||||
|
||||
The validator rejects known identifying fields and values before a passing
|
||||
bundle can be created.
|
||||
|
||||
## Required lab
|
||||
|
||||
Prepare:
|
||||
|
||||
- At least three physical Android devices for three-hop relay testing.
|
||||
- At least two Android API levels and two manufacturer classes.
|
||||
- BLE central and peripheral support on every Android device.
|
||||
- At least one Android 13+ device with Wi-Fi Aware.
|
||||
- One physical device running the current iOS client.
|
||||
- The last supported Android client.
|
||||
- The release-candidate APK built from one exact full Git commit.
|
||||
- A local, disposable Nostr relay/Tor fixture with production network access
|
||||
blocked.
|
||||
|
||||
One physical handset may be reused for the legacy-client phase after the
|
||||
current-client evidence for that slot is complete, but the matrix must keep the
|
||||
logical aliases and installed client versions unambiguous.
|
||||
|
||||
## 1. Verify the deterministic gate
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
./gradlew clientRewriteContractTest checkChangedLineCoverage lintDebug
|
||||
python3 tools/release_gate/release_gate.py validate-manifest
|
||||
```
|
||||
|
||||
Do not begin device work from a dirty tree or a build whose deterministic gate
|
||||
does not pass.
|
||||
|
||||
## 2. Create the device matrix
|
||||
|
||||
Copy `tools/release_gate/device-matrix.example.json` to an ignored working
|
||||
directory under `release-gate-results/`. Replace every template value and set
|
||||
both current-Android commit fields to the exact full commit under test.
|
||||
|
||||
Probe Android capabilities without storing the ADB selector:
|
||||
|
||||
```sh
|
||||
python3 tools/release_gate/android_lab.py probe \
|
||||
--serial "$BITCHAT_ADB_SELECTOR" \
|
||||
--alias android-current
|
||||
```
|
||||
|
||||
Copy only the returned logical metadata into the matrix. Validate it:
|
||||
|
||||
```sh
|
||||
python3 tools/release_gate/release_gate.py validate-matrix \
|
||||
--matrix release-gate-results/device-matrix.json \
|
||||
--commit "$BITCHAT_RELEASE_COMMIT"
|
||||
```
|
||||
|
||||
The matrix validator enforces physical devices, three Android participants, two
|
||||
API levels, two manufacturer classes, Wi-Fi Aware, BLE roles, iOS, and explicit
|
||||
current/legacy client versions.
|
||||
|
||||
## 3. Initialize disposable fixtures
|
||||
|
||||
```sh
|
||||
python3 tools/release_gate/release_gate.py init \
|
||||
--matrix release-gate-results/device-matrix.json \
|
||||
--commit "$BITCHAT_RELEASE_COMMIT" \
|
||||
--run-id rc-lab-01 \
|
||||
--output release-gate-results/rc-lab-01
|
||||
```
|
||||
|
||||
Initialization pins the scenario and fixture manifests, creates every scenario
|
||||
as `pending`, and generates deterministic:
|
||||
|
||||
- zero-byte and small files;
|
||||
- a Unicode-named medium file;
|
||||
- sparse exact-maximum and oversized boundary files.
|
||||
|
||||
The fixture manifest records size and SHA-256. The final archive contains the
|
||||
manifest, not the large fixture bodies.
|
||||
|
||||
Clear only the disposable app data on each selected lab device:
|
||||
|
||||
```sh
|
||||
python3 tools/release_gate/android_lab.py prepare \
|
||||
--serial "$BITCHAT_ADB_SELECTOR" \
|
||||
--confirm-disposable-app-data
|
||||
```
|
||||
|
||||
This stops the app and runs package-data cleanup. The explicit confirmation is
|
||||
required because the operation is destructive to that app's local data.
|
||||
|
||||
## 4. Execute scenarios
|
||||
|
||||
The canonical scenario list is
|
||||
`tools/release_gate/scenarios.json`. It contains 27 mandatory scenarios:
|
||||
|
||||
- the complete physical transport matrix;
|
||||
- Android API/manufacturer/permission/background coverage;
|
||||
- 11 Android-to-Android workflows;
|
||||
- 8 cross-client/backward-compatibility workflows;
|
||||
- 6 background and endurance workflows.
|
||||
|
||||
For each scenario:
|
||||
|
||||
1. Confirm the listed participants and capabilities.
|
||||
2. Perform the corresponding steps in
|
||||
[device-transport-test-matrix.md](device-transport-test-matrix.md) and the
|
||||
Milestone 10 checklist.
|
||||
3. Record connection, lifecycle, transport, receipt, resource, and terminal
|
||||
state as aggregate evidence.
|
||||
4. Append at least one structured trace event.
|
||||
5. Mark the scenario `pass`, `fail`, `blocked`, or `unsupported`.
|
||||
|
||||
Record evidence with the exact keys declared by the scenario:
|
||||
|
||||
```sh
|
||||
python3 tools/release_gate/release_gate.py record \
|
||||
--run release-gate-results/rc-lab-01 \
|
||||
--scenario A2A-001 \
|
||||
--status pass \
|
||||
--evidence connection-transitions=4 \
|
||||
--evidence packet-correlation-count=6 \
|
||||
--evidence failure-reasons=none
|
||||
```
|
||||
|
||||
Append a privacy-safe trace event:
|
||||
|
||||
```sh
|
||||
python3 tools/release_gate/release_gate.py trace \
|
||||
--run release-gate-results/rc-lab-01 \
|
||||
--scenario A2A-001 \
|
||||
--source android-current \
|
||||
--event reconnect-terminal \
|
||||
--outcome pass \
|
||||
--metric reconnect-count=1 \
|
||||
--metric duplicate-delivery-count=0
|
||||
```
|
||||
|
||||
Capture resource snapshots during endurance work:
|
||||
|
||||
```sh
|
||||
python3 tools/release_gate/android_lab.py snapshot \
|
||||
--serial "$BITCHAT_ADB_SELECTOR" \
|
||||
--alias android-current \
|
||||
--run release-gate-results/rc-lab-01 \
|
||||
--scenario END-003
|
||||
```
|
||||
|
||||
Use run-local sequential correlation labels while observing packets; archive
|
||||
only aggregate correlation counts. Record failures with a stable reason code,
|
||||
file a regression issue, and preserve the incomplete artifact.
|
||||
|
||||
## 5. Endurance requirements
|
||||
|
||||
- `END-001` requires at least 240 minutes.
|
||||
- `END-002` requires at least 50 large-transfer/cancellation cycles.
|
||||
- Sample memory, threads, file descriptors, wake locks, connection counts, and
|
||||
late callbacks at consistent intervals.
|
||||
- A passing result requires bounded resource behavior and a clean terminal
|
||||
state; merely completing the time window is insufficient.
|
||||
|
||||
The validator rejects shorter durations and cycle counts.
|
||||
|
||||
## 6. Inspect progress and validate
|
||||
|
||||
During a run:
|
||||
|
||||
```sh
|
||||
python3 tools/release_gate/release_gate.py validate \
|
||||
--run release-gate-results/rc-lab-01 \
|
||||
--allow-incomplete
|
||||
|
||||
python3 tools/release_gate/release_gate.py summary \
|
||||
--run release-gate-results/rc-lab-01
|
||||
```
|
||||
|
||||
The release validator, without `--allow-incomplete`, requires:
|
||||
|
||||
- every scenario to be `pass`;
|
||||
- every declared evidence field;
|
||||
- at least one structured trace per scenario;
|
||||
- the pinned scenario and fixture manifests;
|
||||
- the exact client commit and complete device matrix;
|
||||
- endurance minimums;
|
||||
- a completion timestamp;
|
||||
- no detected identifying fields or values.
|
||||
|
||||
`unsupported`, `blocked`, and `pending` never satisfy release approval.
|
||||
|
||||
## 7. Archive release approval
|
||||
|
||||
After the complete validator passes:
|
||||
|
||||
```sh
|
||||
python3 tools/release_gate/release_gate.py bundle \
|
||||
--run release-gate-results/rc-lab-01 \
|
||||
--output release-gate-results/rc-lab-01.zip
|
||||
```
|
||||
|
||||
The deterministic archive contains the scenario manifest, device/client matrix,
|
||||
results, structured trace, fixture manifest, Markdown summary, and
|
||||
`SHA256SUMS`. Attach it to the release approval record without renaming fields
|
||||
or adding raw diagnostics.
|
||||
|
||||
Finally, clean the disposable app data with the same confirmed `cleanup`
|
||||
command and stop the local relay/Tor fixture.
|
||||
|
||||
## Failure handling
|
||||
|
||||
- `fail`: behavior violated a contract. Record a stable reason code, file a bug,
|
||||
add a deterministic regression where possible, fix it, and rerun the affected
|
||||
scenario plus dependent scenarios.
|
||||
- `blocked`: required lab infrastructure or counterpart client was unavailable.
|
||||
Preserve the artifact and do not approve release.
|
||||
- `unsupported`: the selected device lacks a capability. Because the defined
|
||||
matrix requires Wi-Fi Aware, replace the device or matrix; unsupported does
|
||||
not waive a mandatory scenario.
|
||||
- A flaky result is a failure until its cause is understood. Never average
|
||||
retries into a pass.
|
||||
779
docs/test-implementation-plan.md
Normal file
779
docs/test-implementation-plan.md
Normal file
@ -0,0 +1,779 @@
|
||||
# Test implementation plan
|
||||
|
||||
## Objective
|
||||
|
||||
Build enough deterministic, adversarial, integration, and device-level coverage
|
||||
that the Android client can be rewritten from scratch without silently changing
|
||||
its wire behavior, security properties, delivery semantics, lifecycle behavior,
|
||||
or user-visible workflows.
|
||||
|
||||
The canonical compatibility requirements are documented in
|
||||
[client-rewrite-contracts.md](client-rewrite-contracts.md). This plan describes
|
||||
how to turn those requirements into a complete, continuously enforced test
|
||||
program.
|
||||
|
||||
## Status legend
|
||||
|
||||
- **Complete**: acceptance criteria are met and the tests run in the rewrite gate.
|
||||
- **In progress**: implementation has started but acceptance criteria are not met.
|
||||
- **Not started**: no implementation work has been completed.
|
||||
- **Blocked**: progress requires an external dependency, device, or decision.
|
||||
|
||||
## Current progress
|
||||
|
||||
| Milestone | Status | Progress | Depends on |
|
||||
|---|---|---:|---|
|
||||
| 0. Compatibility baseline | Complete | 100% | — |
|
||||
| 1. Coverage and deterministic test infrastructure | Not started | 0% | 0 |
|
||||
| 2. Adversarial protocol and parser testing | Not started | 0% | 1 |
|
||||
| 3. Noise, cryptography, and identity testing | Not started | 0% | 1 |
|
||||
| 4. BLE, Wi-Fi Aware, and transport lifecycle testing | Not started | 0% | 1, 3 |
|
||||
| 5. Sync, routing, and store-and-forward testing | Not started | 0% | 1, 4 |
|
||||
| 6. Nostr and Tor integration testing | Not started | 0% | 1, 3 |
|
||||
| 7. Android lifecycle and permission testing | Not started | 0% | 1, 4 |
|
||||
| 8. Persistence, migration, and recovery testing | Not started | 0% | 1, 3 |
|
||||
| 9. UI, media, and accessibility testing | Not started | 0% | 1, 7, 8 |
|
||||
| 10. Physical-device and cross-client release gate | Not started | 0% | 2–9 |
|
||||
|
||||
Milestone completion is currently **1 of 11 milestones (9%)**. This is
|
||||
milestone-based progress, not line or branch coverage. Milestone 1 will establish
|
||||
measured coverage baselines and trends.
|
||||
|
||||
## Test levels and execution policy
|
||||
|
||||
| Level | Purpose | Expected execution |
|
||||
|---|---|---|
|
||||
| Pure JVM unit tests | Protocols, state machines, crypto vectors, parsing, routing, and deterministic utilities | Every pull request |
|
||||
| Property and fuzz tests | Malformed inputs, boundary exploration, invariants, and crash resistance | Bounded set on every pull request; extended corpus nightly |
|
||||
| Robolectric tests | Android services, lifecycle, broadcasts, permissions, persistence, and process recreation | Every pull request where stable |
|
||||
| Instrumented emulator tests | Compose semantics, navigation, database/filesystem integration, and permission flows | Main branch and release candidates |
|
||||
| Physical-device tests | BLE, Wi-Fi Aware, radios, background execution, and manufacturer-specific behavior | Nightly where devices are available; mandatory release gate |
|
||||
| Cross-client interoperability | Android/iOS and old/new client wire compatibility | Mandatory release gate |
|
||||
|
||||
## Global rules
|
||||
|
||||
- [x] Keep literal golden vectors for externally visible bytes and hashes.
|
||||
- [x] Run all compatibility and regression tests through
|
||||
`./gradlew clientRewriteContractTest`.
|
||||
- [ ] Prefer public behavior and stable adapter interfaces over implementation
|
||||
details.
|
||||
- [ ] Require deterministic clocks, randomness, dispatchers, storage, and
|
||||
transports in tests.
|
||||
- [ ] Never use production relay or internet availability as a test dependency.
|
||||
- [ ] Every fixed protocol or security defect must receive a regression test.
|
||||
- [ ] Every decoder must have positive, boundary, malformed, and fuzz coverage.
|
||||
- [ ] Every asynchronous test must have bounded completion and must not use
|
||||
arbitrary sleeps.
|
||||
- [ ] Test failures must preserve seeds, inputs, and traces needed to reproduce
|
||||
the failure.
|
||||
- [ ] Golden vectors may change only with an intentional protocol version change
|
||||
and coordinated interoperability review.
|
||||
|
||||
---
|
||||
|
||||
## Milestone 0: Compatibility baseline
|
||||
|
||||
**Status:** Complete
|
||||
**Progress:** 100%
|
||||
|
||||
### Scope
|
||||
|
||||
Establish executable rewrite contracts for the most important deterministic
|
||||
wire formats and reuse the existing regression suite as a single acceptance
|
||||
gate.
|
||||
|
||||
### Completed checklist
|
||||
|
||||
- [x] Create an isolated workspace and
|
||||
`codex/client-rewrite-contract-tests` branch.
|
||||
- [x] Add literal v1 and v2 outer packet vectors.
|
||||
- [x] Add public, private, optional-field, and encrypted message vectors.
|
||||
- [x] Add private-message, Noise envelope, fragment, sync, identity, and file
|
||||
transfer vectors.
|
||||
- [x] Add padding, binary encoding, geohash, gossip, packet ID, GCS, and Noise
|
||||
peer-ID contracts.
|
||||
- [x] Add Bech32, secp256k1, NIP-01, NIP-44, and NIP-13 contracts.
|
||||
- [x] Add required-prefix and representative truncated-input rejection tests.
|
||||
- [x] Add explicit validation for malformed fragment IDs.
|
||||
- [x] Add `clientRewriteContractTest` as the complete rewrite acceptance task.
|
||||
- [x] Verify 32 new tests pass without skips.
|
||||
- [x] Verify the full gate discovers 254 tests with zero failures or errors.
|
||||
- [x] Document the remaining device-only acceptance requirements.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- [x] The original `main` workspace remains unchanged.
|
||||
- [x] All new golden-vector tests pass.
|
||||
- [x] The complete unit suite passes through one documented command.
|
||||
|
||||
---
|
||||
|
||||
## Milestone 1: Coverage and deterministic test infrastructure
|
||||
|
||||
**Status:** Not started
|
||||
**Progress:** 0%
|
||||
|
||||
### Goal
|
||||
|
||||
Make coverage measurable and provide reusable deterministic seams so later
|
||||
milestones test behavior without real time, radios, network access, or flaky
|
||||
scheduling.
|
||||
|
||||
### TODO checklist
|
||||
|
||||
#### Coverage reporting
|
||||
|
||||
- [ ] Add JaCoCo or Kover for JVM unit-test line and branch coverage.
|
||||
- [ ] Generate XML and HTML reports from the rewrite acceptance task.
|
||||
- [ ] Record the initial project-wide line and branch coverage baseline.
|
||||
- [ ] Record package-level baselines for `mesh`, `noise`, `nostr`, `service`,
|
||||
`services`, `sync`, `model`, `protocol`, `identity`, and `ui`.
|
||||
- [ ] Publish coverage artifacts in CI.
|
||||
- [ ] Add a changed-lines coverage check for new production code.
|
||||
- [ ] Add non-regression thresholds without forcing low-value tests for trivial
|
||||
generated or platform glue.
|
||||
- [ ] Exclude generated code, Compose compiler output, Android resource classes,
|
||||
and vendored cryptographic code from first-party coverage metrics.
|
||||
|
||||
#### Deterministic seams
|
||||
|
||||
- [ ] Introduce an injectable monotonic clock and wall clock.
|
||||
- [ ] Introduce injectable secure and non-secure random-byte sources where
|
||||
deterministic vectors are required.
|
||||
- [ ] Introduce injectable coroutine dispatchers and test scopes.
|
||||
- [ ] Introduce an in-memory key/value storage adapter for preferences.
|
||||
- [ ] Introduce an in-memory file store with controllable I/O failures.
|
||||
- [ ] Define a fake mesh transport that can connect, disconnect, delay, drop,
|
||||
duplicate, corrupt, reorder, and fragment packets.
|
||||
- [ ] Define fake BLE scanner, advertiser, GATT client, and GATT server adapters.
|
||||
- [ ] Define a fake Wi-Fi Aware session/socket adapter.
|
||||
- [ ] Define a fake Nostr relay transport or MockWebServer fixture.
|
||||
- [ ] Provide reusable packet, identity, peer, graph, and message fixture
|
||||
builders.
|
||||
- [ ] Provide seed capture and reproduction helpers for randomized tests.
|
||||
- [ ] Add test naming and directory conventions for unit, property, Robolectric,
|
||||
instrumented, and interoperability suites.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- [ ] One command generates a repeatable coverage report.
|
||||
- [ ] Two consecutive clean runs produce identical deterministic test results.
|
||||
- [ ] Fake time and transport behavior require no wall-clock sleeps.
|
||||
- [ ] CI publishes coverage and test-result artifacts.
|
||||
- [ ] The plan's progress table is updated with measured baseline numbers.
|
||||
|
||||
---
|
||||
|
||||
## Milestone 2: Adversarial protocol and parser testing
|
||||
|
||||
**Status:** Not started
|
||||
**Progress:** 0%
|
||||
|
||||
### Goal
|
||||
|
||||
Prove that all wire decoders preserve canonical behavior, reject unsafe input,
|
||||
and never crash or allocate unreasonable memory for attacker-controlled data.
|
||||
|
||||
### TODO checklist
|
||||
|
||||
#### Outer packet protocol
|
||||
|
||||
- [ ] Test every valid flag combination for v1 and v2.
|
||||
- [ ] Test exact minimum and maximum payload sizes.
|
||||
- [ ] Test sender and recipient IDs at 0, 1, 7, 8, 9, and oversized lengths.
|
||||
- [ ] Test signatures at 0, 1, 63, 64, 65, and oversized lengths.
|
||||
- [ ] Test route counts at 0, 1, 254, 255, and truncated route entries.
|
||||
- [ ] Test unknown message type values remain safely representable or are
|
||||
rejected according to the protocol contract.
|
||||
- [ ] Test invalid versions, reserved flags, integer overflow, and unsigned
|
||||
length conversion.
|
||||
- [ ] Test trailing bytes and concatenated frames explicitly.
|
||||
- [ ] Test padding boundaries around 256, 512, 1024, and 2048 bytes.
|
||||
- [ ] Test malformed PKCS#7 tails and ambiguous unpadded frames.
|
||||
- [ ] Test raw DEFLATE and zlib-header compatibility vectors.
|
||||
- [ ] Test forged original-size fields, compression bombs, and truncated
|
||||
compressed streams.
|
||||
- [ ] Add encode/decode property tests for all valid packet shapes.
|
||||
- [ ] Add a mutation corpus derived from every golden packet.
|
||||
|
||||
#### Inner payloads and TLVs
|
||||
|
||||
- [ ] Fuzz `BitchatMessage.fromBinaryPayload`.
|
||||
- [ ] Fuzz `IdentityAnnouncement.decode`.
|
||||
- [ ] Fuzz `AuthenticatedPeerState.decode`.
|
||||
- [ ] Fuzz `PrivateMessagePacket.decode`.
|
||||
- [ ] Fuzz `NoisePayload.decode`.
|
||||
- [ ] Fuzz `BitchatFilePacket.decode`.
|
||||
- [ ] Fuzz `FragmentPayload.decode`.
|
||||
- [ ] Fuzz `RequestSyncPacket.decode`.
|
||||
- [ ] Test missing, duplicated, reordered, unknown, and zero-length TLVs.
|
||||
- [ ] Test truncated headers and values at every byte offset.
|
||||
- [ ] Test UTF-8 ASCII, multi-byte, combining-mark, emoji, invalid-byte, and
|
||||
maximum-byte-length cases.
|
||||
- [ ] Test 255-byte one-byte-length boundaries.
|
||||
- [ ] Test 65,535-byte two-byte-length boundaries.
|
||||
- [ ] Test four-byte file content lengths and impossible content declarations.
|
||||
- [ ] Test fragmented file content using one and multiple content TLVs.
|
||||
- [ ] Define and test whether non-canonical but tolerated inputs re-encode
|
||||
canonically.
|
||||
|
||||
#### Fuzzing operations
|
||||
|
||||
- [ ] Select a JVM-compatible property/fuzz framework.
|
||||
- [ ] Add bounded pull-request fuzz runs with fixed seeds.
|
||||
- [ ] Add extended randomized nightly runs.
|
||||
- [ ] Store minimized failing inputs as regression fixtures.
|
||||
- [ ] Assert no decoder throws for arbitrary byte arrays.
|
||||
- [ ] Assert decoder runtime and allocations stay within configured bounds.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- [ ] Every externally reachable decoder has boundary and malformed-input tests.
|
||||
- [ ] Every decoder has a bounded arbitrary-byte no-crash property.
|
||||
- [ ] All discovered crashes or ambiguous contracts have regression fixtures.
|
||||
- [ ] Extended fuzzing completes nightly and preserves reproduction seeds.
|
||||
|
||||
---
|
||||
|
||||
## Milestone 3: Noise, cryptography, and identity testing
|
||||
|
||||
**Status:** Not started
|
||||
**Progress:** 0%
|
||||
|
||||
### Goal
|
||||
|
||||
Prove confidentiality, authenticity, identity binding, replay behavior, session
|
||||
replacement, rekeying, and recovery across the complete secure-channel
|
||||
lifecycle.
|
||||
|
||||
### TODO checklist
|
||||
|
||||
#### Known vectors and primitives
|
||||
|
||||
- [ ] Add known Curve25519 key agreement vectors.
|
||||
- [ ] Add known Ed25519 signing and verification vectors.
|
||||
- [ ] Add known BIP-340 verification vectors.
|
||||
- [ ] Add known HKDF and channel-key derivation vectors.
|
||||
- [ ] Add external Noise XX handshake transcript vectors where compatible.
|
||||
- [ ] Add deterministic channel encryption vectors with injected nonces.
|
||||
- [ ] Test constant-time verification APIs where the underlying library exposes
|
||||
an appropriate contract.
|
||||
|
||||
#### Noise session lifecycle
|
||||
|
||||
- [ ] Test initiator and responder handshakes without manager wrappers.
|
||||
- [ ] Test all valid handshake state transitions.
|
||||
- [ ] Test every invalid message for every handshake state.
|
||||
- [ ] Test tampered handshake messages and remote static-key substitution.
|
||||
- [ ] Test post-handshake encryption in both directions.
|
||||
- [ ] Test empty, small, maximum, and fragmented plaintext.
|
||||
- [ ] Test tampered ciphertext, nonce, tag, and associated data.
|
||||
- [ ] Test replayed ciphertext.
|
||||
- [ ] Test skipped, duplicated, and out-of-order transport messages.
|
||||
- [ ] Test send and receive nonce progression.
|
||||
- [ ] Test nonce exhaustion and counter-overflow behavior.
|
||||
- [ ] Test rekey thresholds, successful rekey, failed rekey, and simultaneous
|
||||
rekey.
|
||||
- [ ] Test session reset and destruction zeroize or discard sensitive state as
|
||||
designed.
|
||||
- [ ] Test handshake timeouts and stale generation leases with fake time.
|
||||
- [ ] Test simultaneous initiator tie-breaking across a larger peer matrix.
|
||||
- [ ] Test process restart with and without persisted identity.
|
||||
|
||||
#### Identity and downgrade protection
|
||||
|
||||
- [ ] Test peer-ID derivation for valid and malformed static keys.
|
||||
- [ ] Test signing-key rotation with authorized and unauthorized announcements.
|
||||
- [ ] Test private-media capability pinning across restart.
|
||||
- [ ] Test downgrade attempts after a capability has been pinned.
|
||||
- [ ] Test corrupted, missing, partially written, and legacy identity storage.
|
||||
- [ ] Test atomic clearing of identity, capability, and peer mappings.
|
||||
- [ ] Test verification fingerprints remain stable for unchanged identities.
|
||||
- [ ] Test identity replacement does not expose an established session before
|
||||
authentication completes.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- [ ] Known vectors pass independently of Android storage and services.
|
||||
- [ ] Replay, tampering, downgrade, and identity-substitution tests all fail
|
||||
closed.
|
||||
- [ ] All timeouts and rekey tests use fake time.
|
||||
- [ ] No sensitive test fixtures contain production keys or user data.
|
||||
|
||||
---
|
||||
|
||||
## Milestone 4: BLE, Wi-Fi Aware, and transport lifecycle testing
|
||||
|
||||
**Status:** Not started
|
||||
**Progress:** 0%
|
||||
|
||||
### Goal
|
||||
|
||||
Verify connection state machines and packet delivery across unreliable Android
|
||||
transports without requiring real radios for the majority of cases.
|
||||
|
||||
### TODO checklist
|
||||
|
||||
#### BLE discovery and connection
|
||||
|
||||
- [ ] Test scan start, stop, restart, and failure callbacks.
|
||||
- [ ] Test advertising start, stop, restart, and failure callbacks.
|
||||
- [ ] Test Bluetooth-off and Bluetooth-on recovery.
|
||||
- [ ] Test duplicate scan results and rapidly changing peer addresses.
|
||||
- [ ] Test connection success, rejection, timeout, and cancellation.
|
||||
- [ ] Test simultaneous inbound and outbound connection races.
|
||||
- [ ] Test canonical connection selection and duplicate-link teardown.
|
||||
- [ ] Test service discovery failure and missing characteristics.
|
||||
- [ ] Test GATT disconnect during discovery, negotiation, read, and write.
|
||||
- [ ] Test reconnect backoff with fake time.
|
||||
- [ ] Test maximum-connection enforcement and eviction policy.
|
||||
- [ ] Test RSSI thresholds and power-mode transitions.
|
||||
|
||||
#### Packet transfer
|
||||
|
||||
- [ ] Test MTU negotiation at minimum, normal, and maximum values.
|
||||
- [ ] Test partial writes and write callbacks delivered out of order.
|
||||
- [ ] Test notification subscription and notification failure.
|
||||
- [ ] Test queue backpressure and bounded memory use.
|
||||
- [ ] Test fragmentation and reassembly across disconnect/reconnect.
|
||||
- [ ] Test duplicate, missing, reordered, and corrupted fragments.
|
||||
- [ ] Test cancellation cleans pending queues and transfer state.
|
||||
- [ ] Test large file/media transfers under constrained MTU.
|
||||
- [ ] Test broadcast and directed packet delivery.
|
||||
- [ ] Test packet relay while one link disconnects.
|
||||
|
||||
#### Wi-Fi Aware
|
||||
|
||||
- [ ] Test feature unavailable and permission-denied behavior.
|
||||
- [ ] Test publish/subscribe session creation and teardown.
|
||||
- [ ] Test provisional link authentication and canonical promotion.
|
||||
- [ ] Test socket replacement and stale-socket rejection.
|
||||
- [ ] Test partial reads, writes, EOF, exceptions, and cancellation.
|
||||
- [ ] Test reconnect and rediscovery.
|
||||
- [ ] Test coexistence with BLE for the same peer.
|
||||
|
||||
#### Unified transport behavior
|
||||
|
||||
- [ ] Test peer-list union and removal across transports.
|
||||
- [ ] Test preferred-transport selection.
|
||||
- [ ] Test transparent failover between BLE and Wi-Fi Aware.
|
||||
- [ ] Test duplicate packet suppression across transports.
|
||||
- [ ] Test transport bridge TTL decrement and loop prevention.
|
||||
- [ ] Test shutdown cancels all jobs, scans, advertisements, sockets, and queues.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- [ ] Transport state-machine tests run deterministically on the JVM or
|
||||
Robolectric.
|
||||
- [ ] Disconnect and cancellation tests leave no queued work or active jobs.
|
||||
- [ ] Cross-transport duplicate delivery and loops are prevented.
|
||||
- [ ] A smaller physical-device suite confirms the fake adapters match Android
|
||||
behavior.
|
||||
|
||||
---
|
||||
|
||||
## Milestone 5: Sync, routing, and store-and-forward testing
|
||||
|
||||
**Status:** Not started
|
||||
**Progress:** 0%
|
||||
|
||||
### Goal
|
||||
|
||||
Verify eventual delivery, bounded resource usage, correct routing, and duplicate
|
||||
suppression during partitions, topology changes, and reconnects.
|
||||
|
||||
### TODO checklist
|
||||
|
||||
#### Packet identity and sync filters
|
||||
|
||||
- [ ] Add more packet-ID vectors for every message type.
|
||||
- [ ] Prove TTL, route, recipient, and signature mutations do not change sync
|
||||
identity.
|
||||
- [ ] Prove payload, sender, timestamp, and type mutations do change identity.
|
||||
- [ ] Property-test GCS encode/decode membership.
|
||||
- [ ] Test empty, singleton, maximum-capacity, duplicate, and collision-heavy
|
||||
filters.
|
||||
- [ ] Test false-positive behavior statistically against configured tolerances.
|
||||
- [ ] Test maximum accepted filter bytes and malicious bitstreams.
|
||||
- [ ] Test sync requests with unknown TLVs and future capability extensions.
|
||||
|
||||
#### Store and forward
|
||||
|
||||
- [ ] Test caching decisions for public, private, favorite, and offline peers.
|
||||
- [ ] Test cache capacity and deterministic eviction.
|
||||
- [ ] Test cache expiry with fake time.
|
||||
- [ ] Test delivery acknowledgement removal.
|
||||
- [ ] Test retransmission after reconnect.
|
||||
- [ ] Test duplicate acknowledgements and late acknowledgements.
|
||||
- [ ] Test process restart persistence policy.
|
||||
- [ ] Test shutdown and cleanup under active delivery.
|
||||
- [ ] Test memory bounds under repeated undeliverable messages.
|
||||
|
||||
#### Routing and topology
|
||||
|
||||
- [ ] Test shortest paths for disconnected, cyclic, diamond, and changing graphs.
|
||||
- [ ] Test deterministic tie-breaking for equal-length routes.
|
||||
- [ ] Test only confirmed edges are used.
|
||||
- [ ] Test edge expiry and peer disappearance with fake time.
|
||||
- [ ] Test a route invalidated between planning and send.
|
||||
- [ ] Test relay TTL exhaustion at every hop.
|
||||
- [ ] Test source-route loop rejection.
|
||||
- [ ] Test broadcast storm suppression.
|
||||
- [ ] Test delivery across mixed BLE and Wi-Fi Aware paths.
|
||||
- [ ] Test graph updates while sync and relay operations run concurrently.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- [ ] Partition/reconnect scenarios eventually deliver exactly once at the
|
||||
application layer.
|
||||
- [ ] Cache, graph, and filter resource bounds are enforced.
|
||||
- [ ] No topology or bridge scenario produces an infinite relay loop.
|
||||
- [ ] All expiry behavior uses fake time.
|
||||
|
||||
---
|
||||
|
||||
## Milestone 6: Nostr and Tor integration testing
|
||||
|
||||
**Status:** Not started
|
||||
**Progress:** 0%
|
||||
|
||||
### Goal
|
||||
|
||||
Verify relay communication, subscriptions, event validation, NIP-17 delivery,
|
||||
and Tor-mode behavior under realistic network failures.
|
||||
|
||||
### TODO checklist
|
||||
|
||||
#### Relay protocol
|
||||
|
||||
- [ ] Add a scripted local WebSocket relay fixture.
|
||||
- [ ] Test initial connection and clean disconnect.
|
||||
- [ ] Test DNS, TCP, TLS, WebSocket, and protocol failures.
|
||||
- [ ] Test reconnect backoff and cancellation with fake time.
|
||||
- [ ] Test relay notices, acknowledgements, end-of-stored-events, and malformed
|
||||
messages.
|
||||
- [ ] Test subscription creation, replacement, unsubscribe, and reconnect
|
||||
restoration.
|
||||
- [ ] Test duplicate, delayed, reordered, and conflicting events.
|
||||
- [ ] Test multi-relay publish success, partial success, and total failure.
|
||||
- [ ] Test event deduplication across relays.
|
||||
- [ ] Test relay-list selection and invalid relay URLs.
|
||||
|
||||
#### Event security and messaging
|
||||
|
||||
- [ ] Add external NIP-01, NIP-13, NIP-17, and NIP-44 vectors.
|
||||
- [ ] Test invalid event IDs and signatures are rejected before dispatch.
|
||||
- [ ] Test future timestamps, stale timestamps, and integer boundaries.
|
||||
- [ ] Test NIP-17 gift-wrap signer/rumor identity mismatches.
|
||||
- [ ] Test malformed seals, wrong recipients, and tampered ciphertext.
|
||||
- [ ] Test private-message and acknowledgement embedding/extraction.
|
||||
- [ ] Test geohash note, presence, and ephemeral-event filters.
|
||||
- [ ] Test nickname and teleport tags.
|
||||
- [ ] Test proof-of-work policy at exact difficulty boundaries.
|
||||
- [ ] Test cancellation and bounded mining iterations.
|
||||
|
||||
#### Tor behavior
|
||||
|
||||
- [ ] Add a fake Tor-state provider and proxy-selection tests.
|
||||
- [ ] Test direct, Tor-only, and fallback modes.
|
||||
- [ ] Test bootstrap delay, bootstrap failure, proxy failure, and shutdown.
|
||||
- [ ] Verify Tor-only mode never silently uses a direct connection.
|
||||
- [ ] Verify mode changes rebuild clients and close old connections.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- [ ] Nostr integration tests require no public relay or internet connection.
|
||||
- [ ] Invalid or unauthenticated events never reach application state.
|
||||
- [ ] Reconnect restores intended subscriptions without duplicate delivery.
|
||||
- [ ] Tor-only policy fails closed.
|
||||
|
||||
---
|
||||
|
||||
## Milestone 7: Android lifecycle and permission testing
|
||||
|
||||
**Status:** Not started
|
||||
**Progress:** 0%
|
||||
|
||||
### Goal
|
||||
|
||||
Verify the app behaves correctly under Android process, service, permission,
|
||||
Bluetooth, battery, and background-execution rules.
|
||||
|
||||
### TODO checklist
|
||||
|
||||
#### Foreground service
|
||||
|
||||
- [ ] Add Robolectric tests for service create, start, bind, unbind, and destroy.
|
||||
- [ ] Test repeated start commands are idempotent.
|
||||
- [ ] Test foreground notification creation and channel configuration.
|
||||
- [ ] Test explicit shutdown clears transport and application state correctly.
|
||||
- [ ] Test unexpected process/service recreation restores required state.
|
||||
- [ ] Test task removal behavior.
|
||||
- [ ] Test boot-completed handling.
|
||||
- [ ] Test service start restrictions and failure reporting.
|
||||
- [ ] Test all coroutines and resources are cancelled on destroy.
|
||||
|
||||
#### Permissions and system state
|
||||
|
||||
- [ ] Test first-run permission explanations.
|
||||
- [ ] Test denial, permanent denial, and later grant.
|
||||
- [ ] Test partial Bluetooth permission grants by Android version.
|
||||
- [ ] Test location-disabled and Bluetooth-disabled states.
|
||||
- [ ] Test notification permission denial.
|
||||
- [ ] Test microphone permission denial during voice recording.
|
||||
- [ ] Test background-location preferences where applicable.
|
||||
- [ ] Test battery-optimization accepted, declined, and unavailable paths.
|
||||
- [ ] Test configuration changes during onboarding.
|
||||
- [ ] Test onboarding restoration after process recreation.
|
||||
|
||||
#### Android-version matrix
|
||||
|
||||
- [ ] Define minimum, target, and newest-supported API test matrix.
|
||||
- [ ] Add emulator coverage for behavior changes in permissions and foreground
|
||||
services.
|
||||
- [ ] Add at least one low-memory/process-death scenario.
|
||||
- [ ] Add manufacturer-device coverage for known BLE/background differences.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- [ ] Critical service and permission flows have Robolectric or instrumented
|
||||
coverage.
|
||||
- [ ] No permission denial crashes or leaves onboarding irrecoverable.
|
||||
- [ ] Service recreation does not duplicate transports or lose required state.
|
||||
- [ ] Required API-level matrix passes before release.
|
||||
|
||||
---
|
||||
|
||||
## Milestone 8: Persistence, migration, and recovery testing
|
||||
|
||||
**Status:** Not started
|
||||
**Progress:** 0%
|
||||
|
||||
### Goal
|
||||
|
||||
Ensure identities, settings, aliases, favorites, bookmarks, messages, and
|
||||
capability pins survive upgrades and fail safely when storage is incomplete or
|
||||
corrupt.
|
||||
|
||||
### TODO checklist
|
||||
|
||||
- [ ] Inventory every persisted key, file, schema, and version marker.
|
||||
- [ ] Create legacy fixtures for every supported application version.
|
||||
- [ ] Test clean first launch with no persisted state.
|
||||
- [ ] Test upgrade from each retained legacy fixture.
|
||||
- [ ] Test unknown future fields are preserved or ignored safely.
|
||||
- [ ] Test truncated, malformed, empty, and type-mismatched preference values.
|
||||
- [ ] Test partial multi-key identity writes.
|
||||
- [ ] Test storage write failure and rollback.
|
||||
- [ ] Test concurrent readers and writers.
|
||||
- [ ] Test alias merging and canonical conversation migration.
|
||||
- [ ] Test chronological ordering after migration.
|
||||
- [ ] Test favorite and bookmark preservation.
|
||||
- [ ] Test message-retention expiry with fake time.
|
||||
- [ ] Test secure identity clearing removes all linked mappings and pins.
|
||||
- [ ] Test signing-key and capability rotation is atomic.
|
||||
- [ ] Test backup/restore policy does not duplicate or expose sensitive identity
|
||||
material.
|
||||
- [ ] Test migration idempotence by running each migration twice.
|
||||
- [ ] Test downgrade behavior when a newer schema has already been written.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- [ ] Every supported legacy fixture migrates deterministically.
|
||||
- [ ] Failed migrations leave either the old valid state or the new valid state,
|
||||
never a partial mixture.
|
||||
- [ ] Security-sensitive corruption fails closed with a recoverable user path.
|
||||
- [ ] Migration and retention behavior uses deterministic storage and time.
|
||||
|
||||
---
|
||||
|
||||
## Milestone 9: UI, media, and accessibility testing
|
||||
|
||||
**Status:** Not started
|
||||
**Progress:** 0%
|
||||
|
||||
### Goal
|
||||
|
||||
Protect user-visible behavior and media workflows while keeping most assertions
|
||||
at ViewModel/state boundaries and reserving Compose instrumentation for genuine
|
||||
interaction and rendering contracts.
|
||||
|
||||
### TODO checklist
|
||||
|
||||
#### ViewModels and application state
|
||||
|
||||
- [ ] Restore or replace the currently skipped command-processor tests.
|
||||
- [ ] Restore or replace the currently skipped notification tests.
|
||||
- [ ] Test public/private/channel conversation switching.
|
||||
- [ ] Test optimistic send, success, failure, retry, and cancellation.
|
||||
- [ ] Test delivery and read-receipt transitions.
|
||||
- [ ] Test peer arrival, departure, alias change, and identity rotation.
|
||||
- [ ] Test state restoration after configuration change and process recreation.
|
||||
- [ ] Test concurrent inbound messages while changing conversations.
|
||||
- [ ] Test error messages for permission, transport, storage, and crypto failures.
|
||||
|
||||
#### Compose UI
|
||||
|
||||
- [ ] Add semantics tests for critical chat actions.
|
||||
- [ ] Test onboarding navigation and recoverability.
|
||||
- [ ] Test empty, loading, connected, disconnected, and error states.
|
||||
- [ ] Test long nicknames, messages, channels, and localized text.
|
||||
- [ ] Test dynamic font sizes and display scaling.
|
||||
- [ ] Test light, dark, and supported theme variants.
|
||||
- [ ] Add screenshot tests only for stable high-value layouts.
|
||||
- [ ] Test keyboard, focus, back navigation, and bottom-sheet behavior.
|
||||
- [ ] Test screen-reader labels, traversal order, and minimum touch targets.
|
||||
- [ ] Test reduced-motion behavior where animations are nonessential.
|
||||
|
||||
#### Files, images, and voice
|
||||
|
||||
- [ ] Test zero-byte, small, maximum-size, and oversized files.
|
||||
- [ ] Test unsupported and misleading MIME types.
|
||||
- [ ] Test missing filenames and Unicode filenames.
|
||||
- [ ] Test file read/write failures and insufficient storage.
|
||||
- [ ] Test image decode failures, orientation metadata, and large-image memory
|
||||
limits.
|
||||
- [ ] Test voice-recording start, pause/stop, cancellation, and microphone loss.
|
||||
- [ ] Test corrupt and unsupported audio playback.
|
||||
- [ ] Test waveform generation boundaries.
|
||||
- [ ] Test interrupted private-media preparation and commit rollback.
|
||||
- [ ] Test cleanup of temporary files after success, failure, and cancellation.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- [ ] Critical user journeys pass through state-level tests.
|
||||
- [ ] A focused Compose suite protects navigation, semantics, and accessibility.
|
||||
- [ ] Media failures are visible, recoverable, and leak no temporary resources.
|
||||
- [ ] Previously skipped UI-related tests are either active or replaced with
|
||||
equivalent coverage.
|
||||
|
||||
---
|
||||
|
||||
## Milestone 10: Physical-device and cross-client release gate
|
||||
|
||||
**Status:** Not started
|
||||
**Progress:** 0%
|
||||
|
||||
### Goal
|
||||
|
||||
Validate the behavior that JVM, Robolectric, and emulator tests cannot prove:
|
||||
real radios, background limits, device interoperability, and compatibility with
|
||||
released clients.
|
||||
|
||||
### TODO checklist
|
||||
|
||||
#### Device matrix and harness
|
||||
|
||||
- [ ] Define a minimum physical-device matrix covering at least two Android API
|
||||
levels and two manufacturers.
|
||||
- [ ] Include devices supporting BLE only and BLE plus Wi-Fi Aware where
|
||||
available.
|
||||
- [ ] Build a test control channel that does not interfere with mesh transport.
|
||||
- [ ] Capture structured traces, packet IDs, connection transitions, and failure
|
||||
reasons.
|
||||
- [ ] Make test accounts, identities, and files disposable and non-personal.
|
||||
- [ ] Provide deterministic scenario setup and cleanup.
|
||||
|
||||
#### Android-to-Android scenarios
|
||||
|
||||
- [ ] Discover, connect, exchange announcements, disconnect, and reconnect.
|
||||
- [ ] Send public and private messages in both directions.
|
||||
- [ ] Verify delivery and read receipts.
|
||||
- [ ] Transfer image, audio, and generic files at multiple sizes.
|
||||
- [ ] Relay across at least three devices.
|
||||
- [ ] Partition the mesh and verify store-and-forward delivery after reconnect.
|
||||
- [ ] Disable and re-enable Bluetooth during active transfers.
|
||||
- [ ] Lock screens and background both apps during active mesh operation.
|
||||
- [ ] Kill and recreate one process.
|
||||
- [ ] Exercise simultaneous connections and duplicate-link resolution.
|
||||
- [ ] Exercise Wi-Fi Aware failover where supported.
|
||||
|
||||
#### Cross-client and backward compatibility
|
||||
|
||||
- [ ] Test current Android against the current iOS client.
|
||||
- [ ] Test the rewrite against the last supported Android release.
|
||||
- [ ] Test legacy announcements without capabilities.
|
||||
- [ ] Test current capability announcements with an older client.
|
||||
- [ ] Test canonical private-media type and decode-only prerelease alias.
|
||||
- [ ] Compare packet, message, identity, fragment, sync, and file golden vectors
|
||||
across implementations.
|
||||
- [ ] Verify malformed and unauthenticated inputs are rejected consistently.
|
||||
- [ ] Verify Nostr fallback messages and receipts across clients.
|
||||
|
||||
#### Background and endurance
|
||||
|
||||
- [ ] Run a multi-hour discovery/connect/disconnect soak test.
|
||||
- [ ] Run repeated large-transfer and cancellation cycles.
|
||||
- [ ] Monitor memory, threads, file descriptors, wake locks, and battery impact.
|
||||
- [ ] Test foreground-service survival with screens off.
|
||||
- [ ] Test network and Tor availability changes during Nostr operation.
|
||||
- [ ] Confirm shutdown releases radios, sockets, jobs, and wake locks.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- [ ] All mandatory scenarios pass on the defined device matrix.
|
||||
- [ ] Android/iOS and old/new clients exchange every supported critical payload.
|
||||
- [ ] No endurance run shows unbounded growth or leaked resources.
|
||||
- [ ] Failures produce sufficient traces for deterministic reproduction where
|
||||
possible.
|
||||
- [ ] Release approval records the client versions, device matrix, and results.
|
||||
|
||||
---
|
||||
|
||||
## CI rollout
|
||||
|
||||
### Pull-request gate
|
||||
|
||||
- [ ] Run formatting and static analysis.
|
||||
- [ ] Run deterministic JVM unit tests.
|
||||
- [ ] Run bounded property/fuzz tests.
|
||||
- [ ] Run stable Robolectric tests.
|
||||
- [ ] Run `clientRewriteContractTest`.
|
||||
- [ ] Upload JUnit and coverage reports.
|
||||
- [ ] Reject new failures, errors, or unexpected skips.
|
||||
- [ ] Reject golden-vector changes without the protocol-change review label.
|
||||
|
||||
### Main and nightly gate
|
||||
|
||||
- [ ] Run the extended fuzz corpus.
|
||||
- [ ] Run emulator instrumented tests.
|
||||
- [ ] Run local relay/Tor integration tests.
|
||||
- [ ] Run physical-device smoke tests when the lab is available.
|
||||
- [ ] Track runtime, flakes, coverage, and quarantined tests.
|
||||
|
||||
### Release-candidate gate
|
||||
|
||||
- [ ] Run the complete device matrix.
|
||||
- [ ] Run Android/iOS and old/new interoperability.
|
||||
- [ ] Run endurance and background scenarios.
|
||||
- [ ] Review all skipped or quarantined tests.
|
||||
- [ ] Archive coverage, test, trace, and version metadata with the release.
|
||||
|
||||
## Progress update procedure
|
||||
|
||||
When work lands:
|
||||
|
||||
1. Check completed TODOs in the relevant milestone.
|
||||
2. Update the milestone percentage based on completed checklist items.
|
||||
3. Change status to **In progress** when its first TODO is complete.
|
||||
4. Change status to **Complete** only when all acceptance criteria are met.
|
||||
5. Update the top-level progress table and milestone completion count.
|
||||
6. Link the implementing pull request or commit next to material completed work
|
||||
without including personal information.
|
||||
7. Record intentionally deferred items and their justification; do not mark them
|
||||
complete.
|
||||
|
||||
## Final definition of done
|
||||
|
||||
The test program is complete when:
|
||||
|
||||
- [ ] Milestones 0–10 meet every acceptance criterion.
|
||||
- [ ] Project and package-level line/branch coverage no longer regress.
|
||||
- [ ] All critical parsers have adversarial and fuzz coverage.
|
||||
- [ ] All security-sensitive state transitions fail closed under tampering,
|
||||
replay, downgrade, and corruption.
|
||||
- [ ] Transport, sync, and lifecycle tests cover disconnection, cancellation,
|
||||
timeout, and restart.
|
||||
- [ ] Physical-device and cross-client scenarios pass for every release.
|
||||
- [ ] The full rewrite can replace the existing implementation while preserving
|
||||
the unchanged compatibility and acceptance tests.
|
||||
90
docs/testing-conventions.md
Normal file
90
docs/testing-conventions.md
Normal file
@ -0,0 +1,90 @@
|
||||
# Testing conventions
|
||||
|
||||
## Purpose
|
||||
|
||||
These conventions keep the client-rewrite suite deterministic, reproducible,
|
||||
and portable across implementations.
|
||||
|
||||
## Test locations
|
||||
|
||||
| Test type | Location | Naming |
|
||||
|---|---|---|
|
||||
| JVM unit and contract tests | `app/src/test/` | `*Test.kt` |
|
||||
| Shared deterministic fakes and fixtures | `app/src/test/**/testsupport/` | Descriptive fixture name |
|
||||
| Robolectric tests | `app/src/test/` | `*RobolectricTest.kt` |
|
||||
| Android instrumented tests | `app/src/androidTest/` | `*InstrumentedTest.kt` |
|
||||
| Coverage-tool tests | `tools/coverage/` | `test_*.py` |
|
||||
| Interoperability fixtures | `app/src/test/resources/contracts/` | Protocol and version in filename |
|
||||
|
||||
## Required behavior
|
||||
|
||||
- Tests must not use arbitrary sleeps. Advance a fake clock or coroutine test
|
||||
scheduler instead.
|
||||
- Tests must not require public relays, internet access, Bluetooth hardware, or a
|
||||
user's persisted data.
|
||||
- Time, randomness, dispatchers, storage, and transports must be injectable in
|
||||
code exercised by state-machine tests.
|
||||
- Randomized failures must print a reproduction seed. Use `TEST_SEED` for a
|
||||
specific replay.
|
||||
- Mutable byte arrays returned by fixtures and fakes must be defensively copied.
|
||||
- Negative security tests must assert fail-closed behavior.
|
||||
- Protocol round trips must be paired with literal golden vectors for critical
|
||||
externally visible formats.
|
||||
- Asynchronous tests must have a deterministic completion condition and a
|
||||
bounded timeout.
|
||||
- A fixed bug must retain its smallest reproducing input as a regression test.
|
||||
|
||||
## Naming
|
||||
|
||||
Test names should describe observable behavior:
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `replayed ciphertext is rejected without advancing receive state`() {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Avoid names tied to private methods or temporary implementation structure.
|
||||
|
||||
## Fixtures and seeds
|
||||
|
||||
Reusable Kotlin fixtures live under
|
||||
`com.bitchat.android.testsupport`. `ReproducibleTestSeed` resolves
|
||||
`TEST_SEED` and provides a reproduction hint:
|
||||
|
||||
```sh
|
||||
TEST_SEED=12345 ./gradlew clientRewriteContractTest
|
||||
```
|
||||
|
||||
Never use production keys, contact information, messages, or other user data in
|
||||
fixtures.
|
||||
|
||||
## Coverage
|
||||
|
||||
Run the full report and non-regression floor:
|
||||
|
||||
```sh
|
||||
./gradlew clientRewriteContractTest
|
||||
```
|
||||
|
||||
Reports are written to:
|
||||
|
||||
- `app/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml`
|
||||
- `app/build/reports/jacoco/jacocoTestReport/html/`
|
||||
|
||||
Check executable production lines changed from the base branch:
|
||||
|
||||
```sh
|
||||
COVERAGE_BASE_REF=origin/main ./gradlew checkChangedLineCoverage
|
||||
```
|
||||
|
||||
Generated resource classes, Compose-generated singleton classes, platform
|
||||
bridges, and vendored Noise code are excluded from first-party coverage metrics.
|
||||
|
||||
## Quarantine and skips
|
||||
|
||||
- A flaky test must be fixed, not silently retried.
|
||||
- A temporary quarantine must include an issue and removal condition.
|
||||
- Unexpected skips fail review. Existing skips must be restored or replaced by
|
||||
equivalent coverage.
|
||||
1
tools/__init__.py
Normal file
1
tools/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Repository-local verification tooling."""
|
||||
1
tools/coverage/__init__.py
Normal file
1
tools/coverage/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Coverage verification helpers."""
|
||||
154
tools/coverage/check_changed_coverage.py
Normal file
154
tools/coverage/check_changed_coverage.py
Normal file
@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Enforce JaCoCo coverage for executable Kotlin/Java lines changed from a Git base."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
SOURCE_ROOTS = (
|
||||
"app/src/main/java/",
|
||||
"app/src/main/kotlin/",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CoverageLine:
|
||||
missed_instructions: int
|
||||
covered_instructions: int
|
||||
|
||||
@property
|
||||
def covered(self) -> bool:
|
||||
return self.covered_instructions > 0
|
||||
|
||||
|
||||
def parse_jacoco(xml_path: pathlib.Path) -> dict[tuple[str, int], CoverageLine]:
|
||||
root = ET.parse(xml_path).getroot()
|
||||
result: dict[tuple[str, int], CoverageLine] = {}
|
||||
for package in root.findall("package"):
|
||||
package_name = package.attrib["name"]
|
||||
for source_file in package.findall("sourcefile"):
|
||||
relative_path = f"{package_name}/{source_file.attrib['name']}"
|
||||
for line in source_file.findall("line"):
|
||||
result[(relative_path, int(line.attrib["nr"]))] = CoverageLine(
|
||||
missed_instructions=int(line.attrib.get("mi", "0")),
|
||||
covered_instructions=int(line.attrib.get("ci", "0")),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def parse_changed_lines(diff_text: str) -> dict[str, set[int]]:
|
||||
changed: dict[str, set[int]] = {}
|
||||
current_path: str | None = None
|
||||
for raw_line in diff_text.splitlines():
|
||||
if raw_line.startswith("+++ b/"):
|
||||
current_path = raw_line[6:]
|
||||
continue
|
||||
if raw_line.startswith("+++ /dev/null"):
|
||||
current_path = None
|
||||
continue
|
||||
if not raw_line.startswith("@@") or current_path is None:
|
||||
continue
|
||||
match = re.search(r"\+(\d+)(?:,(\d+))?", raw_line)
|
||||
if match is None:
|
||||
continue
|
||||
start = int(match.group(1))
|
||||
count = int(match.group(2) or "1")
|
||||
if count > 0:
|
||||
changed.setdefault(current_path, set()).update(range(start, start + count))
|
||||
return changed
|
||||
|
||||
|
||||
def jacoco_relative_path(repository_path: str) -> str | None:
|
||||
for root in SOURCE_ROOTS:
|
||||
if repository_path.startswith(root):
|
||||
return repository_path[len(root) :]
|
||||
return None
|
||||
|
||||
|
||||
def git_diff_command(base: str) -> list[str]:
|
||||
return [
|
||||
"git",
|
||||
"diff",
|
||||
# Reformatting an executable line without changing its tokens must not turn an otherwise
|
||||
# covered change set into a coverage failure.
|
||||
"--ignore-all-space",
|
||||
"--unified=0",
|
||||
"--diff-filter=AM",
|
||||
base,
|
||||
"--",
|
||||
*SOURCE_ROOTS,
|
||||
]
|
||||
|
||||
|
||||
def git_diff(base: str) -> str:
|
||||
command = git_diff_command(base)
|
||||
result = subprocess.run(command, check=True, capture_output=True, text=True)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def evaluate(
|
||||
changed: dict[str, set[int]],
|
||||
coverage: dict[tuple[str, int], CoverageLine],
|
||||
) -> tuple[int, int, list[str]]:
|
||||
executable = 0
|
||||
covered = 0
|
||||
missed: list[str] = []
|
||||
for repository_path, line_numbers in sorted(changed.items()):
|
||||
source_path = jacoco_relative_path(repository_path)
|
||||
if source_path is None:
|
||||
continue
|
||||
for line_number in sorted(line_numbers):
|
||||
line = coverage.get((source_path, line_number))
|
||||
if line is None:
|
||||
continue
|
||||
executable += 1
|
||||
if line.covered:
|
||||
covered += 1
|
||||
else:
|
||||
missed.append(f"{repository_path}:{line_number}")
|
||||
return covered, executable, missed
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--xml", type=pathlib.Path, required=True)
|
||||
parser.add_argument("--base", required=True)
|
||||
parser.add_argument("--threshold", type=float, default=0.80)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
if not 0.0 <= args.threshold <= 1.0:
|
||||
raise SystemExit("--threshold must be between 0 and 1")
|
||||
if not args.xml.is_file():
|
||||
raise SystemExit(f"JaCoCo XML report not found: {args.xml}")
|
||||
|
||||
coverage = parse_jacoco(args.xml)
|
||||
changed = parse_changed_lines(git_diff(args.base))
|
||||
covered, executable, missed = evaluate(changed, coverage)
|
||||
ratio = 1.0 if executable == 0 else covered / executable
|
||||
|
||||
print(
|
||||
f"Changed executable line coverage: {covered}/{executable} "
|
||||
f"({ratio:.1%}), required {args.threshold:.1%}"
|
||||
)
|
||||
if ratio >= args.threshold:
|
||||
return 0
|
||||
|
||||
for location in missed[:50]:
|
||||
print(f"UNCOVERED {location}")
|
||||
if len(missed) > 50:
|
||||
print(f"... and {len(missed) - 50} more uncovered executable lines")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
85
tools/coverage/test_check_changed_coverage.py
Normal file
85
tools/coverage/test_check_changed_coverage.py
Normal file
@ -0,0 +1,85 @@
|
||||
import pathlib
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from tools.coverage.check_changed_coverage import (
|
||||
CoverageLine,
|
||||
evaluate,
|
||||
git_diff_command,
|
||||
jacoco_relative_path,
|
||||
parse_changed_lines,
|
||||
parse_jacoco,
|
||||
)
|
||||
|
||||
|
||||
class ChangedCoverageToolTest(unittest.TestCase):
|
||||
def test_parses_added_and_modified_hunks(self) -> None:
|
||||
diff = """\
|
||||
diff --git a/app/src/main/java/example/Thing.kt b/app/src/main/java/example/Thing.kt
|
||||
--- a/app/src/main/java/example/Thing.kt
|
||||
+++ b/app/src/main/java/example/Thing.kt
|
||||
@@ -1,0 +2,3 @@
|
||||
+a
|
||||
+b
|
||||
+c
|
||||
@@ -9 +12 @@
|
||||
-old
|
||||
+new
|
||||
"""
|
||||
self.assertEqual(
|
||||
{"app/src/main/java/example/Thing.kt": {2, 3, 4, 12}},
|
||||
parse_changed_lines(diff),
|
||||
)
|
||||
|
||||
def test_parses_jacoco_source_lines(self) -> None:
|
||||
xml = """\
|
||||
<report name="test">
|
||||
<package name="example">
|
||||
<sourcefile name="Thing.kt">
|
||||
<line nr="2" mi="0" ci="3"/>
|
||||
<line nr="3" mi="2" ci="0"/>
|
||||
</sourcefile>
|
||||
</package>
|
||||
</report>
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = pathlib.Path(directory) / "report.xml"
|
||||
path.write_text(xml, encoding="utf-8")
|
||||
parsed = parse_jacoco(path)
|
||||
|
||||
self.assertTrue(parsed[("example/Thing.kt", 2)].covered)
|
||||
self.assertFalse(parsed[("example/Thing.kt", 3)].covered)
|
||||
|
||||
def test_evaluates_only_executable_changed_lines(self) -> None:
|
||||
changed = {"app/src/main/java/example/Thing.kt": {1, 2, 3}}
|
||||
coverage = {
|
||||
("example/Thing.kt", 2): CoverageLine(0, 1),
|
||||
("example/Thing.kt", 3): CoverageLine(1, 0),
|
||||
}
|
||||
|
||||
self.assertEqual(
|
||||
(1, 2, ["app/src/main/java/example/Thing.kt:3"]),
|
||||
evaluate(changed, coverage),
|
||||
)
|
||||
|
||||
def test_maps_both_supported_source_roots(self) -> None:
|
||||
self.assertEqual(
|
||||
"example/Thing.kt",
|
||||
jacoco_relative_path("app/src/main/java/example/Thing.kt"),
|
||||
)
|
||||
self.assertEqual(
|
||||
"example/Thing.kt",
|
||||
jacoco_relative_path("app/src/main/kotlin/example/Thing.kt"),
|
||||
)
|
||||
self.assertIsNone(jacoco_relative_path("app/src/test/example/Thing.kt"))
|
||||
|
||||
def test_changed_line_diff_ignores_formatting_only_edits(self) -> None:
|
||||
command = git_diff_command("origin/main")
|
||||
|
||||
self.assertIn("--ignore-all-space", command)
|
||||
self.assertIn("--unified=0", command)
|
||||
self.assertEqual("origin/main", command[command.index("--diff-filter=AM") + 1])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
1
tools/release_gate/__init__.py
Normal file
1
tools/release_gate/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Physical-device and cross-client release-gate tooling."""
|
||||
209
tools/release_gate/android_lab.py
Normal file
209
tools/release_gate/android_lab.py
Normal file
@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env python3
|
||||
"""USB/ADB control helpers for the physical release gate.
|
||||
|
||||
Device selectors are accepted only as ephemeral command inputs. They are never
|
||||
printed or written to artifacts; all output uses the operator-assigned alias.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(REPOSITORY_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPOSITORY_ROOT))
|
||||
|
||||
from tools.release_gate.release_gate import (
|
||||
GateError,
|
||||
SAFE_ID_RE,
|
||||
append_trace_event,
|
||||
)
|
||||
|
||||
|
||||
APPLICATION_ID = "com.bitchat.droid"
|
||||
|
||||
|
||||
def find_adb() -> str:
|
||||
direct = shutil.which("adb")
|
||||
if direct:
|
||||
return direct
|
||||
android_home = os.environ.get("ANDROID_HOME")
|
||||
if android_home:
|
||||
candidate = Path(android_home) / "platform-tools" / "adb"
|
||||
if candidate.is_file():
|
||||
return str(candidate)
|
||||
raise GateError("adb was not found; set ANDROID_HOME or add adb to PATH")
|
||||
|
||||
|
||||
def run_adb(
|
||||
serial: str,
|
||||
arguments: list[str],
|
||||
*,
|
||||
runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
||||
) -> str:
|
||||
result = runner(
|
||||
[find_adb(), "-s", serial, *arguments],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise GateError("ADB command failed for the selected logical device")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def count_connected_devices(
|
||||
*,
|
||||
runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
||||
) -> int:
|
||||
result = runner(
|
||||
[find_adb(), "devices"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise GateError("could not enumerate ADB devices")
|
||||
return sum(
|
||||
1
|
||||
for line in result.stdout.splitlines()[1:]
|
||||
if line.strip().endswith("\tdevice")
|
||||
)
|
||||
|
||||
|
||||
def probe_device(serial: str, alias: str) -> dict[str, object]:
|
||||
if not SAFE_ID_RE.fullmatch(alias):
|
||||
raise GateError("device alias must be a lowercase logical identifier")
|
||||
api_text = run_adb(serial, ["shell", "getprop", "ro.build.version.sdk"])
|
||||
if not api_text.isdigit():
|
||||
raise GateError("selected device returned an invalid API level")
|
||||
features = run_adb(serial, ["shell", "pm", "list", "features"])
|
||||
manufacturer = run_adb(
|
||||
serial, ["shell", "getprop", "ro.product.manufacturer"]
|
||||
).strip().lower()
|
||||
model = run_adb(serial, ["shell", "getprop", "ro.product.model"]).strip()
|
||||
capabilities = ["ble-central", "ble-peripheral"]
|
||||
if "android.hardware.wifi.aware" in features:
|
||||
capabilities.append("wifi-aware")
|
||||
return {
|
||||
"alias": alias,
|
||||
"platform": "android",
|
||||
"model": model,
|
||||
"manufacturer_class": re.sub(r"[^a-z0-9._-]", "-", manufacturer)[:64],
|
||||
"api_level": int(api_text),
|
||||
"physical": True,
|
||||
"capabilities": capabilities,
|
||||
}
|
||||
|
||||
|
||||
def prepare_disposable_device(serial: str, confirmed: bool) -> None:
|
||||
if not confirmed:
|
||||
raise GateError("prepare requires --confirm-disposable-app-data")
|
||||
run_adb(serial, ["shell", "am", "force-stop", APPLICATION_ID])
|
||||
output = run_adb(serial, ["shell", "pm", "clear", APPLICATION_ID])
|
||||
if "Success" not in output:
|
||||
raise GateError("could not clear disposable app data")
|
||||
|
||||
|
||||
def collect_resource_snapshot(serial: str) -> dict[str, int | bool]:
|
||||
pid_text = run_adb(serial, ["shell", "pidof", APPLICATION_ID])
|
||||
pid = pid_text.split()[0] if pid_text else ""
|
||||
metrics: dict[str, int | bool] = {"process-running": bool(pid)}
|
||||
if not pid.isdigit():
|
||||
return metrics
|
||||
meminfo = run_adb(serial, ["shell", "dumpsys", "meminfo", APPLICATION_ID])
|
||||
total_match = re.search(r"TOTAL\s+(\d+)", meminfo)
|
||||
metrics["total-pss-kb"] = int(total_match.group(1)) if total_match else -1
|
||||
thread_text = run_adb(
|
||||
serial,
|
||||
["shell", "sh", "-c", f"find /proc/{pid}/task -mindepth 1 -maxdepth 1 | wc -l"],
|
||||
)
|
||||
fd_text = run_adb(
|
||||
serial,
|
||||
["shell", "sh", "-c", f"find /proc/{pid}/fd -mindepth 1 -maxdepth 1 | wc -l"],
|
||||
)
|
||||
metrics["thread-count"] = int(thread_text) if thread_text.isdigit() else -1
|
||||
metrics["fd-count"] = int(fd_text) if fd_text.isdigit() else -1
|
||||
power = run_adb(serial, ["shell", "dumpsys", "power"])
|
||||
metrics["app-wakelock-count"] = sum(
|
||||
1
|
||||
for line in power.splitlines()
|
||||
if APPLICATION_ID in line and "WakeLock" in line
|
||||
)
|
||||
battery = run_adb(serial, ["shell", "dumpsys", "battery"])
|
||||
battery_level = re.search(r"^\s*level:\s*(\d+)", battery, re.MULTILINE)
|
||||
metrics["battery-level-percent"] = (
|
||||
int(battery_level.group(1)) if battery_level else -1
|
||||
)
|
||||
return metrics
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
commands.add_parser("count")
|
||||
|
||||
probe = commands.add_parser("probe")
|
||||
probe.add_argument("--serial", required=True, help=argparse.SUPPRESS)
|
||||
probe.add_argument("--alias", required=True)
|
||||
|
||||
prepare = commands.add_parser("prepare")
|
||||
prepare.add_argument("--serial", required=True, help=argparse.SUPPRESS)
|
||||
prepare.add_argument("--confirm-disposable-app-data", action="store_true")
|
||||
|
||||
cleanup = commands.add_parser("cleanup")
|
||||
cleanup.add_argument("--serial", required=True, help=argparse.SUPPRESS)
|
||||
cleanup.add_argument("--confirm-disposable-app-data", action="store_true")
|
||||
|
||||
snapshot = commands.add_parser("snapshot")
|
||||
snapshot.add_argument("--serial", required=True, help=argparse.SUPPRESS)
|
||||
snapshot.add_argument("--alias", required=True)
|
||||
snapshot.add_argument("--run", type=Path, required=True)
|
||||
snapshot.add_argument("--scenario", required=True)
|
||||
snapshot.add_argument("--event", default="resource-snapshot")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
try:
|
||||
if args.command == "count":
|
||||
print(json.dumps({"authorized-device-count": count_connected_devices()}))
|
||||
elif args.command == "probe":
|
||||
print(json.dumps(probe_device(args.serial, args.alias), sort_keys=True))
|
||||
elif args.command in {"prepare", "cleanup"}:
|
||||
prepare_disposable_device(
|
||||
args.serial, args.confirm_disposable_app_data
|
||||
)
|
||||
print(json.dumps({"status": "clean", "application": APPLICATION_ID}))
|
||||
elif args.command == "snapshot":
|
||||
metrics = collect_resource_snapshot(args.serial)
|
||||
append_trace_event(
|
||||
args.run,
|
||||
args.scenario,
|
||||
args.alias,
|
||||
args.event,
|
||||
"observed",
|
||||
None,
|
||||
metrics,
|
||||
)
|
||||
print(json.dumps({"source_alias": args.alias, "metrics": metrics}, sort_keys=True))
|
||||
return 0
|
||||
except (GateError, OSError, subprocess.SubprocessError) as error:
|
||||
print(f"android lab error: {error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
72
tools/release_gate/device-matrix.example.json
Normal file
72
tools/release_gate/device-matrix.example.json
Normal file
@ -0,0 +1,72 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"commit": "0000000000000000000000000000000000000000",
|
||||
"clients": {
|
||||
"android-current": {
|
||||
"version": "replace-with-release-candidate",
|
||||
"commit": "0000000000000000000000000000000000000000"
|
||||
},
|
||||
"android-legacy": {
|
||||
"version": "replace-with-last-supported-release"
|
||||
},
|
||||
"ios-current": {
|
||||
"version": "replace-with-current-ios-release"
|
||||
}
|
||||
},
|
||||
"lab_capabilities": ["local-relay", "tor"],
|
||||
"devices": [
|
||||
{
|
||||
"alias": "android-low",
|
||||
"platform": "android",
|
||||
"model": "replace-with-model",
|
||||
"manufacturer_class": "manufacturer-a",
|
||||
"api_level": 28,
|
||||
"physical": true,
|
||||
"capabilities": ["ble-central", "ble-peripheral"]
|
||||
},
|
||||
{
|
||||
"alias": "android-current",
|
||||
"platform": "android",
|
||||
"model": "replace-with-model",
|
||||
"manufacturer_class": "manufacturer-b",
|
||||
"api_level": 35,
|
||||
"physical": true,
|
||||
"capabilities": ["ble-central", "ble-peripheral", "wifi-aware"]
|
||||
},
|
||||
{
|
||||
"alias": "android-relay",
|
||||
"platform": "android",
|
||||
"model": "replace-with-model",
|
||||
"manufacturer_class": "manufacturer-c",
|
||||
"api_level": 33,
|
||||
"physical": true,
|
||||
"capabilities": ["ble-central", "ble-peripheral"]
|
||||
},
|
||||
{
|
||||
"alias": "android-aware",
|
||||
"platform": "android",
|
||||
"model": "replace-with-model",
|
||||
"manufacturer_class": "manufacturer-b",
|
||||
"api_level": 35,
|
||||
"physical": true,
|
||||
"capabilities": ["ble-central", "ble-peripheral", "wifi-aware"]
|
||||
},
|
||||
{
|
||||
"alias": "android-legacy",
|
||||
"platform": "android",
|
||||
"model": "replace-with-model",
|
||||
"manufacturer_class": "manufacturer-a",
|
||||
"api_level": 28,
|
||||
"physical": true,
|
||||
"capabilities": ["ble-central", "ble-peripheral"]
|
||||
},
|
||||
{
|
||||
"alias": "ios-current",
|
||||
"platform": "ios",
|
||||
"model": "replace-with-model",
|
||||
"manufacturer_class": "apple",
|
||||
"physical": true,
|
||||
"capabilities": ["ble-central", "ble-peripheral"]
|
||||
}
|
||||
]
|
||||
}
|
||||
744
tools/release_gate/release_gate.py
Normal file
744
tools/release_gate/release_gate.py
Normal file
@ -0,0 +1,744 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create, record, validate, and archive the physical release gate.
|
||||
|
||||
The host-side CLI is the control channel. It coordinates operators over USB or
|
||||
local files and never sends control traffic through the mesh under test.
|
||||
Artifacts intentionally contain logical device aliases and aggregate evidence,
|
||||
not device identifiers, addresses, peer IDs, or message contents.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
RESULT_STATUSES = {"pending", "pass", "fail", "blocked", "unsupported"}
|
||||
COMMIT_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
SAFE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
|
||||
DISALLOWED_KEYS = {
|
||||
"serial",
|
||||
"serial_number",
|
||||
"udid",
|
||||
"imei",
|
||||
"bluetooth_address",
|
||||
"mac_address",
|
||||
"ip_address",
|
||||
"peer_id",
|
||||
"username",
|
||||
"user_name",
|
||||
"email",
|
||||
"account",
|
||||
"device_name",
|
||||
}
|
||||
HASH_VALUE_KEYS = {
|
||||
"commit",
|
||||
"sha256",
|
||||
"digest",
|
||||
"scenario_manifest_sha256",
|
||||
"fixture_sha256",
|
||||
"vector_manifest_digest",
|
||||
"corpus_digest",
|
||||
}
|
||||
SENSITIVE_PATTERNS = (
|
||||
re.compile(r"(?:^|[\s/])Users/[^/\s]+"),
|
||||
re.compile(r"(?:^|[\s/])home/[^/\s]+"),
|
||||
re.compile(r"\b[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}\b"),
|
||||
re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b"),
|
||||
re.compile(r"\b(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}\b"),
|
||||
re.compile(r"\b[0-9A-Fa-f]{16,}\b"),
|
||||
)
|
||||
MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024
|
||||
|
||||
|
||||
class GateError(ValueError):
|
||||
"""A release-gate artifact violated an executable contract."""
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise GateError(f"could not read JSON {path.name}: {error}") from error
|
||||
if not isinstance(value, dict):
|
||||
raise GateError(f"{path.name} must contain a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
def write_json(path: Path, value: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def sha256_bytes(value: bytes) -> str:
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def canonical_json_digest(value: dict[str, Any]) -> str:
|
||||
encoded = json.dumps(
|
||||
value, sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
||||
).encode("utf-8")
|
||||
return sha256_bytes(encoded)
|
||||
|
||||
|
||||
def validate_privacy(value: Any, path: tuple[str, ...] = ()) -> None:
|
||||
if isinstance(value, dict):
|
||||
for key, child in value.items():
|
||||
normalized = str(key).lower()
|
||||
if normalized in DISALLOWED_KEYS:
|
||||
raise GateError(f"disallowed identifying field: {'.'.join(path + (str(key),))}")
|
||||
validate_privacy(child, path + (str(key),))
|
||||
return
|
||||
if isinstance(value, list):
|
||||
for index, child in enumerate(value):
|
||||
validate_privacy(child, path + (str(index),))
|
||||
return
|
||||
if not isinstance(value, str):
|
||||
return
|
||||
final_key = path[-1].lower().replace("-", "_") if path else ""
|
||||
for index, pattern in enumerate(SENSITIVE_PATTERNS):
|
||||
if index == len(SENSITIVE_PATTERNS) - 1 and (
|
||||
final_key in HASH_VALUE_KEYS
|
||||
or final_key.endswith("_digest")
|
||||
or final_key.endswith("_sha256")
|
||||
):
|
||||
continue
|
||||
if pattern.search(value):
|
||||
raise GateError(f"potential identifying value at {'.'.join(path)}")
|
||||
|
||||
|
||||
def validate_manifest(manifest: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
if manifest.get("schema_version") != SCHEMA_VERSION:
|
||||
raise GateError("unsupported scenario schema_version")
|
||||
scenarios = manifest.get("scenarios")
|
||||
if not isinstance(scenarios, list) or not scenarios:
|
||||
raise GateError("scenario manifest must contain scenarios")
|
||||
by_id: dict[str, dict[str, Any]] = {}
|
||||
for scenario in scenarios:
|
||||
if not isinstance(scenario, dict):
|
||||
raise GateError("each scenario must be an object")
|
||||
scenario_id = scenario.get("id")
|
||||
if not isinstance(scenario_id, str) or not re.fullmatch(
|
||||
r"[A-Z0-9]{3}-\d{3}", scenario_id
|
||||
):
|
||||
raise GateError(f"invalid scenario id: {scenario_id!r}")
|
||||
if scenario_id in by_id:
|
||||
raise GateError(f"duplicate scenario id: {scenario_id}")
|
||||
if scenario.get("category") not in {
|
||||
"transport",
|
||||
"android-platform",
|
||||
"android-to-android",
|
||||
"cross-client",
|
||||
"endurance",
|
||||
}:
|
||||
raise GateError(f"invalid category for {scenario_id}")
|
||||
if scenario.get("required") is not True:
|
||||
raise GateError(f"release scenario {scenario_id} must be required")
|
||||
if not scenario.get("participants") or not scenario.get("evidence"):
|
||||
raise GateError(f"scenario {scenario_id} lacks participants or evidence")
|
||||
by_id[scenario_id] = scenario
|
||||
validate_privacy(manifest)
|
||||
return by_id
|
||||
|
||||
|
||||
def validate_matrix(
|
||||
matrix: dict[str, Any],
|
||||
expected_commit: str | None = None,
|
||||
*,
|
||||
allow_placeholders: bool = False,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
if matrix.get("schema_version") != SCHEMA_VERSION:
|
||||
raise GateError("unsupported matrix schema_version")
|
||||
commit = matrix.get("commit")
|
||||
if not isinstance(commit, str) or not COMMIT_RE.fullmatch(commit):
|
||||
raise GateError("matrix commit must be a lowercase full Git commit")
|
||||
if expected_commit is not None and commit != expected_commit:
|
||||
raise GateError("matrix commit does not match the release candidate")
|
||||
clients = matrix.get("clients")
|
||||
required_clients = {"android-current", "android-legacy", "ios-current"}
|
||||
if not isinstance(clients, dict) or not required_clients.issubset(clients):
|
||||
raise GateError("matrix must version current Android, legacy Android, and current iOS")
|
||||
for client_name in required_clients:
|
||||
client = clients.get(client_name)
|
||||
if not isinstance(client, dict) or not isinstance(client.get("version"), str):
|
||||
raise GateError(f"{client_name} must declare a version")
|
||||
if not allow_placeholders and client["version"].startswith("replace-with-"):
|
||||
raise GateError(f"{client_name} still contains a template version")
|
||||
current_commit = clients["android-current"].get("commit")
|
||||
if current_commit != commit and not (allow_placeholders and commit == "0" * 40):
|
||||
raise GateError("current Android client commit must match the matrix commit")
|
||||
lab_capabilities = matrix.get("lab_capabilities")
|
||||
if not isinstance(lab_capabilities, list) or any(
|
||||
not isinstance(capability, str) or not SAFE_ID_RE.fullmatch(capability)
|
||||
for capability in lab_capabilities
|
||||
):
|
||||
raise GateError("matrix must declare logical lab_capabilities")
|
||||
devices = matrix.get("devices")
|
||||
if not isinstance(devices, list):
|
||||
raise GateError("matrix devices must be a list")
|
||||
by_alias: dict[str, dict[str, Any]] = {}
|
||||
for device in devices:
|
||||
if not isinstance(device, dict):
|
||||
raise GateError("each device must be an object")
|
||||
alias = device.get("alias")
|
||||
if not isinstance(alias, str) or not SAFE_ID_RE.fullmatch(alias):
|
||||
raise GateError(f"invalid logical device alias: {alias!r}")
|
||||
if alias in by_alias:
|
||||
raise GateError(f"duplicate device alias: {alias}")
|
||||
if device.get("physical") is not True:
|
||||
raise GateError(f"{alias} is not a physical device")
|
||||
if device.get("platform") not in {"android", "ios"}:
|
||||
raise GateError(f"{alias} has an unsupported platform")
|
||||
if not isinstance(device.get("capabilities"), list):
|
||||
raise GateError(f"{alias} must declare capabilities")
|
||||
if not allow_placeholders and (
|
||||
not isinstance(device.get("model"), str)
|
||||
or device["model"].startswith("replace-with-")
|
||||
):
|
||||
raise GateError(f"{alias} still contains template values")
|
||||
by_alias[alias] = device
|
||||
android = [device for device in devices if device.get("platform") == "android"]
|
||||
if len(android) < 3:
|
||||
raise GateError("three physical Android devices are required for relay scenarios")
|
||||
api_levels = {device.get("api_level") for device in android}
|
||||
manufacturers = {device.get("manufacturer_class") for device in android}
|
||||
if len(api_levels) < 2 or not all(isinstance(level, int) for level in api_levels):
|
||||
raise GateError("Android matrix must cover at least two API levels")
|
||||
if len(manufacturers) < 2 or None in manufacturers:
|
||||
raise GateError("Android matrix must cover at least two manufacturer classes")
|
||||
for device in android:
|
||||
capabilities = set(device["capabilities"])
|
||||
if not {"ble-central", "ble-peripheral"}.issubset(capabilities):
|
||||
raise GateError(f"{device['alias']} lacks required BLE roles")
|
||||
if not any("wifi-aware" in device["capabilities"] for device in android):
|
||||
raise GateError("at least one Android device must support Wi-Fi Aware")
|
||||
if not any(device.get("platform") == "ios" for device in devices):
|
||||
raise GateError("a physical iOS device is required")
|
||||
validate_privacy(matrix)
|
||||
return by_alias
|
||||
|
||||
|
||||
def _fixture_bytes(seed: bytes, size: int) -> bytes:
|
||||
output = bytearray()
|
||||
counter = 0
|
||||
while len(output) < size:
|
||||
output.extend(hashlib.sha256(seed + counter.to_bytes(4, "big")).digest())
|
||||
counter += 1
|
||||
return bytes(output[:size])
|
||||
|
||||
|
||||
def create_fixtures(directory: Path, run_id: str) -> dict[str, Any]:
|
||||
fixture_directory = directory / "fixtures"
|
||||
fixture_directory.mkdir()
|
||||
definitions = (
|
||||
("empty.bin", 0, False),
|
||||
("small.bin", 4 * 1024, False),
|
||||
("lab-résumé-秘密.bin", 256 * 1024, False),
|
||||
("maximum.bin", MAX_FILE_SIZE_BYTES, True),
|
||||
("oversized.bin", MAX_FILE_SIZE_BYTES + 1, True),
|
||||
)
|
||||
fixtures: list[dict[str, Any]] = []
|
||||
for name, size, sparse in definitions:
|
||||
path = fixture_directory / name
|
||||
if sparse:
|
||||
with path.open("wb") as stream:
|
||||
stream.truncate(size)
|
||||
else:
|
||||
path.write_bytes(_fixture_bytes(run_id.encode("utf-8"), size))
|
||||
fixtures.append(
|
||||
{
|
||||
"name": name,
|
||||
"size_bytes": size,
|
||||
"sparse": sparse,
|
||||
"fixture_sha256": sha256_file(path),
|
||||
}
|
||||
)
|
||||
manifest = {"schema_version": SCHEMA_VERSION, "fixtures": fixtures}
|
||||
write_json(fixture_directory / "manifest.json", manifest)
|
||||
return manifest
|
||||
|
||||
|
||||
def initialize_run(
|
||||
manifest: dict[str, Any],
|
||||
matrix: dict[str, Any],
|
||||
output: Path,
|
||||
commit: str,
|
||||
run_id: str,
|
||||
*,
|
||||
started_at: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
scenarios = validate_manifest(manifest)
|
||||
devices = validate_matrix(matrix, commit)
|
||||
if not SAFE_ID_RE.fullmatch(run_id):
|
||||
raise GateError("run id must be a non-identifying lowercase logical id")
|
||||
missing_aliases = {
|
||||
participant
|
||||
for scenario in scenarios.values()
|
||||
for participant in scenario["participants"]
|
||||
if participant not in devices
|
||||
}
|
||||
if missing_aliases:
|
||||
raise GateError(f"matrix lacks scenario aliases: {sorted(missing_aliases)}")
|
||||
for scenario_id, scenario in scenarios.items():
|
||||
available = set(matrix["lab_capabilities"])
|
||||
for participant in scenario["participants"]:
|
||||
available.update(devices[participant]["capabilities"])
|
||||
missing_capabilities = set(scenario["capabilities"]) - available
|
||||
if missing_capabilities:
|
||||
raise GateError(
|
||||
f"{scenario_id} lacks capabilities: {sorted(missing_capabilities)}"
|
||||
)
|
||||
output.mkdir(parents=True, exist_ok=False)
|
||||
write_json(output / "manifest.json", manifest)
|
||||
write_json(output / "matrix.json", matrix)
|
||||
fixtures = create_fixtures(output, run_id)
|
||||
results = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"run_id": run_id,
|
||||
"commit": commit,
|
||||
"started_at": started_at or utc_now(),
|
||||
"completed_at": None,
|
||||
"scenario_manifest_sha256": canonical_json_digest(manifest),
|
||||
"fixture_manifest_sha256": canonical_json_digest(fixtures),
|
||||
"scenario_results": {
|
||||
scenario_id: {
|
||||
"status": "pending",
|
||||
"participants": scenario["participants"],
|
||||
"evidence": {},
|
||||
"reason_code": None,
|
||||
"updated_at": None,
|
||||
"history": [],
|
||||
}
|
||||
for scenario_id, scenario in scenarios.items()
|
||||
},
|
||||
}
|
||||
write_json(output / "results.json", results)
|
||||
(output / "trace.jsonl").write_text("", encoding="utf-8")
|
||||
return results
|
||||
|
||||
|
||||
def _parse_scalar(value: str) -> Any:
|
||||
lowered = value.lower()
|
||||
if lowered in {"true", "false"}:
|
||||
return lowered == "true"
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
return value
|
||||
|
||||
|
||||
def parse_evidence(values: Iterable[str]) -> dict[str, Any]:
|
||||
evidence: dict[str, Any] = {}
|
||||
for value in values:
|
||||
if "=" not in value:
|
||||
raise GateError("evidence must use key=value")
|
||||
key, raw = value.split("=", 1)
|
||||
if not SAFE_ID_RE.fullmatch(key):
|
||||
raise GateError(f"invalid evidence key: {key!r}")
|
||||
evidence[key] = _parse_scalar(raw)
|
||||
validate_privacy(evidence)
|
||||
return evidence
|
||||
|
||||
|
||||
def record_result(
|
||||
run_directory: Path,
|
||||
scenario_id: str,
|
||||
status: str,
|
||||
evidence: dict[str, Any],
|
||||
reason_code: str | None,
|
||||
*,
|
||||
updated_at: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
manifest = load_json(run_directory / "manifest.json")
|
||||
scenarios = validate_manifest(manifest)
|
||||
if scenario_id not in scenarios:
|
||||
raise GateError(f"unknown scenario: {scenario_id}")
|
||||
if status not in RESULT_STATUSES - {"pending"}:
|
||||
raise GateError(f"invalid terminal status: {status}")
|
||||
if reason_code is not None and not SAFE_ID_RE.fullmatch(reason_code):
|
||||
raise GateError("reason code must be a non-identifying stable code")
|
||||
if status == "pass" and not evidence:
|
||||
raise GateError("passing a scenario requires structured evidence")
|
||||
if status == "pass" and reason_code is not None:
|
||||
raise GateError("passing a scenario cannot have a failure reason code")
|
||||
if status in {"fail", "blocked", "unsupported"} and reason_code is None:
|
||||
raise GateError(f"{status} requires a stable reason code")
|
||||
if any(not SAFE_ID_RE.fullmatch(str(key)) for key in evidence):
|
||||
raise GateError("evidence keys must be stable logical identifiers")
|
||||
validate_privacy(evidence)
|
||||
results = load_json(run_directory / "results.json")
|
||||
result = results["scenario_results"][scenario_id]
|
||||
terminal_update = {
|
||||
"status": status,
|
||||
"evidence": evidence,
|
||||
"reason_code": reason_code,
|
||||
"updated_at": updated_at or utc_now(),
|
||||
}
|
||||
result.setdefault("history", []).append(terminal_update.copy())
|
||||
result.update(
|
||||
terminal_update
|
||||
)
|
||||
results["completed_at"] = (
|
||||
result["updated_at"]
|
||||
if all(
|
||||
item.get("status") == "pass"
|
||||
for item in results["scenario_results"].values()
|
||||
)
|
||||
else None
|
||||
)
|
||||
write_json(run_directory / "results.json", results)
|
||||
return results
|
||||
|
||||
|
||||
def append_trace_event(
|
||||
run_directory: Path,
|
||||
scenario_id: str,
|
||||
source_alias: str,
|
||||
event: str,
|
||||
outcome: str,
|
||||
reason_code: str | None,
|
||||
metrics: dict[str, Any],
|
||||
*,
|
||||
timestamp: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
manifest = load_json(run_directory / "manifest.json")
|
||||
matrix = load_json(run_directory / "matrix.json")
|
||||
scenarios = validate_manifest(manifest)
|
||||
devices = validate_matrix(matrix, allow_placeholders=False)
|
||||
if scenario_id not in scenarios:
|
||||
raise GateError(f"unknown scenario: {scenario_id}")
|
||||
if source_alias not in devices:
|
||||
raise GateError(f"unknown source alias: {source_alias}")
|
||||
for label, value in (("event", event), ("outcome", outcome)):
|
||||
if not SAFE_ID_RE.fullmatch(value):
|
||||
raise GateError(f"invalid {label}")
|
||||
if reason_code is not None and not SAFE_ID_RE.fullmatch(reason_code):
|
||||
raise GateError("invalid reason code")
|
||||
if any(not SAFE_ID_RE.fullmatch(str(key)) for key in metrics):
|
||||
raise GateError("trace metric keys must be stable logical identifiers")
|
||||
if any(
|
||||
not isinstance(value, (int, float, bool))
|
||||
or isinstance(value, float) and not math.isfinite(value)
|
||||
for value in metrics.values()
|
||||
):
|
||||
raise GateError("trace metrics must be numeric or boolean aggregates")
|
||||
trace = {
|
||||
"timestamp": timestamp or utc_now(),
|
||||
"scenario_id": scenario_id,
|
||||
"source_alias": source_alias,
|
||||
"event": event,
|
||||
"outcome": outcome,
|
||||
"reason_code": reason_code,
|
||||
"metrics": metrics,
|
||||
}
|
||||
validate_privacy(trace)
|
||||
with (run_directory / "trace.jsonl").open("a", encoding="utf-8") as stream:
|
||||
stream.write(json.dumps(trace, sort_keys=True, ensure_ascii=False) + "\n")
|
||||
return trace
|
||||
|
||||
|
||||
def _validate_trace(
|
||||
run_directory: Path,
|
||||
scenarios: dict[str, dict[str, Any]],
|
||||
devices: dict[str, dict[str, Any]],
|
||||
) -> tuple[int, set[str]]:
|
||||
path = run_directory / "trace.jsonl"
|
||||
if not path.exists():
|
||||
raise GateError("trace.jsonl is missing")
|
||||
count = 0
|
||||
traced_scenarios: set[str] = set()
|
||||
for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except json.JSONDecodeError as error:
|
||||
raise GateError(f"invalid trace line {line_number}") from error
|
||||
if not isinstance(event, dict):
|
||||
raise GateError(f"trace line {line_number} must be an object")
|
||||
if event.get("scenario_id") not in scenarios:
|
||||
raise GateError(f"trace line {line_number} has an unknown scenario")
|
||||
if event.get("source_alias") not in devices:
|
||||
raise GateError(f"trace line {line_number} has an unknown source")
|
||||
if set(event) != {
|
||||
"timestamp",
|
||||
"scenario_id",
|
||||
"source_alias",
|
||||
"event",
|
||||
"outcome",
|
||||
"reason_code",
|
||||
"metrics",
|
||||
}:
|
||||
raise GateError(f"trace line {line_number} has unexpected fields")
|
||||
if not isinstance(event.get("metrics"), dict) or any(
|
||||
not isinstance(value, (int, float, bool))
|
||||
for value in event["metrics"].values()
|
||||
):
|
||||
raise GateError(f"trace line {line_number} has invalid metrics")
|
||||
validate_privacy(event)
|
||||
count += 1
|
||||
traced_scenarios.add(event["scenario_id"])
|
||||
return count, traced_scenarios
|
||||
|
||||
|
||||
def validate_run(run_directory: Path, *, allow_incomplete: bool = False) -> dict[str, Any]:
|
||||
manifest = load_json(run_directory / "manifest.json")
|
||||
matrix = load_json(run_directory / "matrix.json")
|
||||
results = load_json(run_directory / "results.json")
|
||||
scenarios = validate_manifest(manifest)
|
||||
devices = validate_matrix(matrix, results.get("commit"))
|
||||
if results.get("schema_version") != SCHEMA_VERSION:
|
||||
raise GateError("unsupported results schema_version")
|
||||
if results.get("scenario_manifest_sha256") != canonical_json_digest(manifest):
|
||||
raise GateError("scenario manifest changed after run initialization")
|
||||
fixture_manifest = load_json(run_directory / "fixtures" / "manifest.json")
|
||||
if results.get("fixture_manifest_sha256") != canonical_json_digest(fixture_manifest):
|
||||
raise GateError("fixture manifest changed after run initialization")
|
||||
actual = results.get("scenario_results")
|
||||
if not isinstance(actual, dict) or set(actual) != set(scenarios):
|
||||
raise GateError("results must contain exactly every declared scenario")
|
||||
validate_privacy(results)
|
||||
for scenario_id, scenario in scenarios.items():
|
||||
result = actual[scenario_id]
|
||||
if result.get("participants") != scenario["participants"]:
|
||||
raise GateError(f"{scenario_id} participants changed")
|
||||
if any(alias not in devices for alias in result["participants"]):
|
||||
raise GateError(f"{scenario_id} references an unknown device")
|
||||
available = set(matrix["lab_capabilities"])
|
||||
for participant in result["participants"]:
|
||||
available.update(devices[participant]["capabilities"])
|
||||
missing_capabilities = set(scenario["capabilities"]) - available
|
||||
if missing_capabilities:
|
||||
raise GateError(
|
||||
f"{scenario_id} lacks capabilities: {sorted(missing_capabilities)}"
|
||||
)
|
||||
status = result.get("status")
|
||||
if status not in RESULT_STATUSES:
|
||||
raise GateError(f"{scenario_id} has invalid status")
|
||||
if not allow_incomplete and status != "pass":
|
||||
raise GateError(f"{scenario_id} is not passing: {status}")
|
||||
if status == "pass":
|
||||
evidence = result.get("evidence")
|
||||
missing = set(scenario["evidence"]) - set(evidence or {})
|
||||
if missing:
|
||||
raise GateError(f"{scenario_id} lacks evidence: {sorted(missing)}")
|
||||
if scenario.get("minimum_duration_minutes") is not None and (
|
||||
evidence.get("duration-minutes", 0)
|
||||
< scenario["minimum_duration_minutes"]
|
||||
):
|
||||
raise GateError(f"{scenario_id} did not meet minimum duration")
|
||||
if scenario.get("minimum_cycles") is not None and (
|
||||
evidence.get("cycle-count", 0) < scenario["minimum_cycles"]
|
||||
):
|
||||
raise GateError(f"{scenario_id} did not meet minimum cycles")
|
||||
trace_events, traced_scenarios = _validate_trace(run_directory, scenarios, devices)
|
||||
if not allow_incomplete:
|
||||
missing_traces = set(scenarios) - traced_scenarios
|
||||
if missing_traces:
|
||||
raise GateError(
|
||||
f"passing scenarios lack structured traces: {sorted(missing_traces)}"
|
||||
)
|
||||
if not results.get("completed_at"):
|
||||
raise GateError("complete results must record completed_at")
|
||||
summary = {
|
||||
status: sum(
|
||||
1 for result in actual.values() if result.get("status") == status
|
||||
)
|
||||
for status in sorted(RESULT_STATUSES)
|
||||
}
|
||||
summary["trace_events"] = trace_events
|
||||
summary["complete"] = all(
|
||||
result.get("status") == "pass" for result in actual.values()
|
||||
)
|
||||
return summary
|
||||
|
||||
|
||||
def render_summary(run_directory: Path) -> str:
|
||||
results = load_json(run_directory / "results.json")
|
||||
summary = validate_run(run_directory, allow_incomplete=True)
|
||||
rows = [
|
||||
"# Physical release-gate result",
|
||||
"",
|
||||
f"- Run: `{results['run_id']}`",
|
||||
f"- Commit: `{results['commit']}`",
|
||||
f"- Complete: `{str(summary['complete']).lower()}`",
|
||||
f"- Trace events: {summary['trace_events']}",
|
||||
"",
|
||||
"| Status | Count |",
|
||||
"|---|---:|",
|
||||
]
|
||||
rows.extend(
|
||||
f"| {status} | {summary[status]} |" for status in sorted(RESULT_STATUSES)
|
||||
)
|
||||
return "\n".join(rows) + "\n"
|
||||
|
||||
|
||||
def create_bundle(run_directory: Path, output: Path) -> None:
|
||||
validate_run(run_directory)
|
||||
if output.exists():
|
||||
raise GateError("refusing to overwrite an existing release-gate bundle")
|
||||
members = [
|
||||
Path("manifest.json"),
|
||||
Path("matrix.json"),
|
||||
Path("results.json"),
|
||||
Path("trace.jsonl"),
|
||||
Path("fixtures/manifest.json"),
|
||||
]
|
||||
generated = {"summary.md": render_summary(run_directory).encode("utf-8")}
|
||||
checksums: list[str] = []
|
||||
payloads: dict[str, bytes] = {}
|
||||
for member in members:
|
||||
payload = (run_directory / member).read_bytes()
|
||||
payloads[member.as_posix()] = payload
|
||||
payloads.update(generated)
|
||||
for name in sorted(payloads):
|
||||
checksums.append(f"{sha256_bytes(payloads[name])} {name}")
|
||||
payloads["SHA256SUMS"] = ("\n".join(checksums) + "\n").encode("utf-8")
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
for name in sorted(payloads):
|
||||
info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
info.external_attr = 0o100644 << 16
|
||||
archive.writestr(info, payloads[name])
|
||||
|
||||
|
||||
def _default_manifest() -> Path:
|
||||
return Path(__file__).with_name("scenarios.json")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
manifest = subparsers.add_parser("validate-manifest")
|
||||
manifest.add_argument("--manifest", type=Path, default=_default_manifest())
|
||||
|
||||
matrix = subparsers.add_parser("validate-matrix")
|
||||
matrix.add_argument("--matrix", type=Path, required=True)
|
||||
matrix.add_argument("--commit")
|
||||
matrix.add_argument("--allow-template", action="store_true")
|
||||
|
||||
initialize = subparsers.add_parser("init")
|
||||
initialize.add_argument("--manifest", type=Path, default=_default_manifest())
|
||||
initialize.add_argument("--matrix", type=Path, required=True)
|
||||
initialize.add_argument("--output", type=Path, required=True)
|
||||
initialize.add_argument("--commit", required=True)
|
||||
initialize.add_argument("--run-id", required=True)
|
||||
|
||||
record = subparsers.add_parser("record")
|
||||
record.add_argument("--run", type=Path, required=True)
|
||||
record.add_argument("--scenario", required=True)
|
||||
record.add_argument("--status", choices=sorted(RESULT_STATUSES - {"pending"}), required=True)
|
||||
record.add_argument("--evidence", action="append", default=[])
|
||||
record.add_argument("--reason-code")
|
||||
|
||||
trace = subparsers.add_parser("trace")
|
||||
trace.add_argument("--run", type=Path, required=True)
|
||||
trace.add_argument("--scenario", required=True)
|
||||
trace.add_argument("--source", required=True)
|
||||
trace.add_argument("--event", required=True)
|
||||
trace.add_argument("--outcome", required=True)
|
||||
trace.add_argument("--reason-code")
|
||||
trace.add_argument("--metric", action="append", default=[])
|
||||
|
||||
validate = subparsers.add_parser("validate")
|
||||
validate.add_argument("--run", type=Path, required=True)
|
||||
validate.add_argument("--allow-incomplete", action="store_true")
|
||||
|
||||
summary = subparsers.add_parser("summary")
|
||||
summary.add_argument("--run", type=Path, required=True)
|
||||
|
||||
bundle = subparsers.add_parser("bundle")
|
||||
bundle.add_argument("--run", type=Path, required=True)
|
||||
bundle.add_argument("--output", type=Path, required=True)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
try:
|
||||
if args.command == "validate-manifest":
|
||||
scenarios = validate_manifest(load_json(args.manifest))
|
||||
print(f"valid scenarios: {len(scenarios)}")
|
||||
elif args.command == "validate-matrix":
|
||||
devices = validate_matrix(
|
||||
load_json(args.matrix),
|
||||
args.commit,
|
||||
allow_placeholders=args.allow_template,
|
||||
)
|
||||
print(f"valid devices: {len(devices)}")
|
||||
elif args.command == "init":
|
||||
initialize_run(
|
||||
load_json(args.manifest),
|
||||
load_json(args.matrix),
|
||||
args.output,
|
||||
args.commit,
|
||||
args.run_id,
|
||||
)
|
||||
print(args.output)
|
||||
elif args.command == "record":
|
||||
record_result(
|
||||
args.run,
|
||||
args.scenario,
|
||||
args.status,
|
||||
parse_evidence(args.evidence),
|
||||
args.reason_code,
|
||||
)
|
||||
elif args.command == "trace":
|
||||
append_trace_event(
|
||||
args.run,
|
||||
args.scenario,
|
||||
args.source,
|
||||
args.event,
|
||||
args.outcome,
|
||||
args.reason_code,
|
||||
parse_evidence(args.metric),
|
||||
)
|
||||
elif args.command == "validate":
|
||||
print(
|
||||
json.dumps(
|
||||
validate_run(args.run, allow_incomplete=args.allow_incomplete),
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
elif args.command == "summary":
|
||||
print(render_summary(args.run), end="")
|
||||
elif args.command == "bundle":
|
||||
create_bundle(args.run, args.output)
|
||||
print(args.output)
|
||||
return 0
|
||||
except GateError as error:
|
||||
print(f"release gate error: {error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
251
tools/release_gate/scenarios.json
Normal file
251
tools/release_gate/scenarios.json
Normal file
@ -0,0 +1,251 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"scenario_version": "1.0",
|
||||
"scenarios": [
|
||||
{
|
||||
"id": "TRN-001",
|
||||
"category": "transport",
|
||||
"title": "Complete the physical BLE, GATT, MTU, Wi-Fi Aware, and failover matrix",
|
||||
"participants": ["android-low", "android-current", "android-aware"],
|
||||
"required": true,
|
||||
"capabilities": ["ble-central", "ble-peripheral", "wifi-aware"],
|
||||
"evidence": ["matrix-check-count", "mtu-case-count", "failure-injection-count", "shutdown-check-count"]
|
||||
},
|
||||
{
|
||||
"id": "AND-001",
|
||||
"category": "android-platform",
|
||||
"title": "Complete API-level, manufacturer, permission-revocation, and background gates",
|
||||
"participants": ["android-low", "android-current", "android-aware"],
|
||||
"required": true,
|
||||
"capabilities": ["ble-central", "ble-peripheral", "wifi-aware"],
|
||||
"evidence": ["api-level-count", "manufacturer-count", "permission-revocation-count", "background-case-count"]
|
||||
},
|
||||
{
|
||||
"id": "A2A-001",
|
||||
"category": "android-to-android",
|
||||
"title": "Discover, connect, announce, disconnect, and reconnect",
|
||||
"participants": ["android-low", "android-current"],
|
||||
"required": true,
|
||||
"capabilities": ["ble-central", "ble-peripheral"],
|
||||
"evidence": ["connection-transitions", "packet-correlation-count", "failure-reasons"]
|
||||
},
|
||||
{
|
||||
"id": "A2A-002",
|
||||
"category": "android-to-android",
|
||||
"title": "Exchange public and private messages in both directions",
|
||||
"participants": ["android-low", "android-current"],
|
||||
"required": true,
|
||||
"capabilities": ["ble-central", "ble-peripheral"],
|
||||
"evidence": ["message-counts", "packet-correlation-count"]
|
||||
},
|
||||
{
|
||||
"id": "A2A-003",
|
||||
"category": "android-to-android",
|
||||
"title": "Advance delivery and read receipts",
|
||||
"participants": ["android-low", "android-current"],
|
||||
"required": true,
|
||||
"capabilities": ["ble-central", "ble-peripheral"],
|
||||
"evidence": ["receipt-transitions"]
|
||||
},
|
||||
{
|
||||
"id": "A2A-004",
|
||||
"category": "android-to-android",
|
||||
"title": "Transfer image, audio, and generic files at multiple sizes",
|
||||
"participants": ["android-low", "android-current"],
|
||||
"required": true,
|
||||
"capabilities": ["ble-central", "ble-peripheral"],
|
||||
"evidence": ["fixture-digests", "transfer-progress", "delivery-digests"]
|
||||
},
|
||||
{
|
||||
"id": "A2A-005",
|
||||
"category": "android-to-android",
|
||||
"title": "Relay across three Android devices",
|
||||
"participants": ["android-low", "android-current", "android-relay"],
|
||||
"required": true,
|
||||
"capabilities": ["ble-central", "ble-peripheral"],
|
||||
"evidence": ["route-transitions", "ttl-values", "packet-correlation-count"]
|
||||
},
|
||||
{
|
||||
"id": "A2A-006",
|
||||
"category": "android-to-android",
|
||||
"title": "Partition and recover store-and-forward delivery",
|
||||
"participants": ["android-low", "android-current", "android-relay"],
|
||||
"required": true,
|
||||
"capabilities": ["ble-central", "ble-peripheral"],
|
||||
"evidence": ["partition-window", "queue-counts", "delivery-counts"]
|
||||
},
|
||||
{
|
||||
"id": "A2A-007",
|
||||
"category": "android-to-android",
|
||||
"title": "Toggle Bluetooth during active transfers",
|
||||
"participants": ["android-low", "android-current"],
|
||||
"required": true,
|
||||
"capabilities": ["ble-central", "ble-peripheral"],
|
||||
"evidence": ["radio-transitions", "transfer-terminal-state"]
|
||||
},
|
||||
{
|
||||
"id": "A2A-008",
|
||||
"category": "android-to-android",
|
||||
"title": "Lock screens and background both apps during mesh operation",
|
||||
"participants": ["android-low", "android-current"],
|
||||
"required": true,
|
||||
"capabilities": ["ble-central", "ble-peripheral"],
|
||||
"evidence": ["lifecycle-transitions", "service-state", "delivery-counts"]
|
||||
},
|
||||
{
|
||||
"id": "A2A-009",
|
||||
"category": "android-to-android",
|
||||
"title": "Kill and recreate one process",
|
||||
"participants": ["android-low", "android-current"],
|
||||
"required": true,
|
||||
"capabilities": ["ble-central", "ble-peripheral"],
|
||||
"evidence": ["process-generation", "restored-state", "reconnect-count"]
|
||||
},
|
||||
{
|
||||
"id": "A2A-010",
|
||||
"category": "android-to-android",
|
||||
"title": "Resolve simultaneous and duplicate links",
|
||||
"participants": ["android-low", "android-current"],
|
||||
"required": true,
|
||||
"capabilities": ["ble-central", "ble-peripheral"],
|
||||
"evidence": ["candidate-links", "canonical-link-count"]
|
||||
},
|
||||
{
|
||||
"id": "A2A-011",
|
||||
"category": "android-to-android",
|
||||
"title": "Fail over through Wi-Fi Aware",
|
||||
"participants": ["android-current", "android-aware"],
|
||||
"required": true,
|
||||
"capabilities": ["wifi-aware"],
|
||||
"evidence": ["transport-selection", "failover-window", "delivery-counts"]
|
||||
},
|
||||
{
|
||||
"id": "XCL-001",
|
||||
"category": "cross-client",
|
||||
"title": "Current Android interoperates with current iOS",
|
||||
"participants": ["android-current", "ios-current"],
|
||||
"required": true,
|
||||
"capabilities": ["ble-central", "ble-peripheral"],
|
||||
"evidence": ["client-versions", "payload-counts"]
|
||||
},
|
||||
{
|
||||
"id": "XCL-002",
|
||||
"category": "cross-client",
|
||||
"title": "Current rewrite contracts interoperate with the last supported Android",
|
||||
"participants": ["android-current", "android-legacy"],
|
||||
"required": true,
|
||||
"capabilities": ["ble-central", "ble-peripheral"],
|
||||
"evidence": ["client-versions", "payload-counts"]
|
||||
},
|
||||
{
|
||||
"id": "XCL-003",
|
||||
"category": "cross-client",
|
||||
"title": "Legacy announcements without capabilities remain compatible",
|
||||
"participants": ["android-current", "android-legacy"],
|
||||
"required": true,
|
||||
"capabilities": ["ble-central", "ble-peripheral"],
|
||||
"evidence": ["announcement-version", "peer-state"]
|
||||
},
|
||||
{
|
||||
"id": "XCL-004",
|
||||
"category": "cross-client",
|
||||
"title": "Older clients safely ignore current capability announcements",
|
||||
"participants": ["android-current", "android-legacy"],
|
||||
"required": true,
|
||||
"capabilities": ["ble-central", "ble-peripheral"],
|
||||
"evidence": ["announcement-version", "peer-state"]
|
||||
},
|
||||
{
|
||||
"id": "XCL-005",
|
||||
"category": "cross-client",
|
||||
"title": "Canonical private media and prerelease decode-only alias interoperate",
|
||||
"participants": ["android-current", "ios-current"],
|
||||
"required": true,
|
||||
"capabilities": ["ble-central", "ble-peripheral"],
|
||||
"evidence": ["wire-type", "delivery-digests", "downgrade-decision"]
|
||||
},
|
||||
{
|
||||
"id": "XCL-006",
|
||||
"category": "cross-client",
|
||||
"title": "Golden vectors match across implementations",
|
||||
"participants": ["android-current", "ios-current", "android-legacy"],
|
||||
"required": true,
|
||||
"capabilities": [],
|
||||
"evidence": ["vector-manifest-digest", "comparison-counts"]
|
||||
},
|
||||
{
|
||||
"id": "XCL-007",
|
||||
"category": "cross-client",
|
||||
"title": "Malformed and unauthenticated inputs are rejected consistently",
|
||||
"participants": ["android-current", "ios-current", "android-legacy"],
|
||||
"required": true,
|
||||
"capabilities": [],
|
||||
"evidence": ["corpus-digest", "rejection-counts", "crash-count"]
|
||||
},
|
||||
{
|
||||
"id": "XCL-008",
|
||||
"category": "cross-client",
|
||||
"title": "Nostr fallback messages and receipts interoperate",
|
||||
"participants": ["android-current", "ios-current"],
|
||||
"required": true,
|
||||
"capabilities": ["local-relay", "tor"],
|
||||
"evidence": ["relay-fixture", "event-id-count", "receipt-counts"]
|
||||
},
|
||||
{
|
||||
"id": "END-001",
|
||||
"category": "endurance",
|
||||
"title": "Multi-hour discovery, connect, and disconnect soak",
|
||||
"participants": ["android-low", "android-current"],
|
||||
"required": true,
|
||||
"capabilities": ["ble-central", "ble-peripheral"],
|
||||
"minimum_duration_minutes": 240,
|
||||
"evidence": ["duration-minutes", "connection-counts", "failure-counts"]
|
||||
},
|
||||
{
|
||||
"id": "END-002",
|
||||
"category": "endurance",
|
||||
"title": "Repeated large-transfer and cancellation cycles",
|
||||
"participants": ["android-low", "android-current"],
|
||||
"required": true,
|
||||
"capabilities": ["ble-central", "ble-peripheral"],
|
||||
"minimum_cycles": 50,
|
||||
"evidence": ["cycle-count", "delivery-counts", "cancellation-counts"]
|
||||
},
|
||||
{
|
||||
"id": "END-003",
|
||||
"category": "endurance",
|
||||
"title": "Resource growth remains bounded",
|
||||
"participants": ["android-low", "android-current"],
|
||||
"required": true,
|
||||
"capabilities": ["ble-central", "ble-peripheral"],
|
||||
"evidence": ["memory-samples", "thread-samples", "fd-samples", "wakelock-samples", "battery-samples"]
|
||||
},
|
||||
{
|
||||
"id": "END-004",
|
||||
"category": "endurance",
|
||||
"title": "Foreground service survives with screens off",
|
||||
"participants": ["android-low", "android-current"],
|
||||
"required": true,
|
||||
"capabilities": ["ble-central", "ble-peripheral"],
|
||||
"evidence": ["service-state", "screen-state", "delivery-counts"]
|
||||
},
|
||||
{
|
||||
"id": "END-005",
|
||||
"category": "endurance",
|
||||
"title": "Nostr survives network and Tor availability changes",
|
||||
"participants": ["android-current"],
|
||||
"required": true,
|
||||
"capabilities": ["local-relay", "tor"],
|
||||
"evidence": ["network-transitions", "tor-transitions", "receipt-counts"]
|
||||
},
|
||||
{
|
||||
"id": "END-006",
|
||||
"category": "endurance",
|
||||
"title": "Shutdown releases radios, sockets, jobs, and wake locks",
|
||||
"participants": ["android-low", "android-current"],
|
||||
"required": true,
|
||||
"capabilities": ["ble-central", "ble-peripheral"],
|
||||
"evidence": ["resource-terminal-state", "late-callback-count"]
|
||||
}
|
||||
]
|
||||
}
|
||||
367
tools/release_gate/test_release_gate.py
Normal file
367
tools/release_gate/test_release_gate.py
Normal file
@ -0,0 +1,367 @@
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from tools.release_gate import android_lab
|
||||
from tools.release_gate.release_gate import (
|
||||
GateError,
|
||||
append_trace_event,
|
||||
canonical_json_digest,
|
||||
create_bundle,
|
||||
initialize_run,
|
||||
load_json,
|
||||
parse_evidence,
|
||||
record_result,
|
||||
validate_manifest,
|
||||
validate_matrix,
|
||||
validate_privacy,
|
||||
validate_run,
|
||||
)
|
||||
|
||||
|
||||
COMMIT = "1" * 40
|
||||
TOOL_DIRECTORY = Path(__file__).parent
|
||||
|
||||
|
||||
def valid_matrix():
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"commit": COMMIT,
|
||||
"clients": {
|
||||
"android-current": {"version": "2.0.0-rc1", "commit": COMMIT},
|
||||
"android-legacy": {"version": "1.9.0"},
|
||||
"ios-current": {"version": "2.0.0"},
|
||||
},
|
||||
"lab_capabilities": ["local-relay", "tor"],
|
||||
"devices": [
|
||||
{
|
||||
"alias": "android-low",
|
||||
"platform": "android",
|
||||
"model": "model-low",
|
||||
"manufacturer_class": "vendor-a",
|
||||
"api_level": 28,
|
||||
"physical": True,
|
||||
"capabilities": ["ble-central", "ble-peripheral"],
|
||||
},
|
||||
{
|
||||
"alias": "android-current",
|
||||
"platform": "android",
|
||||
"model": "model-current",
|
||||
"manufacturer_class": "vendor-b",
|
||||
"api_level": 35,
|
||||
"physical": True,
|
||||
"capabilities": ["ble-central", "ble-peripheral", "wifi-aware"],
|
||||
},
|
||||
{
|
||||
"alias": "android-relay",
|
||||
"platform": "android",
|
||||
"model": "model-relay",
|
||||
"manufacturer_class": "vendor-c",
|
||||
"api_level": 33,
|
||||
"physical": True,
|
||||
"capabilities": ["ble-central", "ble-peripheral"],
|
||||
},
|
||||
{
|
||||
"alias": "android-aware",
|
||||
"platform": "android",
|
||||
"model": "model-aware",
|
||||
"manufacturer_class": "vendor-b",
|
||||
"api_level": 35,
|
||||
"physical": True,
|
||||
"capabilities": ["ble-central", "ble-peripheral", "wifi-aware"],
|
||||
},
|
||||
{
|
||||
"alias": "android-legacy",
|
||||
"platform": "android",
|
||||
"model": "model-legacy",
|
||||
"manufacturer_class": "vendor-a",
|
||||
"api_level": 28,
|
||||
"physical": True,
|
||||
"capabilities": ["ble-central", "ble-peripheral"],
|
||||
},
|
||||
{
|
||||
"alias": "ios-current",
|
||||
"platform": "ios",
|
||||
"model": "ios-model",
|
||||
"manufacturer_class": "apple",
|
||||
"physical": True,
|
||||
"capabilities": ["ble-central", "ble-peripheral"],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class ReleaseGateTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.manifest = load_json(TOOL_DIRECTORY / "scenarios.json")
|
||||
|
||||
def test_manifest_covers_every_release_category(self):
|
||||
scenarios = validate_manifest(self.manifest)
|
||||
self.assertEqual(27, len(scenarios))
|
||||
self.assertEqual(
|
||||
{
|
||||
"transport",
|
||||
"android-platform",
|
||||
"android-to-android",
|
||||
"cross-client",
|
||||
"endurance",
|
||||
},
|
||||
{scenario["category"] for scenario in scenarios.values()},
|
||||
)
|
||||
|
||||
def test_documented_matrix_template_is_schema_valid_but_not_runnable(self):
|
||||
template = load_json(TOOL_DIRECTORY / "device-matrix.example.json")
|
||||
self.assertEqual(6, len(validate_matrix(template, allow_placeholders=True)))
|
||||
with self.assertRaises(GateError):
|
||||
validate_matrix(template)
|
||||
|
||||
def test_matrix_enforces_distinct_api_manufacturer_and_counterpart_clients(self):
|
||||
matrix = valid_matrix()
|
||||
self.assertEqual(6, len(validate_matrix(matrix, COMMIT)))
|
||||
|
||||
for device in matrix["devices"]:
|
||||
if device["platform"] == "android":
|
||||
device["manufacturer_class"] = "one-vendor"
|
||||
with self.assertRaisesRegex(GateError, "manufacturer"):
|
||||
validate_matrix(matrix, COMMIT)
|
||||
|
||||
def test_privacy_policy_rejects_identifiers_paths_addresses_and_long_ids(self):
|
||||
rejected = (
|
||||
{"serial": "device-selector"},
|
||||
{"note": "/" + "home/operator/result"},
|
||||
{"note": "operator@example.test"},
|
||||
{"note": "192.0.2.1"},
|
||||
{"note": "aa:bb:cc:dd:ee:ff"},
|
||||
{"note": "0123456789abcdef"},
|
||||
)
|
||||
for value in rejected:
|
||||
with self.subTest(value=value), self.assertRaises(GateError):
|
||||
validate_privacy(value)
|
||||
validate_privacy({"commit": COMMIT, "packet-correlation-count": 3})
|
||||
|
||||
def test_initialize_creates_disposable_fixtures_and_pending_results(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
run = Path(temporary) / "rc-run"
|
||||
results = initialize_run(
|
||||
self.manifest,
|
||||
valid_matrix(),
|
||||
run,
|
||||
COMMIT,
|
||||
"rc-run",
|
||||
started_at="2026-01-01T00:00:00+00:00",
|
||||
)
|
||||
self.assertTrue(all(
|
||||
result["status"] == "pending"
|
||||
for result in results["scenario_results"].values()
|
||||
))
|
||||
fixtures = load_json(run / "fixtures" / "manifest.json")["fixtures"]
|
||||
sizes = {fixture["name"]: fixture["size_bytes"] for fixture in fixtures}
|
||||
self.assertEqual(0, sizes["empty.bin"])
|
||||
self.assertEqual(50 * 1024 * 1024, sizes["maximum.bin"])
|
||||
self.assertEqual(50 * 1024 * 1024 + 1, sizes["oversized.bin"])
|
||||
self.assertEqual(
|
||||
results["scenario_manifest_sha256"],
|
||||
canonical_json_digest(self.manifest),
|
||||
)
|
||||
summary = validate_run(run, allow_incomplete=True)
|
||||
self.assertEqual(27, summary["pending"])
|
||||
self.assertFalse(summary["complete"])
|
||||
|
||||
def test_record_requires_structured_evidence_and_rejects_pii(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
run = Path(temporary) / "rc-run"
|
||||
initialize_run(self.manifest, valid_matrix(), run, COMMIT, "rc-run")
|
||||
with self.assertRaises(GateError):
|
||||
record_result(run, "A2A-001", "pass", {}, None)
|
||||
with self.assertRaises(GateError):
|
||||
record_result(
|
||||
run,
|
||||
"A2A-001",
|
||||
"pass",
|
||||
{"connection-transitions": "operator@example.test"},
|
||||
None,
|
||||
)
|
||||
with self.assertRaisesRegex(GateError, "reason code"):
|
||||
record_result(run, "A2A-001", "fail", {}, None)
|
||||
recorded = record_result(
|
||||
run,
|
||||
"A2A-001",
|
||||
"blocked",
|
||||
{},
|
||||
"counterpart-unavailable",
|
||||
updated_at="2026-01-01T00:00:00+00:00",
|
||||
)
|
||||
self.assertEqual(
|
||||
["blocked"],
|
||||
[
|
||||
item["status"]
|
||||
for item in recorded["scenario_results"]["A2A-001"]["history"]
|
||||
],
|
||||
)
|
||||
|
||||
def test_complete_run_requires_all_evidence_traces_and_endurance_bounds(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
run = Path(temporary) / "rc-run"
|
||||
initialize_run(self.manifest, valid_matrix(), run, COMMIT, "rc-run")
|
||||
scenarios = validate_manifest(self.manifest)
|
||||
for scenario_id, scenario in scenarios.items():
|
||||
evidence = {key: 1 for key in scenario["evidence"]}
|
||||
if "duration-minutes" in evidence:
|
||||
evidence["duration-minutes"] = 240
|
||||
if "cycle-count" in evidence:
|
||||
evidence["cycle-count"] = 50
|
||||
record_result(
|
||||
run,
|
||||
scenario_id,
|
||||
"pass",
|
||||
evidence,
|
||||
None,
|
||||
updated_at="2026-01-01T04:00:00+00:00",
|
||||
)
|
||||
append_trace_event(
|
||||
run,
|
||||
scenario_id,
|
||||
scenario["participants"][0],
|
||||
"scenario-terminal",
|
||||
"pass",
|
||||
None,
|
||||
{"assertion-count": len(evidence)},
|
||||
timestamp="2026-01-01T04:00:00+00:00",
|
||||
)
|
||||
summary = validate_run(run)
|
||||
self.assertTrue(summary["complete"])
|
||||
self.assertEqual(27, summary["pass"])
|
||||
self.assertEqual(27, summary["trace_events"])
|
||||
|
||||
bundle = Path(temporary) / "release-gate.zip"
|
||||
create_bundle(run, bundle)
|
||||
with zipfile.ZipFile(bundle) as archive:
|
||||
self.assertEqual(
|
||||
{
|
||||
"SHA256SUMS",
|
||||
"fixtures/manifest.json",
|
||||
"manifest.json",
|
||||
"matrix.json",
|
||||
"results.json",
|
||||
"summary.md",
|
||||
"trace.jsonl",
|
||||
},
|
||||
set(archive.namelist()),
|
||||
)
|
||||
with self.assertRaisesRegex(GateError, "overwrite"):
|
||||
create_bundle(run, bundle)
|
||||
|
||||
def test_manifest_tampering_after_initialization_is_detected(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
run = Path(temporary) / "rc-run"
|
||||
initialize_run(self.manifest, valid_matrix(), run, COMMIT, "rc-run")
|
||||
manifest = load_json(run / "manifest.json")
|
||||
manifest["scenario_version"] = "tampered"
|
||||
(run / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||
with self.assertRaisesRegex(GateError, "changed"):
|
||||
validate_run(run, allow_incomplete=True)
|
||||
|
||||
def test_trace_accepts_only_aggregate_metrics(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
run = Path(temporary) / "rc-run"
|
||||
initialize_run(self.manifest, valid_matrix(), run, COMMIT, "rc-run")
|
||||
with self.assertRaisesRegex(GateError, "numeric"):
|
||||
append_trace_event(
|
||||
run,
|
||||
"A2A-001",
|
||||
"android-current",
|
||||
"packet",
|
||||
"observed",
|
||||
None,
|
||||
{"raw-packet": "payload"},
|
||||
)
|
||||
|
||||
def test_evidence_parser_is_typed_and_privacy_checked(self):
|
||||
self.assertEqual(
|
||||
{"count": 3, "ratio": 0.5, "clean": True},
|
||||
parse_evidence(["count=3", "ratio=0.5", "clean=true"]),
|
||||
)
|
||||
with self.assertRaises(GateError):
|
||||
parse_evidence(["note=10.0.0.1"])
|
||||
|
||||
@mock.patch("tools.release_gate.android_lab.find_adb", return_value="adb")
|
||||
def test_adb_device_count_never_returns_selectors(self, _find_adb):
|
||||
completed = subprocess.CompletedProcess(
|
||||
["adb", "devices"],
|
||||
0,
|
||||
"List of devices attached\nselector-one\tdevice\nselector-two\toffline\n",
|
||||
"",
|
||||
)
|
||||
count = android_lab.count_connected_devices(runner=lambda *args, **kwargs: completed)
|
||||
self.assertEqual(1, count)
|
||||
|
||||
@mock.patch("tools.release_gate.android_lab.run_adb")
|
||||
def test_adb_probe_emits_only_logical_device_metadata(self, run_adb):
|
||||
run_adb.side_effect = [
|
||||
"35",
|
||||
"feature:android.hardware.wifi.aware",
|
||||
"Vendor",
|
||||
"Model",
|
||||
]
|
||||
probe = android_lab.probe_device("ephemeral-selector", "android-current")
|
||||
self.assertEqual("android-current", probe["alias"])
|
||||
self.assertNotIn("serial", probe)
|
||||
self.assertIn("wifi-aware", probe["capabilities"])
|
||||
|
||||
@mock.patch("tools.release_gate.android_lab.run_adb")
|
||||
def test_disposable_cleanup_targets_the_real_application_id(self, run_adb):
|
||||
run_adb.side_effect = ["", "Success"]
|
||||
|
||||
android_lab.prepare_disposable_device("ephemeral-selector", confirmed=True)
|
||||
|
||||
self.assertEqual(
|
||||
[
|
||||
mock.call(
|
||||
"ephemeral-selector",
|
||||
["shell", "am", "force-stop", "com.bitchat.droid"],
|
||||
),
|
||||
mock.call(
|
||||
"ephemeral-selector",
|
||||
["shell", "pm", "clear", "com.bitchat.droid"],
|
||||
),
|
||||
],
|
||||
run_adb.call_args_list,
|
||||
)
|
||||
|
||||
@mock.patch("tools.release_gate.android_lab.run_adb")
|
||||
def test_resource_snapshot_returns_only_aggregate_metrics(self, run_adb):
|
||||
run_adb.side_effect = [
|
||||
"123",
|
||||
"TOTAL 2048",
|
||||
"7",
|
||||
"11",
|
||||
"WakeLock com.bitchat.droid\nWakeLock another.package",
|
||||
"level: 73",
|
||||
]
|
||||
metrics = android_lab.collect_resource_snapshot("ephemeral-selector")
|
||||
self.assertEqual(
|
||||
{
|
||||
"process-running": True,
|
||||
"total-pss-kb": 2048,
|
||||
"thread-count": 7,
|
||||
"fd-count": 11,
|
||||
"app-wakelock-count": 1,
|
||||
"battery-level-percent": 73,
|
||||
},
|
||||
metrics,
|
||||
)
|
||||
self.assertEqual(
|
||||
mock.call(
|
||||
"ephemeral-selector",
|
||||
["shell", "pidof", "com.bitchat.droid"],
|
||||
),
|
||||
run_adb.call_args_list[0],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
x
Reference in New Issue
Block a user