diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt index 13b346b4c7..f3c938ee65 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt @@ -115,6 +115,19 @@ class AccountMarmotActions( nostrGroupId: HexKey, innerEvent: Event, ) { + // The same two guards sendMarmotGroupMessage returns on. Checked here + // too, because marking a message as sending and then returning early + // would leave the bubble pulsing forever with no failure glyph and + // therefore no way to retry it. + if (account.marmotManager == null || !account.isWriteable()) { + Log.w("MarmotDbg") { + "beginMarmotGroupMessage: cannot send in ${nostrGroupId.take(8)}… (no manager, or a read-only account)" + } + showOwnMessageLocally(nostrGroupId, innerEvent) + account.chatDeliveryTracker.markFailed(innerEvent.id) + return + } + // Marked before the note is indexed, so the bubble never renders a // frame without its state. Re-resolving the relay set on retry is the // point of taking the id rather than the relays: the commonest reason diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt index c179dc5c0d..e7641cb3bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMarmotMessageStore.kt @@ -323,7 +323,19 @@ class AndroidMarmotMessageStore( private val log = EncryptedAppendLog( encrypt = { encryption.encrypt(it) }, - decrypt = { encryption.decrypt(it) }, + // EncryptedAppendLog requires null, not a throw, for a segment it + // cannot open — KeyStoreEncryption.decrypt rethrows. Without this + // one bad segment would abort the whole read, and a caller that + // then sees an empty log can overwrite a history that was merely + // unreadable. + decrypt = { + try { + encryption.decrypt(it) + } catch (e: Exception) { + Log.w(TAG, "a log segment could not be decrypted and was skipped: ${e.message}", e) + null + } + }, ) private fun readAll(nostrGroupId: String): List = readAllFrom(messagesFile(nostrGroupId)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMlsGroupStateStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMlsGroupStateStore.kt index ef4d4f18a4..504037d630 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMlsGroupStateStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/marmot/AndroidMlsGroupStateStore.kt @@ -174,13 +174,15 @@ class AndroidMlsGroupStateStore( * The per-send ratchet position, written on its own so a message does not * re-encrypt the whole group state to record ~72 bytes. * - * Deliberately NOT the atomic temp-file-and-rename the state uses. That - * costs two file operations plus a directory sync for a record smaller than - * a block, and it is not what durability needs here: the record is written - * whole, and a half-written one fails to decrypt and is discarded, which - * falls back to the full state's position. What matters is that the bytes - * are on the device before the message they describe goes out, so the write - * is followed by an fsync. + * Written atomically, through a temp file and a rename. An in-place write + * looked adequate — the record is small and written whole — but the failure + * it allows is the one this record exists to prevent. A torn record is + * discarded on load, which falls back to the position in the full state: + * that is the position as of the last COMMIT, behind by every send since. + * The next send would then re-emit every generation in between, reusing + * AEAD key+nonce pairs. With a rename, a crash leaves the previous complete + * record instead, which is at worst one send behind — and that send's + * ciphertext was never published, because publishing waits for this write. */ override suspend fun saveSenderRatchet( nostrGroupId: String, @@ -191,10 +193,17 @@ class AndroidMlsGroupStateStore( try { file.parentFile?.mkdirs() val encrypted = encryption.encrypt(state) - FileOutputStream(file).use { out -> + val tempFile = File(file.parentFile, "${file.name}.tmp") + FileOutputStream(tempFile).use { out -> out.write(encrypted) out.fd.sync() } + if (!tempFile.renameTo(file)) { + tempFile.copyTo(file, overwrite = true) + if (!tempFile.delete()) { + Log.w(TAG) { "Failed to delete the temp ratchet file: ${tempFile.absolutePath}" } + } + } true } catch (e: Exception) { // Returning false sends the caller to a full state write, which @@ -212,10 +221,11 @@ class AndroidMlsGroupStateStore( try { encryption.decrypt(file.readBytes()) } catch (e: Exception) { - // A torn or unreadable record says nothing trustworthy about - // where the ratchet is. The full state's own position stands, - // which is behind but never ahead. - Log.w(TAG, "loadSenderRatchet($nostrGroupId) unreadable, ignoring: ${e.message}", e) + // Serious: the fallback is the full state's position, which is + // BEHIND, and a behind position re-emits used generations. The + // atomic write above is what should make this unreachable, so + // if it ever fires the group's key material is suspect. + Log.e(TAG, "loadSenderRatchet($nostrGroupId) unreadable — the ratchet may rewind: ${e.message}", e) null } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/chatDelivery/ChatDeliveryTracker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/chatDelivery/ChatDeliveryTracker.kt index 3f89f5ef38..d30a82fb24 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/chatDelivery/ChatDeliveryTracker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/chatDelivery/ChatDeliveryTracker.kt @@ -179,6 +179,11 @@ class ChatDeliveryTracker( /** The send reached the relay pool; relay acceptance takes over from here. */ fun markSent(displayedNoteId: HexKey) { lock.withLock { + // The action captured whatever the send needed to repeat itself — + // for a DM, every gift wrap. Once the event is with the relay pool + // there is nothing to retry, and holding it would pin that graph + // for as long as the message stays in the window. + retries.remove(displayedNoteId) val flow = deliveries[displayedNoteId] ?: return flow.value = flow.value?.copy(sendState = ChatSendState.SENT) } @@ -217,11 +222,27 @@ class ChatDeliveryTracker( val flow = flowForLocked(displayedNoteId) val current = flow.value + val existing = current?.recipients.orEmpty() + val known = existing.firstOrNull { it.recipient == recipient } + // Replace rather than append. A retry re-runs the same publish loop + // and would otherwise register every recipient twice, which shows up + // as a doubled k/n count ("0/2" on a 1:1 DM) and a detail dialog + // listing everyone twice. Acceptances already collected for this + // recipient are kept: the wraps are the same events, so relay OKs + // from the first attempt still count. + val updated = + RecipientDelivery( + recipient = recipient, + targetRelays = (known?.targetRelays ?: emptySet()) + targetRelays, + acceptedRelays = known?.acceptedRelays ?: emptySet(), + isSelf = isSelf, + ) + flow.value = ChatDelivery( targetRelays = (current?.targetRelays ?: emptySet()) + targetRelays, acceptedRelays = current?.acceptedRelays ?: emptySet(), - recipients = (current?.recipients ?: emptyList()) + RecipientDelivery(recipient, targetRelays, isSelf = isSelf), + recipients = existing.filterNot { it.recipient == recipient } + updated, // A DM registers one wrap at a time while the remaining // recipients are still being sealed, so the send is not // done just because the first wrap landed here. Only @@ -325,7 +346,14 @@ class ChatDeliveryTracker( knownIds = knownIds + noteId while (deliveries.size > MAX_TRACKED) { - val evicted = deliveries.keys.first() + // Most entries here were created by the UI merely rendering a + // bubble, so plain insertion order would let scrolling a long chat + // evict a failed message's retry — leaving a tappable warning glyph + // that does nothing. Anything still holding a retry is spared until + // nothing else is left to drop. + val evicted = + deliveries.keys.firstOrNull { it !in retries } + ?: deliveries.keys.first() deliveries.remove(evicted) retries.remove(evicted) knownIds = knownIds - evicted diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedAppendLog.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedAppendLog.kt index b516946e5e..b8ef668cb8 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedAppendLog.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedAppendLog.kt @@ -22,14 +22,14 @@ package com.vitorpamplona.amethyst.commons.marmot import java.io.File import java.io.FileOutputStream +import java.io.RandomAccessFile /** * An encrypted-at-rest log of UTF-8 entries that can be appended to in * constant time. * - * The file is a sequence of independently encrypted SEGMENTS: * ``` - * file := MAGIC segment* + * file := MAGIC(8) foldedLength(uint64) segment* * segment := uint32 encLen, byte[encLen] // whatever [encrypt] produces * plain := uint32 count, (uint32 len, byte[len])* * ``` @@ -41,14 +41,20 @@ import java.io.FileOutputStream * conversation a few thousand messages long was moving hundreds of KB through * a hardware-backed cipher to append a couple of hundred bytes. * - * Appending writes one small segment. Loose segments are folded back into one - * every [compactAfterSegments] appends, which bounds what a read costs: without - * that, an old conversation would need one cipher round-trip per message ever - * sent. + * Appending writes one small segment. Every [compactAfterSegments] appends the + * loose run — everything past `foldedLength` — is folded into a single segment, + * which bounds how many cipher round trips a read costs. A fold re-encrypts + * only the run it collapses; the already-folded prefix is copied across as + * ciphertext, so no append ever pays for the whole history. + * + * `foldedLength` lives in the header rather than in memory so that bound + * survives a restart. Held only in memory, every session would treat whatever + * it found as already folded, leave its own run permanently unfoldable, and + * segments would creep back towards one per message — which is the cost the + * fold exists to prevent. * * A file written by the older format has no magic prefix and is read as a - * single legacy blob; the next append rewrites it in this format. Nothing else - * migrates it, and nothing needs to — reading handles both. + * single legacy blob; the next append rewrites it in this format. * * **Not thread-safe.** Entries are cached in memory so an append never has to * read the log back, and that cache assumes one owner. Callers hold their own @@ -57,8 +63,10 @@ import java.io.FileOutputStream * * @param encrypt must produce a self-describing blob — it carries its own IV / * nonce, since every segment is encrypted separately. - * @param decrypt returns null for a segment it cannot open; that segment's - * entries are skipped and the rest of the log is still read. + * @param decrypt MUST return null rather than throwing for a segment it cannot + * open. One unreadable segment then costs only its own entries; a throw would + * abort the whole read, and a caller that reads an empty log can overwrite a + * history that was merely unreadable. */ class EncryptedAppendLog( private val encrypt: (ByteArray) -> ByteArray, @@ -71,9 +79,9 @@ class EncryptedAppendLog( val seen: MutableSet, /** False when the file has no header yet: it is new, or still in the old format. */ var headed: Boolean, - /** Byte length of the part that has already been folded; the loose run starts here. */ + /** Byte offset where the loose run begins; everything before it is one folded prefix. */ var foldedLength: Long, - /** Segments appended since the last fold. */ + /** Segments sitting past [foldedLength]. */ var looseSegments: Int, /** Entries carried by those segments. */ var looseEntries: Int, @@ -83,18 +91,29 @@ class EncryptedAppendLog( private fun stateFor(file: File): LogState = logs.getOrPut(file.absolutePath) { - val (entries, headed) = decodeFile(file) - // Everything already on disk counts as folded. Whatever loose - // segments a previous session left behind stay where they are — - // re-folding them would re-encrypt bytes that are already encrypted, - // which is the cost this whole design exists to avoid. + val decoded = decodeFile(file) + + // A torn trailing segment is dropped from the FILE, not merely from + // what we just read. Leaving it there puts the append position past + // a record that parsing always stops at, so every later append is + // written and then never read back — the log goes silently + // write-only, for good. + if (decoded.headed && decoded.validLength < file.length()) { + try { + RandomAccessFile(file, "rw").use { it.setLength(decoded.validLength) } + } catch (_: Exception) { + // Best effort: the next fold rewrites the file wholesale and + // resolves it anyway. + } + } + LogState( - entries = entries.toMutableList(), - seen = entries.toMutableSet(), - headed = headed, - foldedLength = if (headed) file.length() else 0L, - looseSegments = 0, - looseEntries = 0, + entries = decoded.entries.toMutableList(), + seen = decoded.entries.toMutableSet(), + headed = decoded.headed, + foldedLength = decoded.foldedLength, + looseSegments = decoded.looseSegments, + looseEntries = decoded.looseEntries, ) } @@ -107,23 +126,28 @@ class EncryptedAppendLog( entry: String, ): Boolean = entry in stateFor(file).seen - /** Append one entry. Constant time, apart from a periodic compaction. */ + /** Append one entry. Constant time, apart from a periodic fold. */ fun append( file: File, entry: String, ) { val state = stateFor(file) - state.entries.add(entry) - state.seen.add(entry) // No header to append after — a file that does not exist yet, or one // still in the old format. A rewrite lays one down. if (!state.headed) { - rewrite(file, state.entries.toList()) + rewrite(file, state.entries + entry) return } + // The disk write comes FIRST. Recording the entry in memory before it is + // durable would let a failed write leave it marked as held: the caller's + // duplicate check would skip it from then on, and for Marmot this store + // holds the only copy of the plaintext. appendSegment(file, listOf(entry)) + + state.entries.add(entry) + state.seen.add(entry) state.looseSegments += 1 state.looseEntries += 1 @@ -149,13 +173,18 @@ class EncryptedAppendLog( state: LogState, ) { if (state.looseEntries == 0) return - val prefix = file.readBytes().copyOfRange(0, state.foldedLength.toInt()) - val folded = encrypt(encodeEntries(state.entries.takeLast(state.looseEntries))) + val prefix = ByteArray(state.foldedLength.toInt()) + RandomAccessFile(file, "r").use { it.readFully(prefix) } + + val folded = encrypt(encodeEntries(state.entries.takeLast(state.looseEntries))) val out = ByteArray(prefix.size + 4 + folded.size) prefix.copyInto(out, 0) lengthPrefix(folded.size).copyInto(out, prefix.size) folded.copyInto(out, prefix.size + 4) + // The copied prefix still carries the old boundary; the whole file is + // folded now. + writeLong(out, MAGIC.size, out.size.toLong()) atomicWrite(file, out) state.foldedLength = out.size.toLong() @@ -178,7 +207,7 @@ class EncryptedAppendLog( } } - /** Replace the whole log with [entries], as a single segment. */ + /** Replace the whole log with [entries], as a single folded segment. */ fun rewrite( file: File, entries: List, @@ -186,10 +215,11 @@ class EncryptedAppendLog( file.parentFile?.mkdirs() val segment = encrypt(encodeEntries(entries)) - val out = ByteArray(MAGIC.size + 4 + segment.size) + val out = ByteArray(HEADER_LENGTH + 4 + segment.size) MAGIC.copyInto(out, 0) - lengthPrefix(segment.size).copyInto(out, MAGIC.size) - segment.copyInto(out, MAGIC.size + 4) + writeLong(out, MAGIC.size, out.size.toLong()) + lengthPrefix(segment.size).copyInto(out, HEADER_LENGTH) + segment.copyInto(out, HEADER_LENGTH + 4) atomicWrite(file, out) val state = @@ -211,34 +241,66 @@ class EncryptedAppendLog( logs.remove(file.absolutePath) } - /** Every entry in [file], and whether the file already carries a header. */ - private fun decodeFile(file: File): Pair, Boolean> { + private class Decoded( + val entries: List, + val headed: Boolean, + /** Bytes up to the end of the last segment that parsed cleanly. */ + val validLength: Long, + val foldedLength: Long, + val looseSegments: Int, + val looseEntries: Int, + ) + + private fun decodeFile(file: File): Decoded { // A file that does not exist yet has no header, so the first append has // to write one rather than tack a bare segment onto nothing. - if (!file.exists()) return emptyList() to false + if (!file.exists()) return Decoded(emptyList(), false, 0L, 0L, 0, 0) val bytes = file.readBytes() - if (!bytes.startsWithMagic()) { + if (!bytes.startsWithMagic() || bytes.size < HEADER_LENGTH) { // The older format: the file is one encrypted blob and nothing else. - val plain = decrypt(bytes) ?: return emptyList() to false - return decodeEntries(plain) to false + val plain = decrypt(bytes) ?: return Decoded(emptyList(), false, 0L, 0L, 0, 0) + return Decoded(decodeEntries(plain), false, 0L, 0L, 0, 0) } + val foldedLength = readLong(bytes, MAGIC.size).coerceIn(HEADER_LENGTH.toLong(), bytes.size.toLong()) + val result = ArrayList() - var offset = MAGIC.size + var offset = HEADER_LENGTH + var looseSegments = 0 + var looseEntries = 0 while (offset + 4 <= bytes.size) { + val segmentStart = offset val encLen = readInt(bytes, offset) offset += 4 // A truncated tail is a half-finished append (process death between // the write and the sync). Everything before it is intact and is // what we keep; the torn record is dropped rather than failing the - // whole log. - if (encLen <= 0 || offset + encLen > bytes.size) break + // whole log. Rewinding to the record's own start is what makes + // validLength the place a later append may safely resume from. + if (encLen <= 0 || offset + encLen > bytes.size) { + offset = segmentStart + break + } val plain = decrypt(bytes.copyOfRange(offset, offset + encLen)) offset += encLen - if (plain != null) result.addAll(decodeEntries(plain)) + + val entries = if (plain != null) decodeEntries(plain) else emptyList() + result.addAll(entries) + if (segmentStart >= foldedLength) { + looseSegments += 1 + looseEntries += entries.size + } } - return result to true + + return Decoded( + entries = result, + headed = true, + validLength = offset.toLong(), + foldedLength = foldedLength.coerceAtMost(offset.toLong()), + looseSegments = looseSegments, + looseEntries = looseEntries, + ) } private fun encodeEntries(entries: List): ByteArray { @@ -282,7 +344,10 @@ class EncryptedAppendLog( data: ByteArray, ) { val tempFile = File(target.parentFile, "${target.name}.tmp") - tempFile.writeBytes(data) + FileOutputStream(tempFile).use { out -> + out.write(data) + out.fd.sync() + } if (!tempFile.renameTo(target)) { tempFile.copyTo(target, overwrite = true) tempFile.delete() @@ -317,9 +382,29 @@ class EncryptedAppendLog( ((source[offset + 2].toInt() and 0xFF) shl 8) or (source[offset + 3].toInt() and 0xFF) + private fun writeLong( + target: ByteArray, + offset: Int, + value: Long, + ) { + for (i in 0 until 8) target[offset + i] = (value shr (56 - 8 * i)).toByte() + } + + private fun readLong( + source: ByteArray, + offset: Int, + ): Long { + var value = 0L + for (i in 0 until 8) value = (value shl 8) or (source[offset + i].toLong() and 0xFF) + return value + } + companion object { /** Marks the segmented format; a file without it predates it. */ - private val MAGIC = "MRMTLOG2".encodeToByteArray() + private val MAGIC = "MRMTLOG3".encodeToByteArray() + + /** MAGIC plus the uint64 fold boundary. */ + private const val HEADER_LENGTH = 16 private const val COMPACT_AFTER_SEGMENTS = 200 diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedAppendLogTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedAppendLogTest.kt index 0568154912..acf4be7de4 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedAppendLogTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/marmot/EncryptedAppendLogTest.kt @@ -122,14 +122,18 @@ class EncryptedAppendLogTest { (1..4).forEach { log.append(file, "second-run-$it") } val afterSecondFold = file.readBytes() - // The bytes the first fold produced must survive verbatim. If they were - // re-encrypted they would differ, since the stand-in cipher — like + // The SEGMENTS the first fold produced must survive verbatim. If they + // were re-encrypted they would differ, since the stand-in cipher — like // AES-GCM — uses a fresh nonce per call. This is the property that keeps // a send from paying for the whole conversation. + // + // The header is excluded on purpose: a fold moves the boundary it + // records, so those eight bytes are expected to change and only the + // ciphertext after them is expected to be copied. assertContentEquals( - afterFirstFold.toList(), - afterSecondFold.copyOfRange(0, afterFirstFold.size).toList(), - "folding the tail must copy the already-folded prefix as ciphertext", + afterFirstFold.copyOfRange(HEADER_LEN, afterFirstFold.size).toList(), + afterSecondFold.copyOfRange(HEADER_LEN, afterFirstFold.size).toList(), + "folding the tail must copy the already-folded segments as ciphertext", ) assertContentEquals((1..5).map { "first-run-$it" } + (1..4).map { "second-run-$it" }, cipher().readAll(file)) } @@ -155,7 +159,7 @@ class EncryptedAppendLogTest { assertContentEquals(listOf("old-one", "old-two", "new-one"), cipher().readAll(file)) // and the upgraded file is in the new format, so the next append is cheap - assertTrue(file.readBytes().decodeToString().startsWith("MRMTLOG2")) + assertTrue(file.readBytes().decodeToString().startsWith("MRMTLOG3")) } @Test @@ -183,12 +187,79 @@ class EncryptedAppendLogTest { // Corrupt the first segment's nonce marker so decrypt returns null for it. val bytes = file.readBytes() - bytes[MAGIC_LEN + 4] = 0 + bytes[HEADER_LEN + 4] = 0 file.writeBytes(bytes) assertContentEquals(listOf("after"), cipher().readAll(file)) } + @Test + fun `a torn tail does not stop the log accepting new entries`() { + // The bug this pins: decoding stopped at the torn record but the append + // position was left at the end of the file, so every later append landed + // behind a record that parsing always stops at. The log kept accepting + // writes and never read one back again — silently write-only, for good. + val file = tempFile() + val log = cipher() + log.append(file, "kept-one") + log.append(file, "kept-two") + + val intact = file.readBytes() + log.append(file, "lost") + val torn = file.readBytes() + file.writeBytes(torn.copyOfRange(0, intact.size + 6)) + + val reopened = cipher() + assertContentEquals(listOf("kept-one", "kept-two"), reopened.readAll(file)) + + reopened.append(file, "three") + assertContentEquals( + listOf("kept-one", "kept-two", "three"), + cipher().readAll(file), + "an append after a torn tail must be readable by the next reader", + ) + } + + @Test + fun `the fold boundary survives a restart`() { + // Held only in memory, each session treated whatever it found as already + // folded and left its own run permanently unfoldable — segments crept + // back towards one per message, which is the per-message cipher round + // trip the fold exists to prevent. + val file = tempFile() + repeat(12) { session -> + // A fresh instance per session, as a cold process gets. + val log = cipher(compactAfter = 4) + repeat(3) { log.append(file, "s$session-m$it") } + } + + val expected = (0 until 12).flatMap { session -> (0 until 3).map { "s$session-m$it" } } + assertContentEquals(expected, cipher().readAll(file)) + + // 36 entries at a fold every 4 appends: a handful of segments, not 36. + assertTrue( + file.length() < expected.size * SEGMENT_OVERHEAD_CEILING, + "segments should still be folding across restarts, file was ${file.length()} bytes", + ) + } + + @Test + fun `a failed write does not mark an entry as held`() { + // The duplicate check is what keeps a redelivered message from being + // written twice. If a failed append still registered the entry, the + // check would skip it forever and the only copy of that plaintext would + // be gone. + val file = tempFile() + val log = + EncryptedAppendLog( + encrypt = { throw IllegalStateException("cipher unavailable") }, + decrypt = { null }, + ) + + runCatching { log.append(file, "never-written") } + assertFalse(log.contains(file, "never-written"), "a write that failed must not count as persisted") + } + @Test fun `entries keep their bytes through the round trip`() { val file = tempFile() @@ -222,7 +293,9 @@ class EncryptedAppendLogTest { companion object { private const val NONCE_MARK: Byte = 0x7F - private const val MAGIC_LEN = 8 + + /** MAGIC plus the uint64 fold boundary. */ + private const val HEADER_LEN = 16 private const val SEGMENT_OVERHEAD_CEILING = 64 } }