diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt index 75eb69da6c..16202f3cbc 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt @@ -162,6 +162,9 @@ object ConcordModeration { citation: AuthorityCitation? = null, owner: HexKey, ): Event { + // Every reader drops an edition naming an empty or over-cap Channel (CORD-03 §2), so + // refuse to mint one rather than publish an edition nobody will honor. + require(channel.hasValidName()) { "Channel name must be 1..${ChannelEntity.NAME_MAX_BYTES} UTF-8 bytes" } val (version, prev) = versioning(current, channelId, owner) val content = contentOver(ChannelEntity.serializer(), channel, current, channelId, owner) return wrap(actor, controlPlane, ControlEntityKind.CHANNEL, channelId, version, prev, content, createdAt, citation) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt index 0199c137c3..760bcb09b1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt @@ -171,8 +171,24 @@ data class ConcordCommunityState( ?.let { ConcordJson.decodeOrNull(it.content) } // Channels are gated by MANAGE_CHANNELS, per channel entity, dropping the tombstoned ones. + // The gate also enforces the name rule (non-empty, <= 64 UTF-8 bytes, CORD-03 §2): an + // edition breaking it is unauthorized and the fold falls back to the previous candidate. + val channelEditions = editions.filter { it.entityKind == ControlEntityKind.CHANNEL } + val channelGate = { edition: ControlEdition -> + (authority.isOwner(edition.author) || authority.hasPermission(edition.author, ConcordPermissions.MANAGE_CHANNELS)) && + ConcordJson.decodeOrNull(edition.content)?.hasValidName() == true + } + // Deletion is terminal (CORD-03 §2): any gated edition anywhere in a channel's accepted + // chain that says `deleted` retires it for good, even if a later edition "restores" it — + // members may already have discarded its keys, so a resurrection would split them. + val everDeleted = + channelEditions + .filter { edition -> + channelGate(edition) && ConcordJson.decodeOrNull(edition.content)?.deleted == true + }.mapTo(HashSet()) { it.entityIdHex } val channels = LinkedHashMap() - for (head in foldGatedBy(ControlEntityKind.CHANNEL, ConcordPermissions.MANAGE_CHANNELS).values) { + for (head in EditionFold.foldGated(channelEditions, floors, snapshot = snapshot, gate = channelGate).values) { + if (head.entityIdHex in everDeleted) continue val def = ConcordJson.decodeOrNull(head.content) ?: continue if (def.deleted) continue channels[head.entityIdHex] = ConcordChannel(head.entityIdHex, def) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt index 616e7efed5..6f3e69560e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt @@ -162,7 +162,23 @@ data class ChannelEntity( val name: String = "", val private: Boolean = false, val deleted: Boolean = false, -) +) { + /** True when [name] is within the protocol's name rule ([isValidName]). */ + fun hasValidName(): Boolean = isValidName(name) + + companion object { + /** The protocol-wide name cap, in UTF-8 bytes (CORD-03 §2, CORD-04). */ + const val NAME_MAX_BYTES = 64 + + /** + * A Channel name must be non-empty and at most [NAME_MAX_BYTES] UTF-8 bytes. Enforced when + * building an edition and again when folding one: an edition naming an empty or over-cap + * Channel is unauthorized, and the fold falls back to the previous candidate (the reference + * client's channel gate). + */ + fun isValidName(name: String): Boolean = name.isNotEmpty() && name.encodeToByteArray().size <= NAME_MAX_BYTES + } +} /** * A community's Metadata content (CORD-02): display [name], optional [description], the community's diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ChannelFoldConformanceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ChannelFoldConformanceTest.kt new file mode 100644 index 0000000000..881e134384 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ChannelFoldConformanceTest.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.concord.cord02Community + +import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * CORD-03 §2 channel fold rules: deletion is terminal across the whole accepted chain, and a + * Channel name is 1..64 UTF-8 bytes — an edition breaking the name rule is unauthorized and the + * fold falls back to the previous candidate (the reference client's channel gate). + */ +class ChannelFoldConformanceTest { + private val owner = "0f".repeat(32) + private val chan = "c1".repeat(32) + + private fun chained( + version: Long, + prev: ControlEdition?, + content: String, + author: String = owner, + ) = ControlEdition(ControlEntityKind.CHANNEL, chan.hexToByteArray(), version, prev?.hash, null, content, author, "r-$version", version) + + @Test + fun aDeletedChannelCannotBeResurrectedByALaterEdition() { + val c0 = chained(0, null, """{"name":"general"}""") + val c1 = chained(1, c0, """{"name":"general","deleted":true}""") + val c2 = chained(2, c1, """{"name":"general","deleted":false}""") + + val state = ConcordCommunityState.fold(listOf(c0, c1, c2), owner) + assertNull(state.channels[chan], "a deletion anywhere in the accepted chain is terminal") + } + + @Test + fun anUnauthorizedDeleteDoesNotRetireTheChannel() { + val troll = "77".repeat(32) + val c0 = chained(0, null, """{"name":"general"}""") + val forged = chained(1, c0, """{"name":"general","deleted":true}""", author = troll) + + val state = ConcordCommunityState.fold(listOf(c0, forged), owner) + assertEquals("general", state.channels[chan]?.definition?.name) + } + + @Test + fun anOverCapOrEmptyNameFallsBackToThePreviousEdition() { + val c0 = chained(0, null, """{"name":"general"}""") + val tooLong = chained(1, c0, """{"name":"${"x".repeat(65)}"}""") + assertEquals( + "general", + ConcordCommunityState + .fold(listOf(c0, tooLong), owner) + .channels[chan] + ?.definition + ?.name, + ) + + val empty = chained(1, c0, """{"name":""}""") + assertEquals( + "general", + ConcordCommunityState + .fold(listOf(c0, empty), owner) + .channels[chan] + ?.definition + ?.name, + ) + + // Exactly 64 bytes is fine — counted in UTF-8, so 16 four-byte emoji hit the cap too. + val atCap = chained(1, c0, """{"name":"${"x".repeat(64)}"}""") + assertEquals( + "x".repeat(64), + ConcordCommunityState + .fold(listOf(c0, atCap), owner) + .channels[chan] + ?.definition + ?.name, + ) + } + + @Test + fun theNameRuleCountsUtf8Bytes() { + val emoji = "😀" // 4 bytes in UTF-8 + assertTrue(ChannelEntity.isValidName(emoji.repeat(16))) + assertFalse(ChannelEntity.isValidName(emoji.repeat(16) + "a")) + assertFalse(ChannelEntity.isValidName("")) + assertTrue(ChannelEntity(name = "general").hasValidName()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlPlaneVersionExhaustionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlPlaneVersionExhaustionTest.kt index c97ed0dba9..e2bbad7c12 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlPlaneVersionExhaustionTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlPlaneVersionExhaustionTest.kt @@ -127,24 +127,51 @@ class ControlPlaneVersionExhaustionTest { ) } + /** + * The poison here renames rather than deletes: CORD-03 §2 makes a Channel deletion by any + * authorized holder terminal across the whole accepted edition set (the reference client's + * `everDeleted`), whatever its version, so a max-version *delete* from bob now retires the + * Channel by design. What must still hold is that a max-version edition cannot pin the Channel + * to bob's content. + */ @Test - fun oneEditionAtMaxVersionNoLongerDeletesAChannel() { + fun oneEditionAtMaxVersionNoLongerPinsAChannel() { val channelV0 = edition(ControlEntityKind.CHANNEL, channelEntity, 0, null, """{"name":"general"}""", owner, "chan-0") val community = communityWhereBobHolds(ConcordPermissions.of(ConcordPermissions.MANAGE_CHANNELS).toWire(), channelV0) val floorsBefore = ConcordCommunityState.authorizedHeads(community, owner) - val poison = edition(ControlEntityKind.CHANNEL, channelEntity, Long.MAX_VALUE, channelV0.hash, """{"name":"general","deleted":true}""", bob, "chan-poison") + val poison = edition(ControlEntityKind.CHANNEL, channelEntity, Long.MAX_VALUE, channelV0.hash, """{"name":"PWNED"}""", bob, "chan-poison") val floorsAfter = ConcordCommunityState.authorizedHeads(community + poison, owner, floorsBefore) val repair = edition(ControlEntityKind.CHANNEL, channelEntity, 1, channelV0.hash, """{"name":"general"}""", owner, "chan-1") val pool = community + poison + repair - assertEquals(1, ConcordCommunityState.fold(pool, owner).channels.size, "a fresh joiner still sees the channel") - assertEquals(1, ConcordCommunityState.fold(pool, owner, floorsAfter).channels.size, "and so does a client holding a floor") assertEquals( - 1, - ConcordCommunityState.fold(community + repair, owner, floorsAfter).channels.size, - "the channel survives a Refounding too", + "general", + ConcordCommunityState + .fold(pool, owner) + .channels[channelEntity] + ?.definition + ?.name, + "a fresh joiner follows the honest chain", + ) + assertEquals( + "general", + ConcordCommunityState + .fold(pool, owner, floorsAfter) + .channels[channelEntity] + ?.definition + ?.name, + "and so does a client holding a floor", + ) + assertEquals( + "general", + ConcordCommunityState + .fold(community + repair, owner, floorsAfter) + .channels[channelEntity] + ?.definition + ?.name, + "the channel stays repaired across a Refounding too", ) }