diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 019571d0f5..5c7a3909a5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -3270,6 +3270,19 @@ object LocalCache : ILocalCache, ICacheProvider { fun cachedModificationEventsForNote(note: Note): List = findLatestModificationForNote(note) + /** + * The kind-40003 Buzz edit currently overlaying [note], or null when unedited. Like every other + * edit kind, only the ORIGINAL message author's edits count — the send side already gates Edit to + * your own messages, and the relay is not trusted to reject a cross-author edit, so a 40003 signed + * by anyone else can never rewrite your message. The newest by created_at wins (Buzz's rule). + */ + fun findLatestBuzzEditForNote(note: Note): Note? { + val authorHex = note.author?.pubkeyHex ?: return null + return note.edits + .filter { it.event is StreamMessageEditEvent && it.author?.pubkeyHex == authorHex } + .maxByOrNull { it.createdAt() ?: 0L } + } + fun cleanMemory() { Log.d("LargeCache") { "Notes cleanup started. Current size: ${notes.size()}" } notes.cleanUp() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt index bbe3986ef2..92441d91ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderBuzzNotes.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -60,7 +61,6 @@ import com.vitorpamplona.quartz.buzz.jobs.JobProgressEvent import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent import com.vitorpamplona.quartz.buzz.jobs.JobResultEvent import com.vitorpamplona.quartz.buzz.stream.StreamMessageDiffEvent -import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent import com.vitorpamplona.quartz.buzz.stream.SystemMessageEvent import com.vitorpamplona.quartz.nip01Core.core.Event @@ -69,19 +69,16 @@ import com.vitorpamplona.quartz.nip01Core.core.Event * arrive. Returns null when the message is unedited. * * Each edit is anchored on the message it edits ([Note.edits], where [LocalCache] consumes it), - * so it is held for as long as its message and read straight off the note — no channel-keyed - * side store. Buzz keeps the newest by `created_at` regardless of author (its own last-write-wins - * rule); [addEdit] invalidates the note's edits flow, so collecting it re-runs the fold. + * so it is held for as long as its message and read straight off the note — no channel-keyed side + * store. [LocalCache.findLatestBuzzEditForNote] applies only the original author's newest edit; + * [addEdit] invalidates the note's edits flow, so collecting it re-runs the fold. */ @Composable fun observeBuzzEdit(note: Note): Note? { val latest by produceState(initialValue = null, note.idHex) { note.flow().edits.stateFlow.collect { - value = - note.edits - .filter { it.event is StreamMessageEditEvent } - .maxByOrNull { it.createdAt() ?: 0L } + value = LocalCache.findLatestBuzzEditForNote(note) } } return latest diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt index 173b889dc4..7ba6894f6e 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/BuzzWorkspaceChannelTest.kt @@ -39,6 +39,7 @@ import org.junit.After 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.Before import org.junit.Test @@ -177,6 +178,36 @@ class BuzzWorkspaceChannelTest { assertEquals("edited offline", newest?.event?.content) } + @Test + fun aForgedEditByAnotherAuthorNeverOverridesTheMessage() = + runBlocking { + val channelId = newChannelId() + val original = streamMessage(channelId, "the truth") // authored by `signer` + LocalCache.checkDeletionAndConsume(original, buzzRelay, false) + + // Mallory publishes a well-formed, VERIFIED 40003 targeting someone else's message. + val mallory = NostrSignerInternal(KeyPair()) + val forged = + mallory.sign( + StreamMessageEditEvent.build(channelId, original.id, "lies", createdAt = original.createdAt + 100), + ) + LocalCache.checkDeletionAndConsume(forged, buzzRelay, false) + + // The forged edit still lands in the store (it is a valid signed event)… + val target = LocalCache.getNoteIfExists(original.id)!! + assertTrue("the forged edit is stored", target.edits.any { it.idHex == forged.id }) + // …but the overlay only applies the ORIGINAL author's edits, so it is ignored. + assertNull( + "an edit by a different author must never override the message", + LocalCache.findLatestBuzzEditForNote(target), + ) + + // The real author's own later edit does apply. + val real = signer.sign(StreamMessageEditEvent.build(channelId, original.id, "the fix", createdAt = original.createdAt + 200)) + LocalCache.checkDeletionAndConsume(real, buzzRelay, false) + assertEquals("the fix", LocalCache.findLatestBuzzEditForNote(target)?.event?.content) + } + @Test fun pruningAMessageReleasesItsEdits() = runBlocking {