diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupIconDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupIconDisplay.kt index 27abf2ec10..d26d064494 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupIconDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/MarmotGroupIconDisplay.kt @@ -28,6 +28,8 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.model.marmotGroups.MarmotGroupImage import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.marmot.appComponents.GroupAvatarUrlV1 +import com.vitorpamplona.quartz.marmot.appComponents.MarmotHttpsUrl import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageCipher import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @@ -92,6 +94,38 @@ fun rememberMarmotGroupIconUrl( return url } +/** + * The avatar URL for a group that may carry either avatar carrier, applying the + * components' precedence: `marmot.group.avatar-url.v1` wins over + * `marmot.group.blossom.image.v1`, and clearing the URL one falls back to the + * Blossom blob. + * + * The URL avatar is a plain link with no key material, so there is no cipher to + * register — it just goes to Coil. It does get a contact check first: a URL can + * be valid group state and still be somewhere we refuse to fetch from, and the + * spec puts that decision squarely on the client. An unsafe destination renders + * as no URL avatar rather than as an error, which lets the Blossom image (or the + * relay icon) take over. + * + * Returns null when the group has neither carrier. + */ +@Composable +fun rememberMarmotGroupAvatarUrl( + avatarUrl: GroupAvatarUrlV1?, + image: MarmotGroupImage?, + accountViewModel: AccountViewModel, + adminPubkeys: List = emptyList(), +): String? { + val link = + remember(avatarUrl) { + avatarUrl?.url?.takeIf { it.isNotEmpty() && MarmotHttpsUrl.isSafeToContact(it) } + } + // Branch rather than resolving both: the Blossom path registers a + // decryption cipher and probes servers as a side effect, and neither is + // worth doing for an avatar the renderer is not going to show. + return if (link != null) link else rememberMarmotGroupIconUrl(image, accountViewModel, adminPubkeys) +} + /** * The NIP-11 icon of the group's first resolvable relay, used as a fallback avatar * when the group has no image of its own. Fetches the relay's NIP-11 document on a diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index 2951df49ca..a2859f96ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -103,7 +103,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.buzzTimeli import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.loadMarmotRelayIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.marmotGroupLastReadRoute -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.rememberMarmotGroupIconUrl +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.rememberMarmotGroupAvatarUrl import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.reportWarningContentDescription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCommunityPill @@ -466,6 +466,7 @@ private fun MarmotGroupRoomCompose( ) { val displayName by chatroom.displayName.collectAsStateWithLifecycle() val image by chatroom.image.collectAsStateWithLifecycle() + val avatarUrl by chatroom.avatarUrl.collectAsStateWithLifecycle() val relays by chatroom.relays.collectAsStateWithLifecycle() val adminPubkeys by chatroom.adminPubkeys.collectAsStateWithLifecycle() @@ -473,11 +474,12 @@ private fun MarmotGroupRoomCompose( val noteEvent = lastMessage.event val groupName = displayName?.takeIf { it.isNotBlank() } ?: "Group ${chatroom.nostrGroupId.take(8)}" - // Prefer the group's own (encrypted) avatar; when it has none, fall back to the - // NIP-11 icon of one of the group's relays (fetched on a cache miss). + // Prefer the group's own avatar — the plain https link first, then the + // encrypted Blossom blob; when it has neither, fall back to the NIP-11 icon + // of one of the group's relays (fetched on a cache miss). val channelPicture = - if (image != null) { - rememberMarmotGroupIconUrl(image, accountViewModel, adminPubkeys) + if (avatarUrl != null || image != null) { + rememberMarmotGroupAvatarUrl(avatarUrl, image, accountViewModel, adminPubkeys) } else { loadMarmotRelayIcon(relays) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt index 0e09fbba20..f0315bbf43 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt @@ -90,7 +90,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.loadMarmotRelayIcon -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.rememberMarmotGroupIconUrl +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.rememberMarmotGroupAvatarUrl import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.rememberConcordImageModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.SubPurposeLabels import com.vitorpamplona.amethyst.ui.stringRes @@ -593,14 +593,15 @@ private fun rememberMarmotEntity( val displayName by chatroom.displayName.collectAsStateWithLifecycle() val image by chatroom.image.collectAsStateWithLifecycle() + val avatarUrl by chatroom.avatarUrl.collectAsStateWithLifecycle() val relays by chatroom.relays.collectAsStateWithLifecycle() val adminPubkeys by chatroom.adminPubkeys.collectAsStateWithLifecycle() // Same name/icon precedence the chat-rooms list uses, so a group reads identically in both places. val name = displayName?.takeIf { it.isNotBlank() } ?: stringRes(Res.string.marmot_group_fallback_name, id.take(8)) val picture = - if (image != null) { - rememberMarmotGroupIconUrl(image, accountViewModel, adminPubkeys) + if (avatarUrl != null || image != null) { + rememberMarmotGroupAvatarUrl(avatarUrl, image, accountViewModel, adminPubkeys) } else { loadMarmotRelayIcon(relays) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCommands.kt index eff9dd0b90..4a1c239843 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupCommands.kt @@ -41,6 +41,9 @@ object GroupCommands { | marmot group set-image GID FILE encrypt + commit a group avatar | [--server URL] (--server uploads the ciphertext to Blossom) | marmot group clear-image GID remove the group avatar + | marmot group set-avatar-url GID URL commit a plain https avatar link + | [--dim WxH] [--thumbhash TEXT] (optional opaque render hints) + | marmot group clear-avatar-url GID remove the https avatar link | marmot group remove GID NPUB remove member | marmot group leave GID self-remove """.trimMargin() @@ -65,6 +68,8 @@ object GroupCommands { "demote" to { rest -> GroupMetadataCommands.demote(dataDir, rest) }, "set-image" to { rest -> GroupMetadataCommands.setImage(dataDir, rest) }, "clear-image" to { rest -> GroupMetadataCommands.clearImage(dataDir, rest) }, + "set-avatar-url" to { rest -> GroupMetadataCommands.setAvatarUrl(dataDir, rest) }, + "clear-avatar-url" to { rest -> GroupMetadataCommands.clearAvatarUrl(dataDir, rest) }, "remove" to { rest -> GroupMembershipCommands.remove(dataDir, rest) }, "leave" to { rest -> GroupMembershipCommands.leave(dataDir, rest) }, ), diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt index 1b3ea5d64c..3b298ffb32 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupMetadataCommands.kt @@ -29,7 +29,9 @@ import com.vitorpamplona.amethyst.commons.service.upload.BlossomAuth import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient import com.vitorpamplona.amethyst.commons.util.deleteOrWarn import com.vitorpamplona.quartz.marmot.OutboundGroupEvent +import com.vitorpamplona.quartz.marmot.appComponents.GroupAvatarUrlV1 import com.vitorpamplona.quartz.marmot.appComponents.GroupBlossomImageV1 +import com.vitorpamplona.quartz.marmot.appComponents.MarmotHttpsUrl import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageEncryption import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray @@ -139,6 +141,51 @@ object GroupMetadataCommands { return commit(dataDir, rest[0]) { ctx, gid, _ -> ctx.marmot.setGroupImage(gid, null) } } + /** + * Point the group avatar at a plain `https` URL (`0x8007`). + * + * The URL is normalized by the component's encoder, so what gets committed + * may differ from what was typed — the emitted `avatar_url` is the stored + * form, not the argument. + * + * `group set-avatar-url [--dim WIDTHxHEIGHT] [--thumbhash TEXT]` + */ + suspend fun setAvatarUrl( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val gid = args.positional(0, "gid") + val url = args.positional(1, "url") + val dim = args.flag("dim") + val thumbhash = args.flag("thumbhash") + args.rejectUnknown() + + val avatar = + try { + GroupAvatarUrlV1( + url = MarmotHttpsUrl.normalize(url), + dim = dim?.encodeToByteArray() ?: ByteArray(0), + thumbhash = thumbhash?.encodeToByteArray() ?: ByteArray(0), + ) + } catch (e: IllegalArgumentException) { + return Output.error("bad_args", e.message ?: "invalid avatar URL") + } + + return commit(dataDir, gid, mapOf("avatar_url" to avatar.url)) { ctx, resolved, _ -> + ctx.marmot.setGroupAvatarUrl(resolved, avatar) + } + } + + /** Remove the https avatar link. `group clear-avatar-url ` */ + suspend fun clearAvatarUrl( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.isEmpty()) return Output.error("bad_args", "group clear-avatar-url ") + return commit(dataDir, rest[0]) { ctx, gid, _ -> ctx.marmot.setGroupAvatarUrl(gid, null) } + } + /** * Run one metadata commit and report it. * diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupReadCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupReadCommands.kt index 114118aff9..cacd651fe8 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupReadCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GroupReadCommands.kt @@ -72,6 +72,21 @@ object GroupReadCommands { "epoch" to ctx.marmot.groupEpoch(gid), "admins" to (meta?.adminPubkeys ?: emptyList()), "relays" to (meta?.relays ?: emptyList()), + "avatar_url" to meta?.avatarUrl?.url, + // Hints are opaque bytes by contract; render them as text + // only for the conventional UTF-8 case an operator can read. + "avatar_dim" to + meta + ?.avatarUrl + ?.dim + ?.takeIf { it.isNotEmpty() } + ?.decodeToString(), + "avatar_thumbhash" to + meta + ?.avatarUrl + ?.thumbhash + ?.takeIf { it.isNotEmpty() } + ?.decodeToString(), "members" to members, "is_admin" to (meta?.adminPubkeys?.contains(ctx.identity.pubKeyHex) == true), ), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt index cef8174ea6..727c0e00db 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/marmot/MarmotManager.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.marmot.WelcomeDelivery import com.vitorpamplona.quartz.marmot.WelcomeResult import com.vitorpamplona.quartz.marmot.appComponents.AdminPolicyV1 import com.vitorpamplona.quartz.marmot.appComponents.CurrentProfileGroupFactory +import com.vitorpamplona.quartz.marmot.appComponents.GroupAvatarUrlV1 import com.vitorpamplona.quartz.marmot.appComponents.GroupBlossomImageV1 import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1 import com.vitorpamplona.quartz.marmot.appComponents.MarmotGroupState @@ -1116,6 +1117,16 @@ class MarmotManager( val adminPubkeys: List, val relays: List, val image: MarmotGroupImage?, + /** + * The plain-https avatar (`0x8007`), or null when the group carries + * none. Absent-but-present state reads as null here: a cleared avatar + * and no avatar look identical to a renderer, and the difference only + * matters to the codec. + * + * When this and [image] are both set, this one wins — see + * [MarmotGroupState.preferredAvatar]. + */ + val avatarUrl: GroupAvatarUrlV1?, /** True when the group requires `0x8009` — see [MarmotGroupState.isCurrentProfile]. */ val isCurrentProfile: Boolean, ) @@ -1140,6 +1151,7 @@ class MarmotManager( else -> null }, + avatarUrl = state.avatarUrl?.takeIf { !it.isAbsent }, isCurrentProfile = state.isCurrentProfile, ) } @@ -1242,6 +1254,41 @@ class MarmotManager( }.event } + /** + * Set or clear the group's plain-https avatar (`marmot.group.avatar-url.v1`). + * + * [avatar] null clears it by writing the canonical EMPTY state rather than + * removing the component. That is the spec's own clear ("Clearing the + * avatar sends the empty state"), and removal is not a free substitute for + * it: a component MUST NOT be removed while `app_components` still lists it + * as required, so a remove would only be legal in the same Commit that + * stopped requiring it. + * + * There is no legacy carrier for this. MIP-01's `0xF2EE` blob had only the + * encrypted-Blossom fields, so a legacy group genuinely cannot hold a URL + * avatar and this refuses rather than silently writing somewhere else. + */ + suspend fun setGroupAvatarUrl( + nostrGroupId: HexKey, + avatar: GroupAvatarUrlV1?, + relays: List = groupRelays(nostrGroupId), + ): OutboundGroupEvent { + val view = groupView(nostrGroupId) ?: throw IllegalStateException("Not a member of group $nostrGroupId") + check(view.isCurrentProfile) { + "Group $nostrGroupId is a legacy MIP-01 group and has no carrier for a URL avatar" + } + // Encode before staging: an invalid or non-normalizable URL should fail + // the caller here, not halfway through building a Commit. + val encoded = (avatar?.takeIf { !it.isAbsent } ?: GroupAvatarUrlV1.ABSENT).encode() + return commitAndPublish(nostrGroupId, relays) { + groupManager.stageAppDataUpdate( + nostrGroupId, + GroupAvatarUrlV1.COMPONENT_ID, + encoded, + ) + }.event + } + // --- KeyPackage Management --- /** @@ -1473,6 +1520,7 @@ class MarmotManager( chatroom.adminPubkeys.value = view.adminPubkeys chatroom.relays.value = view.relays chatroom.image.value = view.image + chatroom.avatarUrl.value = view.avatarUrl } val previousCount = chatroom.members.value.size val members = memberPubkeys(nostrGroupId) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt index 16e27c49fe..6486941c80 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/marmotGroups/MarmotGroupChatroom.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.NotesGatherer import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.WeakReference import com.vitorpamplona.amethyst.commons.util.withLock +import com.vitorpamplona.quartz.marmot.appComponents.GroupAvatarUrlV1 import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.channels.BufferOverflow @@ -55,6 +56,14 @@ class MarmotGroupChatroom( * it; when null they fall back to the host relay's NIP-11 icon. */ var image = MutableStateFlow(null) + + /** + * The group's plain-https avatar (`marmot.group.avatar-url.v1`), or null + * when it has none. It takes precedence over [image]: a group carrying + * both shows this one, and only falls back to the encrypted Blossom blob + * once this is cleared. + */ + var avatarUrl = MutableStateFlow(null) var adminPubkeys = MutableStateFlow>(emptyList()) var relays = MutableStateFlow>(emptyList()) var memberCount = MutableStateFlow(0) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupAvatarUrlV1.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupAvatarUrlV1.kt new file mode 100644 index 0000000000..3d90ce510d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupAvatarUrlV1.kt @@ -0,0 +1,147 @@ +/* + * 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.marmot.appComponents + +import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader +import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter + +/** + * `marmot.group.avatar-url.v1`, component `0x8007` — a group avatar behind an + * ordinary `https` URL. + * + * ```text + * struct { + * opaque url<0..2048>; + * opaque dim<0..256>; + * opaque thumbhash<0..256>; + * } MarmotGroupAvatarUrlV1; + * ``` + * + * The lightweight alternative to [GroupBlossomImageV1]: no key material, no + * encrypted blob, just a link. An absent avatar is the empty state — an empty + * `url` AND empty hints; a partially-empty state is invalid, so "no avatar" has + * exactly one encoding. + * + * [dim] and [thumbhash] are opaque by contract. A decoder checks only their + * length, and a hint it cannot interpret is treated as absent rather than + * invalidating otherwise-valid group state — which is why they are kept as raw + * bytes here and interpreted only at render time ([dimensions]). + * + * **Precedence:** a group may carry this AND [GroupBlossomImageV1]. When both + * are present the URL avatar wins; clearing this one falls back to the Blossom + * image. + */ +data class GroupAvatarUrlV1( + val url: String, + val dim: ByteArray = ByteArray(0), + val thumbhash: ByteArray = ByteArray(0), +) { + /** True for the cleared/absent avatar. */ + val isAbsent: Boolean get() = url.isEmpty() + + /** + * `dim` read as the conventional `WIDTHxHEIGHT`, or null when it is absent + * or in a shape this renderer does not understand. Never an error: an + * uninterpretable hint is not a validity problem. + */ + val dimensions: Pair? + get() { + if (dim.isEmpty()) return null + val text = + try { + dim.decodeToString(throwOnInvalidSequence = true) + } catch (_: Exception) { + return null + } + val parts = text.split('x', 'X') + if (parts.size != 2) return null + val w = parts[0].toIntOrNull() ?: return null + val h = parts[1].toIntOrNull() ?: return null + if (w <= 0 || h <= 0) return null + return w to h + } + + fun encode(): ByteArray { + require(!(isAbsent && (dim.isNotEmpty() || thumbhash.isNotEmpty()))) { + "group avatar absent state must not carry hints" + } + require(dim.size <= HINT_MAX_BYTES) { "group avatar dim exceeds $HINT_MAX_BYTES bytes" } + require(thumbhash.size <= HINT_MAX_BYTES) { "group avatar thumbhash exceeds $HINT_MAX_BYTES bytes" } + + // Normalizing at encode is the producer's job: the stored bytes ARE the + // serialized form, and every decoder re-derives them to check. + val stored = if (isAbsent) "" else MarmotHttpsUrl.normalize(url) + + val writer = TlsWriter() + writer.putOpaqueVarInt(stored.encodeToByteArray()) + writer.putOpaqueVarInt(dim) + writer.putOpaqueVarInt(thumbhash) + return writer.toByteArray() + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is GroupAvatarUrlV1) return false + return url == other.url && dim.contentEquals(other.dim) && thumbhash.contentEquals(other.thumbhash) + } + + override fun hashCode(): Int { + var result = url.hashCode() + result = 31 * result + dim.contentHashCode() + result = 31 * result + thumbhash.contentHashCode() + return result + } + + companion object { + const val COMPONENT_ID = AppComponentIds.GROUP_AVATAR_URL_V1 + const val URL_MAX_BYTES = MarmotHttpsUrl.MAX_BYTES + const val HINT_MAX_BYTES = 256 + + /** The cleared avatar: every field empty. */ + val ABSENT = GroupAvatarUrlV1("") + + fun decode(bytes: ByteArray): GroupAvatarUrlV1 { + val reader = TlsReader(bytes) + val url = reader.readOpaqueVarInt() + val dim = reader.readOpaqueVarInt() + val thumbhash = reader.readOpaqueVarInt() + require(!reader.hasRemaining) { "group avatar component has trailing bytes" } + require(url.size <= URL_MAX_BYTES) { "group avatar URL exceeds $URL_MAX_BYTES bytes" } + require(dim.size <= HINT_MAX_BYTES) { "group avatar dim exceeds $HINT_MAX_BYTES bytes" } + require(thumbhash.size <= HINT_MAX_BYTES) { "group avatar thumbhash exceeds $HINT_MAX_BYTES bytes" } + + val text = url.decodeToString() + // Presence is decided on the bytes, before anything is parsed. + require(!(text.isEmpty() && (dim.isNotEmpty() || thumbhash.isNotEmpty()))) { + "group avatar absent state must not carry hints" + } + if (text.isNotEmpty()) { + // "A decoder re-runs validation and the WHATWG parse-and-serialize + // on the decoded url and MUST reject state whose stored URL bytes + // differ from the serializer's output." A decoder never repairs a + // non-normalized URL into canonical state — two members would then + // hold different bytes for the same group. + require(MarmotHttpsUrl.normalize(text) == text) { "group avatar URL is not normalized" } + } + return GroupAvatarUrlV1(text, dim, thumbhash) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/MarmotGroupState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/MarmotGroupState.kt index 358a75dab3..6a5081da53 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/MarmotGroupState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/MarmotGroupState.kt @@ -56,10 +56,25 @@ data class MarmotGroupState( val adminPolicy: AdminPolicyV1?, val routing: NostrRoutingV1?, val image: GroupBlossomImageV1?, + val avatarUrl: GroupAvatarUrlV1?, val retention: MessageRetentionV1?, val lifecycle: GroupLifecycleV1?, val agentTextStream: AgentTextStreamQuicPolicyV1?, ) { + /** + * The avatar a renderer should show, honouring the components' documented + * precedence: "When both are present, the URL avatar wins." + * + * A cleared URL avatar (present but empty) is not the same as an absent + * one — it explicitly falls back to the Blossom image, which is why this + * checks [GroupAvatarUrlV1.isAbsent] rather than nullness alone. + */ + val preferredAvatar: MarmotGroupAvatar? + get() { + avatarUrl?.takeIf { !it.isAbsent }?.let { return MarmotGroupAvatar.Url(it) } + return image?.let { MarmotGroupAvatar.Blossom(it) } + } + /** True once a disband Commit has been applied. Absorbing and terminal. */ val isDisbanded: Boolean get() = lifecycle == GroupLifecycleV1.DISBANDED @@ -99,6 +114,7 @@ data class MarmotGroupState( adminPolicy = dictionary[AdminPolicyV1.COMPONENT_ID]?.let { AdminPolicyV1.decode(it) }, routing = dictionary[NostrRoutingV1.COMPONENT_ID]?.let { NostrRoutingV1.decode(it) }, image = dictionary[GroupBlossomImageV1.COMPONENT_ID]?.let { GroupBlossomImageV1.decode(it) }, + avatarUrl = dictionary[GroupAvatarUrlV1.COMPONENT_ID]?.let { GroupAvatarUrlV1.decode(it) }, retention = dictionary[MessageRetentionV1.COMPONENT_ID]?.let { MessageRetentionV1.decode(it) }, lifecycle = dictionary[GroupLifecycleV1.COMPONENT_ID]?.let { GroupLifecycleV1.decode(it) }, agentTextStream = @@ -121,6 +137,7 @@ data class MarmotGroupState( routing: NostrRoutingV1? = null, profile: GroupProfileV1? = null, image: GroupBlossomImageV1? = null, + avatarUrl: GroupAvatarUrlV1? = null, retention: MessageRetentionV1? = null, lifecycle: GroupLifecycleV1? = GroupLifecycleV1.ACTIVE, agentTextStream: AgentTextStreamQuicPolicyV1? = null, @@ -144,6 +161,10 @@ data class MarmotGroupState( required.add(GroupBlossomImageV1.COMPONENT_ID) dictionary = dictionary.with(GroupBlossomImageV1.COMPONENT_ID, it.encode()) } + avatarUrl?.let { + required.add(GroupAvatarUrlV1.COMPONENT_ID) + dictionary = dictionary.with(GroupAvatarUrlV1.COMPONENT_ID, it.encode()) + } retention?.let { required.add(MessageRetentionV1.COMPONENT_ID) dictionary = dictionary.with(MessageRetentionV1.COMPONENT_ID, it.encode()) @@ -161,3 +182,16 @@ data class MarmotGroupState( } } } + +/** Which avatar surface a group's state resolves to, after precedence. */ +sealed class MarmotGroupAvatar { + /** `marmot.group.avatar-url.v1` — a plain https link, no key material. */ + data class Url( + val avatar: GroupAvatarUrlV1, + ) : MarmotGroupAvatar() + + /** `marmot.group.blossom.image.v1` — an encrypted blob on a Blossom server. */ + data class Blossom( + val image: GroupBlossomImageV1, + ) : MarmotGroupAvatar() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/MarmotHttpsUrl.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/MarmotHttpsUrl.kt new file mode 100644 index 0000000000..cc6cab0053 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/MarmotHttpsUrl.kt @@ -0,0 +1,266 @@ +/* + * 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.marmot.appComponents + +/** + * The `https`-only WHATWG URL normalizer Marmot group state needs. + * + * This exists because normalization is part of the wire format, not a + * convenience: `marmot.group.avatar-url.v1` stores the serialized form, and a + * decoder "MUST reject state whose stored URL bytes differ from the + * serializer's output". So this has to agree with every other implementation + * byte for byte — too lax and we accept state a peer rejects, too strict and we + * reject a group somebody else made. + * + * It is not a general URL library. It handles exactly the shape the component + * allows — `https`, a host, no userinfo, no fragment — and refuses everything + * else rather than guessing. + * + * **Known limit: no IDNA.** A host with non-ASCII characters is refused instead + * of punycoded. That costs nothing on the decode side, where it matters: a + * conformant producer already stored the punycoded form (which is ASCII and + * passes through untouched), and a stored raw-Unicode host is non-normalized + * and must be rejected anyway. It only stops us from *accepting* a + * Unicode-typed host from our own user, who can paste the punycode form. + */ +object MarmotHttpsUrl { + const val MAX_BYTES = 2048 + + private const val SCHEME = "https://" + private const val DEFAULT_PORT = "443" + + /** + * Parse [raw] and return its WHATWG serialization. + * + * @throws IllegalArgumentException when the URL is not a valid group-avatar + * URL, or when normalizing it would need something this does not do. + */ + fun normalize(raw: String): String { + require(raw.isNotEmpty()) { "avatar URL must not be empty" } + require(raw.encodeToByteArray().size <= MAX_BYTES) { "avatar URL exceeds $MAX_BYTES bytes" } + + val schemeEnd = raw.indexOf("://") + require(schemeEnd > 0) { "avatar URL must be an absolute https URL" } + require(raw.substring(0, schemeEnd).lowercase() == "https") { "avatar URL scheme must be https" } + + var rest = raw.substring(schemeEnd + 3) + require(!rest.contains('#')) { "avatar URL must not include a fragment" } + + // The authority runs to the first "/" or "?" — everything after is path + // and query. + val authorityEnd = rest.indexOfFirst { it == '/' || it == '?' }.let { if (it < 0) rest.length else it } + val authority = rest.substring(0, authorityEnd) + rest = rest.substring(authorityEnd) + require(!authority.contains('@')) { "avatar URL must not include credentials" } + require(authority.isNotEmpty()) { "avatar URL must include a host" } + + val (host, port) = splitHostPort(authority) + require(host.isNotEmpty()) { "avatar URL must include a host" } + require(host.all { it.code < 0x80 }) { + "avatar URL host must be ASCII — encode an international host as punycode first" + } + + val queryStart = rest.indexOf('?') + val rawPath = if (queryStart < 0) rest else rest.substring(0, queryStart) + val rawQuery = if (queryStart < 0) null else rest.substring(queryStart + 1) + + val out = StringBuilder(SCHEME) + out.append(host.lowercase()) + if (port != null && port != DEFAULT_PORT) out.append(':').append(port) + out.append(normalizePath(rawPath)) + if (rawQuery != null) out.append('?').append(percentEncode(rawQuery, QUERY_KEEP)) + + val normalized = out.toString() + require(normalized.encodeToByteArray().size <= MAX_BYTES) { "avatar URL exceeds $MAX_BYTES bytes" } + return normalized + } + + /** True when [normalize] accepts [raw] and returns it unchanged. */ + fun isNormalized(raw: String): Boolean = + try { + normalize(raw) == raw + } catch (_: IllegalArgumentException) { + false + } + + /** + * Whether this client should be willing to FETCH [raw]. + * + * Deliberately separate from [normalize]: a URL can be perfectly valid group + * state and still be somewhere we refuse to go. Validity is the group's + * business and is the same for every member; contact is ours alone, and the + * spec is explicit that it "MUST NOT affect component or commit validity". + * + * The rule is the ordinary SSRF one — no loopback, no link-local, no private + * range — so a group avatar cannot make a member probe its own network. + */ + fun isSafeToContact(raw: String): Boolean { + val host = + try { + hostOf(normalize(raw)) + } catch (_: IllegalArgumentException) { + return false + } + if (host == "localhost" || host.endsWith(".localhost")) return false + if (host.startsWith("[")) return !isNonRoutableIpv6(host.trim('[', ']')) + val v4 = host.split('.').mapNotNull { it.toIntOrNull() } + if (v4.size == 4 && v4.all { it in 0..255 }) return !isNonRoutableIpv4(v4) + return true + } + + private fun hostOf(normalized: String): String { + val rest = normalized.substring(SCHEME.length) + val end = rest.indexOfFirst { it == '/' || it == '?' }.let { if (it < 0) rest.length else it } + return splitHostPort(rest.substring(0, end)).first + } + + private fun isNonRoutableIpv4(o: List): Boolean = + o[0] == 0 || + o[0] == 127 || + o[0] == 10 || + (o[0] == 172 && o[1] in 16..31) || + (o[0] == 192 && o[1] == 168) || + (o[0] == 169 && o[1] == 254) || + (o[0] == 100 && o[1] in 64..127) || + o[0] >= 224 + + private fun isNonRoutableIpv6(addr: String): Boolean { + val a = addr.lowercase() + if (a == "::1" || a == "::") return true + // Unique-local (fc00::/7) and link-local (fe80::/10). + return a.startsWith("fc") || a.startsWith("fd") || a.startsWith("fe8") || + a.startsWith("fe9") || a.startsWith("fea") || a.startsWith("feb") + } + + /** Splits `host:port`, keeping an IPv6 literal's brackets on the host. */ + private fun splitHostPort(authority: String): Pair { + if (authority.startsWith("[")) { + val close = authority.indexOf(']') + require(close > 0) { "avatar URL has an unterminated IPv6 host" } + val host = authority.substring(0, close + 1) + val tail = authority.substring(close + 1) + if (tail.isEmpty()) return host to null + require(tail.startsWith(":")) { "avatar URL has a malformed IPv6 authority" } + return host to validPort(tail.substring(1)) + } + val colon = authority.lastIndexOf(':') + if (colon < 0) return authority to null + return authority.substring(0, colon) to validPort(authority.substring(colon + 1)) + } + + private fun validPort(port: String): String { + require(port.isNotEmpty() && port.all { it.isDigit() }) { "avatar URL has a malformed port" } + val value = port.toIntOrNull() + require(value != null && value in 1..65535) { "avatar URL port is out of range" } + // WHATWG serializes the port as a decimal number, so "0443" is "443". + return value.toString() + } + + /** + * Resolve dot segments and percent-encode what the path set demands. An + * empty path serializes as `/`. + */ + private fun normalizePath(rawPath: String): String { + if (rawPath.isEmpty()) return "/" + // WHATWG keeps the path as a segment LIST and serializes it as "/" + + // segments joined by "/". A trailing slash is therefore a final EMPTY + // segment, not a suffix — which is also why "/a/." ends in a slash: the + // dot segment is dropped and an empty one takes its place. + val segments = rawPath.removePrefix("/").split('/') + val out = ArrayList() + segments.forEachIndexed { index, segment -> + val isLast = index == segments.size - 1 + when { + isDoubleDot(segment) -> { + if (out.isNotEmpty()) out.removeAt(out.size - 1) + if (isLast) out.add("") + } + + isSingleDot(segment) -> if (isLast) out.add("") + + else -> out.add(percentEncode(segment, PATH_KEEP)) + } + } + return "/" + out.joinToString("/") + } + + /** WHATWG counts `%2e` as a dot for segment resolution, case-insensitively. */ + private fun isSingleDot(segment: String) = segment == "." || segment.equals("%2e", ignoreCase = true) + + private fun isDoubleDot(segment: String): Boolean { + val s = segment.lowercase() + return s == ".." || s == ".%2e" || s == "%2e." || s == "%2e%2e" + } + + /** + * Percent-encode every byte outside [keep], leaving an existing `%XX` + * sequence exactly as it was found. + * + * Preserving is not laziness: the reference serializer does not re-case or + * decode what is already encoded (`%7e` stays `%7e`, `%7E` stays `%7E`), and + * "canonicalising" either way would make our bytes differ from a peer's for + * the same URL. + */ + private fun percentEncode( + value: String, + keep: (Char) -> Boolean, + ): String { + val out = StringBuilder(value.length) + var i = 0 + while (i < value.length) { + val c = value[i] + if (c == '%' && i + 2 < value.length && value[i + 1].isHex() && value[i + 2].isHex()) { + out.append(value, i, i + 3) + i += 3 + continue + } + if (keep(c)) { + out.append(c) + } else { + for (b in c.toString().encodeToByteArray()) { + out.append('%').append(HEX[(b.toInt() shr 4) and 0xf]).append(HEX[b.toInt() and 0xf]) + } + } + i++ + } + return out.toString() + } + + private fun Char.isHex() = this in '0'..'9' || this in 'a'..'f' || this in 'A'..'F' + + private const val HEX = "0123456789ABCDEF" + + /** + * WHATWG "path percent-encode set": the C0 set (below 0x20, above 0x7E) + * plus space, `"`, `<`, `>`, backtick, `#`, `?`, `{`, `}`. + */ + private val PATH_KEEP: (Char) -> Boolean = { c -> + c.code in 0x20..0x7e && c != ' ' && c != '"' && c != '<' && c != '>' && c != '`' && c != '#' && c != '?' && c != '{' && c != '}' + } + + /** + * WHATWG "special-query percent-encode set": the C0 set plus space, `"`, + * `#`, `<`, `>`, and — because `https` is a special scheme — `'`. + */ + private val QUERY_KEEP: (Char) -> Boolean = { c -> + c.code in 0x20..0x7e && c != ' ' && c != '"' && c != '#' && c != '<' && c != '>' && c != '\'' + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupAvatarUrlV1Test.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupAvatarUrlV1Test.kt new file mode 100644 index 0000000000..993105d109 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/GroupAvatarUrlV1Test.kt @@ -0,0 +1,235 @@ +/* + * 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.marmot.appComponents + +import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * `marmot.group.avatar-url.v1` (`0x8007`) — the plain-https alternative to a + * Blossom group image. + * + * The load-bearing rule is normalization. The spec says a producer stores the + * WHATWG-serialized form and "a decoder re-runs validation and the WHATWG + * parse-and-serialize on the decoded `url` and MUST reject state whose stored + * URL bytes differ from the serializer's output". So our normalizer has to + * agree with everybody else's byte for byte: too lax and we accept state a peer + * rejects, too strict and we reject a group MDK made. + * + * The expectations below are not invented. They are the output of the Rust + * `url` 2.5.8 crate — the one MDK calls through + * `validate_and_normalize_group_avatar_url` — run over each input. + */ +class GroupAvatarUrlV1Test { + @Test + fun normalizationMatchesTheReferenceSerializerCaseForCase() { + val vectors = + listOf( + // scheme + host lowercased, default port dropped + "https://CDN.Example.COM:443/a.png" to "https://cdn.example.com/a.png", + // an empty path serializes as "/" + "https://cdn.example.com" to "https://cdn.example.com/", + "https://cdn.example.com/" to "https://cdn.example.com/", + // dot segments resolve + "https://cdn.example.com/a/./b/../c.png" to "https://cdn.example.com/a/c.png", + // percent-encoding case is PRESERVED, not canonicalised, and an + // already-encoded sequence is never decoded + "https://cdn.example.com/%7euser/a.png" to "https://cdn.example.com/%7euser/a.png", + "https://cdn.example.com/%7Euser/a.png" to "https://cdn.example.com/%7Euser/a.png", + "https://cdn.example.com/A%2fB.png" to "https://cdn.example.com/A%2fB.png", + // characters outside the path set are encoded, in UPPERCASE hex + "https://cdn.example.com/a b.png" to "https://cdn.example.com/a%20b.png", + "https://cdn.example.com/ünïcode.png" to "https://cdn.example.com/%C3%BCn%C3%AFcode.png", + // a non-default port stays + "https://cdn.example.com:8443/a.png" to "https://cdn.example.com:8443/a.png", + // the query is carried through, including a bare trailing "?" + "https://cdn.example.com/a.png?x=1&y=2" to "https://cdn.example.com/a.png?x=1&y=2", + "https://cdn.example.com/a.png?" to "https://cdn.example.com/a.png?", + // an IPv6 literal lowercases inside its brackets + "https://[2001:DB8::1]/a.png" to "https://[2001:db8::1]/a.png", + // an already-punycoded host is left alone + "https://xn--bcher-kva.example/a.png" to "https://xn--bcher-kva.example/a.png", + ) + + for ((raw, expected) in vectors) { + assertEquals("normalizing $raw", expected, MarmotHttpsUrl.normalize(raw)) + } + } + + @Test + fun normalizationIsIdempotent() { + // A decoder compares stored bytes against its own serialization, so a + // normalizer that moves on the second pass would reject its own output. + for (raw in listOf( + "https://CDN.Example.COM:443/a/./b/../c.png?x=1", + "https://cdn.example.com/%7euser/a b.png", + "https://[2001:DB8::1]:8443/", + )) { + val once = MarmotHttpsUrl.normalize(raw) + assertEquals(once, MarmotHttpsUrl.normalize(once)) + } + } + + @Test + fun onlyHttpsWithAHostAndNoUserinfoOrFragmentIsValid() { + for (bad in listOf( + "http://cdn.example.com/a.png", + "ftp://cdn.example.com/a.png", + "blossom://cdn.example.com/a.png", + "https://user:pass@cdn.example.com/a.png", + "https://user@cdn.example.com/a.png", + "https://cdn.example.com/a.png#frag", + "https:///a.png", + "https://", + "cdn.example.com/a.png", + "", + )) { + assertThrows("must reject $bad", IllegalArgumentException::class.java) { + MarmotHttpsUrl.normalize(bad) + } + } + } + + @Test + fun contactSafetyIsNotAValidityQuestion() { + // "Whether a client contacts or renders the parsed destination is local + // application policy and MUST NOT affect component or commit validity." + // A group whose avatar points at localhost is still a valid group. + for (raw in listOf( + "https://localhost/avatar.png", + "https://127.0.0.1/avatar.png", + "https://10.0.0.1/avatar.png", + "https://[::1]/avatar.png", + )) { + MarmotHttpsUrl.normalize(raw) + } + assertTrue(MarmotHttpsUrl.isSafeToContact("https://cdn.example.com/a.png")) + assertTrue(!MarmotHttpsUrl.isSafeToContact("https://localhost/a.png")) + assertTrue(!MarmotHttpsUrl.isSafeToContact("https://127.0.0.1/a.png")) + assertTrue(!MarmotHttpsUrl.isSafeToContact("https://10.0.0.1/a.png")) + assertTrue(!MarmotHttpsUrl.isSafeToContact("https://192.168.1.1/a.png")) + assertTrue(!MarmotHttpsUrl.isSafeToContact("https://[::1]/a.png")) + } + + @Test + fun aHostThatWouldNeedIdnaIsRefusedRatherThanGuessedAt() { + // We do not implement IDNA/punycode, so we cannot produce the encoded + // form a peer expects. Refusing at the producer is safe; it costs + // nothing on decode, because a conformant producer already stored + // punycode and a raw Unicode host is non-normalized anyway. + val failure = + assertThrows(IllegalArgumentException::class.java) { + MarmotHttpsUrl.normalize("https://bücher.example/a.png") + } + assertTrue(failure.message.orEmpty().contains("punycode")) + } + + @Test + fun stateRoundTripsWithAndWithoutHints() { + val full = + GroupAvatarUrlV1( + url = "https://cdn.example.com/avatar.png", + dim = "512x512".encodeToByteArray(), + thumbhash = byteArrayOf(1, 2, 3), + ) + assertEquals(full, GroupAvatarUrlV1.decode(full.encode())) + + val urlOnly = GroupAvatarUrlV1(url = "https://cdn.example.com/avatar.png") + assertEquals(urlOnly, GroupAvatarUrlV1.decode(urlOnly.encode())) + + val absent = GroupAvatarUrlV1.ABSENT + assertEquals(absent, GroupAvatarUrlV1.decode(absent.encode())) + assertTrue(GroupAvatarUrlV1.decode(absent.encode()).isAbsent) + } + + @Test + fun anAbsentAvatarCannotCarryHints() { + assertThrows(IllegalArgumentException::class.java) { + GroupAvatarUrlV1(url = "", dim = "512x512".encodeToByteArray()).encode() + } + // …and the same state rejected on the way in, hand-built. + val writer = TlsWriter() + writer.putOpaqueVarInt(ByteArray(0)) + writer.putOpaqueVarInt("512x512".encodeToByteArray()) + writer.putOpaqueVarInt(ByteArray(0)) + assertThrows(IllegalArgumentException::class.java) { GroupAvatarUrlV1.decode(writer.toByteArray()) } + } + + @Test + fun decodeRejectsAStoredUrlThatIsNotNormalized() { + val writer = TlsWriter() + writer.putOpaqueVarInt("https://CDN.EXAMPLE.COM/a.png".encodeToByteArray()) + writer.putOpaqueVarInt(ByteArray(0)) + writer.putOpaqueVarInt(ByteArray(0)) + val failure = assertThrows(IllegalArgumentException::class.java) { GroupAvatarUrlV1.decode(writer.toByteArray()) } + assertTrue(failure.message.orEmpty().contains("normalized")) + } + + @Test + fun decodeRejectsTrailingBytes() { + val encoded = GroupAvatarUrlV1(url = "https://cdn.example.com/a.png").encode() + assertThrows(IllegalArgumentException::class.java) { + GroupAvatarUrlV1.decode(encoded + byteArrayOf(0)) + } + } + + @Test + fun theBoundsAreEnforcedOnBothFields() { + val longPath = "https://cdn.example.com/" + "a".repeat(2100) + assertThrows(IllegalArgumentException::class.java) { MarmotHttpsUrl.normalize(longPath) } + assertThrows(IllegalArgumentException::class.java) { + GroupAvatarUrlV1(url = "https://cdn.example.com/a.png", dim = ByteArray(257)).encode() + } + assertThrows(IllegalArgumentException::class.java) { + GroupAvatarUrlV1(url = "https://cdn.example.com/a.png", thumbhash = ByteArray(257)).encode() + } + } + + @Test + fun aHintTheRendererCannotReadIsNotAValidityProblem() { + // "A hint the renderer cannot interpret is treated as absent and MUST + // NOT invalidate otherwise-valid group state." + val weird = + GroupAvatarUrlV1( + url = "https://cdn.example.com/a.png", + dim = byteArrayOf(0xff.toByte(), 0xfe.toByte()), + thumbhash = byteArrayOf(0x00), + ) + val decoded = GroupAvatarUrlV1.decode(weird.encode()) + assertEquals(weird, decoded) + assertEquals(null, decoded.dimensions) + } + + @Test + fun dimensionsAreParsedOnlyWhenTheyAreTheConventionalShape() { + assertEquals( + 512 to 512, + GroupAvatarUrlV1("https://cdn.example.com/a.png", dim = "512x512".encodeToByteArray()).dimensions, + ) + assertEquals( + null, + GroupAvatarUrlV1("https://cdn.example.com/a.png", dim = "not-a-size".encodeToByteArray()).dimensions, + ) + } +}