From c84e08cd323df8ca29ce075fba9940d7cbb73f9d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 16:24:27 +0000 Subject: [PATCH] fix(quartz): audit fixes and hot-path tuning for Decentralized Lists Bugs, each reproduced by a failing test in AuditRegressionTest first: - Event taggings could not federate: parse took a single namespace and read any other `nostr-event-tag` z as a tagging header. Every tagging builder now takes a collection of concept namespaces and every parser the set the reader honors (single-namespace overloads kept). - HeaderResolution picked whichever version of a header came first instead of the newest. - `b` tags accepted any string as a target; the value form is closed, so only coordinates are read now. - ItemKindTag.isTag accepted negative kinds that parse rejected. - An explicit null in word-section lists made the whole section unreadable. Performance: - The NIP-50 read path walks the tags in two allocation-free passes instead of one full scan and one parsed object per field. - Hint providers are single pass, and z coordinates skip the address parser; a shared CoordinateShape check also keeps plain list names (which may contain colons) from logging parser warnings. - Single-value readers use the iterator-free fast* tag operators. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A2NXYQuDhvx4cmH1Nq5Zxq --- .../decentralizedLists/CoordinateShape.kt | 45 +++++++ .../experimental/decentralizedLists/README.md | 4 + .../decentralizedLists/SearchExt.kt | 54 +++++--- .../decentralizedLists/TagArrayExt.kt | 8 +- .../assistant/HeaderResolution.kt | 3 +- .../assistant/TagArrayExt.kt | 5 +- .../concepts/WordWrapper.kt | 12 +- .../decentralizedLists/header/TagArrayExt.kt | 12 +- .../header/tags/ItemKindTag.kt | 2 +- .../item/AddressableListItemEvent.kt | 17 ++- .../decentralizedLists/item/ListItemEvent.kt | 17 ++- .../decentralizedLists/item/TagArrayExt.kt | 9 +- .../item/tags/ParentListTag.kt | 9 +- .../taggings/EventTagging.kt | 55 ++++++-- .../taggings/PubKeyTagging.kt | 38 ++++-- .../taggings/TagArrayBuilderExt.kt | 13 ++ .../taggings/TagArrayExt.kt | 6 +- .../decentralizedLists/taggings/TagElement.kt | 15 ++- .../decentralizedLists/taggings/TagPin.kt | 30 +++-- .../decentralizedLists/tags/InheritFromTag.kt | 8 +- .../decentralizedLists/AuditRegressionTest.kt | 120 ++++++++++++++++++ 21 files changed, 401 insertions(+), 81 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/CoordinateShape.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/AuditRegressionTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/CoordinateShape.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/CoordinateShape.kt new file mode 100644 index 0000000000..e311973efb --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/CoordinateShape.kt @@ -0,0 +1,45 @@ +/* + * 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.experimental.decentralizedLists + +/** + * A cheap, allocation-free check that a value has the `:<64-hex pubkey>:` coordinate + * shape, done before anything reaches `AddressSerializer.parse`. That parser splits the string + * and logs a warning for every value it rejects — and in this family a rejected value is + * routine: `z` tags legitimately carry plain list names, which may be long and contain colons. + */ +internal object CoordinateShape { + fun matches(value: String): Boolean { + val firstColon = value.indexOf(':') + // kinds are at most 5 digits (0..65535) + if (firstColon !in 1..5) return false + for (i in 0 until firstColon) { + if (value[i] !in '0'..'9') return false + } + val pubKeyEnd = firstColon + 1 + 64 + if (value.length <= pubKeyEnd || value[pubKeyEnd] != ':') return false + for (i in firstColon + 1 until pubKeyEnd) { + val c = value[i] + if (c !in '0'..'9' && c !in 'a'..'f' && c !in 'A'..'F') return false + } + return true + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/README.md index 91beb7be2d..060f22cfb4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/README.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/README.md @@ -128,6 +128,10 @@ All kind 39999 items, told apart by their `z` tags: NIP-09 deletion. - `TaggingHeader` + `EventTagging`: tagging events. The target sits in `a`/`e`, so the tag is reached through a `z` to a per-tag header. +- Federation: every builder takes a collection of concept namespaces and + writes one concept `z` per namespace; every parser takes the set of + namespaces the reader honors. Pass them all: a concept `z` the reader didn't + list would be taken for something else, e.g. a tagging header. - `polarity()`: no tag means apply, `≥ 0.5` applied, `≤ -0.5` disputed, and anything in between is not counted in v1. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/SearchExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/SearchExt.kt index 8433037d25..e866f645b9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/SearchExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/SearchExt.kt @@ -20,11 +20,13 @@ */ package com.vitorpamplona.quartz.experimental.decentralizedLists -import com.vitorpamplona.quartz.experimental.decentralizedLists.header.names -import com.vitorpamplona.quartz.experimental.decentralizedLists.header.titles -import com.vitorpamplona.quartz.experimental.decentralizedLists.item.comments -import com.vitorpamplona.quartz.experimental.decentralizedLists.item.name -import com.vitorpamplona.quartz.experimental.decentralizedLists.item.title +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.NamesTag +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.SingularPlural +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.TitlesTag +import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.CommentsTag +import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.NameTag +import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.TitleTag +import com.vitorpamplona.quartz.experimental.decentralizedLists.tags.DescriptionTag import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.fastForEach import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag @@ -42,18 +44,40 @@ import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor * @return false when the visitor stopped the walk. */ fun TagArray.forEachSearchableListField(visitor: IndexableFieldVisitor): Boolean { - names()?.let { - if (!visitor.visit(it.singular)) return false - if (!visitor.visit(it.plural)) return false + // The read path runs once per event per keystroke, so this is two allocation-free passes + // instead of one full scan (and one parsed object) per field. The first pass only remembers + // the first well-formed tag of each field; the fixed visiting order is applied afterwards. + var names: Array? = null + var titles: Array? = null + var name: String? = null + var title: String? = null + var description: String? = null + var comments: String? = null + + fastForEach { tag -> + if (tag.size < 2 || tag[1].isEmpty()) return@fastForEach + when (tag[0]) { + NamesTag.TAG_NAME -> if (names == null && SingularPlural.isTag(tag, NamesTag.TAG_NAME)) names = tag + TitlesTag.TAG_NAME -> if (titles == null && SingularPlural.isTag(tag, TitlesTag.TAG_NAME)) titles = tag + NameTag.TAG_NAME -> if (name == null) name = tag[1] + TitleTag.TAG_NAME -> if (title == null) title = tag[1] + DescriptionTag.TAG_NAME -> if (description == null) description = tag[1] + CommentsTag.TAG_NAME -> if (comments == null) comments = tag[1] + } } - titles()?.let { - if (!visitor.visit(it.singular)) return false - if (!visitor.visit(it.plural)) return false + + names?.let { + if (!visitor.visit(it[1])) return false + if (!visitor.visit(it[2])) return false } - name()?.let { if (!visitor.visit(it)) return false } - title()?.let { if (!visitor.visit(it)) return false } - description()?.let { if (!visitor.visit(it)) return false } - comments()?.let { if (!visitor.visit(it)) return false } + titles?.let { + if (!visitor.visit(it[1])) return false + if (!visitor.visit(it[2])) return false + } + name?.let { if (!visitor.visit(it)) return false } + title?.let { if (!visitor.visit(it)) return false } + description?.let { if (!visitor.visit(it)) return false } + comments?.let { if (!visitor.visit(it)) return false } fastForEach { tag -> HashtagTag.parse(tag)?.let { if (!visitor.visit(it)) return false } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TagArrayExt.kt index 01c6bb3fbb..6eaa5f03bf 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/TagArrayExt.kt @@ -26,8 +26,10 @@ import com.vitorpamplona.quartz.experimental.decentralizedLists.tags.InheritFrom import com.vitorpamplona.quartz.experimental.decentralizedLists.tags.InheritType import com.vitorpamplona.quartz.experimental.decentralizedLists.tags.JsonTag import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull -fun TagArray.description() = firstNotNullOfOrNull(DescriptionTag::parse) +fun TagArray.description() = fastFirstNotNullOfOrNull(DescriptionTag::parse) /** Every `b` link, in tag order. Excludes the `b-tag-deferred` marker. */ fun TagArray.inheritFrom() = mapNotNull(InheritFromTag::parse) @@ -39,10 +41,10 @@ fun TagArray.inheritFrom() = mapNotNull(InheritFromTag::parse) fun TagArray.inheritFromTargets(type: InheritType) = mapNotNull { InheritFromTag.parseTarget(it, type) } /** True when the event carries `["b", "b-tag-deferred"]`: deliberately affiliated with nothing. */ -fun TagArray.isDeliberatelyUnaffiliated() = any(InheritFromTag::isUnaffiliatedMarker) +fun TagArray.isDeliberatelyUnaffiliated() = fastAny(InheritFromTag::isUnaffiliatedMarker) /** The raw `json` tag. */ -fun TagArray.json() = firstNotNullOfOrNull(JsonTag::parse) +fun TagArray.json() = fastFirstNotNullOfOrNull(JsonTag::parse) /** The `json` tag parsed as a JSON object, or null when absent or malformed. */ fun TagArray.wordWrapper() = json()?.let(WordWrapper::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/HeaderResolution.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/HeaderResolution.kt index b4842783bf..e659137211 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/HeaderResolution.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/HeaderResolution.kt @@ -61,6 +61,7 @@ object HeaderResolution { headers: Collection, ): AddressableListHeaderEvent? = candidates(user, slug, treasureMap).firstNotNullOfOrNull { candidate -> - headers.firstOrNull { it.pubKey == candidate.pubKeyHex && it.dTag() == candidate.dTag } + // several versions of one addressable header may be at hand: the newest is the header + headers.filter { it.pubKey == candidate.pubKeyHex && it.dTag() == candidate.dTag }.maxByOrNull { it.createdAt } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/TagArrayExt.kt index 1ea8169845..fe0172e3da 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/assistant/TagArrayExt.kt @@ -25,12 +25,13 @@ import com.vitorpamplona.quartz.experimental.decentralizedLists.assistant.tags.A import com.vitorpamplona.quartz.experimental.decentralizedLists.assistant.tags.DListCuration import com.vitorpamplona.quartz.experimental.decentralizedLists.assistant.tags.DListCurationTag import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull // Both entry families ride NIP-85's kind 10040 but are bounded away from it: NIP-85 only reads // 30382-30385 keys, and these parsers only read 39998/39999 ones. /** The blanket assistant designation. On duplicates the first occurrence wins. */ -fun TagArray.dListAssistant() = firstNotNullOfOrNull(AssistantDesignationTag::parse) +fun TagArray.dListAssistant() = fastFirstNotNullOfOrNull(AssistantDesignationTag::parse) /** Every per-list curation entry, first occurrence per (kind, d-tag). */ fun TagArray.dListCurations() = mapNotNull(DListCurationTag::parse).distinctBy { it.kind to it.dTag } @@ -38,7 +39,7 @@ fun TagArray.dListCurations() = mapNotNull(DListCurationTag::parse).distinctBy { fun TagArray.dListCuration( kind: Int, dTag: String, -) = firstNotNullOfOrNull { tag -> DListCurationTag.parse(tag)?.takeIf { it.kind == kind && it.dTag == dTag } } +) = fastFirstNotNullOfOrNull { tag -> DListCurationTag.parse(tag)?.takeIf { it.kind == kind && it.dTag == dTag } } /** * Sets the blanket designation in place, keeping every other tag verbatim — 10040 is diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/concepts/WordWrapper.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/concepts/WordWrapper.kt index 42811878c9..4bdae9b55d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/concepts/WordWrapper.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/concepts/WordWrapper.kt @@ -73,10 +73,16 @@ data class WordSection( val slug: String? = null, val name: String? = null, val title: String? = null, - val wordTypes: List = emptyList(), + // Nullable: this is authored JSON, and an explicit `null` must read as "absent" rather + // than fail the whole section. Use [types] / [memberOf] for the non-null view. + val wordTypes: List? = null, /** Set on a concept's core nodes, pointing back at the concept; omitted by the header itself. */ - val coreMemberOf: List = emptyList(), -) + val coreMemberOf: List? = null, +) { + fun types() = wordTypes.orEmpty() + + fun memberOf() = coreMemberOf.orEmpty() +} @Immutable @Serializable diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/TagArrayExt.kt index 52de07f8ea..27d2e7318b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/TagArrayExt.kt @@ -28,12 +28,14 @@ import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.TagR import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.TagRuleType import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.TitlesTag import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull -fun TagArray.names() = firstNotNullOfOrNull(NamesTag::parse) +fun TagArray.names() = fastFirstNotNullOfOrNull(NamesTag::parse) -fun TagArray.titles() = firstNotNullOfOrNull(TitlesTag::parse) +fun TagArray.titles() = fastFirstNotNullOfOrNull(TitlesTag::parse) -fun TagArray.slugs() = firstNotNullOfOrNull(SlugsTag::parse) +fun TagArray.slugs() = fastFirstNotNullOfOrNull(SlugsTag::parse) /** Every `required` / `allowed` / `recommended` / `disallowed` rule, in tag order. */ fun TagArray.tagRules() = mapNotNull(TagRuleTag::parse) @@ -52,10 +54,10 @@ fun TagArray.disallowedTags() = tagRuleNames(TagRuleType.DISALLOWED) * True when the tags carry a list declaration. Standard headers (9998/39998) must; an item * (9999/39999) that does is using the spec's "nonstandard" method to declare a list. */ -fun TagArray.declaresList() = any(NamesTag::isTag) +fun TagArray.declaresList() = fastAny(NamesTag::isTag) /** The `concept-graph` pointer when present. See [ConceptGraphTag.compute] for the fallback. */ -fun TagArray.conceptGraph() = firstNotNullOfOrNull(ConceptGraphTag::parse) +fun TagArray.conceptGraph() = fastFirstNotNullOfOrNull(ConceptGraphTag::parse) /** The `item-kind` declarations, in tag order. */ fun TagArray.itemKinds() = mapNotNull(ItemKindTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/ItemKindTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/ItemKindTag.kt index 60ae269125..39cbee0a47 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/ItemKindTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/header/tags/ItemKindTag.kt @@ -44,7 +44,7 @@ class ItemKindTag { companion object { const val TAG_NAME = "item-kind" - fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].toIntOrNull() != null + fun isTag(tag: Array) = parse(tag) != null fun parse(tag: Array): ItemKind? { ensure(tag.has(1)) { return null } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/AddressableListItemEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/AddressableListItemEvent.kt index f8dcfcf213..cdc356d13b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/AddressableListItemEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/AddressableListItemEvent.kt @@ -49,6 +49,7 @@ import com.vitorpamplona.quartz.experimental.decentralizedLists.wordWrapper import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.fastForEach import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider @@ -89,13 +90,21 @@ class AddressableListItemEvent( override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) - /** Event items plus any parent lists referenced by id. */ - override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + tags.mapNotNull(ParentListTag::parseEventId) + /** Event items plus any parent lists referenced by id, in one pass. */ + override fun linkedEventIds(): List { + val out = ArrayList() + tags.fastForEach { tag -> (ETag.parseId(tag) ?: ParentListTag.parseEventId(tag))?.let { out.add(it) } } + return out + } override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) - /** Addressable items plus any parent lists referenced by coordinate. */ - override fun linkedAddressIds() = tags.mapNotNull(ATag::parseValidAddress) + tags.mapNotNull { ParentListTag.parseAddress(it)?.toValue() } + /** Addressable items plus any parent lists referenced by coordinate, in one pass. */ + override fun linkedAddressIds(): List { + val out = ArrayList() + tags.fastForEach { tag -> (ATag.parseValidAddress(tag) ?: ParentListTag.parseCoordinate(tag))?.let { out.add(it) } } + return out + } override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/ListItemEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/ListItemEvent.kt index 37ca19be52..ba838382d3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/ListItemEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/ListItemEvent.kt @@ -42,6 +42,7 @@ import com.vitorpamplona.quartz.experimental.decentralizedLists.searchableListCo import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.fastForEach import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider @@ -83,13 +84,21 @@ class ListItemEvent( override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) - /** Event items plus any parent lists referenced by id. */ - override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + tags.mapNotNull(ParentListTag::parseEventId) + /** Event items plus any parent lists referenced by id, in one pass. */ + override fun linkedEventIds(): List { + val out = ArrayList() + tags.fastForEach { tag -> (ETag.parseId(tag) ?: ParentListTag.parseEventId(tag))?.let { out.add(it) } } + return out + } override fun addressHints() = tags.mapNotNull(ATag::parseAsHint) - /** Addressable items plus any parent lists referenced by coordinate. */ - override fun linkedAddressIds() = tags.mapNotNull(ATag::parseValidAddress) + tags.mapNotNull { ParentListTag.parseAddress(it)?.toValue() } + /** Addressable items plus any parent lists referenced by coordinate, in one pass. */ + override fun linkedAddressIds(): List { + val out = ArrayList() + tags.fastForEach { tag -> (ATag.parseValidAddress(tag) ?: ParentListTag.parseCoordinate(tag))?.let { out.add(it) } } + return out + } override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/TagArrayExt.kt index c97708fcfe..1ffb8c4897 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/TagArrayExt.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.SlugTa import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.SubsetOfTag import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.TitleTag import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag @@ -39,13 +40,13 @@ fun TagArray.parentLists() = mapNotNull(ParentListTag::parse) /** The raw `z` values, as they would go into a `#z` filter. */ fun TagArray.parentListPointers() = mapNotNull(ParentListTag::parseValue) -fun TagArray.name() = firstNotNullOfOrNull(NameTag::parse) +fun TagArray.name() = fastFirstNotNullOfOrNull(NameTag::parse) -fun TagArray.title() = firstNotNullOfOrNull(TitleTag::parse) +fun TagArray.title() = fastFirstNotNullOfOrNull(TitleTag::parse) -fun TagArray.slug() = firstNotNullOfOrNull(SlugTag::parse) +fun TagArray.slug() = fastFirstNotNullOfOrNull(SlugTag::parse) -fun TagArray.comments() = firstNotNullOfOrNull(CommentsTag::parse) +fun TagArray.comments() = fastFirstNotNullOfOrNull(CommentsTag::parse) /** Pubkeys declared as items (`p`). */ fun TagArray.itemPubKeys() = mapNotNull(PTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/ParentListTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/ParentListTag.kt index 7db4355938..19753a4e7b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/ParentListTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/item/tags/ParentListTag.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.decentralizedLists.CoordinateShape import com.vitorpamplona.quartz.experimental.decentralizedLists.DecentralizedListEvent import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.AddressSerializer @@ -101,6 +102,12 @@ class ParentListTag { return if (looksLikeAddress(value)) AddressSerializer.parse(value) else null } + /** The raw `kind:pubkey:d` value when [tag] points at a coordinate. No parsing, no allocation. */ + fun parseCoordinate(tag: Array): String? { + val value = parseValue(tag) ?: return null + return if (looksLikeAddress(value)) value else null + } + fun classify(value: String): ParentList { if (isEventId(value)) return ParentList.EventId(value) if (looksLikeAddress(value)) { @@ -113,7 +120,7 @@ class ParentListTag { // Only coordinate-shaped values reach the address parser: it logs a warning for // everything it rejects, and a plain list name such as "dog" is not an error. - private fun looksLikeAddress(value: String) = value.length >= 66 && value.contains(':') + private fun looksLikeAddress(value: String) = CoordinateShape.matches(value) fun assemble(pointer: String) = arrayOf(TAG_NAME, pointer) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/EventTagging.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/EventTagging.kt index 20130201ee..1e157b23e6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/EventTagging.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/EventTagging.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.Parent import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.tags.Polarity 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.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.utils.TimeUtils @@ -71,21 +72,31 @@ object TaggingHeader { * @param taggingWithSpecificTagConcept the deployment's `tagging-with-specific-tag` concept. */ fun build( - taggingWithSpecificTagConcept: String, + taggingWithSpecificTagConcepts: Collection, tagElement: Address, singularName: String, pluralName: String, description: String? = null, createdAt: Long = TimeUtils.now(), ) = AddressableListItemEvent.build( - parent = ParentListTag.classify(taggingWithSpecificTagConcept), + parent = taggingWithSpecificTagConcepts.firstNamespace(), dTag = dTag(tagElement.dTag), createdAt = createdAt, ) { + conceptNamespaces(taggingWithSpecificTagConcepts) names(singularName, pluralName) description?.let { this.description(it) } itemAddress(tagElement) } + + fun build( + taggingWithSpecificTagConcept: String, + tagElement: Address, + singularName: String, + pluralName: String, + description: String? = null, + createdAt: Long = TimeUtils.now(), + ) = build(listOf(taggingWithSpecificTagConcept), tagElement, singularName, pluralName, description, createdAt) } /** @@ -118,13 +129,14 @@ data class EventTagging( ) = "event-tag-$tagSlug-${target.prefix}-${asserter.take(8)}" /** - * @param nostrEventTagConcept the deployment's `nostr-event-tag` concept address. + * @param nostrEventTagConcepts the `nostr-event-tag` concept of each authority namespace + * to join: the deployment's own, plus any shared one it federates with. * @param taggingHeader the per-tag tagging header's coordinate. * @param tagSlug the applied tag's slug, for the deterministic `d`. * @param asserter the pubkey that will sign, for the deterministic `d`. */ fun build( - nostrEventTagConcept: String, + nostrEventTagConcepts: Collection, taggingHeader: Address, tagSlug: String, target: TaggingTarget, @@ -132,10 +144,11 @@ data class EventTagging( apply: Boolean = true, createdAt: Long = TimeUtils.now(), ) = AddressableListItemEvent.build( - parent = ParentListTag.classify(nostrEventTagConcept), + parent = nostrEventTagConcepts.firstNamespace(), dTag = dTag(tagSlug, target, asserter), createdAt = createdAt, ) { + conceptNamespaces(nostrEventTagConcepts) parentList(ParentListTag.classify(taggingHeader.toValue())) when (target) { is TaggingTarget.ByAddress -> itemAddress(target.address) @@ -144,22 +157,42 @@ data class EventTagging( polarity(apply) } - /** Null unless [event] joins [nostrEventTagConcept], names a tagging header and a target. */ + fun build( + nostrEventTagConcept: String, + taggingHeader: Address, + tagSlug: String, + target: TaggingTarget, + asserter: HexKey, + apply: Boolean = true, + createdAt: Long = TimeUtils.now(), + ) = build(listOf(nostrEventTagConcept), taggingHeader, tagSlug, target, asserter, apply, createdAt) + + /** + * Null unless [event] joins one of the [honoredNamespaces]' `nostr-event-tag` concepts, + * names a tagging header and a target. Every namespace the reader honors must be passed: + * a federated tagging carries one concept `z` per namespace it joined, and any concept + * `z` the reader did not list would otherwise be taken for a tagging header. + */ fun parse( event: AddressableListItemEvent, - nostrEventTagConcept: String, + honoredNamespaces: Set, ): EventTagging? { val pointers = event.parentListPointers() - if (nostrEventTagConcept !in pointers) return null - val headers = pointers.filter { it != nostrEventTagConcept } + if (pointers.none { it in honoredNamespaces }) return null + val headers = pointers.filter { it !in honoredNamespaces } if (headers.isEmpty()) return null val target = - event.tags.firstNotNullOfOrNull(ATag::parseAddress)?.let { TaggingTarget.ByAddress(it) } - ?: event.tags.firstNotNullOfOrNull(ETag::parseId)?.let { TaggingTarget.ByEventId(it) } + event.tags.fastFirstNotNullOfOrNull(ATag::parseAddress)?.let { TaggingTarget.ByAddress(it) } + ?: event.tags.fastFirstNotNullOfOrNull(ETag::parseId)?.let { TaggingTarget.ByEventId(it) } ?: return null return EventTagging(target, headers, event.tags.polarity()) } + + fun parse( + event: AddressableListItemEvent, + nostrEventTagConcept: String, + ) = parse(event, setOf(nostrEventTagConcept)) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/PubKeyTagging.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/PubKeyTagging.kt index 8dd33ef46e..4654adcaf4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/PubKeyTagging.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/PubKeyTagging.kt @@ -25,11 +25,11 @@ import com.vitorpamplona.quartz.experimental.decentralizedLists.item.Addressable import com.vitorpamplona.quartz.experimental.decentralizedLists.item.itemAddress import com.vitorpamplona.quartz.experimental.decentralizedLists.item.itemEvent import com.vitorpamplona.quartz.experimental.decentralizedLists.item.itemPubKey -import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentListTag import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.tags.Polarity import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag @@ -81,11 +81,11 @@ data class PubKeyTagging( ) = "profile-tag-$tagSlug-${target.take(8)}-${asserter.take(8)}" /** - * @param userTagConcept the deployment's `nostr-user-tag` concept address. + * @param userTagConcepts the `nostr-user-tag` concept of each authority namespace to join. * @param asserter the pubkey that will sign, needed for the deterministic `d`. */ fun build( - userTagConcept: String, + userTagConcepts: Collection, tagElement: Address, tagEventId: HexKey?, target: HexKey, @@ -93,11 +93,12 @@ data class PubKeyTagging( apply: Boolean = true, createdAt: Long = TimeUtils.now(), ) = AddressableListItemEvent.build( - parent = ParentListTag.classify(userTagConcept), + parent = userTagConcepts.firstNamespace(), dTag = dTag(tagElement.dTag, target, asserter), createdAt = createdAt, content = JsonMapper.toJson(NostrUserTagContent(NostrUserTagInfo(target, tagEventId))), ) { + conceptNamespaces(userTagConcepts) itemPubKey(target) itemAddress(tagElement) tagEventId?.let { itemEvent(it) } @@ -111,21 +112,36 @@ data class PubKeyTagging( asserter: HexKey, apply: Boolean = true, createdAt: Long = TimeUtils.now(), - ) = build(userTagConcept, tagElement.address(), tagElement.id, target, asserter, apply, createdAt) + ) = build(listOf(userTagConcept), tagElement.address(), tagElement.id, target, asserter, apply, createdAt) - /** Null unless [event] joins [userTagConcept] and names a target pubkey. */ + fun build( + userTagConcept: String, + tagElement: Address, + tagEventId: HexKey?, + target: HexKey, + asserter: HexKey, + apply: Boolean = true, + createdAt: Long = TimeUtils.now(), + ) = build(listOf(userTagConcept), tagElement, tagEventId, target, asserter, apply, createdAt) + + /** Null unless [event] joins one of the [honoredNamespaces] and names a target pubkey. */ fun parse( event: AddressableListItemEvent, - userTagConcept: String, + honoredNamespaces: Set, ): PubKeyTagging? { - if (userTagConcept !in event.parentListPointers()) return null - val target = event.tags.firstNotNullOfOrNull(PTag::parseKey) ?: return null + if (event.parentListPointers().none { it in honoredNamespaces }) return null + val target = event.tags.fastFirstNotNullOfOrNull(PTag::parseKey) ?: return null return PubKeyTagging( target = target, - tag = event.tags.firstNotNullOfOrNull(ATag::parseAddress), - tagEventId = event.tags.firstNotNullOfOrNull(ETag::parseId), + tag = event.tags.fastFirstNotNullOfOrNull(ATag::parseAddress), + tagEventId = event.tags.fastFirstNotNullOfOrNull(ETag::parseId), polarity = event.tags.polarity(), ) } + + fun parse( + event: AddressableListItemEvent, + userTagConcept: String, + ) = parse(event, setOf(userTagConcept)) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagArrayBuilderExt.kt index 950eb80cb2..e4a2295e90 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagArrayBuilderExt.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.experimental.decentralizedLists.taggings import com.vitorpamplona.quartz.experimental.decentralizedLists.item.AddressableListItemEvent +import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentList import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentListTag import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.tags.CurationMethod import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.tags.CurationMethodTag @@ -33,3 +34,15 @@ fun TagArrayBuilder.curationMethod(method: CurationMet /** Rides alongside the concept-membership `z`, never instead of it. */ fun TagArrayBuilder.applicabilityHint(hint: TagApplicabilityHint) = addUniqueValueIfNew(ParentListTag.assemble(hint.code)) + +/** + * Federation: one concept `z` per authority namespace the publisher joins (e.g. a shared + * canonical `nostr-event-tag` and the deployment's own). Joining several is opt-in; a reader + * scanning any one of them finds the event. + */ +fun TagArrayBuilder.conceptNamespaces(concepts: Collection) = concepts.forEach { addUniqueValueIfNew(ParentListTag.assemble(it)) } + +internal fun Collection.firstNamespace(): ParentList { + require(isNotEmpty()) { "At least one concept namespace is required" } + return ParentListTag.classify(first()) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagArrayExt.kt index 6d3aeb3eab..5d4028203c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagArrayExt.kt @@ -25,18 +25,20 @@ import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.tags.Cu import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.tags.Polarity import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.tags.PolarityTag import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull +import com.vitorpamplona.quartz.nip01Core.core.fastFirstOrNull /** * How a v1 reader counts this tagging. No `polarity` tag means apply; a tag whose value is * not a number is not counted, rather than guessed. */ fun TagArray.polarity(): Polarity { - val tag = firstOrNull(PolarityTag::isTag) ?: return Polarity.APPLIED + val tag = fastFirstOrNull(PolarityTag::isTag) ?: return Polarity.APPLIED val value = PolarityTag.parseValue(tag) ?: return Polarity.UNCOUNTED return PolarityTag.bucket(value) } -fun TagArray.curationMethod() = firstNotNullOfOrNull(CurationMethodTag::parse) +fun TagArray.curationMethod() = fastFirstNotNullOfOrNull(CurationMethodTag::parse) /** The applicability hints a tag-element's author recorded. Hints, never gates. */ fun TagArray.applicabilityHints() = parentListPointers().mapNotNull(TagApplicabilityHint::fromCode) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagElement.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagElement.kt index 4d53f9d492..d5daa4b8bd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagElement.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagElement.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.quartz.experimental.decentralizedLists.taggings import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.experimental.decentralizedLists.item.AddressableListItemEvent -import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentListTag import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.serialization.Serializable @@ -79,20 +78,30 @@ object TagElement { * Deployment-specific: never hardcode it. */ fun build( - tagConcept: String, + tagConcepts: Collection, slug: String, name: String, description: String? = null, hints: Set = emptySet(), createdAt: Long = TimeUtils.now(), ) = AddressableListItemEvent.build( - parent = ParentListTag.classify(tagConcept), + parent = tagConcepts.firstNamespace(), dTag = slug, createdAt = createdAt, content = TagElementContent(TagInfo(slug, name, description)).toContent(), ) { + conceptNamespaces(tagConcepts) hints.forEach { applicabilityHint(it) } } + fun build( + tagConcept: String, + slug: String, + name: String, + description: String? = null, + hints: Set = emptySet(), + createdAt: Long = TimeUtils.now(), + ) = build(listOf(tagConcept), slug, name, description, hints, createdAt) + fun content(event: AddressableListItemEvent) = TagElementContent.parse(event.content) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagPin.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagPin.kt index ec871fba8a..b0d82202c5 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagPin.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/taggings/TagPin.kt @@ -24,11 +24,11 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.experimental.decentralizedLists.item.AddressableListItemEvent import com.vitorpamplona.quartz.experimental.decentralizedLists.item.itemAddress import com.vitorpamplona.quartz.experimental.decentralizedLists.item.itemEvent -import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentListTag import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.tags.CurationMethod import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.utils.TimeUtils @@ -79,33 +79,47 @@ data class TagPin( * @param viewer the pubkey that will sign, needed for the deterministic `d`. */ fun build( - tagPinningConcept: String, + tagPinningConcepts: Collection, tagElement: AddressableListItemEvent, viewer: HexKey, curationMethod: CurationMethod, createdAt: Long = TimeUtils.now(), ) = AddressableListItemEvent.build( - parent = ParentListTag.classify(tagPinningConcept), + parent = tagPinningConcepts.firstNamespace(), dTag = dTag(tagElement.dTag(), tagElement.pubKey, viewer), createdAt = createdAt, content = JsonMapper.toJson(TagPinningContent(TagPinningInfo(tagElement.id, curationMethod))), ) { + conceptNamespaces(tagPinningConcepts) itemEvent(tagElement.id) itemAddress(tagElement.address()) curationMethod(curationMethod) } - /** Null unless [event] joins [tagPinningConcept]. */ + fun build( + tagPinningConcept: String, + tagElement: AddressableListItemEvent, + viewer: HexKey, + curationMethod: CurationMethod, + createdAt: Long = TimeUtils.now(), + ) = build(listOf(tagPinningConcept), tagElement, viewer, curationMethod, createdAt) + + /** Null unless [event] joins one of the [honoredNamespaces]. */ fun parse( event: AddressableListItemEvent, - tagPinningConcept: String, + honoredNamespaces: Set, ): TagPin? { - if (tagPinningConcept !in event.parentListPointers()) return null + if (event.parentListPointers().none { it in honoredNamespaces }) return null return TagPin( - tag = event.tags.firstNotNullOfOrNull(ATag::parseAddress), - tagEventId = event.tags.firstNotNullOfOrNull(ETag::parseId), + tag = event.tags.fastFirstNotNullOfOrNull(ATag::parseAddress), + tagEventId = event.tags.fastFirstNotNullOfOrNull(ETag::parseId), curationMethod = event.tags.curationMethod(), ) } + + fun parse( + event: AddressableListItemEvent, + tagPinningConcept: String, + ) = parse(event, setOf(tagPinningConcept)) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/tags/InheritFromTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/tags/InheritFromTag.kt index ec948c0a6a..b60a5e1aea 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/tags/InheritFromTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/tags/InheritFromTag.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.experimental.decentralizedLists.tags import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.decentralizedLists.CoordinateShape import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.has import com.vitorpamplona.quartz.utils.ensure @@ -82,15 +83,16 @@ data class InheritFromTag( */ const val UNAFFILIATED = "b-tag-deferred" - fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() && tag[1] != UNAFFILIATED + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && CoordinateShape.matches(tag[1]) fun isUnaffiliatedMarker(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == UNAFFILIATED fun parse(tag: Array): InheritFromTag? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } - ensure(tag[1].isNotEmpty()) { return null } - ensure(tag[1] != UNAFFILIATED) { return null } + // The value form is closed: a coordinate, or the reserved marker (which is not a + // target). Anything else derives nothing rather than becoming a bogus parent. + ensure(CoordinateShape.matches(tag[1])) { return null } return InheritFromTag(tag[1], InheritType.fromCode(tag.getOrNull(2))) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/AuditRegressionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/AuditRegressionTest.kt new file mode 100644 index 0000000000..ab0579b6df --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/decentralizedLists/AuditRegressionTest.kt @@ -0,0 +1,120 @@ +/* + * 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.experimental.decentralizedLists + +import com.vitorpamplona.quartz.experimental.decentralizedLists.assistant.HeaderResolution +import com.vitorpamplona.quartz.experimental.decentralizedLists.concepts.WordWrapper +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.AddressableListHeaderEvent +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.itemKinds +import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.ItemKindTag +import com.vitorpamplona.quartz.experimental.decentralizedLists.item.AddressableListItemEvent +import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentList +import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.EventTagging +import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.TaggingTarget +import com.vitorpamplona.quartz.experimental.decentralizedLists.tags.InheritType +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.utils.EventFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs + +/** Regressions found in the audit: each failed before its fix. */ +class AuditRegressionTest { + private val alice = "a".repeat(64) + private val bob = "b".repeat(64) + private val ta = "d".repeat(64) + private val other = "f".repeat(64) + + private fun item(tags: Array>) = assertIs(EventFactory.create("e".repeat(64), alice, 1L, 39999, tags, "", "")) + + // Event Taggings § "Concept namespaces & federation": one nostr-event-tag `z` per namespace. + @Test + fun federatedTaggingsDoNotMistakeOtherNamespacesForTaggingHeaders() { + val tagging = + item( + arrayOf( + arrayOf("d", "event-tag-awesome-tag-cccccccc-aaaaaaaa"), + arrayOf("a", "39999:$bob:good-tag"), + arrayOf("z", "39998:$ta:nostr-event-tag"), + arrayOf("z", "39998:$other:nostr-event-tag"), + arrayOf("z", "39999:$bob:tagging:awesome-tag-tagging"), + ), + ) + + val parsed = EventTagging.parse(tagging, setOf("39998:$ta:nostr-event-tag", "39998:$other:nostr-event-tag"))!! + assertEquals(listOf("39999:$bob:tagging:awesome-tag-tagging"), parsed.taggingHeaders) + } + + @Test + fun federatedTaggingsCanBeBuiltForSeveralNamespaces() { + val template = + EventTagging.build( + nostrEventTagConcepts = listOf("39998:$ta:nostr-event-tag", "39998:$other:nostr-event-tag"), + taggingHeader = Address(39999, bob, "tagging:awesome-tag-tagging"), + tagSlug = "awesome-tag", + target = TaggingTarget.ByEventId("1".repeat(64)), + asserter = alice, + ) + assertEquals( + listOf("39998:$ta:nostr-event-tag", "39998:$other:nostr-event-tag", "39999:$bob:tagging:awesome-tag-tagging"), + template.tags.filter { it[0] == "z" }.map { it[1] }, + ) + } + + // Two versions of the same personal header must resolve to the newest, not whichever came first. + @Test + fun governingHeaderIsTheNewestVersionOfTheWinningAuthor() { + val old = assertIs(EventFactory.create("1".repeat(64), alice, 1L, 39998, arrayOf(arrayOf("d", "dogs")), "", "")) + val new = assertIs(EventFactory.create("2".repeat(64), alice, 5L, 39998, arrayOf(arrayOf("d", "dogs")), "", "")) + assertEquals(new, HeaderResolution.governing(alice, "dogs", null, listOf(old, new))) + } + + // Inherit-From: element 2 is an a-tag; the value form is closed. + @Test + fun bTagsWhoseTargetIsNotACoordinateAreIgnored() { + val node = item(arrayOf(arrayOf("d", "x"), arrayOf("b", "hello", "inherit"), arrayOf("b", "39998:$bob:dogs", "inherit"))) + assertEquals(listOf("39998:$bob:dogs"), node.inheritFromTargets(InheritType.INHERIT)) + } + + @Test + fun itemKindIsTagAgreesWithParse() { + val tag = arrayOf("item-kind", "-1") + assertEquals(ItemKindTag.parse(tag) != null, ItemKindTag.isTag(tag)) + assertEquals(emptyList(), arrayOf(tag).itemKinds()) + } + + // A long list name containing a colon is still a name, and never reaches the address parser. + @Test + fun longNamesWithColonsStayNames() { + val name = "a list of: " + "x".repeat(70) + val node = item(arrayOf(arrayOf("d", "x"), arrayOf("z", name))) + assertIs(node.parentLists().single()) + assertFalse(name in node.linkedAddressIds()) + } + + // Authored JSON: an explicit null in a list field must not make the whole section unreadable. + @Test + fun explicitNullListsInTheWordSectionAreTolerated() { + val wrapper = WordWrapper.parse("""{"word":{"slug":"dogs","wordTypes":null,"coreMemberOf":null}}""")!! + assertEquals("dogs", wrapper.word()?.slug) + } +}