Merge remote-tracking branch 'origin/main' into claude/serene-galileo-punak3

This commit is contained in:
Claude
2026-09-26 22:47:12 +00:00
3 changed files with 171 additions and 6 deletions
@@ -32,29 +32,80 @@ import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.tags.Po
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.sha256.sha256
/** What an event tagging is about: an addressable event (`a`) or a plain one (`e`). */
@Immutable
sealed interface TaggingTarget {
/** The first 8 characters used in the assertion `d`. */
/**
* What stands for this target in the assertion `d`: `<id8>` for a plain event,
* `<author8>-<d16>-<hash8>` for an addressable one. Kind 39999 is addressable, so two
* taggings that share a `d` do not sit side by side — the later one replaces the earlier.
*
* The draft fixes these widths, so the separation is 32 bits of hash rather than a guarantee:
* it removes the old rule's *certain* collision between any two of an author's events, and
* leaves a birthday-bound residue that the draft accepts. Widening it is an upstream change,
* not ours.
*/
val prefix: String
@Immutable
data class ByAddress(
val address: Address,
) : TaggingTarget {
// the author segment of the coordinate, not its kind
override val prefix get() = address.pubKeyHex.take(8)
/**
* `<author8>-<d16>-<hash8>`. Only `hash8` carries uniqueness — it is taken over the WHOLE
* coordinate, because no single segment identifies the target: the author repeats across
* everything they write, and the kind across everything of a type. The other two are
* decoration the draft asks for so a `d` stays readable.
*
* An empty `dTag` yields an empty `d16`, i.e. `<author8>--<hash8>`, which the draft spells
* out. A plain event uses its own id (below): that already covers the whole event.
*/
override val prefix get() = "${address.pubKeyHex.take(AUTHOR_CHARS)}-${d16()}-${hash8()}"
/**
* The draft counts `d16` in UTF-16 code units, which can cut an astral character in half
* and leave a dangling high surrogate. That is not survivable: the id is hashed from the
* intact char, but UTF-8 encoding the event for the wire has no encoding for half a
* character and substitutes `?`, so the relay re-hashes different bytes, gets a different
* id, and rejects the event. Verified end to end — see the test.
*
* So the half character is dropped, giving 15 units instead of 16. `d16` is decoration
* the draft says carries no uniqueness, and dropping it stays deterministic, so nothing
* that matters is lost; `hash8` still separates the targets.
*/
private fun d16(): String {
val cut = address.dTag.take(D_TAG_CHARS)
return if (cut.lastOrNull()?.isHighSurrogate() == true) cut.dropLast(1) else cut
}
// Lowercase hex of the UTF-8 coordinate exactly as the `a` tag carries it: no trimming,
// no normalization, no reordering. Hex.encode already emits lower case.
private fun hash8() = sha256(address.toValue().encodeToByteArray()).toHexKey().take(HASH_CHARS)
}
@Immutable
data class ByEventId(
val eventId: HexKey,
) : TaggingTarget {
override val prefix get() = eventId.take(8)
override val prefix get() = eventId.take(EVENT_ID_CHARS)
}
companion object {
const val EVENT_ID_CHARS = 8
const val AUTHOR_CHARS = 8
const val HASH_CHARS = 8
/**
* UTF-16 code units, which is what the draft pins ("as JavaScript `String.prototype.slice`
* counts them") and what [String.take] counts.
*/
const val D_TAG_CHARS = 16
}
}
@@ -105,7 +156,8 @@ object TaggingHeader {
* [TaggingHeader]:
*
* ```
* ["d", "event-tag-<tagSlug>-<target8>-<asserter8>"]
* ["d", "event-tag-<tagSlug>-<target8>-<asserter8>"] // <target8> = <id8>, or
* // <author8>-<d16>-<hash8> for an `a`
* ["a", <target coordinate>] or ["e", <target id>]
* ["z", <nostr-event-tag concept>]
* ["z", <tagging header coordinate>]
@@ -113,6 +165,10 @@ object TaggingHeader {
* ```
*
* An `a`/`e` reference without such a `z` is not a tagging and must not be read as one.
*
* The `d` is a replaceability key, never a source of truth: the draft forbids parsing it back into
* its fields, because `d16` is user-influenced text that may itself contain hyphens. [parse] reads
* the target from the `a`/`e` tag, which is authoritative.
*/
@Immutable
data class EventTagging(
@@ -36,6 +36,7 @@ import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertNotEquals
/** Regressions found in the audit: each failed before its fix. */
class AuditRegressionTest {
@@ -111,6 +112,111 @@ class AuditRegressionTest {
assertFalse(name in node.linkedAddressIds())
}
// The `d` must separate two addressable targets. kind 39999 is addressable, so a colliding
// `d` means the second tagging silently replaces the first.
@Test
fun twoAddressableTargetsByTheSameAuthorGetDifferentDTags() {
val first = TaggingTarget.ByAddress(Address(39999, bob, "good-tag"))
val second = TaggingTarget.ByAddress(Address(39999, bob, "other-tag"))
assertNotEquals(
EventTagging.dTag("awesome-tag", first, alice),
EventTagging.dTag("awesome-tag", second, alice),
)
}
// Same idea across the other two coordinate segments: only one of the three may differ.
@Test
fun addressableTargetsDifferingOnlyByKindGetDifferentDTags() {
assertNotEquals(
EventTagging.dTag("awesome-tag", TaggingTarget.ByAddress(Address(39999, bob, "tag")), alice),
EventTagging.dTag("awesome-tag", TaggingTarget.ByAddress(Address(30023, bob, "tag")), alice),
)
}
@Test
fun addressableTargetsDifferingOnlyByAuthorGetDifferentDTags() {
assertNotEquals(
EventTagging.dTag("awesome-tag", TaggingTarget.ByAddress(Address(39999, bob, "tag")), alice),
EventTagging.dTag("awesome-tag", TaggingTarget.ByAddress(Address(39999, other, "tag")), alice),
)
}
// ...while the `d` stays deterministic, which is what lets a re-tag replace its own assertion
// instead of piling up a second one. `event-taggings.md` § "The assertion d-tag (normative)":
// `<author8>-<d16>-<hash8>`, only hash8 unique. The hashes are SHA-256 prefixes of the full
// coordinate computed outside this codebase, so the test cross-checks the derivation rather
// than restating it: a change of hash input would silently orphan every assertion signed.
@Test
fun theAddressablePrefixIsAuthorThenDTagThenTheHashOfTheWholeCoordinate() {
assertEquals(
"event-tag-awesome-tag-bbbbbbbb-good-tag-6a5e1c40-aaaaaaaa",
EventTagging.dTag("awesome-tag", TaggingTarget.ByAddress(Address(39999, bob, "good-tag")), alice),
)
assertEquals(
"event-tag-awesome-tag-bbbbbbbb-other-tag-e89b797c-aaaaaaaa",
EventTagging.dTag("awesome-tag", TaggingTarget.ByAddress(Address(39999, bob, "other-tag")), alice),
)
// same author8 AND same d16 as the first; only the kind differs, so only hash8 separates them
assertEquals(
"event-tag-awesome-tag-bbbbbbbb-good-tag-96856fd8-aaaaaaaa",
EventTagging.dTag("awesome-tag", TaggingTarget.ByAddress(Address(30023, bob, "good-tag")), alice),
)
}
// The draft spells this case out: an empty `d` yields an empty `d16`, hence the double hyphen.
@Test
fun anEmptyDTagSegmentLeavesADoubleHyphen() {
assertEquals(
"event-tag-awesome-tag-bbbbbbbb--76600e1a-aaaaaaaa",
EventTagging.dTag("awesome-tag", TaggingTarget.ByAddress(Address(39999, bob, "")), alice),
)
}
// d16 is the first 16 UTF-16 code units, verbatim and truncated — decoration, not identity.
@Test
fun aLongDTagIsTruncatedToSixteenCharactersInTheDecoration() {
val d = "a-very-long-d-tag-that-exceeds-sixteen"
assertEquals(
"event-tag-awesome-tag-bbbbbbbb-a-very-long-d-ta-58899d62-aaaaaaaa",
EventTagging.dTag("awesome-tag", TaggingTarget.ByAddress(Address(39999, bob, d)), alice),
)
}
// Truncating at 16 UTF-16 code units can cut an astral character in half. The id is hashed
// from the intact char while the wire bytes get `?` in its place, so the relay re-hashes
// different bytes and rejects the event. The half character is dropped instead.
@Test
fun aDTagCutMidAstralCharacterDoesNotLeaveHalfACharacterInTheD() {
// 15 ASCII then an emoji: the 16th code unit is the high surrogate of the pair.
val dTag = "a".repeat(15) + "\uD83D\uDE00" + "tail"
val d = EventTagging.dTag("awesome-tag", TaggingTarget.ByAddress(Address(39999, bob, dTag)), alice)
assertFalse(d.any { it.isHighSurrogate() || it.isLowSurrogate() }, "the `d` still carries half a character: $d")
// 15 units of decoration, not 16, and hash8 is over the untruncated coordinate.
assertEquals("event-tag-awesome-tag-bbbbbbbb-${"a".repeat(15)}-15c09d87-aaaaaaaa", d)
}
// The property the above protects: what is hashed for the id must survive UTF-8 encoding,
// or the relay recomputes a different id. Pins the bytes, not just the string.
@Test
fun theDSurvivesAUtf8RoundTripByteForByte() {
val dTag = "a".repeat(15) + "\uD83D\uDE00" + "tail"
val d = EventTagging.dTag("awesome-tag", TaggingTarget.ByAddress(Address(39999, bob, dTag)), alice)
assertEquals(d, d.encodeToByteArray().decodeToString(), "the `d` does not survive UTF-8 encoding")
}
// A plain event is still named by its own id: it already covers the whole event, and hashing
// it would only cost a round of SHA-256 per assertion built. The `e` branch is unchanged.
@Test
fun plainEventTargetsStillUseTheirOwnId() {
assertEquals(
"event-tag-awesome-tag-11111111-aaaaaaaa",
EventTagging.dTag("awesome-tag", TaggingTarget.ByEventId("1".repeat(64)), alice),
)
}
// Authored JSON: an explicit null in a list field must not make the whole section unreadable.
@Test
fun explicitNullListsInTheWordSectionAreTolerated() {
@@ -247,7 +247,10 @@ class TapestryExtensionsTest {
assertEquals(
listOf(
listOf("d", "event-tag-awesome-tag-cccccccc-aaaaaaaa"),
// <author8>-<d16>-<hash8>: cccccccc is the assistant, good-tag the target's own
// `d`, and 4d7a80b1 = sha256("39999:${"c".repeat(64)}:good-tag").take(8), the one
// segment that makes two of the assistant's tags land on different addresses.
listOf("d", "event-tag-awesome-tag-cccccccc-good-tag-4d7a80b1-aaaaaaaa"),
listOf("z", "39998:$ta:nostr-event-tag"),
listOf("z", "39999:$bob:tagging:awesome-tag-tagging"),
listOf("a", "39999:$assistant:good-tag"),