Off the record: edits, reactions and pins work, and none of them reach disk
`record()` and `history()` knew an off-the-record thread lives in memory. `applyEdit` and `applyReaction` did not. They read `vault.history`, found nothing there for such a thread, and returned false, so an edit or a reaction to an off-the-record message was silently dropped. Not leaked, dropped: the better of the two faults, and still a broken feature. `applyPin` went the other way and wrote the pin to the vault regardless, a record on disk naming the thread for a conversation whose one promise is that nothing reaches disk. One helper now mutates a thread's lines where they actually live, memory or vault, and both mutators use it; a pin on an off-the-record thread is held beside the thread in memory. The comment on `record()` claiming a single gate was enough for the writes it knew about, and these were the writes it did not. Falsified one at a time: each old behaviour restored fails exactly its own test and no other. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
4235754d0c
commit
6c4289ee42
@@ -749,15 +749,40 @@ class Session(
|
||||
* A private one is kept apart from the public tally, so the author can see
|
||||
* it was meant for them alone and it never reads as something the group saw.
|
||||
*/
|
||||
/**
|
||||
* Mutate a thread's lines where they actually live.
|
||||
*
|
||||
* An off-the-record thread is held in `ephemeral` and never reaches the
|
||||
* vault; `record()` knows that and `history()` knows that, and these two
|
||||
* did not. They read `vault.history`, found nothing there for such a
|
||||
* thread, and returned false: an edit or a reaction to an off-the-record
|
||||
* message was silently dropped. Not leaked, dropped, which is the better
|
||||
* of the two faults and still a broken feature. The block returns whether
|
||||
* it changed anything; the write-back happens only for a vaulted thread,
|
||||
* because the in-memory array IS the live one.
|
||||
*/
|
||||
private fun mutateHistory(
|
||||
threadId: String,
|
||||
block: (org.json.JSONArray) -> Boolean,
|
||||
): Boolean {
|
||||
val inMemory = ephemeral[threadId]
|
||||
val stored = inMemory ?: vault.history(threadId)
|
||||
val hit = block(stored)
|
||||
if (hit && inMemory == null) vault.replaceHistory(threadId, stored)
|
||||
return hit
|
||||
}
|
||||
|
||||
/** Pins for off-the-record threads, which must never reach the vault. */
|
||||
private val ephemeralPins = mutableMapOf<String, String>()
|
||||
|
||||
fun applyReaction(
|
||||
threadId: String,
|
||||
messageId: String,
|
||||
author: Long,
|
||||
emoji: String,
|
||||
private: Boolean = false,
|
||||
): Boolean {
|
||||
): Boolean = mutateHistory(threadId) { stored ->
|
||||
val key = if (private) "rxp" else "rx"
|
||||
val stored = vault.history(threadId)
|
||||
var hit = false
|
||||
for (i in 0 until stored.length()) {
|
||||
val row = stored.getJSONObject(i)
|
||||
@@ -781,8 +806,7 @@ class Session(
|
||||
if (rebuilt.length() > 0) row.put(key, rebuilt) else row.remove(key)
|
||||
break
|
||||
}
|
||||
if (hit) vault.replaceHistory(threadId, stored)
|
||||
return hit
|
||||
hit
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -794,8 +818,7 @@ class Session(
|
||||
author: Long,
|
||||
newText: String,
|
||||
at: Long = System.currentTimeMillis(),
|
||||
): Boolean {
|
||||
val stored = vault.history(threadId)
|
||||
): Boolean = mutateHistory(threadId) { stored ->
|
||||
var hit = false
|
||||
for (i in 0 until stored.length()) {
|
||||
val row = stored.getJSONObject(i)
|
||||
@@ -812,14 +835,14 @@ class Session(
|
||||
hit = true
|
||||
break
|
||||
}
|
||||
if (hit) vault.replaceHistory(threadId, stored)
|
||||
return hit
|
||||
hit
|
||||
}
|
||||
|
||||
/**
|
||||
* The message pinned in a thread, or null (§6.19).
|
||||
*/
|
||||
fun pinnedMessage(threadId: String): String? = vault.pinnedMessage(threadId)
|
||||
fun pinnedMessage(threadId: String): String? =
|
||||
if (vault.offTheRecord(threadId)) ephemeralPins[threadId] else vault.pinnedMessage(threadId)
|
||||
|
||||
/**
|
||||
* Pin a message, or take the pin down with an empty [messageId].
|
||||
@@ -828,7 +851,15 @@ class Session(
|
||||
threadId: String,
|
||||
messageId: String,
|
||||
) {
|
||||
vault.savePinnedMessage(threadId, messageId.takeIf { it.isNotBlank() })
|
||||
val mid = messageId.takeIf { it.isNotBlank() }
|
||||
// A pin on an off-the-record thread went to the vault regardless, a
|
||||
// record on disk naming the thread for a conversation whose promise is
|
||||
// that nothing reaches disk. It stays in memory with the thread now.
|
||||
if (vault.offTheRecord(threadId)) {
|
||||
if (mid == null) ephemeralPins.remove(threadId) else ephemeralPins[threadId] = mid
|
||||
} else {
|
||||
vault.savePinnedMessage(threadId, mid)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import io.flamenet.client.MessengerApi
|
||||
import io.flamenet.client.RelayClient
|
||||
import io.flamenet.e2e.DeviceIdentity
|
||||
import io.flamenet.e2e.Vault
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Off the record: edits, reactions and pins work, and none of them reach disk.
|
||||
*
|
||||
* `record()` and `history()` knew an off-the-record thread lives in memory.
|
||||
* `applyEdit` and `applyReaction` did not: they read `vault.history`, found
|
||||
* nothing there for such a thread, and returned false, so an edit or a
|
||||
* reaction to an off-the-record message was silently dropped. `applyPin`
|
||||
* went the other way and wrote the pin to the vault regardless, a record on
|
||||
* disk naming the thread for a conversation whose promise is that nothing
|
||||
* reaches disk.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class OffTheRecordEditsAndReactionsTest {
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var vault: Vault
|
||||
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer().also { it.start() }
|
||||
}
|
||||
|
||||
@AfterTest fun stop() = server.shutdown()
|
||||
|
||||
private class MapStorage : Vault.Storage {
|
||||
private val records = mutableMapOf<String, ByteArray>()
|
||||
|
||||
override fun read(name: String) = records[name]
|
||||
|
||||
override fun write(
|
||||
name: String,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
records[name] = bytes
|
||||
}
|
||||
|
||||
override fun delete(name: String) {
|
||||
records.remove(name)
|
||||
}
|
||||
|
||||
override fun names() = records.keys.toList()
|
||||
}
|
||||
|
||||
private fun key(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey()
|
||||
|
||||
private val peer = 99L
|
||||
private val thread = Vault.threadForUser(peer)
|
||||
|
||||
private fun session(): Session {
|
||||
val identity = DeviceIdentity.create()
|
||||
vault = Vault(MapStorage(), key()).also { it.saveIdentity(identity) }
|
||||
vault.saveOffTheRecord(thread, on = true)
|
||||
val base = server.url("/").toString()
|
||||
return Session(
|
||||
handle = "alice",
|
||||
userId = 1,
|
||||
identity = identity,
|
||||
vault = vault,
|
||||
relay = RelayClient(base, "alice", "app pass word"),
|
||||
api = MessengerApi(base, "alice", "app pass word"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reaction to an off-the-record message applies, and nothing reaches the vault`() {
|
||||
val s = session()
|
||||
s.record(thread, "meet at nine", mine = false, author = peer, messageId = "m-1")
|
||||
|
||||
val applied = s.applyReaction(thread, "m-1", peer, "❤️")
|
||||
|
||||
assertTrue(applied, "the reaction was dropped: it looked in the vault for a thread held in memory")
|
||||
assertTrue(s.history(thread).single().reactions.isNotEmpty())
|
||||
assertEquals(0, vault.history(thread).length(), "an off-the-record line reached the vault")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an edit to an off-the-record message applies`() {
|
||||
val s = session()
|
||||
s.record(thread, "meet at nine", mine = false, author = peer, messageId = "m-1")
|
||||
|
||||
val applied = s.applyEdit(thread, "m-1", peer, "meet at ten")
|
||||
|
||||
assertTrue(applied, "the edit was dropped")
|
||||
assertEquals("meet at ten", s.history(thread).single().text)
|
||||
assertEquals(0, vault.history(thread).length())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pin on an off-the-record thread stays in memory`() {
|
||||
val s = session()
|
||||
s.record(thread, "meet at nine", mine = false, author = peer, messageId = "m-1")
|
||||
|
||||
s.applyPin(thread, "m-1")
|
||||
|
||||
assertEquals("m-1", s.pinnedMessage(thread), "the pin does not show")
|
||||
assertNull(vault.pinnedMessage(thread), "the pin was written to disk for an off-the-record thread")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user