mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
fix(concord): terminal channel deletion and the 64-byte name cap
CORD-03 §2 (I14): - Deletion is terminal across the whole accepted edition set: any MANAGE_CHANNELS-gated channel edition with `deleted: true` retires the channel even if a later edition "restores" it (Armada `everDeleted`). - The channel gate enforces the name rule (non-empty, at most 64 UTF-8 bytes): an edition breaking it is unauthorized and the fold falls back to the previous candidate. ConcordModeration.defineChannel refuses to mint one. ControlPlaneVersionExhaustionTest's channel case now poisons with a max-version rename: a max-version delete from an authorized holder is terminal by design now, while a max-version edition still must not pin the channel's content. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N
This commit is contained in:
+3
@@ -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)
|
||||
|
||||
+17
-1
@@ -171,8 +171,24 @@ data class ConcordCommunityState(
|
||||
?.let { ConcordJson.decodeOrNull<MetadataEntity>(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<ChannelEntity>(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<ChannelEntity>(edition.content)?.deleted == true
|
||||
}.mapTo(HashSet()) { it.entityIdHex }
|
||||
val channels = LinkedHashMap<String, ConcordChannel>()
|
||||
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<ChannelEntity>(head.content) ?: continue
|
||||
if (def.deleted) continue
|
||||
channels[head.entityIdHex] = ConcordChannel(head.entityIdHex, def)
|
||||
|
||||
+17
-1
@@ -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
|
||||
|
||||
+112
@@ -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())
|
||||
}
|
||||
}
|
||||
+34
-7
@@ -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",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user