From 0f1d35740766cb9466d211275f15dc99fa32d821 Mon Sep 17 00:00:00 2001 From: Guillermo Castro Date: Fri, 23 Oct 2026 16:25:56 -0600 Subject: [PATCH 1/4] Move message-tags, echo-message, away-notify, multi-prefix and batch onto the capability SPI Core still carried behaviour for five capabilities through name checks, contributeTags and includeSenderInFanOut, so a capability module was a marker rather than the owner of its behaviour. Each module now registers SPI hooks and core keeps only CAP negotiation plus generic mechanisms: - MessageDelivery is shared by PRIVMSG, NOTICE and TAGMSG. deliver only accepts an opaque authorization from authorize, so a caller cannot bypass send gates by supplying its own recipients. - Forwarded client tags record every capability that selected them and reach only recipients of one of those; unowned tags fail closed. - TAGMSG moves into message-tags (421 when it is absent). - Committed join/part/quit/nick/away facts are published after locks; away-notify builds its fan-out from them. - multi-prefix renders member status through generic member facts and declares its own reply bound. - batch shapes output groups; SessionWriter.enqueueGroup admits a group whole, and a framed group that exceeds SendQ overflows instead of falling back to plain. Existing integration tests are unchanged; irctest shows no case-level difference from main. Fixes #101 Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Claude Opus 5.5 --- README.md | 63 +- .../awaynotify/AwayNotifyExtension.java | 33 +- .../awaynotify/AwayNotifyExtensionTest.java | 220 +++++++ .../capabilities/batch/BatchExtension.java | 61 +- .../batch/BatchExtensionTest.java | 241 ++++++++ .../echomessage/EchoMessageExtension.java | 16 +- .../echomessage/EchoMessageExtensionTest.java | 196 ++++++ .../messagetags/MessageTagsExtension.java | 78 ++- .../messagetags/TagmsgCommandHandler.java | 82 +++ .../MessageTagsExtensionPurityTest.java | 55 -- .../messagetags/MessageTagsExtensionTest.java | 136 +++++ .../messagetags/TagmsgCommandHandlerTest.java | 222 +++++++ .../multiprefix/MultiPrefixExtension.java | 29 +- .../multiprefix/MultiPrefixExtensionTest.java | 120 ++++ .../servertime/ServerTimeExtensionTest.java | 5 - .../core/config/ConfigurationLoader.java | 11 +- .../core/config/HostmaskReplyBudget.java | 5 +- .../AbstractCapabilityExtension.java | 5 +- .../core/extension/CapabilityExtension.java | 34 +- .../core/extension/ExtensionRegistry.java | 9 +- .../extension/capability/HookDispatcher.java | 72 +-- .../capability/ReplyBudgetCatalog.java | 11 - .../extension/capability/RuntimeSnapshot.java | 6 - .../extension/capability/package-info.java | 8 +- .../core/session/CapabilityTagRenderer.java | 57 +- .../java/net/jircd/core/session/Channel.java | 72 ++- .../jircd/core/session/ChannelRegistry.java | 7 +- .../jircd/core/session/CommittedEvents.java | 162 +++++ .../jircd/core/session/DisconnectCleanup.java | 1 + .../net/jircd/core/session/JoinResult.java | 9 +- .../net/jircd/core/session/MemberFacts.java | 31 + .../net/jircd/core/session/MemberReplies.java | 95 +++ .../jircd/core/session/MessageDelivery.java | 268 +++++++++ .../jircd/core/session/OutboundMessage.java | 62 +- .../net/jircd/core/session/SessionWriter.java | 74 ++- .../net/jircd/core/session/TagRenderer.java | 17 +- .../session/command/AwayCommandHandler.java | 34 +- .../session/command/JoinCommandHandler.java | 56 +- .../command/MessageCommandHandler.java | 151 +---- .../session/command/NamesCommandHandler.java | 4 +- .../session/command/NickCommandHandler.java | 1 + .../session/command/PartCommandHandler.java | 8 + .../session/command/TagmsgCommandHandler.java | 105 ---- .../session/command/WhoCommandHandler.java | 8 +- .../config/CapabilityReplyBudgetTest.java | 16 +- .../config/ReplyBudgetConfigurationTest.java | 19 +- .../capability/CapabilityCompositionTest.java | 258 ++++++++ .../capability/CapabilityDispatchTest.java | 22 - .../CapabilityTagRendererBudgetTest.java | 48 +- .../CapabilityTagRendererMigrationTest.java | 135 ++--- .../core/session/ChannelRegistryTest.java | 20 +- .../net/jircd/core/session/ChannelTest.java | 25 +- .../core/session/ClientTagForwarder.java | 51 ++ .../core/session/CommittedEventTest.java | 461 +++++++++++++++ .../net/jircd/core/session/ForwardedTags.java | 33 ++ .../session/MemberReplyRenderingTest.java | 200 +++++++ .../core/session/MessageDeliveryTest.java | 558 ++++++++++++++++++ .../core/session/SelfDirectMessageTest.java | 7 +- .../core/session/SelfDirectTagmsgTest.java | 139 ----- .../core/session/SessionWriterBatchTest.java | 118 ---- .../session/SessionWriterByteLimitTest.java | 75 ++- .../core/session/SessionWriterGroupTest.java | 235 ++++++++ .../net/jircd/core/session/TagProvider.java | 42 ++ .../session/command/HostmaskReplyFitTest.java | 5 +- .../session/command/MultiPrefixReplyTest.java | 1 + .../core/session/command/MultiPrefixStub.java | 40 ++ .../integration/CapabilityGroupTest.java | 172 ++++++ .../CapabilityProviderAbsenceTest.java | 275 +++++++++ .../net/jircd/protocol/CapabilityName.java | 13 +- .../admin/SajoinCommandHandler.java | 8 +- .../jircd/server/JircdServerApplication.java | 9 +- specs/014-capability-plugin-spi/tasks.md | 50 +- .../validation/pr-101/irctest.md | 32 + .../validation/pr-101/migrations.md | 55 ++ .../validation/pr-101/results.md | 86 +++ .../validation/pr-101/review.md | 70 +++ 76 files changed, 5088 insertions(+), 1130 deletions(-) create mode 100644 jircd-capabilities/away-notify/src/test/java/net/jircd/capabilities/awaynotify/AwayNotifyExtensionTest.java create mode 100644 jircd-capabilities/batch/src/test/java/net/jircd/capabilities/batch/BatchExtensionTest.java create mode 100644 jircd-capabilities/echo-message/src/test/java/net/jircd/capabilities/echomessage/EchoMessageExtensionTest.java create mode 100644 jircd-capabilities/message-tags/src/main/java/net/jircd/capabilities/messagetags/TagmsgCommandHandler.java delete mode 100644 jircd-capabilities/message-tags/src/test/java/net/jircd/capabilities/messagetags/MessageTagsExtensionPurityTest.java create mode 100644 jircd-capabilities/message-tags/src/test/java/net/jircd/capabilities/messagetags/MessageTagsExtensionTest.java create mode 100644 jircd-capabilities/message-tags/src/test/java/net/jircd/capabilities/messagetags/TagmsgCommandHandlerTest.java create mode 100644 jircd-capabilities/multi-prefix/src/test/java/net/jircd/capabilities/multiprefix/MultiPrefixExtensionTest.java create mode 100644 jircd-core/src/main/java/net/jircd/core/session/CommittedEvents.java create mode 100644 jircd-core/src/main/java/net/jircd/core/session/MemberFacts.java create mode 100644 jircd-core/src/main/java/net/jircd/core/session/MemberReplies.java create mode 100644 jircd-core/src/main/java/net/jircd/core/session/MessageDelivery.java delete mode 100644 jircd-core/src/main/java/net/jircd/core/session/command/TagmsgCommandHandler.java create mode 100644 jircd-core/src/test/java/net/jircd/core/extension/capability/CapabilityCompositionTest.java create mode 100644 jircd-core/src/test/java/net/jircd/core/session/ClientTagForwarder.java create mode 100644 jircd-core/src/test/java/net/jircd/core/session/CommittedEventTest.java create mode 100644 jircd-core/src/test/java/net/jircd/core/session/ForwardedTags.java create mode 100644 jircd-core/src/test/java/net/jircd/core/session/MemberReplyRenderingTest.java create mode 100644 jircd-core/src/test/java/net/jircd/core/session/MessageDeliveryTest.java delete mode 100644 jircd-core/src/test/java/net/jircd/core/session/SelfDirectTagmsgTest.java delete mode 100644 jircd-core/src/test/java/net/jircd/core/session/SessionWriterBatchTest.java create mode 100644 jircd-core/src/test/java/net/jircd/core/session/SessionWriterGroupTest.java create mode 100644 jircd-core/src/test/java/net/jircd/core/session/TagProvider.java create mode 100644 jircd-core/src/test/java/net/jircd/core/session/command/MultiPrefixStub.java create mode 100644 jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityGroupTest.java create mode 100644 jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityProviderAbsenceTest.java create mode 100644 specs/014-capability-plugin-spi/validation/pr-101/irctest.md create mode 100644 specs/014-capability-plugin-spi/validation/pr-101/migrations.md create mode 100644 specs/014-capability-plugin-spi/validation/pr-101/results.md create mode 100644 specs/014-capability-plugin-spi/validation/pr-101/review.md diff --git a/README.md b/README.md index 75e3b4c..85ae3d9 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,8 @@ reload. Startup and reload reject unsafe combinations of the resolved server-name byte length `S`, nickname limit `N`, and channel byte limit `Cmax`: both -`S + 2N + Cmax + 15 <= 512` (353) and `S + N + Cmax + 29 <= 512` (366) -must hold. Validation names the keys, computed reply sizes, and available channel +`S + 2N + Cmax + 15 <= 512` (353, with the two-symbol `multi-prefix` status +installed) and `S + N + Cmax + 29 <= 512` (366) must hold. Validation names the keys, computed reply sizes, and available channel byte budget. Reduce the configured limits or use a shorter server name to migrate an incompatible configuration; a failed reload keeps the previous settings. Replies that carry a nick!user@host are budgeted too, with a username of up to 27 @@ -321,20 +321,25 @@ release), use `distZip` or `distTar`, which land in archives — pushing a `vX.Y.Z` tag builds and publishes these automatically (see [`.github/workflows/release.yml`](.github/workflows/release.yml)). -### Writing a capability provider (SPI, in transition) +### Writing a capability provider (SPI) A capability module (`jircd-capabilities/*`) is a `CapabilityExtension` that owns its behaviour through typed registrations instead of core branching on its name. Override `CapabilityExtension.register(CapabilityRegistrar)` to stage hooks; nothing is visible until the -whole descriptor validates and is published, and a failed enable leaves nothing published. - -| Registration | Purpose | -| --- | --- | -| `onOutboundTags(keys, maxBytes, hook)` | Owned server tags for recipients that negotiated the capability; pure in the immutable message, validated against the declared escaped-byte bound | -| `onEvent`, `onInboundTags`, `onRecipients` | Committed-event delivery, client-tag selection, sender-copy roles | -| `onMemberStatus`, `onNamesIdentity`, `onReplyField` | Bounded decorations of schema-declared reply slots | -| `onOutputGroup` | Framing of a finite group, falling back to plain members | -| `command` | Provider-owned command; it can never replace a command core or another extension handles, and is removed (answered as unknown) when the provider is withdrawn | +whole descriptor validates and is published, and a failed enable leaves nothing published. Core +keeps CAP negotiation and the generic mechanisms (authorized delivery, committed events, reply +layouts and budgets, whole-frame queue admission); all six bundled capabilities now own their +behaviour, so removing a module from the classpath removes its offer and its behaviour together. + +| Registration | Purpose | Used by | +| --- | --- | --- | +| `onOutboundTags(keys, maxBytes, hook)` | Owned server tags for recipients that negotiated the capability; pure in the immutable message, validated against the declared escaped-byte bound | `server-time` (`time`), `message-tags` (`msgid`) | +| `onInboundTags` | Selects the client-only (`+`) tags to forward and provider-scoped request context; a recipient that negotiated the owner sees the forwarded tags | `message-tags` | +| `onRecipients` | Asks for a `SENDER_COPY` role for the sender of a permitted `PRIVMSG`/`NOTICE`/`TAGMSG`; core merges roles so a recipient gets the message once | `echo-message` | +| `onEvent(kind, hook)` | Reacts to a committed `AWAY_CHANGED`, `JOINED`, `PARTED`, `QUIT` or `NICK_CHANGED` with delivery intents for peers drawn from the event's audience; recipients are narrowed to those that negotiated the capability | `away-notify` | +| `onMemberStatus`, `onNamesIdentity`, `onReplyField` | Bounded decorations of schema-declared reply slots (the status of a NAMES token or channel WHO entry, the NAMES identity) | `multi-prefix` (status) | +| `onOutputGroup(prerequisites, hook)` | Framing of a finite group; the group is validated and queued whole, or sent plain when the owner or a prerequisite was not negotiated | `batch` | +| `command` | Provider-owned command; it can never replace a command core or another extension handles, and is removed (answered as unknown) when the provider is withdrawn | `message-tags` (`TAGMSG`) | Hook results are untrusted. An exception, a `null` or an out-of-bounds result is dropped whole, reported by provider id, hook and category only, and replaced by the documented default; committed @@ -342,17 +347,35 @@ core state and other providers are unaffected. Hooks must be pure, nonblocking a applies the owner's enabled state and the declared negotiation prerequisites, so a hook never reads a session's capability set. -**Status.** Only `server-time` is migrated. The other five providers still use the temporary -`contributeTags` / `includeSenderInFanOut` bridge, which is removed once the last one migrates; -core routes a provider's tags through exactly one path. Runtime FAILED withdrawal of a faulty -provider, event publication and the remaining migrations are later changes in the same feature -(`specs/014-capability-plugin-spi/`). `0.1.0-SNAPSHOT` is unreleased, so the SPI changes carry no -version bump until the first release. +Core publishes committed events (`CommittedEvents`) after the state change and with no registry or +channel monitor held; a kind nobody listens for costs nothing, and a faulty listener never undoes +the transition. Client messages go through `MessageDelivery`, which owns target resolution, the +send gates, the envelope (one id and send time for every recipient) and role selection. + +**Status.** Runtime FAILED withdrawal of a faulty provider, atomic runtime enable/disable with +`CAP NEW`/`DEL`, and the dedicated load measurements are later changes in the same feature +(`specs/014-capability-plugin-spi/`); this change does not provide them. + +**Migrating code that used the earlier API.** `0.1.0-SNAPSHOT` is unreleased, so these changes +carry no version bump until the first release. + +| Removed | Use instead | +| --- | --- | +| `CapabilityExtension.contributeTags` | `CapabilityRegistrar.onOutboundTags` | +| `CapabilityExtension.includeSenderInFanOut` | `CapabilityRegistrar.onRecipients` returning `DeliveryRole.SENDER_COPY` | +| `Channel.memberListing(boolean)`, `Channel.statusPrefixes(member, boolean)` | `Channel.memberFacts()` / `heldStatuses(member)` for unformatted facts; `MemberReplies` renders them for a requester. `memberListing()` is the highest-status form | +| `JoinResult.memberListing()` | `JoinResult.members()` (unformatted `MemberFacts`) | +| `JoinCommandHandler.sendNamesReply(requester, channel, server)` and `sendNamesLine(requester, channel, server)` | The overloads that take the `ExtensionRegistry` first. `sendNamesReply(requester, channel, server, List tokens)` still sends tokens you rendered | +| `SessionWriter.enqueueBatch` | `SessionWriter.enqueueGroup`; framing comes from the owning provider | +| `MessageCommandHandler.resolveRecipients`, `deliveryTargets`, `includeSenderInFanOut` | `MessageDelivery.authorize` and `deliver` | +| `TagmsgCommandHandler` in core | Owned by the `message-tags` module | +| `ReplyBudgetCatalog.legacyCompatible()` | `ReplyBudgetCatalog.empty()` plus the providers' own declared bounds | Reply decorations are budgeted by generic layouts (`ReplyLayout`) rather than a per-capability width: startup, `REHASH` and runtime enable reserve every installed provider's declared slot bytes -with the same arithmetic. Until `multi-prefix` declares its own bound the server keeps reserving the -two-symbol status width, so accepted configurations stay accepted. +with the same arithmetic. `multi-prefix` declares the two operator and voice symbols of +`PREFIX=(ov)@+`, so with the module installed (as in every shipped build) the reserved envelope is +unchanged; a server without it reserves only core's one-byte highest status. ### Outbound queue (SendQ) policy diff --git a/jircd-capabilities/away-notify/src/main/java/net/jircd/capabilities/awaynotify/AwayNotifyExtension.java b/jircd-capabilities/away-notify/src/main/java/net/jircd/capabilities/awaynotify/AwayNotifyExtension.java index 88628de..f672398 100644 --- a/jircd-capabilities/away-notify/src/main/java/net/jircd/capabilities/awaynotify/AwayNotifyExtension.java +++ b/jircd-capabilities/away-notify/src/main/java/net/jircd/capabilities/awaynotify/AwayNotifyExtension.java @@ -15,16 +15,25 @@ */ package net.jircd.capabilities.awaynotify; +import java.util.List; +import java.util.Map; import net.jircd.core.extension.AbstractCapabilityExtension; +import net.jircd.core.extension.capability.CapabilityRegistrar; +import net.jircd.core.extension.capability.CommittedEvent; +import net.jircd.core.extension.capability.EventIntent; +import net.jircd.core.extension.capability.EventKind; +import net.jircd.core.session.ClientSession; import net.jircd.protocol.CapabilityName; +import net.jircd.protocol.Command; +import net.jircd.protocol.Message; /** * The {@code away-notify} capability (011-away-notify-batch FR-001 through FR-006): unlike {@code * message-tags}/{@code server-time}/{@code echo-message}, this doesn't decorate an existing fan-out - * — it gates a wholly new one. {@link net.jircd.core.session.command.AwayCommandHandler} checks - * this capability's id directly against each channel-mate's negotiated set when broadcasting an - * away-status change, via the shared {@link CapabilityName#AWAY_NOTIFY} constant rather than - * importing this class (jircd-core can't depend on jircd-capabilities). + * — it gates a wholly new one. It listens for the committed {@code AWAY_CHANGED} event and answers + * with one {@code AWAY} notice for the peers who share a channel with the user, deduplicated and + * without the user itself by the event's audience. Core narrows the notice to peers that negotiated + * this capability; it keeps the away state and the confirmations to the user. */ public final class AwayNotifyExtension extends AbstractCapabilityExtension { @@ -33,4 +42,20 @@ public final class AwayNotifyExtension extends AbstractCapabilityExtension { public AwayNotifyExtension() { super(ID); } + + @Override + public void register(CapabilityRegistrar registrar) { + registrar.onEvent(EventKind.AWAY_CHANGED, AwayNotifyExtension::awayNotice); + } + + /** {@code : AWAY [:]}; no parameter means the user is back. */ + static List awayNotice(CommittedEvent event) { + List peers = event.audience().peers(); + if (peers.isEmpty()) { + return List.of(); + } + List params = event.after().isEmpty() ? List.of() : List.of(event.after()); + Message notice = new Message(Map.of(), event.subject(), Command.AWAY, "AWAY", params); + return List.of(new EventIntent(notice, peers)); + } } diff --git a/jircd-capabilities/away-notify/src/test/java/net/jircd/capabilities/awaynotify/AwayNotifyExtensionTest.java b/jircd-capabilities/away-notify/src/test/java/net/jircd/capabilities/awaynotify/AwayNotifyExtensionTest.java new file mode 100644 index 0000000..fb7b8b0 --- /dev/null +++ b/jircd-capabilities/away-notify/src/test/java/net/jircd/capabilities/awaynotify/AwayNotifyExtensionTest.java @@ -0,0 +1,220 @@ +/* + * Copyright 2026 Guillermo Castro + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package net.jircd.capabilities.awaynotify; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import net.jircd.core.extension.ExtensionRegistry; +import net.jircd.core.extension.HostnameDisplayExtension; +import net.jircd.core.extension.ServerContext; +import net.jircd.core.extension.ServerExtension; +import net.jircd.core.extension.capability.Audience; +import net.jircd.core.extension.capability.CapabilityDescriptor; +import net.jircd.core.extension.capability.CapabilityRegistrar; +import net.jircd.core.extension.capability.CommittedEvent; +import net.jircd.core.extension.capability.EventIntent; +import net.jircd.core.extension.capability.EventKind; +import net.jircd.core.extension.capability.HookKind; +import net.jircd.core.session.ChannelRegistry; +import net.jircd.core.session.ClientSession; +import net.jircd.core.session.NicknameRegistry; +import net.jircd.core.session.RateLimitBucket; +import net.jircd.core.session.SessionWriter; +import net.jircd.core.session.TagRenderer; +import net.jircd.core.session.command.AwayCommandHandler; +import net.jircd.protocol.MessageParser; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class AwayNotifyExtensionTest { + private final NicknameRegistry nicknames = new NicknameRegistry(); + private final ChannelRegistry channels = new ChannelRegistry(); + private final ExtensionRegistry extensions = new ExtensionRegistry(); + private final Map outputs = new HashMap<>(); + private final List writers = new ArrayList<>(); + private final AwayCommandHandler away = new AwayCommandHandler(() -> "server", extensions); + + @AfterEach + void closeWriters() { + writers.forEach(SessionWriter::close); + } + + private void enable() { + extensions.register(new AwayNotifyExtension()); + extensions.enable(AwayNotifyExtension.ID); + } + + private ClientSession user(String nick, boolean awayNotify) { + ClientSession session = new ClientSession(nick, "host.example", RateLimitBucket.withDefaults()); + session.setNickname(nick); + session.setIdent("ident"); + if (awayNotify) session.replaceNegotiatedCapabilities(List.of(AwayNotifyExtension.ID)); + nicknames.claim(nick, session); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + outputs.put(session, out); + @SuppressWarnings("PMD.CloseResource") // closed by closeWriters() + SessionWriter writer = new SessionWriter(session, out, TagRenderer.NONE); + writers.add(writer); + session.attachWriter(writer); + return session; + } + + private String received(ClientSession session) { + writers.forEach(SessionWriter::close); + return outputs.get(session).toString(StandardCharsets.UTF_8); + } + + @Test + void registersOnlyAnAwayChangedEventHook() { + CapabilityRegistrar registrar = new CapabilityRegistrar("away-notify", "away-notify"); + new AwayNotifyExtension().register(registrar); + CapabilityDescriptor descriptor = registrar.build(); + assertThat(descriptor.registrations()).hasSize(1); + assertThat(descriptor.registrations().getFirst().kind()).isEqualTo(HookKind.EVENT); + assertThat(descriptor.registrations().getFirst().eventKind()).isEqualTo(EventKind.AWAY_CHANGED); + } + + @Test + void theNoticeCarriesTheSubjectAndReasonAndAClearHasNoParameter() { + ClientSession peer = user("peer", true); + Audience audience = new Audience(() -> List.of(peer, peer)); + + List set = + AwayNotifyExtension.awayNotice( + new CommittedEvent(EventKind.AWAY_CHANGED, 1, "a!i@h", "", "lunch", audience)); + List cleared = + AwayNotifyExtension.awayNotice( + new CommittedEvent(EventKind.AWAY_CHANGED, 2, "a!i@h", "lunch", "", audience)); + + assertThat(set).hasSize(1); + assertThat(set.getFirst().recipients()).containsExactly(peer); + assertThat(set.getFirst().message().prefix()).isEqualTo("a!i@h"); + assertThat(set.getFirst().message().params()).containsExactly("lunch"); + assertThat(cleared.getFirst().message().params()).isEmpty(); + } + + @Test + void noPeersMeansNoIntent() { + assertThat( + AwayNotifyExtension.awayNotice( + new CommittedEvent( + EventKind.AWAY_CHANGED, 1, "a!i@h", "", "x", new Audience(List::of)))) + .isEmpty(); + } + + @Test + void onlyPeersThatNegotiatedItSeeTheNoticeOnceAndNeverTheUser() throws Exception { + enable(); + ClientSession alice = user("alice", true); + ClientSession bob = user("bob", true); + ClientSession carol = user("carol", false); + for (String channel : List.of("#a", "#b")) { + for (ClientSession member : List.of(alice, bob, carol)) { + channels.join(channel, member, ch -> true); + } + } + + away.handle(alice, MessageParser.parse("AWAY :at lunch")); + + assertThat(received(bob)).isEqualTo(":alice!ident@host.example AWAY :at lunch\r\n"); + assertThat(received(carol)).isEmpty(); + assertThat(received(alice)).contains(" 306 ").doesNotContain(" AWAY "); + } + + @Test + void aClearIsSentWithoutParametersAndARepeatedReasonIsSentAgain() throws Exception { + enable(); + ClientSession alice = user("alice", false); + ClientSession bob = user("bob", true); + channels.join("#c", alice, ch -> true); + channels.join("#c", bob, ch -> true); + + away.handle(alice, MessageParser.parse("AWAY :x")); + away.handle(alice, MessageParser.parse("AWAY :x")); + away.handle(alice, MessageParser.parse("AWAY")); + + assertThat(received(bob)) + .isEqualTo( + String.join( + "\r\n", + ":alice!ident@host.example AWAY x", + ":alice!ident@host.example AWAY x", + ":alice!ident@host.example AWAY", + "")); + } + + @Test + void theNoticeUsesThePresentedIdentity() throws Exception { + extensions.register(new Cloak()); + extensions.enable("test-cloak"); + enable(); + ClientSession alice = user("alice", false); + ClientSession bob = user("bob", true); + channels.join("#c", alice, ch -> true); + channels.join("#c", bob, ch -> true); + + away.handle(alice, MessageParser.parse("AWAY :x")); + + assertThat(received(bob)).isEqualTo(":alice!ident@cloak.example AWAY x\r\n"); + } + + @Test + void withoutTheProviderAwayStillWorksButNobodyIsNotified() throws Exception { + ClientSession alice = user("alice", false); + ClientSession bob = user("bob", true); + channels.join("#c", alice, ch -> true); + channels.join("#c", bob, ch -> true); + + away.handle(alice, MessageParser.parse("AWAY :x")); + + assertThat(alice.awayReason()).isEqualTo("x"); + assertThat(received(alice)).contains(" 306 "); + assertThat(received(bob)).isEmpty(); + } + + private static final class Cloak implements ServerExtension, HostnameDisplayExtension { + @Override + public String id() { + return "test-cloak"; + } + + @Override + public String extensionPoint() { + return "hostname-display"; + } + + @Override + public String display(String hostname) { + return "cloak.example"; + } + + @Override + public void start(ServerContext context) { + // Stateless test cloak: no resources to initialize. + } + + @Override + public void stop() { + // Stateless test cloak: no resources to release. + } + } +} diff --git a/jircd-capabilities/batch/src/main/java/net/jircd/capabilities/batch/BatchExtension.java b/jircd-capabilities/batch/src/main/java/net/jircd/capabilities/batch/BatchExtension.java index af02d89..cd66675 100644 --- a/jircd-capabilities/batch/src/main/java/net/jircd/capabilities/batch/BatchExtension.java +++ b/jircd-capabilities/batch/src/main/java/net/jircd/capabilities/batch/BatchExtension.java @@ -15,22 +15,73 @@ */ package net.jircd.capabilities.batch; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; import net.jircd.core.extension.AbstractCapabilityExtension; +import net.jircd.core.extension.capability.CapabilityRegistrar; +import net.jircd.core.extension.capability.FramingPlan; +import net.jircd.core.extension.capability.OutputGroup; +import net.jircd.core.extension.capability.RequestContext; import net.jircd.protocol.CapabilityName; +import net.jircd.protocol.Message; /** * The {@code batch} capability (011-away-notify-batch FR-007 through FR-011): a general-purpose - * message-grouping mechanism with no hook overrides of its own — {@link - * net.jircd.core.session.SessionWriter#enqueueBatch} checks this capability's id (and {@code - * message-tags}) directly against the recipient's negotiated set, via the shared {@link - * CapabilityName#BATCH}/{@link CapabilityName#MESSAGE_TAGS} constants rather than importing this - * class (jircd-core can't depend on jircd-capabilities). + * message-grouping mechanism. It owns the group shaper: for a recipient that negotiated both {@code + * batch} and {@code message-tags} it opens the group with {@code BATCH +ref type params}, merges a + * {@code batch=ref} tag onto every member and closes it with {@code BATCH -ref}. For any other + * recipient core sends the members plain, which is exactly the shaper not being asked: {@code + * message-tags} is a session prerequisite of this registration, not a hard dependency, so batch + * stays offered and negotiable without it but inert. + * + *

Core validates the plan, reserves all of its frames at once and queues them as one entry, so + * the closer is admitted with the opener. The open and close {@code BATCH} lines carry no prefix, + * the same precedent {@code KillCommandHandler}'s {@code ERROR} line establishes for a + * server-framing line with no natural sender identity. */ public final class BatchExtension extends AbstractCapabilityExtension { public static final String ID = CapabilityName.BATCH; + private static final String BATCH = "BATCH"; + private static final String BATCH_TAG_KEY = "batch"; + public BatchExtension() { super(ID); } + + @Override + public void register(CapabilityRegistrar registrar) { + registrar.onOutputGroup(Set.of(CapabilityName.MESSAGE_TAGS), BatchExtension::frame); + } + + /** + * {@code ref} is a fresh {@link UUID} per recipient and group — a unique ASCII token, collision + * safe without any shared state (research.md Decision 3). Members keep their own tags and gain + * the {@code batch} tag unless they already carry one, which marks them as members of a nested + * group the caller built. + */ + static FramingPlan frame(OutputGroup group, RequestContext context) { + String ref = UUID.randomUUID().toString(); + List openParams = new ArrayList<>(); + openParams.add("+" + ref); + openParams.add(group.type()); + openParams.addAll(group.typeParams()); + List frames = new ArrayList<>(group.members().size() + 2); + frames.add(new Message(Map.of(), null, null, BATCH, List.copyOf(openParams))); + for (Message member : group.members()) { + Map tags = new LinkedHashMap<>(member.tags()); + // A member that already belongs to a nested group the caller built keeps that group. + tags.putIfAbsent(BATCH_TAG_KEY, ref); + frames.add( + new Message( + tags, member.prefix(), member.command(), member.rawCommand(), member.params())); + } + frames.add(new Message(Map.of(), null, null, BATCH, List.of("-" + ref))); + return new FramingPlan(frames); + } } diff --git a/jircd-capabilities/batch/src/test/java/net/jircd/capabilities/batch/BatchExtensionTest.java b/jircd-capabilities/batch/src/test/java/net/jircd/capabilities/batch/BatchExtensionTest.java new file mode 100644 index 0000000..be12b77 --- /dev/null +++ b/jircd-capabilities/batch/src/test/java/net/jircd/capabilities/batch/BatchExtensionTest.java @@ -0,0 +1,241 @@ +/* + * Copyright 2026 Guillermo Castro + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package net.jircd.capabilities.batch; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.jircd.core.capability.Capability; +import net.jircd.core.extension.ExtensionRegistry; +import net.jircd.core.extension.capability.CapabilityRegistrar; +import net.jircd.core.extension.capability.HookKind; +import net.jircd.core.extension.capability.HookRegistration; +import net.jircd.core.session.CapabilityTagRenderer; +import net.jircd.core.session.ClientSession; +import net.jircd.core.session.RateLimitBucket; +import net.jircd.core.session.SessionWriter; +import net.jircd.protocol.CapabilityName; +import net.jircd.protocol.Command; +import net.jircd.protocol.Message; +import org.junit.jupiter.api.Test; + +/** 011-away-notify-batch User Story 2, through the real provider and core's group admission. */ +class BatchExtensionTest { + + private static final String BATCH = CapabilityName.BATCH; + private static final String TAGS = CapabilityName.MESSAGE_TAGS; + + private final ExtensionRegistry extensions = new ExtensionRegistry(); + + BatchExtensionTest() { + extensions.register(new BatchExtension()); + extensions.enable(BatchExtension.ID); + } + + private static ClientSession session(String... negotiated) { + ClientSession session = new ClientSession("c1", "host.example", RateLimitBucket.withDefaults()); + session.replaceNegotiatedCapabilities(List.of(negotiated)); + return session; + } + + private static Message privmsg(String target, String body) { + return new Message( + Map.of(), "sender!ident@host", Command.PRIVMSG, "PRIVMSG", List.of(target, body)); + } + + private static String[] lines(ByteArrayOutputStream out) { + String text = out.toString(StandardCharsets.UTF_8); + return text.isEmpty() ? new String[0] : text.split("\r\n"); + } + + private static String tagValue(String line, String key) { + assertThat(line).startsWith("@"); + String tagSection = line.substring(1, line.indexOf(' ')); + for (String tag : tagSection.split(";")) { + int eq = tag.indexOf('='); + if (eq > 0 && tag.substring(0, eq).equals(key)) { + return tag.substring(eq + 1); + } + } + throw new AssertionError("Tag '" + key + "' not found in: " + line); + } + + private String[] send( + ClientSession recipient, String type, List params, Message... members) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (SessionWriter writer = + new SessionWriter(recipient, out, new CapabilityTagRenderer(extensions))) { + writer.enqueueGroup(type, params, List.of(members)); + } + return lines(out); + } + + @Test + void registersOneGroupShaperGatedOnMessageTagsAsASessionPrerequisite() { + CapabilityRegistrar registrar = new CapabilityRegistrar("batch", "batch"); + new BatchExtension().register(registrar); + List registrations = registrar.build().registrations(); + assertThat(registrations).hasSize(1); + assertThat(registrations.getFirst().kind()).isEqualTo(HookKind.OUTPUT_GROUP); + assertThat(registrations.getFirst().requiresNegotiated()).containsExactly(TAGS); + } + + @Test + void bothCapabilitiesFrameTheGroupWithoutAPrefixAndTagEveryMember() { + String[] lines = + send( + session(BATCH, TAGS), + "type-a", + List.of("param1"), + privmsg("#chan", "a1"), + privmsg("#chan", "a2")); + + assertThat(lines).hasSize(4); + assertThat(lines[0]).startsWith("BATCH +").endsWith(" type-a param1").doesNotStartWith(":"); + String ref = lines[0].substring("BATCH +".length(), lines[0].indexOf(' ', "BATCH +".length())); + assertThat(tagValue(lines[1], "batch")).isEqualTo(ref); + assertThat(lines[1]).contains("a1"); + assertThat(tagValue(lines[2], "batch")).isEqualTo(ref); + assertThat(lines[2]).contains("a2"); + assertThat(lines[3]).isEqualTo("BATCH -" + ref); + } + + @Test + void aRecipientLackingEitherCapabilityGetsMembersCompletelyUnwrapped() { + for (ClientSession recipient : List.of(session(), session(BATCH), session(TAGS))) { + String[] lines = send(recipient, "type-a", List.of(), privmsg("#chan", "hello")); + assertThat(lines).hasSize(1); + assertThat(lines[0]).doesNotStartWith("@").doesNotContain("BATCH").contains("hello"); + } + } + + @Test + void batchAloneStillNegotiatesAndTheOfferSurvives() { + assertThat(extensions.offeredCapabilities()) + .extracting(Capability::name) + .containsExactly(BATCH); + } + + @Test + void twoConcurrentGroupsAreDistinctAndCorrectlyFramed() { + ClientSession recipient = session(BATCH, TAGS); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (SessionWriter writer = + new SessionWriter(recipient, out, new CapabilityTagRenderer(extensions))) { + writer.enqueueGroup( + "type-a", List.of(), List.of(privmsg("#chan", "a1"), privmsg("#chan", "a2"))); + writer.enqueueGroup("type-b", List.of("param1"), List.of(privmsg("#chan", "b1"))); + } + String[] lines = lines(out); + assertThat(lines).hasSize(7); + String refA = lines[0].substring("BATCH +".length(), lines[0].indexOf(' ', "BATCH +".length())); + String refB = lines[4].substring("BATCH +".length(), lines[4].indexOf(' ', "BATCH +".length())); + assertThat(refA).isNotEqualTo(refB); + assertThat(lines[3]).isEqualTo("BATCH -" + refA); + assertThat(lines[6]).isEqualTo("BATCH -" + refB); + assertThat(tagValue(lines[5], "batch")).isEqualTo(refB); + } + + @Test + void referencesAreUniqueAsciiTokens() { + Set refs = new HashSet<>(); + ClientSession recipient = session(BATCH, TAGS); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (SessionWriter writer = + new SessionWriter(recipient, out, new CapabilityTagRenderer(extensions), 1 << 20)) { + for (int i = 0; i < 200; i++) { + writer.enqueueGroup("t", List.of(), List.of(privmsg("#c", "x"))); + } + } + for (String line : lines(out)) { + if (line.startsWith("BATCH +")) refs.add(line.substring(7, line.indexOf(' ', 7))); + } + assertThat(refs).hasSize(200).allMatch(ref -> ref.matches("[0-9a-f-]{36}")); + } + + @Test + void anEmptyGroupIsAnOpenerAndACloserOnlyForABatchRecipient() { + String[] batched = send(session(BATCH, TAGS), "empty", List.of()); + assertThat(batched).hasSize(2); + assertThat(batched[0]).startsWith("BATCH +").endsWith(" empty"); + assertThat(batched[1]) + .isEqualTo("BATCH -" + batched[0].substring(7, batched[0].indexOf(' ', 7))); + assertThat(send(session(), "empty", List.of())).isEmpty(); + } + + @Test + void aNestedGroupKeepsItsMembersTagsAndTheOuterGroupTagsTheRest() { + Message innerOpen = new Message(Map.of(), null, null, "BATCH", List.of("+inner", "sub")); + Message innerMember = + new Message( + Map.of("batch", "inner"), "s!i@h", Command.PRIVMSG, "PRIVMSG", List.of("#c", "deep")); + Message innerClose = new Message(Map.of(), null, null, "BATCH", List.of("-inner")); + + String[] lines = + send(session(BATCH, TAGS), "outer", List.of(), innerOpen, innerMember, innerClose); + + assertThat(lines).hasSize(5); + String ref = lines[0].substring(7, lines[0].indexOf(' ', 7)); + assertThat(tagValue(lines[1], "batch")) + .isEqualTo(ref); // the inner opener joins the outer group + assertThat(tagValue(lines[2], "batch")).isEqualTo("inner"); + assertThat(lines[2]).contains("deep"); + assertThat(tagValue(lines[3], "batch")).isEqualTo(ref); + assertThat(lines[4]).isEqualTo("BATCH -" + ref); + } + + @Test + void aGroupTooLargeForTheQueueIsRejectedWholeWithoutAnOrphanOpener() { + ClientSession recipient = session(BATCH, TAGS); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + List members = new ArrayList<>(); + for (int i = 0; i < 20; i++) members.add(privmsg("#chan", "x".repeat(100))); + try (SessionWriter writer = + new SessionWriter(recipient, out, new CapabilityTagRenderer(extensions), 800)) { + writer.enqueueGroup("big", List.of(), members); + } + String written = out.toString(StandardCharsets.UTF_8); + assertThat(written).doesNotContain("BATCH +").doesNotContain("PRIVMSG"); + } + + @Test + void aGroupAdmittedBeforeWithdrawalStillEndsWithItsCloser() { + ClientSession recipient = session(BATCH, TAGS); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (SessionWriter writer = + new SessionWriter(recipient, out, new CapabilityTagRenderer(extensions))) { + writer.enqueueGroup("t", List.of(), List.of(privmsg("#c", "x"))); + extensions.disable(BatchExtension.ID); + } + String[] lines = lines(out); + assertThat(lines).hasSize(3); + assertThat(lines[2]).startsWith("BATCH -"); + } + + @Test + void withoutTheProviderGroupsAreSentPlain() { + extensions.disable(BatchExtension.ID); + String[] lines = send(session(BATCH, TAGS), "t", List.of(), privmsg("#c", "x")); + assertThat(lines).hasSize(1); + assertThat(lines[0]).doesNotContain("BATCH"); + } +} diff --git a/jircd-capabilities/echo-message/src/main/java/net/jircd/capabilities/echomessage/EchoMessageExtension.java b/jircd-capabilities/echo-message/src/main/java/net/jircd/capabilities/echomessage/EchoMessageExtension.java index 54671c7..f44f02b 100644 --- a/jircd-capabilities/echo-message/src/main/java/net/jircd/capabilities/echomessage/EchoMessageExtension.java +++ b/jircd-capabilities/echo-message/src/main/java/net/jircd/capabilities/echomessage/EchoMessageExtension.java @@ -15,15 +15,19 @@ */ package net.jircd.capabilities.echomessage; +import java.util.Set; import net.jircd.core.extension.AbstractCapabilityExtension; -import net.jircd.core.session.ClientSession; +import net.jircd.core.extension.capability.CapabilityRegistrar; +import net.jircd.core.extension.capability.DeliveryRole; import net.jircd.protocol.CapabilityName; /** * The {@code echo-message} capability (FR-025): unlike {@code message-tags}/{@code server-time}, - * this affects recipient-set construction, not per-recipient formatting — {@link - * net.jircd.core.session.command.MessageCommandHandler} calls {@link #includeSenderInFanOut} once, - * when building a fan-out's recipient list, to decide whether the sender's own session is included. + * this affects recipient-set construction, not per-recipient formatting. It registers a recipient + * hook that asks core to add a {@link DeliveryRole#SENDER_COPY} for the sender of a permitted + * {@code PRIVMSG}, {@code NOTICE} or {@code TAGMSG}. Core applies the hook only when the sender + * negotiated this capability, merges the copy with any ordinary role so a recipient gets the + * message once, and applies the same recipient rules to it as to every other recipient. */ public final class EchoMessageExtension extends AbstractCapabilityExtension { @@ -34,7 +38,7 @@ public EchoMessageExtension() { } @Override - public boolean includeSenderInFanOut(ClientSession sender) { - return sender.negotiatedCapabilities().contains(ID); + public void register(CapabilityRegistrar registrar) { + registrar.onRecipients((intent, context) -> Set.of(DeliveryRole.SENDER_COPY)); } } diff --git a/jircd-capabilities/echo-message/src/test/java/net/jircd/capabilities/echomessage/EchoMessageExtensionTest.java b/jircd-capabilities/echo-message/src/test/java/net/jircd/capabilities/echomessage/EchoMessageExtensionTest.java new file mode 100644 index 0000000..6e100cb --- /dev/null +++ b/jircd-capabilities/echo-message/src/test/java/net/jircd/capabilities/echomessage/EchoMessageExtensionTest.java @@ -0,0 +1,196 @@ +/* + * Copyright 2026 Guillermo Castro + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package net.jircd.capabilities.echomessage; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.jircd.core.extension.ExtensionRegistry; +import net.jircd.core.extension.capability.CapabilityDescriptor; +import net.jircd.core.extension.capability.CapabilityRegistrar; +import net.jircd.core.extension.capability.DeliveryIntent; +import net.jircd.core.extension.capability.DeliveryRole; +import net.jircd.core.extension.capability.HookDispatcher; +import net.jircd.core.extension.capability.HookKind; +import net.jircd.core.extension.capability.RequestContext; +import net.jircd.core.extension.capability.RuntimeSnapshot; +import net.jircd.core.session.CapabilityTagRenderer; +import net.jircd.core.session.ChannelRegistry; +import net.jircd.core.session.ClientSession; +import net.jircd.core.session.MessageDelivery; +import net.jircd.core.session.NicknameRegistry; +import net.jircd.core.session.RateLimitBucket; +import net.jircd.core.session.SessionWriter; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** echo-message's sender-copy policy, through core's shared delivery with the real provider. */ +class EchoMessageExtensionTest { + private final NicknameRegistry nicknames = new NicknameRegistry(); + private final ChannelRegistry channels = new ChannelRegistry(); + private final ExtensionRegistry extensions = new ExtensionRegistry(); + private final MessageDelivery delivery = + new MessageDelivery(channels, nicknames, extensions, () -> "test.jircd.local"); + private final Map outputs = new HashMap<>(); + private final List writers = new ArrayList<>(); + + EchoMessageExtensionTest() { + extensions.register(new EchoMessageExtension()); + extensions.enable(EchoMessageExtension.ID); + } + + @AfterEach + void closeWriters() { + writers.forEach(SessionWriter::close); + } + + private ClientSession user(String nick, boolean echo) { + ClientSession session = new ClientSession(nick, "host.example", RateLimitBucket.withDefaults()); + session.setNickname(nick); + session.setIdent("ident"); + if (echo) session.replaceNegotiatedCapabilities(List.of(EchoMessageExtension.ID)); + nicknames.claim(nick, session); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + outputs.put(session, out); + @SuppressWarnings("PMD.CloseResource") // closed by closeWriters() + SessionWriter writer = new SessionWriter(session, out, new CapabilityTagRenderer(extensions)); + writers.add(writer); + session.attachWriter(writer); + return session; + } + + private MessageDelivery.Delivery send(ClientSession sender, String command, String target) { + return delivery.deliver( + sender, + command, + target, + command.equals("TAGMSG") ? null : "hello", + Map.of(), + delivery.authorize(sender, target, true), + recipient -> true); + } + + private List lines(ClientSession session) { + writers.forEach(SessionWriter::close); + return outputs.get(session).toString(StandardCharsets.UTF_8).lines().toList(); + } + + @Test + void registersOnlyARecipientHook() { + CapabilityRegistrar registrar = new CapabilityRegistrar("echo-message", "echo-message"); + new EchoMessageExtension().register(registrar); + CapabilityDescriptor descriptor = registrar.build(); + assertThat(descriptor.registrations()).hasSize(1); + assertThat(descriptor.registrations().getFirst().kind()).isEqualTo(HookKind.RECIPIENTS); + } + + @Test + void theHookAddsASenderCopyAndNothingElseForAnyDelivery() { + CapabilityRegistrar registrar = new CapabilityRegistrar("echo-message", "echo-message"); + new EchoMessageExtension().register(registrar); + RuntimeSnapshot snapshot = RuntimeSnapshot.EMPTY.with(registrar.build()); + HookDispatcher dispatcher = new HookDispatcher((provider, kind, category) -> {}); + DeliveryIntent intent = + new DeliveryIntent(java.util.UUID.randomUUID(), Set.of(DeliveryRole.ORDINARY_TARGET)); + RequestContext context = RequestContext.of(7, Map.of()); + + assertThat(dispatcher.recipientRoles(snapshot, Set.of("echo-message"), intent, context)) + .containsExactly(DeliveryRole.SENDER_COPY); + // The sender gate is central: a sender that did not negotiate the owner is never asked. + assertThat(dispatcher.recipientRoles(snapshot, Set.of(), intent, context)).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = {"PRIVMSG", "NOTICE", "TAGMSG"}) + void aChannelMessageIsCopiedToTheSenderOnlyWhenItNegotiatedEcho(String command) { + ClientSession echoing = user("echoing", true); + ClientSession plain = user("plain", false); + ClientSession bob = user("bob", false); + for (ClientSession member : List.of(echoing, plain, bob)) { + channels.join("#c", member, ch -> true); + } + + MessageDelivery.Delivery withEcho = send(echoing, command, "#c"); + MessageDelivery.Delivery withoutEcho = send(plain, command, "#c"); + + assertThat(withEcho.recipients().get(echoing)).containsExactly(DeliveryRole.SENDER_COPY); + assertThat(withoutEcho.recipients()).doesNotContainKey(plain); + // echoing: its own copy plus plain's message; plain only echoing's message. + assertThat(lines(echoing)).hasSize(2); + assertThat(lines(plain)).hasSize(1); + } + + @ParameterizedTest + @ValueSource(strings = {"PRIVMSG", "NOTICE", "TAGMSG"}) + void aDirectMessageToSomeoneElseIsCopiedToTheSenderOnlyWithEcho(String command) { + ClientSession echoing = user("echoing", true); + ClientSession plain = user("plain", false); + ClientSession bob = user("bob", false); + + send(echoing, command, "bob"); + send(plain, command, "bob"); + + assertThat(lines(echoing)).hasSize(1); + assertThat(lines(plain)).isEmpty(); + assertThat(lines(bob)).hasSize(2); + } + + @ParameterizedTest + @ValueSource(strings = {"PRIVMSG", "NOTICE", "TAGMSG"}) + void aSelfDirectMessageIsDeliveredExactlyOnceWithOrWithoutEcho(String command) { + ClientSession echoing = user("echoing", true); + ClientSession plain = user("plain", false); + + MessageDelivery.Delivery withEcho = send(echoing, command, "echoing"); + send(plain, command, "plain"); + + assertThat(withEcho.recipients().get(echoing)) + .containsExactlyInAnyOrder(DeliveryRole.ORDINARY_TARGET, DeliveryRole.SENDER_COPY); + assertThat(lines(echoing)).hasSize(1); + assertThat(lines(plain)).hasSize(1); + } + + @Test + void aRoleChangeAfterTheSenderSnapshotDoesNotSplitOneDelivery() { + ClientSession echoing = user("echoing", true); + ClientSession bob = user("bob", false); + channels.join("#c", echoing, ch -> true); + channels.join("#c", bob, ch -> true); + + // The negotiated set is read once for the delivery; a later REQ cannot add a second copy. + MessageDelivery.Delivery sent = send(echoing, "PRIVMSG", "#c"); + echoing.replaceNegotiatedCapabilities(List.of()); + + assertThat(sent.recipients().get(echoing)).containsExactly(DeliveryRole.SENDER_COPY); + assertThat(lines(echoing)).hasSize(1); + } + + @Test + void theRequestContextIsTheOneTheDeliveryCarries() { + ClientSession echoing = user("echoing", true); + user("bob", false); + MessageDelivery.Delivery sent = send(echoing, "PRIVMSG", "bob"); + assertThat(sent.context().requestId()).isPositive(); + } +} diff --git a/jircd-capabilities/message-tags/src/main/java/net/jircd/capabilities/messagetags/MessageTagsExtension.java b/jircd-capabilities/message-tags/src/main/java/net/jircd/capabilities/messagetags/MessageTagsExtension.java index 2395c7a..b186fab 100644 --- a/jircd-capabilities/message-tags/src/main/java/net/jircd/capabilities/messagetags/MessageTagsExtension.java +++ b/jircd-capabilities/message-tags/src/main/java/net/jircd/capabilities/messagetags/MessageTagsExtension.java @@ -15,27 +15,93 @@ */ package net.jircd.capabilities.messagetags; +import java.util.LinkedHashMap; import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; import net.jircd.core.extension.AbstractCapabilityExtension; +import net.jircd.core.extension.ServerContext; +import net.jircd.core.extension.capability.CapabilityRegistrar; +import net.jircd.core.extension.capability.InboundTagResult; +import net.jircd.core.extension.capability.InboundTags; +import net.jircd.core.extension.capability.RequestContext; +import net.jircd.core.session.MessageDelivery; import net.jircd.core.session.OutboundMessage; import net.jircd.protocol.CapabilityName; +import net.jircd.protocol.Command; /** - * The {@code message-tags} capability (FR-025): contributes the {@code msgid} tag unconditionally — - * present on every delivered message for a recipient that has this capability negotiated at all, - * regardless of whether {@code server-time} is also negotiated (FR-059, data-model.md - * "OutboundMessage" — {@code messageId}). + * The {@code message-tags} capability (FR-025): owns the semantics of IRCv3 message tags. It + * selects the client-only ({@code +}) tags of an inbound message for forwarding to recipients that + * negotiated it, contributes the {@code msgid} tag unconditionally — present on every delivered + * message for a recipient that has this capability negotiated at all, regardless of whether {@code + * server-time} is also negotiated (FR-059) — and owns the {@code TAGMSG} command. + * + *

Core still parses and size-checks tag syntax whether or not this module is present; without it + * no client tag is forwarded, no {@code msgid} is added and {@code TAGMSG} is an unknown command. */ public final class MessageTagsExtension extends AbstractCapabilityExtension { public static final String ID = CapabilityName.MESSAGE_TAGS; + private static final String MSGID_TAG = "msgid"; + + /** {@code msgid=} plus the 36-character UUID; no character in it needs tag escaping. */ + static final long MAX_MSGID_TAG_BYTES = 42; + + private final AtomicReference context = new AtomicReference<>(); + public MessageTagsExtension() { super(ID); } @Override - public Map contributeTags(OutboundMessage message) { - return Map.of("msgid", message.messageId().toString()); + public void start(ServerContext serverContext) { + context.set(serverContext); + } + + @Override + public void stop() { + context.set(null); + } + + @Override + public void register(CapabilityRegistrar registrar) { + registrar.onInboundTags(MessageTagsExtension::forwardClientOnlyTags); + registrar.onOutboundTags(Set.of(MSGID_TAG), MAX_MSGID_TAG_BYTES, MessageTagsExtension::msgid); + registrar.command( + Command.TAGMSG, + new TagmsgCommandHandler(this::currentDelivery, () -> started().serverName().get())); + } + + private MessageDelivery currentDelivery() { + ServerContext started = started(); + return new MessageDelivery( + started.channelRegistry(), + started.nicknameRegistry(), + started.extensionRegistry(), + started.serverName()); + } + + private ServerContext started() { + ServerContext current = context.get(); + if (current == null) throw new IllegalStateException("message-tags is not started"); + return current; + } + + /** Only client-only tags are forwarded; a client's attempt to set a server tag is dropped. */ + static InboundTagResult forwardClientOnlyTags(InboundTags tags, RequestContext context) { + Map forwarded = new LinkedHashMap<>(); + tags.tags() + .forEach( + (key, value) -> { + if (key.startsWith("+")) forwarded.put(key, value); + }); + return forwarded.isEmpty() ? InboundTagResult.NONE : new InboundTagResult(forwarded, Map.of()); + } + + /** Pure in the envelope: every recipient of one message sees the same identifier. */ + static Map msgid(OutboundMessage message) { + return Map.of(MSGID_TAG, message.messageId().toString()); } } diff --git a/jircd-capabilities/message-tags/src/main/java/net/jircd/capabilities/messagetags/TagmsgCommandHandler.java b/jircd-capabilities/message-tags/src/main/java/net/jircd/capabilities/messagetags/TagmsgCommandHandler.java new file mode 100644 index 0000000..1870b5e --- /dev/null +++ b/jircd-capabilities/message-tags/src/main/java/net/jircd/capabilities/messagetags/TagmsgCommandHandler.java @@ -0,0 +1,82 @@ +/* + * Copyright 2026 Guillermo Castro + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package net.jircd.capabilities.messagetags; + +import java.util.List; +import java.util.function.Supplier; +import net.jircd.core.session.ClientSession; +import net.jircd.core.session.MessageDelivery; +import net.jircd.core.session.command.CommandHandler; +import net.jircd.core.session.command.Replies; +import net.jircd.protocol.Message; +import net.jircd.protocol.NumericReply; + +/** + * {@code TAGMSG} — a tag-only message, no text body (002-extended-irc-commands FR-020 through + * FR-023): reuses core's {@link MessageDelivery} target resolution and send gates verbatim, then + * fans out to recipients that have {@code message-tags} negotiated only — a client without it has + * nothing to render from a message with no body. That includes a sender copy: it is delivered under + * the same recipient rule. + */ +final class TagmsgCommandHandler implements CommandHandler { + + private static final String COMMAND_NAME = "TAGMSG"; + + private final Supplier delivery; + private final Supplier serverName; + + TagmsgCommandHandler(Supplier delivery, Supplier serverName) { + this.delivery = delivery; + this.serverName = serverName; + } + + @Override + public void handle(ClientSession session, Message message) { + if (message.params().isEmpty()) { + Replies.sendFitted( + session, + serverName.get(), + NumericReply.ERR_NEEDMOREPARAMS, + List.of(COMMAND_NAME), + "Not enough parameters"); + return; + } + if (message.tags().isEmpty()) { + Replies.sendFitted( + session, + serverName.get(), + NumericReply.ERR_UNKNOWNCOMMAND, + List.of(COMMAND_NAME), + "Malformed message"); + return; + } + String target = message.params().getFirst(); + MessageDelivery shared = delivery.get(); + MessageDelivery.AuthorizedTarget recipients = shared.authorize(session, target, true); + if (recipients.isEmpty()) { + return; + } + shared.deliver( + session, + COMMAND_NAME, + target, + null, + message.tags(), + recipients, + // FR-021: nothing to render for a client without message-tags negotiated. + recipient -> recipient.negotiatedCapabilities().contains(MessageTagsExtension.ID)); + } +} diff --git a/jircd-capabilities/message-tags/src/test/java/net/jircd/capabilities/messagetags/MessageTagsExtensionPurityTest.java b/jircd-capabilities/message-tags/src/test/java/net/jircd/capabilities/messagetags/MessageTagsExtensionPurityTest.java deleted file mode 100644 index e9d1659..0000000 --- a/jircd-capabilities/message-tags/src/test/java/net/jircd/capabilities/messagetags/MessageTagsExtensionPurityTest.java +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright 2026 Guillermo Castro - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - * implied. See the License for the specific language governing - * permissions and limitations under the License. - */ -package net.jircd.capabilities.messagetags; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.time.Instant; -import java.util.Map; -import java.util.UUID; -import net.jircd.core.session.OutboundMessage; -import org.junit.jupiter.api.Test; - -class MessageTagsExtensionPurityTest { - @Test - void contributionDependsOnlyOnTheMessage() { - var extension = new MessageTagsExtension(); - var message = - new OutboundMessage( - "n!u@h", - "PRIVMSG", - "#c", - "hello", - Map.of(), - Instant.parse("2020-01-02T03:04:05.006Z"), - UUID.fromString("00000000-0000-0000-0000-000000000123")); - var equal = - new OutboundMessage( - message.senderPresentedForm(), - message.command(), - message.target(), - message.body(), - message.clientTags(), - message.sentAt(), - message.messageId()); - for (int i = 0; i < 10; i++) { - assertThat(extension.contributeTags(message)) - .isEqualTo(Map.of("msgid", message.messageId().toString())); - assertThat(new MessageTagsExtension().contributeTags(equal)) - .isEqualTo(extension.contributeTags(message)); - } - } -} diff --git a/jircd-capabilities/message-tags/src/test/java/net/jircd/capabilities/messagetags/MessageTagsExtensionTest.java b/jircd-capabilities/message-tags/src/test/java/net/jircd/capabilities/messagetags/MessageTagsExtensionTest.java new file mode 100644 index 0000000..c0f7567 --- /dev/null +++ b/jircd-capabilities/message-tags/src/test/java/net/jircd/capabilities/messagetags/MessageTagsExtensionTest.java @@ -0,0 +1,136 @@ +/* + * Copyright 2026 Guillermo Castro + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package net.jircd.capabilities.messagetags; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import net.jircd.core.extension.capability.CapabilityDescriptor; +import net.jircd.core.extension.capability.CapabilityRegistrar; +import net.jircd.core.extension.capability.HookDispatcher; +import net.jircd.core.extension.capability.HookKind; +import net.jircd.core.extension.capability.HookRegistration; +import net.jircd.core.extension.capability.InboundTagResult; +import net.jircd.core.extension.capability.InboundTags; +import net.jircd.core.extension.capability.RequestContext; +import net.jircd.core.extension.capability.RuntimeSnapshot; +import net.jircd.core.session.OutboundMessage; +import net.jircd.protocol.Command; +import net.jircd.protocol.MessageSerializer; +import org.junit.jupiter.api.Test; + +class MessageTagsExtensionTest { + private final List faults = new ArrayList<>(); + private final HookDispatcher dispatcher = + new HookDispatcher( + (provider, kind, category) -> faults.add(provider + "/" + kind + "/" + category)); + + private static CapabilityDescriptor descriptor() { + CapabilityRegistrar registrar = new CapabilityRegistrar("message-tags", "message-tags"); + new MessageTagsExtension().register(registrar); + return registrar.build(); + } + + private static HookRegistration hook(HookKind kind) { + return descriptor().registrations().stream() + .filter(registration -> registration.kind() == kind) + .findFirst() + .orElseThrow(); + } + + private static OutboundMessage message() { + return new OutboundMessage( + "n!u@h", + "PRIVMSG", + "#c", + "hello", + Map.of(), + Instant.parse("2020-01-02T03:04:05.006Z"), + UUID.fromString("00000000-0000-0000-0000-000000000123")); + } + + @Test + void ownsInboundTagsTheMsgidTagAndTheTagmsgCommand() { + CapabilityDescriptor descriptor = descriptor(); + assertThat(descriptor.registrations()) + .extracting(HookRegistration::kind) + .containsExactlyInAnyOrder(HookKind.INBOUND_TAGS, HookKind.OUTBOUND_TAGS, HookKind.COMMAND); + assertThat(hook(HookKind.OUTBOUND_TAGS).ownedKeys()).containsExactly("msgid"); + assertThat(hook(HookKind.COMMAND).command()).isEqualTo(Command.TAGMSG); + // The sender needs nothing negotiated: client-only tags are accepted as before. + assertThat(hook(HookKind.INBOUND_TAGS).requiresNegotiated()).isEmpty(); + } + + @Test + void msgidIsPureInTheEnvelopeAndWithinItsDeclaredBound() { + HookRegistration hook = hook(HookKind.OUTBOUND_TAGS); + OutboundMessage message = message(); + for (int i = 0; i < 10; i++) { + Map tags = dispatcher.outboundTags(hook, message); + assertThat(tags).containsExactly(Map.entry("msgid", message.messageId().toString())); + assertThat(MessageSerializer.tagDataBytes(tags)).isLessThanOrEqualTo((int) hook.maxBytes()); + } + assertThat(faults).isEmpty(); + } + + @Test + void forwardsOnlyClientOnlyTagsAndDropsForgedServerKeys() { + RuntimeSnapshot snapshot = RuntimeSnapshot.EMPTY.with(descriptor()); + InboundTags input = + new InboundTags( + "PRIVMSG", + Map.of("+typing", "active", "+draft/reply", "abc", "msgid", "forged", "time", "x")); + + InboundTagResult result = + dispatcher.inboundTags(snapshot, Set.of(), input, RequestContext.of(1, Map.of())); + + assertThat(result.forwarded()) + .containsOnly(Map.entry("+typing", "active"), Map.entry("+draft/reply", "abc")); + assertThat(result.context()).isEmpty(); + assertThat(faults).isEmpty(); + } + + @Test + void noClientTagsYieldsNoForwarding() { + RuntimeSnapshot snapshot = RuntimeSnapshot.EMPTY.with(descriptor()); + InboundTagResult result = + dispatcher.inboundTags( + snapshot, + Set.of(), + new InboundTags("PRIVMSG", Map.of()), + RequestContext.of(1, Map.of())); + assertThat(result).isEqualTo(InboundTagResult.NONE); + } + + @Test + void theSelectionRecordsMessageTagsAsTheOwnerOfEachForwardedTag() { + RuntimeSnapshot snapshot = RuntimeSnapshot.EMPTY.with(descriptor()); + HookDispatcher.InboundSelection selection = + dispatcher.inboundSelection( + snapshot, + Set.of(), + new InboundTags("PRIVMSG", Map.of("+typing", "active", "msgid", "forged")), + RequestContext.of(1, Map.of())); + + assertThat(selection.ownersByKey()) + .containsExactly(Map.entry("+typing", Set.of("message-tags"))); + } +} diff --git a/jircd-capabilities/message-tags/src/test/java/net/jircd/capabilities/messagetags/TagmsgCommandHandlerTest.java b/jircd-capabilities/message-tags/src/test/java/net/jircd/capabilities/messagetags/TagmsgCommandHandlerTest.java new file mode 100644 index 0000000..144ffae --- /dev/null +++ b/jircd-capabilities/message-tags/src/test/java/net/jircd/capabilities/messagetags/TagmsgCommandHandlerTest.java @@ -0,0 +1,222 @@ +/* + * Copyright 2026 Guillermo Castro + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package net.jircd.capabilities.messagetags; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import net.jircd.core.extension.CapabilityExtension; +import net.jircd.core.extension.CommandRegistrar; +import net.jircd.core.extension.ExtensionRegistry; +import net.jircd.core.extension.ServerContext; +import net.jircd.core.session.CapabilityTagRenderer; +import net.jircd.core.session.ChannelRegistry; +import net.jircd.core.session.ClientSession; +import net.jircd.core.session.NicknameRegistry; +import net.jircd.core.session.RateLimitBucket; +import net.jircd.core.session.SessionWriter; +import net.jircd.core.session.command.CommandHandler; +import net.jircd.protocol.Command; +import net.jircd.protocol.MessageParser; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** TAGMSG through the real provider, registry, shared delivery and tag renderer. */ +class TagmsgCommandHandlerTest { + private final NicknameRegistry nicknames = new NicknameRegistry(); + private final ChannelRegistry channels = new ChannelRegistry(); + private final ExtensionRegistry extensions = new ExtensionRegistry(); + private final Map commands = new EnumMap<>(Command.class); + private final Map outputs = new HashMap<>(); + private final List writers = new ArrayList<>(); + + @BeforeEach + void startProvider() { + CommandRegistrar registrar = + new CommandRegistrar() { + @Override + public void register(Command command, CommandHandler handler) { + commands.put(command, handler); + } + + @Override + public boolean isRegistered(Command command) { + return commands.containsKey(command); + } + + @Override + public void unregister(Command command, CommandHandler installed) { + commands.remove(command, installed); + } + }; + extensions.attachContext( + new ServerContext( + nicknames, + channels, + extensions, + () -> "test.jircd.local", + null, + null, + registrar, + extensions.capabilityServices())); + CapabilityExtension provider = new MessageTagsExtension(); + extensions.register(provider); + extensions.enable(provider.id()); + } + + @AfterEach + void closeWriters() { + writers.forEach(SessionWriter::close); + } + + private ClientSession user(String nick, String... negotiated) { + ClientSession session = new ClientSession(nick, "host.example", RateLimitBucket.withDefaults()); + session.setNickname(nick); + session.setIdent("ident"); + session.replaceNegotiatedCapabilities(List.of(negotiated)); + nicknames.claim(nick, session); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + outputs.put(session, out); + @SuppressWarnings("PMD.CloseResource") // closed by closeWriters() + SessionWriter writer = new SessionWriter(session, out, new CapabilityTagRenderer(extensions)); + writers.add(writer); + session.attachWriter(writer); + return session; + } + + private void tagmsg(ClientSession sender, String line) throws Exception { + commands.get(Command.TAGMSG).handle(sender, MessageParser.parse(line)); + } + + private String received(ClientSession session) { + writers.forEach(SessionWriter::close); + return outputs.get(session).toString(StandardCharsets.UTF_8); + } + + @Test + void theProviderInstallsTagmsgAndWithdrawalRemovesIt() { + assertThat(commands).containsKey(Command.TAGMSG); + extensions.disable("message-tags"); + assertThat(commands).doesNotContainKey(Command.TAGMSG); + } + + @Test + void deliversClientOnlyTagsAndAMsgidToRecipientsWithMessageTags() throws Exception { + ClientSession alice = user("alice"); + ClientSession bob = user("bob", "message-tags"); + + tagmsg(alice, "@+typing=active;msgid=forged TAGMSG bob"); + + String line = received(bob); + assertThat(line) + .startsWith("@") + .contains("+typing=active") + .contains("msgid=") + .doesNotContain("msgid=forged") + .endsWith(":alice!ident@host.example TAGMSG bob\r\n"); + assertThat(received(alice)).isEmpty(); + } + + @Test + void aSenderWithoutMessageTagsMayStillSendTagmsg() throws Exception { + ClientSession alice = user("alice"); + ClientSession bob = user("bob", "message-tags"); + tagmsg(alice, "@+x=y TAGMSG bob"); + assertThat(received(bob)).contains("+x=y"); + } + + @Test + void recipientsWithoutMessageTagsGetNothing() throws Exception { + ClientSession alice = user("alice", "message-tags"); + ClientSession bob = user("bob"); + ClientSession carol = user("carol", "message-tags"); + channels.join("#c", alice, ch -> true); + channels.join("#c", bob, ch -> true); + channels.join("#c", carol, ch -> true); + + tagmsg(alice, "@+x=y TAGMSG #c"); + + assertThat(received(bob)).isEmpty(); + assertThat(received(carol)).contains("TAGMSG #c"); + assertThat(received(alice)).isEmpty(); + } + + @Test + void channelRecipientsShareOneMsgid() throws Exception { + ClientSession alice = user("alice"); + ClientSession bob = user("bob", "message-tags"); + ClientSession carol = user("carol", "message-tags"); + channels.join("#c", alice, ch -> true); + channels.join("#c", bob, ch -> true); + channels.join("#c", carol, ch -> true); + + tagmsg(alice, "@+x=y TAGMSG #c"); + + String bobLine = received(bob); + String msgid = bobLine.substring(bobLine.indexOf("msgid=") + 6).split("[; ]")[0]; + assertThat(received(carol)).contains("msgid=" + msgid); + } + + @Test + void missingParametersAndMissingTagsAreRejected() throws Exception { + ClientSession alice = user("alice"); + tagmsg(alice, "TAGMSG"); + tagmsg(alice, "TAGMSG bob"); + assertThat(received(alice)) + .contains(" 461 alice TAGMSG ") + .contains(" 421 alice TAGMSG :Malformed message"); + } + + @Test + void unknownTargetsAndBlockedChannelsReportTheSharedErrors() throws Exception { + ClientSession alice = user("alice", "message-tags"); + ClientSession bob = user("bob", "message-tags"); + channels.join("#c", bob, ch -> true); + channels + .lookup("#c") + .orElseThrow() + .setMode(net.jircd.core.session.CoreChannelModes.MEMBERS_ONLY); + + tagmsg(alice, "@+x=y TAGMSG ghost"); + tagmsg(alice, "@+x=y TAGMSG #c"); + + assertThat(received(alice)).contains(" 401 alice ghost ").contains(" 404 alice #c "); + assertThat(received(bob)).isEmpty(); + } + + @Test + void anOversizeClientTagSetIsDroppedForTheRecipientWithoutBreakingLaterMessages() + throws Exception { + ClientSession alice = user("alice"); + ClientSession bob = user("bob", "message-tags"); + String huge = "x".repeat(5000); + + tagmsg(alice, "@+big=" + huge + " TAGMSG bob"); + tagmsg(alice, "@+ok=1 TAGMSG bob"); + + String received = received(bob); + assertThat(received).doesNotContain("+big="); + assertThat(received.lines()).hasSize(1); + assertThat(received).contains("+ok=1"); + } +} diff --git a/jircd-capabilities/multi-prefix/src/main/java/net/jircd/capabilities/multiprefix/MultiPrefixExtension.java b/jircd-capabilities/multi-prefix/src/main/java/net/jircd/capabilities/multiprefix/MultiPrefixExtension.java index e2d2bea..f29d8bd 100644 --- a/jircd-capabilities/multi-prefix/src/main/java/net/jircd/capabilities/multiprefix/MultiPrefixExtension.java +++ b/jircd-capabilities/multi-prefix/src/main/java/net/jircd/capabilities/multiprefix/MultiPrefixExtension.java @@ -16,21 +16,38 @@ package net.jircd.capabilities.multiprefix; import net.jircd.core.extension.AbstractCapabilityExtension; +import net.jircd.core.extension.capability.CapabilityRegistrar; +import net.jircd.core.extension.capability.MemberSnapshot; import net.jircd.protocol.CapabilityName; /** - * The {@code multi-prefix} capability: like {@code away-notify}, it gates behaviour that lives in - * core. {@code Channel.memberListing}/{@code statusPrefixes}, reached from {@code JOIN}, {@code - * NAMES} and {@code WHO}, list every status prefix a member holds when the requester's negotiated - * set contains {@link CapabilityName#MULTI_PREFIX}. The prefix logic itself stays in core beside - * {@code ISUPPORT PREFIX}, which defines the order; this module only registers the capability so it - * is advertised, ACKed and configurable. + * The {@code multi-prefix} capability: replaces the status slot of {@code NAMES} entries and + * channel {@code WHO} flags with every status symbol a member holds, in {@code PREFIX} order, for a + * requester that negotiated it. Core keeps the member facts, the {@code PREFIX} order that defines + * the statuses and the default of the highest status only; this module only chooses how many to + * show. */ public final class MultiPrefixExtension extends AbstractCapabilityExtension { public static final String CAPABILITY_ID = CapabilityName.MULTI_PREFIX; + /** + * The most statuses a member can hold, one byte each: the operator and voice symbols of {@code + * PREFIX=(ov)@+}. Core reserves this width in the reply budget of every entry the slot decorates. + */ + static final long MAX_STATUS_BYTES = 2; + public MultiPrefixExtension() { super(CAPABILITY_ID); } + + @Override + public void register(CapabilityRegistrar registrar) { + registrar.onMemberStatus(limits -> MAX_STATUS_BYTES, MultiPrefixExtension::allStatuses); + } + + /** Every held status, highest first, exactly as core ordered them. */ + static String allStatuses(MemberSnapshot member) { + return String.join("", member.heldStatuses()); + } } diff --git a/jircd-capabilities/multi-prefix/src/test/java/net/jircd/capabilities/multiprefix/MultiPrefixExtensionTest.java b/jircd-capabilities/multi-prefix/src/test/java/net/jircd/capabilities/multiprefix/MultiPrefixExtensionTest.java new file mode 100644 index 0000000..37b049b --- /dev/null +++ b/jircd-capabilities/multi-prefix/src/test/java/net/jircd/capabilities/multiprefix/MultiPrefixExtensionTest.java @@ -0,0 +1,120 @@ +/* + * Copyright 2026 Guillermo Castro + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package net.jircd.capabilities.multiprefix; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import net.jircd.core.extension.ExtensionRegistry; +import net.jircd.core.extension.capability.CapabilityRegistrar; +import net.jircd.core.extension.capability.HookDispatcher; +import net.jircd.core.extension.capability.HookKind; +import net.jircd.core.extension.capability.HookRegistration; +import net.jircd.core.extension.capability.MemberSnapshot; +import net.jircd.core.extension.capability.ReplyBudgetCatalog; +import net.jircd.core.extension.capability.ReplyLayout; +import net.jircd.core.extension.capability.ReplyLimits; +import net.jircd.core.extension.capability.ReplySlot; +import net.jircd.core.extension.capability.RuntimeSnapshot; +import net.jircd.core.session.Channel; +import net.jircd.core.session.ChannelRegistry; +import net.jircd.core.session.ClientSession; +import net.jircd.core.session.MemberReplies; +import net.jircd.core.session.RateLimitBucket; +import org.junit.jupiter.api.Test; + +class MultiPrefixExtensionTest { + private final List faults = new ArrayList<>(); + private final HookDispatcher dispatcher = + new HookDispatcher((provider, kind, category) -> faults.add(provider + "/" + kind)); + + private static HookRegistration hook() { + CapabilityRegistrar registrar = new CapabilityRegistrar("multi-prefix", "multi-prefix"); + new MultiPrefixExtension().register(registrar); + List registrations = registrar.build().registrations(); + assertThat(registrations).hasSize(1); + return registrations.getFirst(); + } + + private static MemberSnapshot member(String... statuses) { + return new MemberSnapshot("nick", "ident", "host", List.of(statuses)); + } + + @Test + void ownsTheStatusSlotAndDeclaresAnExactBoundForTheServerPrefix() { + HookRegistration hook = hook(); + assertThat(hook.kind()).isEqualTo(HookKind.MEMBER_STATUS); + assertThat(hook.slot()).isEqualTo(ReplySlot.STATUS); + // PREFIX=(ov)@+ : the operator and voice symbols, one byte each. + assertThat(hook.replyBound().maxBytes(ReplyLimits.REFERENCE)).isEqualTo(2); + } + + @Test + void listsEveryHeldStatusInPrefixOrder() { + RuntimeSnapshot snapshot = RuntimeSnapshot.EMPTY.with(registrar()); + Set negotiated = Set.of("multi-prefix"); + + assertThat(dispatcher.memberStatus(snapshot, negotiated, member("@", "+"))).isEqualTo("@+"); + assertThat(dispatcher.memberStatus(snapshot, negotiated, member("@"))).isEqualTo("@"); + assertThat(dispatcher.memberStatus(snapshot, negotiated, member("+"))).isEqualTo("+"); + assertThat(dispatcher.memberStatus(snapshot, negotiated, member())).isEmpty(); + assertThat(faults).isEmpty(); + } + + @Test + void aRequesterWithoutItGetsCoresHighestStatus() { + RuntimeSnapshot snapshot = RuntimeSnapshot.EMPTY.with(registrar()); + assertThat(dispatcher.memberStatus(snapshot, Set.of(), member("@", "+"))).isEqualTo("@"); + } + + private static net.jircd.core.extension.capability.CapabilityDescriptor registrar() { + CapabilityRegistrar registrar = new CapabilityRegistrar("multi-prefix", "multi-prefix"); + new MultiPrefixExtension().register(registrar); + return registrar.build(); + } + + @Test + void reservesTheWidthOfAllStatusesInNamesAndWhoLayouts() { + ReplyBudgetCatalog catalog = ReplyBudgetCatalog.fromDescriptors(List.of(registrar())); + ReplyLimits limits = ReplyLimits.REFERENCE; + + assertThat(catalog.slotBytes(limits)).containsEntry(ReplySlot.STATUS, 2L); + assertThat(catalog.worstCaseBytes(ReplyLayout.NAMES_MEMBER, limits)) + .isEqualTo(ReplyBudgetCatalog.empty().worstCaseBytes(ReplyLayout.NAMES_MEMBER, limits) + 1); + assertThat(catalog.worstCaseBytes(ReplyLayout.WHO_ENTRY, limits)) + .isEqualTo(ReplyBudgetCatalog.empty().worstCaseBytes(ReplyLayout.WHO_ENTRY, limits) + 1); + } + + @Test + void namesAndWhoThroughTheRenderingServiceUseTheProvider() { + ExtensionRegistry extensions = new ExtensionRegistry(); + extensions.register(new MultiPrefixExtension()); + extensions.enable(MultiPrefixExtension.CAPABILITY_ID); + MemberReplies replies = new MemberReplies(extensions); + ClientSession operator = new ClientSession("o", "host", RateLimitBucket.withDefaults()); + operator.setNickname("op"); + Channel channel = + new ChannelRegistry().join("#c", operator, ignored -> true).orElseThrow().channel(); + channel.grantVoice(operator); + ClientSession requester = new ClientSession("r", "host", RateLimitBucket.withDefaults()); + requester.replaceNegotiatedCapabilities(List.of("multi-prefix")); + + assertThat(replies.namesTokens(requester, channel.memberFacts())).containsExactly("@+op"); + assertThat(replies.whoStatus(requester, channel.memberFacts(operator))).isEqualTo("@+"); + } +} diff --git a/jircd-capabilities/server-time/src/test/java/net/jircd/capabilities/servertime/ServerTimeExtensionTest.java b/jircd-capabilities/server-time/src/test/java/net/jircd/capabilities/servertime/ServerTimeExtensionTest.java index 1e86a38..139b0a6 100644 --- a/jircd-capabilities/server-time/src/test/java/net/jircd/capabilities/servertime/ServerTimeExtensionTest.java +++ b/jircd-capabilities/server-time/src/test/java/net/jircd/capabilities/servertime/ServerTimeExtensionTest.java @@ -113,11 +113,6 @@ void aFailureIsContainedAndYieldsNoTagInsteadOfPropagating() { assertThat(faults).containsExactly("server-time/OUTBOUND_TAGS/" + FaultCategory.EXCEPTION); } - @Test - void legacyHookIsNoLongerUsedByTheProvider() { - assertThat(new ServerTimeExtension().contributeTags(message(Instant.EPOCH))).isEmpty(); - } - @Test void decoratesWithoutMessageTagsNegotiatedOrEnabled() { ExtensionRegistry registry = new ExtensionRegistry(); diff --git a/jircd-core/src/main/java/net/jircd/core/config/ConfigurationLoader.java b/jircd-core/src/main/java/net/jircd/core/config/ConfigurationLoader.java index 4cac2a9..de40605 100644 --- a/jircd-core/src/main/java/net/jircd/core/config/ConfigurationLoader.java +++ b/jircd-core/src/main/java/net/jircd/core/config/ConfigurationLoader.java @@ -76,10 +76,7 @@ public ConfigurationLoader( public void checkReplyBudgets(String serverName, int nicknameMaxLength, int channelNameMaxLength) throws ConfigurationException { validateReplyBudgets( - serverName, - nicknameMaxLength, - channelNameMaxLength, - ReplyBudgetCatalog.legacyCompatible().plus(discoveredReplyCatalog.get())); + serverName, nicknameMaxLength, channelNameMaxLength, discoveredReplyCatalog.get()); } public ServerConfiguration load(InputStream yamlInput) throws ConfigurationException { @@ -271,7 +268,7 @@ static void validateReplyBudgets( String serverName, int nicknameMaxLength, int channelNameMaxLength) throws ConfigurationException { validateReplyBudgets( - serverName, nicknameMaxLength, channelNameMaxLength, ReplyBudgetCatalog.legacyCompatible()); + serverName, nicknameMaxLength, channelNameMaxLength, ReplyBudgetCatalog.empty()); } static void validateReplyBudgets( @@ -300,7 +297,7 @@ static void validateHostmaskBudget( String serverName, int nicknameMaxLength, int channelNameMaxLength) throws ConfigurationException { validateHostmaskBudget( - serverName, nicknameMaxLength, channelNameMaxLength, ReplyBudgetCatalog.legacyCompatible()); + serverName, nicknameMaxLength, channelNameMaxLength, ReplyBudgetCatalog.empty()); } static void validateHostmaskBudget( @@ -348,7 +345,7 @@ static void validateNamesBudget( String serverName, int nicknameMaxLength, int channelNameMaxLength) throws ConfigurationException { validateNamesBudget( - serverName, nicknameMaxLength, channelNameMaxLength, ReplyBudgetCatalog.legacyCompatible()); + serverName, nicknameMaxLength, channelNameMaxLength, ReplyBudgetCatalog.empty()); } static void validateNamesBudget( diff --git a/jircd-core/src/main/java/net/jircd/core/config/HostmaskReplyBudget.java b/jircd-core/src/main/java/net/jircd/core/config/HostmaskReplyBudget.java index c02c1e7..a77d3d6 100644 --- a/jircd-core/src/main/java/net/jircd/core/config/HostmaskReplyBudget.java +++ b/jircd-core/src/main/java/net/jircd/core/config/HostmaskReplyBudget.java @@ -43,13 +43,12 @@ public final class HostmaskReplyBudget { private HostmaskReplyBudget() {} /** - * Worst-case bytes per reply under the widths reserved before providers declared theirs; callers + * Worst-case bytes per reply with no provider decorations, i.e. core's own fields only; callers * compare to the limit. */ public static Map worstCaseBytes( int serverBytes, int nicknameMax, int channelMax) { - return worstCaseBytes( - serverBytes, nicknameMax, channelMax, ReplyBudgetCatalog.legacyCompatible()); + return worstCaseBytes(serverBytes, nicknameMax, channelMax, ReplyBudgetCatalog.empty()); } /** diff --git a/jircd-core/src/main/java/net/jircd/core/extension/AbstractCapabilityExtension.java b/jircd-core/src/main/java/net/jircd/core/extension/AbstractCapabilityExtension.java index 7081bb7..2f17f6e 100644 --- a/jircd-core/src/main/java/net/jircd/core/extension/AbstractCapabilityExtension.java +++ b/jircd-core/src/main/java/net/jircd/core/extension/AbstractCapabilityExtension.java @@ -20,9 +20,8 @@ /** * The {@code id}/{@code providedCapability} boilerplate every {@link CapabilityExtension} shares, * with documented no-op lifecycle defaults: {@link #start} and {@link #stop} do nothing, because a - * provider that holds no resources has nothing to prepare or release. A migrated provider overrides - * {@link #register} to stage its typed hooks; a provider still on the temporary bridge overrides - * {@link #contributeTags}/{@link #includeSenderInFanOut} instead. + * provider that holds no resources has nothing to prepare or release. A provider overrides {@link + * #register} to stage its typed hooks. */ public abstract class AbstractCapabilityExtension implements CapabilityExtension { diff --git a/jircd-core/src/main/java/net/jircd/core/extension/CapabilityExtension.java b/jircd-core/src/main/java/net/jircd/core/extension/CapabilityExtension.java index 588800b..4b1c165 100644 --- a/jircd-core/src/main/java/net/jircd/core/extension/CapabilityExtension.java +++ b/jircd-core/src/main/java/net/jircd/core/extension/CapabilityExtension.java @@ -15,11 +15,8 @@ */ package net.jircd.core.extension; -import java.util.Map; import net.jircd.core.capability.Capability; import net.jircd.core.extension.capability.CapabilityRegistrar; -import net.jircd.core.session.ClientSession; -import net.jircd.core.session.OutboundMessage; /** * An {@link Extension} that also provides exactly one client-negotiable {@link Capability} @@ -36,39 +33,12 @@ public interface CapabilityExtension extends Extension { * visible until the descriptor is published, and an exception here fails the provider with * nothing published. * - *

Providers that have not migrated stage nothing and keep the legacy {@link #contributeTags} - * and {@link #includeSenderInFanOut} hooks. Core routes a provider's tags through exactly one - * path: a provider that publishes any hooks is never also asked for {@link #contributeTags}. + *

A provider that stages nothing still offers its capability; core simply has no behavior to + * run for it. * *

Runs while the registry's lifecycle lock is held, so it must not call {@code * ExtensionRegistry.enable/disable} or {@code ConfigurationReloader.reload} (they throw {@link * IllegalStateException}). */ default void register(CapabilityRegistrar registrar) {} - - /** - * Temporary bridge, removed once every provider has migrated. Tags this capability - * contributes to a delivered message, for a recipient that has this capability negotiated - * (T088/T089) — the caller ({@code CapabilityTagRenderer}) checks that precondition before - * calling this; implementations don't re-check it themselves. Extensions that don't decorate tags - * (e.g. {@code echo-message}) leave this at its no-op default. - * - *

This is a pure function of the immutable message: equal message inputs produce equal maps, - * independently of wall clock, randomness, recipient or mutable external state. Identifiers and - * timestamps derive from {@link OutboundMessage#messageId()} and {@link - * OutboundMessage#sentAt()}. Contribution byte metadata is memoized once per message and - * capability identity; rendering may invoke this function again at drain time. - */ - default Map contributeTags(OutboundMessage message) { - return Map.of(); - } - - /** - * Whether the sender's own session should be included among a fan-out's recipients (T090) — only - * {@code echo-message} overrides this; every other capability affects per-recipient formatting - * ({@link #contributeTags}), not recipient-set construction. - */ - default boolean includeSenderInFanOut(ClientSession sender) { - return false; - } } diff --git a/jircd-core/src/main/java/net/jircd/core/extension/ExtensionRegistry.java b/jircd-core/src/main/java/net/jircd/core/extension/ExtensionRegistry.java index f65df31..1d45d91 100644 --- a/jircd-core/src/main/java/net/jircd/core/extension/ExtensionRegistry.java +++ b/jircd-core/src/main/java/net/jircd/core/extension/ExtensionRegistry.java @@ -38,6 +38,7 @@ import net.jircd.core.extension.capability.RuntimeSnapshot; import net.jircd.core.session.ChannelMode; import net.jircd.core.session.ClientSession; +import net.jircd.core.session.CommittedEvents; import net.jircd.core.session.UserMode; import net.jircd.core.session.command.CommandHandler; import net.jircd.core.session.command.Replies; @@ -87,6 +88,7 @@ private static final class Entry { private final CapabilityRuntime capabilityRuntime = new CapabilityRuntime(); private final HookDispatcher hookDispatcher = new HookDispatcher(FaultReporter.logging()); + private final CommittedEvents committedEvents = new CommittedEvents(this); /** * Serializes enable and disable. It is not the registry monitor and no message path takes it, so @@ -117,6 +119,11 @@ public CapabilityServices capabilityServices() { return new CapabilityServices(capabilityRuntime::snapshot); } + /** Publishes committed state transitions to the providers that listen for them. */ + public CommittedEvents committedEvents() { + return committedEvents; + } + /** The containing dispatcher every capability hook call goes through. */ public HookDispatcher hookDispatcher() { return hookDispatcher; @@ -441,7 +448,7 @@ private void validateReplyBudget(RuntimeSnapshot active) { net.jircd.protocol.MessageSerializer.utf8Bytes(context.serverName().get()), features.nicknameMaxLength(), features.channelNameMaxLength(), - ReplyBudgetCatalog.legacyCompatible().plus(active.replyBudgetCatalog())); + active.replyBudgetCatalog()); } /** Whether {@code stop} failed; the failure is logged, not thrown. */ diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/HookDispatcher.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/HookDispatcher.java index f0e28fa..5a93cec 100644 --- a/jircd-core/src/main/java/net/jircd/core/extension/capability/HookDispatcher.java +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/HookDispatcher.java @@ -97,12 +97,37 @@ private static boolean validEventIntents(List intents, CommittedEve return true; } + /** + * The merged inbound-tag decisions together with which capability selected each forwarded tag, so + * a tag is later shown only to recipients of the capability that chose to forward it. + * + * @param ownersByKey forwarded tag key to the names of every capability whose hook selected it; a + * recipient of any one of them may see the tag + */ + public record InboundSelection(InboundTagResult merged, Map> ownersByKey) { + public InboundSelection { + Map> copy = new LinkedHashMap<>(); + ownersByKey.forEach((key, owners) -> copy.put(key, Set.copyOf(owners))); + ownersByKey = Map.copyOf(copy); + } + } + /** Merged inbound-tag decisions from hooks whose sender prerequisites are met. */ public InboundTagResult inboundTags( RuntimeSnapshot snapshot, Set senderNegotiated, InboundTags input, RequestContext context) { + return inboundSelection(snapshot, senderNegotiated, input, context).merged(); + } + + /** As {@link #inboundTags}, also recording the capability that selected each forwarded tag. */ + public InboundSelection inboundSelection( + RuntimeSnapshot snapshot, + Set senderNegotiated, + InboundTags input, + RequestContext context) { + Map> owners = new LinkedHashMap<>(); Map forwarded = new LinkedHashMap<>(); Map attributes = new LinkedHashMap<>(); for (HookRegistration registration : snapshot.hooks(HookKind.INBOUND_TAGS)) { @@ -115,12 +140,21 @@ public InboundTagResult inboundTags( result.ifPresent( r -> { forwarded.putAll(r.forwarded()); + r.forwarded() + .keySet() + .forEach( + key -> + owners + .computeIfAbsent(key, ignored -> new java.util.LinkedHashSet<>()) + .add(registration.capabilityName())); attributes.putAll(r.context()); }); } - return forwarded.isEmpty() && attributes.isEmpty() - ? InboundTagResult.NONE - : new InboundTagResult(forwarded, attributes); + InboundTagResult merged = + forwarded.isEmpty() && attributes.isEmpty() + ? InboundTagResult.NONE + : new InboundTagResult(forwarded, attributes); + return new InboundSelection(merged, owners); } private static boolean validInbound( @@ -170,33 +204,6 @@ public Map outboundTags(HookRegistration registration, OutboundM .orElse(Map.of()); } - /** - * The temporary legacy path: a not yet migrated provider's tags, contained like a hook. A throw, - * a {@code null} or more than one origin may carry is dropped whole and reported, so the core - * message still goes out. - */ - public Map legacyOutboundTags( - String providerId, Supplier> call) { - Map copy; - try { - Map raw = call.get(); - if (raw == null) { - reporter.report(providerId, HookKind.OUTBOUND_TAGS, FaultCategory.NULL_RESULT); - return Map.of(); - } - copy = Map.copyOf(raw); - } catch (RuntimeException | LinkageError _) { - reporter.report(providerId, HookKind.OUTBOUND_TAGS, FaultCategory.EXCEPTION); - return Map.of(); - } - if (!MessageSerializer.tagDataBytesAtMost( - copy, MessageSerializer.MAX_TAG_DATA_BYTES_PER_ORIGIN)) { - reporter.report(providerId, HookKind.OUTBOUND_TAGS, FaultCategory.INVALID_RESULT); - return Map.of(); - } - return copy; - } - private static boolean validTags(Map tags, HookRegistration registration) { for (var entry : tags.entrySet()) { String value = entry.getValue(); @@ -402,11 +409,6 @@ public void reportAggregateOverflow(HookRegistration registration) { fault(registration, FaultCategory.INVALID_RESULT); } - /** The same for a legacy provider, which has no registration. */ - public void reportAggregateOverflow(String providerId) { - reporter.report(providerId, HookKind.OUTBOUND_TAGS, FaultCategory.INVALID_RESULT); - } - /** Wraps a provider command handler so its fault is contained and reported. */ public CommandHandler guardedCommand(HookRegistration registration) { return guardedCommand(registration, () -> true, (session, message) -> {}); diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/ReplyBudgetCatalog.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/ReplyBudgetCatalog.java index 13687ce..b22e7e1 100644 --- a/jircd-core/src/main/java/net/jircd/core/extension/capability/ReplyBudgetCatalog.java +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/ReplyBudgetCatalog.java @@ -31,13 +31,6 @@ public record ReplyBudgetCatalog(List contributions) { private static final ReplyBudgetCatalog EMPTY = new ReplyBudgetCatalog(List.of()); - /** - * Widths the server reserved before providers declared them: a two-symbol status (@ and +), so - * configurations accepted today stay accepted until the multi-prefix provider declares its own. - */ - private static final ReplyBudgetCatalog LEGACY = - new ReplyBudgetCatalog(List.of(new ReplyContribution("legacy", ReplySlot.STATUS, l -> 2))); - public ReplyBudgetCatalog { contributions = List.copyOf(contributions); } @@ -46,10 +39,6 @@ public static ReplyBudgetCatalog empty() { return EMPTY; } - public static ReplyBudgetCatalog legacyCompatible() { - return LEGACY; - } - /** The slot decorations declared by {@code descriptors}' hooks. */ public static ReplyBudgetCatalog fromDescriptors(Collection descriptors) { List contributions = new ArrayList<>(); diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/RuntimeSnapshot.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/RuntimeSnapshot.java index d9aa0c8..5ccaf08 100644 --- a/jircd-core/src/main/java/net/jircd/core/extension/capability/RuntimeSnapshot.java +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/RuntimeSnapshot.java @@ -162,12 +162,6 @@ public Optional descriptor(String providerId) { return Optional.ofNullable(providers.get(providerId)); } - /** Whether {@code providerId} published at least one hook, i.e. has migrated off legacy hooks. */ - public boolean hasHooks(String providerId) { - CapabilityDescriptor descriptor = providers.get(providerId); - return descriptor != null && !descriptor.registrations().isEmpty(); - } - public Set providerIds() { return providers.keySet(); } diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/package-info.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/package-info.java index f78e39c..3033d68 100644 --- a/jircd-core/src/main/java/net/jircd/core/extension/capability/package-info.java +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/package-info.java @@ -21,10 +21,8 @@ * *

Every hook result is untrusted and is validated against the bounds declared at registration * before it can affect output. A faulty result is dropped, reported by provider id, hook kind and - * category only, and replaced by the documented default. This package is the foundation delivered - * with the server-time migration; the other providers still use the temporary {@code - * contributeTags}/{@code includeSenderInFanOut} bridge on {@link - * net.jircd.core.extension.CapabilityExtension}, and runtime FAILED withdrawal of a faulty provider - * is delivered later. + * category only, and replaced by the documented default. All six bundled capabilities register + * their behavior here; core keeps only {@code CAP} negotiation and the generic mechanisms the hooks + * plug into. Runtime FAILED withdrawal of a faulty provider is delivered later. */ package net.jircd.core.extension.capability; diff --git a/jircd-core/src/main/java/net/jircd/core/session/CapabilityTagRenderer.java b/jircd-core/src/main/java/net/jircd/core/session/CapabilityTagRenderer.java index f9d5f2a..8d75f39 100644 --- a/jircd-core/src/main/java/net/jircd/core/session/CapabilityTagRenderer.java +++ b/jircd-core/src/main/java/net/jircd/core/session/CapabilityTagRenderer.java @@ -20,20 +20,20 @@ import java.util.List; import java.util.Map; import java.util.Set; -import net.jircd.core.extension.CapabilityExtension; import net.jircd.core.extension.ExtensionRegistry; +import net.jircd.core.extension.capability.FramingPlan; import net.jircd.core.extension.capability.HookDispatcher; import net.jircd.core.extension.capability.HookKind; import net.jircd.core.extension.capability.HookRegistration; +import net.jircd.core.extension.capability.OutputGroup; +import net.jircd.core.extension.capability.RequestContext; import net.jircd.core.extension.capability.RuntimeSnapshot; -import net.jircd.protocol.CapabilityName; import net.jircd.protocol.MessageSerializer; /** * Selects negotiated enabled contributions at enqueue and renders that selection at drain. Typed * hook contributions are evaluated and validated at enqueue and shared immutably; no provider - * callback runs at drain for them. The legacy per-extension method is the temporary path for - * providers that have not migrated. + * callback runs at drain for them. */ public final class CapabilityTagRenderer implements TagRenderer { private final ExtensionRegistry extensionRegistry; @@ -46,25 +46,16 @@ public CapabilityTagRenderer(ExtensionRegistry extensionRegistry) { public PreparedTags prepare(ClientSession recipient, OutboundMessage message) { Set negotiated = recipient.negotiatedCapabilities(); if (negotiated.isEmpty()) return TagRenderer.NONE.prepare(recipient, message); - boolean forwardClientTags = negotiated.contains(CapabilityName.MESSAGE_TAGS); - WireBytes.Tags bytes = forwardClientTags ? message.clientTagBytes() : WireBytes.Tags.NONE; // One captured view per preparation: the typed hooks and the unmigrated providers both come // from it, so a provider withdrawn after the capture contributes nothing inconsistent. RuntimeSnapshot snapshot = extensionRegistry.capabilitySnapshot(); + // The tags an inbound-tag provider selected go to recipients that negotiated that provider. + Map forwarded = message.forwardedFor(negotiated); + boolean forwardClientTags = !forwarded.isEmpty(); + WireBytes.Tags bytes = WireBytes.tags(forwarded); HookDispatcher dispatcher = extensionRegistry.hookDispatcher(); Map composed = new LinkedHashMap<>(); List> contributions = new ArrayList<>(); - for (CapabilityExtension capability : legacySelection(snapshot, negotiated)) { - OutboundMessage.Contribution contribution = - message.legacyContribution(capability, dispatcher); - if (fitsAlongside(composed, contribution.tags())) { - composed.putAll(contribution.tags()); - contributions.add(contribution.tags()); - bytes = bytes.plus(contribution.bytes()); - } else { - dispatcher.reportAggregateOverflow(capability.id()); - } - } for (HookRegistration hook : snapshot.hooksFor(HookKind.OUTBOUND_TAGS, negotiated)) { OutboundMessage.Contribution contribution = message.contribution(hook, dispatcher); if (fitsAlongside(composed, contribution.tags())) { @@ -85,21 +76,7 @@ public PreparedTags prepare(ClientSession recipient, OutboundMessage message) { // No emitted section exceeds the protocol maximum, even when the summed bound does. return new PreparedTags( Math.min(bytes.wireBytes(), MessageSerializer.MAX_TAG_SECTION_BYTES), - () -> renderSelected(message, forwardClientTags, selected)); - } - - /** The enabled unmigrated providers of the captured snapshot that the recipient negotiated. */ - private List legacySelection( - RuntimeSnapshot snapshot, Set negotiated) { - List selected = new ArrayList<>(); - for (String providerId : snapshot.providerIds()) { - if (snapshot.hasHooks(providerId)) continue; - if (extensionRegistry.find(providerId).orElse(null) instanceof CapabilityExtension capability - && negotiated.contains(capability.providedCapability().name())) { - selected.add(capability); - } - } - return selected; + () -> renderSelected(forwarded, selected)); } private static boolean fitsAlongside(Map composed, Map added) { @@ -110,15 +87,25 @@ private static boolean fitsAlongside(Map composed, Map render(ClientSession recipient, OutboundMessage message) { return prepare(recipient, message).tags().get(); } private static Map renderSelected( - OutboundMessage message, boolean forwardClientTags, List> contributions) { - Map tags = new LinkedHashMap<>(); - if (forwardClientTags) tags.putAll(message.forwardedClientTags()); + Map forwarded, List> contributions) { + Map tags = new LinkedHashMap<>(forwarded); checkOriginBudget(tags); Map serverTags = new LinkedHashMap<>(); contributions.forEach(serverTags::putAll); diff --git a/jircd-core/src/main/java/net/jircd/core/session/Channel.java b/jircd-core/src/main/java/net/jircd/core/session/Channel.java index 9a7683d..bf46d7a 100644 --- a/jircd-core/src/main/java/net/jircd/core/session/Channel.java +++ b/jircd-core/src/main/java/net/jircd/core/session/Channel.java @@ -42,10 +42,11 @@ * non-member is refused and changes nothing. C-2: {@code secret} and {@code private} are never both * active; setting one clears the other in the same step. C-3: at most {@link #MAX_BANS} entries per * mask list, with unique masks under RFC1459 case folding. C-4: the first member added to an empty - * channel becomes an operator. C-5: {@link #memberListing} is a weakly consistent traversal taken - * without the monitor. It never lists a session twice but is not a snapshot of one instant. Each - * member's prefix, however, comes from one consistent view of the operator and voice sets (see - * {@link #statusPrefixes}), so it never shows a status combination the member did not hold. + * channel becomes an operator. C-5: {@link #memberListing} and {@link #memberFacts()} are weakly + * consistent traversals taken without the monitor. They never list a session twice but are not a + * snapshot of one instant. Each member's statuses, however, come from one consistent view of the + * operator and voice sets (see {@link #heldStatuses}), so they never show a status combination the + * member did not hold. * *

The collection accessors return unmodifiable views. Membership changes arrive only through * {@link ChannelRegistry} transitions. @@ -54,6 +55,10 @@ public final class Channel { public static final int MAX_BANS = 100; + private static final List OPERATOR = List.of("@"); + private static final List VOICE = List.of("+"); + private static final List OPERATOR_AND_VOICE = List.of("@", "+"); + private final String name; private final String foldedName; private final Set members = ConcurrentHashMap.newKeySet(); @@ -108,39 +113,50 @@ public Set members() { } /** - * Returns one status-prefixed nickname per member, in member iteration order. Reads the - * concurrent sets without this monitor, so a NAMES reply on a large channel never delays a - * transition waiting for it while holding the registry monitor. The traversal never visits a - * session twice, but it is not a snapshot of one instant: concurrent joins and departures may or - * may not appear. A member's prefix is read consistently, as in {@link #statusPrefixes}. + * Returns one status-prefixed nickname per member, in member iteration order, each with its + * highest status only — the form an ordinary requester gets. A capability that renders + * differently starts from {@link #memberFacts()} instead. Reads the concurrent sets without this + * monitor, so a NAMES reply on a large channel never delays a transition waiting for it while + * holding the registry monitor. The traversal never visits a session twice, but it is not a + * snapshot of one instant: concurrent joins and departures may or may not appear. A member's + * statuses are read consistently, as in {@link #heldStatuses}. */ public List memberListing() { - return memberListing(false); + List listing = new ArrayList<>(); + for (ClientSession member : members) { + List held = heldStatuses(member); + listing.add((held.isEmpty() ? "" : held.getFirst()) + member.nickname()); + } + return listing; } /** - * As {@link #memberListing()}, but with {@code multiPrefix} every status the member holds is - * listed, in {@code PREFIX} order ({@code @} before {@code +}), as {@code multi-prefix} requires. - * The flag describes the client receiving the listing, not the members listed. + * One {@link MemberFacts} per member in member iteration order, with the same weak consistency as + * {@link #memberListing()}: unformatted facts that carry no knowledge of any capability. */ - public List memberListing(boolean multiPrefix) { - List listing = new ArrayList<>(); + public List memberFacts() { + List facts = new ArrayList<>(); for (ClientSession member : members) { - listing.add(statusPrefixes(member, multiPrefix) + member.nickname()); + facts.add(new MemberFacts(member, heldStatuses(member))); } - return listing; + return facts; + } + + /** The facts of one member; no statuses for a session that is not a member. */ + public MemberFacts memberFacts(ClientSession member) { + return new MemberFacts(member, heldStatuses(member)); } /** - * The status symbols a member holds, highest first: {@code @} for operator, {@code +} for voice. - * Without {@code multiPrefix} only the highest is returned. Both sets are read as one view - * without taking the monitor, so a listing never waits for a transition: every privilege update - * (made under the monitor) brackets itself with {@code privilegeVersion} changes, and the read - * retries when an update was in progress or finished meanwhile. A concurrent "-o then +v" can - * therefore never render {@code @+} for a member who never held both. An update only adds to or - * removes from the sets, so the retry wait is short and never calls out to a session. + * The status symbols a member holds, highest first and in {@code PREFIX} order: {@code @} for + * operator, {@code +} for voice. Both sets are read as one view without taking the monitor, so a + * listing never waits for a transition: every privilege update (made under the monitor) brackets + * itself with {@code privilegeVersion} changes, and the read retries when an update was in + * progress or finished meanwhile. A concurrent "-o then +v" can therefore never show {@code @+} + * for a member who never held both. An update only adds to or removes from the sets, so the retry + * wait is short and never calls out to a session. */ - public String statusPrefixes(ClientSession member, boolean multiPrefix) { + public List heldStatuses(ClientSession member) { boolean operator; boolean voice; while (true) { @@ -153,9 +169,9 @@ public String statusPrefixes(ClientSession member, boolean multiPrefix) { Thread.onSpinWait(); } if (operator) { - return multiPrefix && voice ? "@+" : "@"; + return voice ? OPERATOR_AND_VOICE : OPERATOR; } - return voice ? "+" : ""; + return voice ? VOICE : List.of(); } public Set operators() { @@ -406,7 +422,7 @@ synchronized void removeMember(ClientSession session) { } } - /** Adds to or removes from a privilege set, bracketed for {@link #statusPrefixes}' retry. */ + /** Adds to or removes from a privilege set, bracketed for {@link #heldStatuses}' retry. */ private boolean updateHolders(Set holders, ClientSession member, boolean add) { privilegeVersion.incrementAndGet(); try { diff --git a/jircd-core/src/main/java/net/jircd/core/session/ChannelRegistry.java b/jircd-core/src/main/java/net/jircd/core/session/ChannelRegistry.java index 6cfbfbd..74b031a 100644 --- a/jircd-core/src/main/java/net/jircd/core/session/ChannelRegistry.java +++ b/jircd-core/src/main/java/net/jircd/core/session/ChannelRegistry.java @@ -25,7 +25,6 @@ import java.util.Set; import java.util.function.Consumer; import java.util.function.Predicate; -import net.jircd.protocol.CapabilityName; /** * Owns channel existence and membership transitions, with case-folded channel-name uniqueness. @@ -107,13 +106,11 @@ public Optional join( } } joinSnapshotHook.accept(channel); - List listing = - channel.memberListing( - session.negotiatedCapabilities().contains(CapabilityName.MULTI_PREFIX)); + List members = channel.memberFacts(); Set recipients = new LinkedHashSet<>(channel.members()); recipients.add(session); return Optional.of( - new JoinResult(channel, recipients, grantedOperator, alreadyMember, listing)); + new JoinResult(channel, recipients, grantedOperator, alreadyMember, members)); } /** diff --git a/jircd-core/src/main/java/net/jircd/core/session/CommittedEvents.java b/jircd-core/src/main/java/net/jircd/core/session/CommittedEvents.java new file mode 100644 index 0000000..de6928c --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/session/CommittedEvents.java @@ -0,0 +1,162 @@ +/* + * Copyright 2026 Guillermo Castro + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package net.jircd.core.session; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Supplier; +import net.jircd.core.extension.ExtensionRegistry; +import net.jircd.core.extension.capability.Audience; +import net.jircd.core.extension.capability.CommittedEvent; +import net.jircd.core.extension.capability.EventIntent; +import net.jircd.core.extension.capability.EventKind; +import net.jircd.core.extension.capability.HookKind; +import net.jircd.core.extension.capability.HookRegistration; +import net.jircd.core.extension.capability.RuntimeSnapshot; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Publishes facts about state transitions that have already committed to the capability providers + * that listen for them, and delivers whatever they ask for. Core calls this after the transition, + * with every registry and channel monitor released, and never rolls back or retries because of a + * provider: a faulty hook is contained by the dispatcher and the committed state stands. + * + *

Nothing is collected unless a provider listens for the kind: the presented identity is + * resolved and the audience is traversed only on demand, at most once per event, so an event costs + * nothing when no capability wants it. A rejected or no-op operation publishes nothing; the caller + * decides what counts as a transition. + */ +public final class CommittedEvents { + private static final Logger LOG = LoggerFactory.getLogger(CommittedEvents.class); + + private static final AtomicLong OPERATION_IDS = new AtomicLong(); + + private final ExtensionRegistry extensionRegistry; + + public CommittedEvents(ExtensionRegistry extensionRegistry) { + this.extensionRegistry = Objects.requireNonNull(extensionRegistry); + } + + /** + * Publishes one event. {@code subject} is resolved, and {@code audience} collected, only if some + * provider listens for {@code kind}. Empty strings stand for a missing {@code before}/{@code + * after} value. + */ + public void publish( + EventKind kind, + Supplier subject, + String before, + String after, + Supplier> audience) { + RuntimeSnapshot snapshot = extensionRegistry.capabilitySnapshot(); + if (!listened(snapshot, kind)) return; + try { + CommittedEvent event = + new CommittedEvent( + kind, + OPERATION_IDS.incrementAndGet(), + subject.get(), + before, + after, + new Audience(() -> List.copyOf(audience.get()))); + for (EventIntent intent : extensionRegistry.hookDispatcher().event(snapshot, event)) { + Recipients.deliver(intent.recipients(), intent.message()); + } + } catch (RuntimeException e) { + // The transition has committed and the caller still owes its client the remaining replies: + // a failure resolving the presented identity (hostname-display extension code) must not + // cost it those. Only the type is logged; the message may carry extension or client data. + LOG.warn("Committed {} event not published: {}", kind, e.getClass().getSimpleName()); + } + } + + private static boolean listened(RuntimeSnapshot snapshot, EventKind kind) { + for (HookRegistration registration : snapshot.hooks(HookKind.EVENT)) { + if (registration.eventKind() == kind) return true; + } + return false; + } + + /** {@code session} set, changed or cleared its away reason; empty strings mean not away. */ + public void awayChanged(ClientSession session, String before, String after) { + publish( + EventKind.AWAY_CHANGED, + () -> PresentedIdentity.presentedForm(session, extensionRegistry), + before, + after, + () -> Recipients.sharedChannelNeighbors(session, false, member -> true)); + } + + /** {@code session} joined {@code channel}; {@code recipients} are its members afterwards. */ + public void joined( + ClientSession session, + String presentedForm, + String channel, + Collection recipients) { + publish( + EventKind.JOINED, + () -> presentedForm, + "", + channel, + () -> recipients.stream().filter(member -> member != session).toList()); + } + + /** {@code session} left {@code channel}; {@code recipients} were captured before it left. */ + public void parted( + ClientSession session, + String presentedForm, + String channel, + String reason, + Collection recipients) { + publish( + EventKind.PARTED, + () -> presentedForm, + channel, + reason, + () -> recipients.stream().filter(member -> member != session).toList()); + } + + /** {@code session} disconnected; {@code neighbors} were captured as its memberships went. */ + public void quit( + ClientSession session, + String presentedForm, + String reason, + Collection neighbors) { + publish( + EventKind.QUIT, + () -> presentedForm, + "", + reason, + () -> neighbors.stream().filter(member -> member != session).toList()); + } + + /** + * {@code session} changed nickname. The subject is the identity peers knew it by before, and + * peers are the shared-channel neighbors, which a nickname change does not alter. + */ + public void nickChanged( + ClientSession session, String oldPresentedForm, String oldNickname, String newNickname) { + publish( + EventKind.NICK_CHANGED, + () -> oldPresentedForm, + oldNickname, + newNickname, + () -> Recipients.sharedChannelNeighbors(session, false, member -> true)); + } +} diff --git a/jircd-core/src/main/java/net/jircd/core/session/DisconnectCleanup.java b/jircd-core/src/main/java/net/jircd/core/session/DisconnectCleanup.java index cf1b2b3..af91d3c 100644 --- a/jircd-core/src/main/java/net/jircd/core/session/DisconnectCleanup.java +++ b/jircd-core/src/main/java/net/jircd/core/session/DisconnectCleanup.java @@ -133,6 +133,7 @@ public void cleanupClaimed(ClientSession session, String reason) { Message quit = new Message(java.util.Map.of(), prefix, Command.QUIT, "QUIT", java.util.List.of(reason)); Recipients.deliver(neighbors, quit); + extensionRegistry.committedEvents().quit(session, prefix, reason, neighbors); // One WHOWAS entry per disconnection, regardless of // cause (QUIT, KILL, keep-alive timeout) — this is the single point every cause funnels // through, so no cause-specific recording is needed anywhere else. presentedHostname is a diff --git a/jircd-core/src/main/java/net/jircd/core/session/JoinResult.java b/jircd-core/src/main/java/net/jircd/core/session/JoinResult.java index a2be30d..23eda16 100644 --- a/jircd-core/src/main/java/net/jircd/core/session/JoinResult.java +++ b/jircd-core/src/main/java/net/jircd/core/session/JoinResult.java @@ -21,19 +21,20 @@ import java.util.Set; /** - * A completed join. The NAMES tokens and then the recipients are read from the live channel in two + * A completed join. The member facts and then the recipients are read from the live channel in two * separate weakly consistent traversals after the membership monitors are released, so neither is a * snapshot of one instant and the two need not agree. The joiner is always among the recipients; - * with a concurrent departure it may be missing from the listing, which is never patched. + * with a concurrent departure it may be missing from the facts, which are never patched. The facts + * are unformatted: the NAMES tokens are rendered for the requester afterwards. */ public record JoinResult( Channel channel, Set recipients, boolean grantedOperator, boolean alreadyMember, - List memberListing) { + List members) { public JoinResult { recipients = Collections.unmodifiableSet(new LinkedHashSet<>(recipients)); - memberListing = List.copyOf(memberListing); + members = List.copyOf(members); } } diff --git a/jircd-core/src/main/java/net/jircd/core/session/MemberFacts.java b/jircd-core/src/main/java/net/jircd/core/session/MemberFacts.java new file mode 100644 index 0000000..f5145bd --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/session/MemberFacts.java @@ -0,0 +1,31 @@ +/* + * Copyright 2026 Guillermo Castro + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package net.jircd.core.session; + +import java.util.List; +import java.util.Objects; + +/** + * What core knows about one channel member, unformatted: the session and every status symbol it + * holds, highest first in {@code PREFIX} order. Reply rendering turns this into a listing token or + * a WHO flag for the requester, applying whichever capability providers that requester negotiated. + */ +public record MemberFacts(ClientSession member, List heldStatuses) { + public MemberFacts { + Objects.requireNonNull(member); + heldStatuses = List.copyOf(heldStatuses); + } +} diff --git a/jircd-core/src/main/java/net/jircd/core/session/MemberReplies.java b/jircd-core/src/main/java/net/jircd/core/session/MemberReplies.java new file mode 100644 index 0000000..6329f50 --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/session/MemberReplies.java @@ -0,0 +1,95 @@ +/* + * Copyright 2026 Guillermo Castro + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package net.jircd.core.session; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import net.jircd.core.extension.ExtensionRegistry; +import net.jircd.core.extension.capability.HookDispatcher; +import net.jircd.core.extension.capability.HookKind; +import net.jircd.core.extension.capability.MemberSnapshot; +import net.jircd.core.extension.capability.RuntimeSnapshot; + +/** + * Renders member facts for one requester: the NAMES listing token and the channel WHO status flag. + * Core's defaults are the nickname and the highest held status. The status and the NAMES identity + * are separate slots, each replaceable by at most one provider the requester negotiated, so a + * provider that lists every status and one that changes the identity compose without knowing of one + * another. The hooks run on the caller's thread outside every registry and channel monitor, against + * one published snapshot captured per call, and a faulty hook costs only its own contribution. + * + *

Identity fields are resolved only if a provider could use them: the presented hostname may run + * hostname-display extension code, which a plain NAMES must not pay for on every member. + */ +public final class MemberReplies { + private final ExtensionRegistry extensionRegistry; + + public MemberReplies(ExtensionRegistry extensionRegistry) { + this.extensionRegistry = Objects.requireNonNull(extensionRegistry); + } + + /** One NAMES token per member, in the order given. */ + public List namesTokens(ClientSession requester, List facts) { + RuntimeSnapshot snapshot = extensionRegistry.capabilitySnapshot(); + Set negotiated = requester.negotiatedCapabilities(); + boolean customStatus = !snapshot.hooksFor(HookKind.MEMBER_STATUS, negotiated).isEmpty(); + boolean customIdentity = !snapshot.hooksFor(HookKind.NAMES_IDENTITY, negotiated).isEmpty(); + List tokens = new ArrayList<>(facts.size()); + if (!customStatus && !customIdentity) { + for (MemberFacts fact : facts) { + tokens.add(highest(fact) + fact.member().nickname()); + } + return tokens; + } + HookDispatcher dispatcher = extensionRegistry.hookDispatcher(); + for (MemberFacts fact : facts) { + MemberSnapshot member = snapshot(fact, customIdentity); + tokens.add( + dispatcher.memberStatus(snapshot, negotiated, member) + + dispatcher.namesIdentity(snapshot, negotiated, member)); + } + return tokens; + } + + /** The status symbols to append to a channel WHO entry's {@code H}/{@code G}/{@code *} flags. */ + public String whoStatus(ClientSession requester, MemberFacts fact) { + RuntimeSnapshot snapshot = extensionRegistry.capabilitySnapshot(); + Set negotiated = requester.negotiatedCapabilities(); + if (snapshot.hooksFor(HookKind.MEMBER_STATUS, negotiated).isEmpty()) { + return highest(fact); + } + return extensionRegistry + .hookDispatcher() + .memberStatus(snapshot, negotiated, snapshot(fact, false)); + } + + private MemberSnapshot snapshot(MemberFacts fact, boolean withIdentity) { + ClientSession member = fact.member(); + return new MemberSnapshot( + Objects.toString(member.nickname(), ""), + withIdentity ? Objects.toString(member.ident(), "") : "", + withIdentity + ? Objects.toString(PresentedIdentity.displayHostname(member, extensionRegistry), "") + : "", + fact.heldStatuses()); + } + + private static String highest(MemberFacts fact) { + return fact.heldStatuses().isEmpty() ? "" : fact.heldStatuses().getFirst(); + } +} diff --git a/jircd-core/src/main/java/net/jircd/core/session/MessageDelivery.java b/jircd-core/src/main/java/net/jircd/core/session/MessageDelivery.java new file mode 100644 index 0000000..6412979 --- /dev/null +++ b/jircd-core/src/main/java/net/jircd/core/session/MessageDelivery.java @@ -0,0 +1,268 @@ +/* + * Copyright 2026 Guillermo Castro + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package net.jircd.core.session; + +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Predicate; +import java.util.function.Supplier; +import net.jircd.core.extension.ExtensionRegistry; +import net.jircd.core.extension.capability.DeliveryIntent; +import net.jircd.core.extension.capability.DeliveryRole; +import net.jircd.core.extension.capability.HookDispatcher; +import net.jircd.core.extension.capability.InboundTagResult; +import net.jircd.core.extension.capability.InboundTags; +import net.jircd.core.extension.capability.RequestContext; +import net.jircd.core.extension.capability.RuntimeSnapshot; +import net.jircd.core.session.command.Replies; +import net.jircd.protocol.Hostmask; +import net.jircd.protocol.NumericReply; + +/** + * The shared path of every client-to-client message ({@code PRIVMSG}, {@code NOTICE} and a + * provider's own tag-only command): target resolution with the core send gates, one immutable + * envelope per message, and role-based recipient selection. A provider cannot supply an arbitrary + * peer set; it can only add a {@link DeliveryRole#SENDER_COPY} for the sender through a recipient + * hook, and core merges roles so a recipient gets the envelope once. + * + *

Each delivery captures the published capability hooks once and runs each hook once, however + * many recipients there are. A {@link RequestContext} belongs to one inbound request: only the + * recipient hooks see it, and it is never attached to the envelope, so peers do not inherit the + * sender's request correlation. + */ +public final class MessageDelivery { + private static final AtomicLong REQUEST_IDS = new AtomicLong(); + + private final ChannelRegistry channelRegistry; + private final NicknameRegistry nicknameRegistry; + private final ExtensionRegistry extensionRegistry; + private final Supplier serverName; + + public MessageDelivery( + ChannelRegistry channelRegistry, + NicknameRegistry nicknameRegistry, + ExtensionRegistry extensionRegistry, + Supplier serverName) { + this.channelRegistry = Objects.requireNonNull(channelRegistry); + this.nicknameRegistry = Objects.requireNonNull(nicknameRegistry); + this.extensionRegistry = Objects.requireNonNull(extensionRegistry); + this.serverName = Objects.requireNonNull(serverName); + } + + /** + * One completed delivery: the envelope all recipients share, the request it answered and who + * received it in which roles. + */ + public record Delivery( + OutboundMessage envelope, + RequestContext context, + Map> recipients) { + public Delivery { + recipients = java.util.Collections.unmodifiableMap(new LinkedHashMap<>(recipients)); + } + } + + /** + * The outcome of {@link #authorize}: the members {@code sender} may address at {@code target}, + * empty if it may address none. Only {@code authorize} creates one, and {@link #deliver} accepts + * nothing else, so a caller cannot supply its own peer set and skip the target and send gates. + */ + public static final class AuthorizedTarget implements Iterable { + private final MessageDelivery issuer; + private final ClientSession sender; + private final String target; + private final Set recipients; + + private AuthorizedTarget( + MessageDelivery issuer, + ClientSession sender, + String target, + Set recipients) { + this.issuer = issuer; + this.sender = sender; + this.target = target; + this.recipients = Set.copyOf(recipients); + } + + public boolean isEmpty() { + return recipients.isEmpty(); + } + + public Set recipients() { + return recipients; + } + + @Override + public java.util.Iterator iterator() { + return recipients.iterator(); + } + } + + /** + * Resolves {@code target} into the members that may be addressed: a channel name must exist and + * pass the {@code SEND} gates, a nickname must be connected. Returns an empty set on failure, + * having already sent the appropriate error to {@code sender} when {@code reportErrors} is {@code + * true} ({@code NOTICE}-style silence otherwise). + */ + public AuthorizedTarget authorize(ClientSession sender, String target, boolean reportErrors) { + return new AuthorizedTarget(this, sender, target, resolve(sender, target, reportErrors)); + } + + private Set resolve(ClientSession sender, String target, boolean reportErrors) { + if (target.startsWith("#")) { + var channel = channelRegistry.lookup(target); + if (channel.isEmpty() || !passesSendGate(channel.get(), sender)) { + if (reportErrors) { + Replies.sendFitted( + sender, + serverName.get(), + NumericReply.ERR_CANNOTSENDTOCHAN, + List.of(target), + "Cannot send to channel"); + } + return Set.of(); + } + return channel.get().members(); + } + var recipient = nicknameRegistry.lookup(target); + if (recipient.isEmpty()) { + if (reportErrors) { + Replies.sendFitted( + sender, + serverName.get(), + NumericReply.ERR_NOSUCHNICK, + List.of(target), + "No such nick/channel"); + } + return Set.of(); + } + return Set.of(recipient.get()); + } + + /** + * Builds the envelope for {@code authorized} and enqueues it to every recipient that passes + * {@code recipientGate}. A direct target always receives it, even when it is the sender itself, + * exactly once; a channel's own member set drops the sender unless a recipient hook added a + * sender copy, and a direct message to someone else is copied to the sender only that way. + * + * @param authorized what {@link #authorize} returned for this sender and target + * @param clientTags the sender's tags; only those an inbound-tag hook selects are forwarded + * @param body the text, or {@code null} for a tag-only message + */ + public Delivery deliver( + ClientSession sender, + String command, + String target, + String body, + Map clientTags, + AuthorizedTarget authorized, + Predicate recipientGate) { + if (authorized.issuer != this + || authorized.sender != sender + || !authorized.target.equals(target)) { + throw new IllegalArgumentException( + "The authorization was issued for another sender, target or delivery service"); + } + if (authorized.isEmpty()) { + // Nothing was authorized, so there is nothing to copy to the sender either. + OutboundMessage unsent = OutboundMessage.now("", command, target, body); + return new Delivery(unsent, RequestContext.of(0, Map.of()), Map.of()); + } + RuntimeSnapshot snapshot = extensionRegistry.capabilitySnapshot(); + HookDispatcher dispatcher = extensionRegistry.hookDispatcher(); + Set senderNegotiated = sender.negotiatedCapabilities(); + + long requestId = REQUEST_IDS.incrementAndGet(); + RequestContext opening = RequestContext.of(requestId, Map.of()); + HookDispatcher.InboundSelection selection = + dispatcher.inboundSelection( + snapshot, senderNegotiated, new InboundTags(command, clientTags), opening); + InboundTagResult inbound = selection.merged(); + RequestContext context = RequestContext.of(requestId, inbound.context()); + + String presentedForm = PresentedIdentity.presentedForm(sender, extensionRegistry); + OutboundMessage envelope = + OutboundMessage.now(presentedForm, command, target, body, inbound.forwarded()) + .forwardedBy(selection.ownersByKey()); + + Set senderRoles = + dispatcher.recipientRoles( + snapshot, + senderNegotiated, + new DeliveryIntent(envelope.messageId(), EnumSet.of(DeliveryRole.ORDINARY_TARGET)), + context); + boolean senderCopy = senderRoles.contains(DeliveryRole.SENDER_COPY); + + Map> roles = new LinkedHashMap<>(); + boolean channelTarget = target.startsWith("#"); + for (ClientSession member : authorized.recipients) { + // A direct target always receives it, even when it is the sender; a channel's own member + // set drops the sender, who is re-added below only as a copy. + if (member != sender || !channelTarget) { + roles.put(member, EnumSet.of(DeliveryRole.ORDINARY_TARGET)); + } + } + if (senderCopy) { + roles + .computeIfAbsent(sender, ignored -> EnumSet.noneOf(DeliveryRole.class)) + .add(DeliveryRole.SENDER_COPY); + } + + Map> delivered = new LinkedHashMap<>(); + roles.forEach( + (recipient, recipientRoles) -> { + if (recipientGate.test(recipient)) { + delivered.put(recipient, Set.copyOf(recipientRoles)); + // Borrowed reference to the recipient's own writer, closed by its connection. + @SuppressWarnings("PMD.CloseResource") + SessionWriter writer = recipient.writer(); + if (writer != null) writer.enqueue(envelope); + } + }); + return new Delivery(envelope, context, delivered); + } + + /** + * The {@code SEND}-gate check point: every currently-recognized flag whose {@code gates} includes + * {@code SEND}, checked independently — {@code members-only} requires membership; {@code + * moderated} requires operator or voice; {@code ban-mask} requires neither the sender's presented + * nor real identity to match any active ban (dual-matched to resist {@code cloak} evasion) — + * muting a matched sender without removing them from members. + */ + private boolean passesSendGate(Channel channel, ClientSession session) { + if (channel.activeModes().contains(CoreChannelModes.MEMBERS_ONLY) + && !channel.members().contains(session)) { + return false; + } + if (channel.activeModes().contains(CoreChannelModes.MODERATED) + && !channel.operators().contains(session) + && !channel.voiced().contains(session)) { + return false; + } + if (!channel.bans().isEmpty()) { + String presented = PresentedIdentity.presentedForm(session, extensionRegistry); + String real = Hostmask.format(session.nickname(), session.ident(), session.realHostname()); + return !channel.matchesMask(CoreChannelModes.BAN_MASK, presented, real) + || channel.matchesMask(CoreChannelModes.BAN_EXCEPTION, presented, real); + } + return true; + } +} diff --git a/jircd-core/src/main/java/net/jircd/core/session/OutboundMessage.java b/jircd-core/src/main/java/net/jircd/core/session/OutboundMessage.java index 7de8a6c..fd7ee71 100644 --- a/jircd-core/src/main/java/net/jircd/core/session/OutboundMessage.java +++ b/jircd-core/src/main/java/net/jircd/core/session/OutboundMessage.java @@ -20,20 +20,20 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.UUID; -import net.jircd.core.extension.CapabilityExtension; -import net.jircd.core.extension.capability.FaultReporter; import net.jircd.core.extension.capability.HookDispatcher; import net.jircd.core.extension.capability.HookRegistration; /** * Immutable fan-out content shared by recipients, with message-scoped contribution byte metadata. * Recipient selection and rendering remain in the tag renderer; metadata never changes content. + * + *

Client-only ({@code +}) tags passed to a constructor are candidates for forwarding, not + * forwarded: a recipient sees one only if it negotiated a capability that selected it, which {@code + * MessageDelivery} records. An envelope built without that record forwards no client tag. */ public final class OutboundMessage { - private static final HookDispatcher LEGACY_DISPATCHER = - new HookDispatcher(FaultReporter.logging()); - private final String senderPresentedForm; private final String command; private final String target; @@ -43,8 +43,13 @@ public final class OutboundMessage { private final UUID messageId; private final long componentBytes; private final Map forwardedClientTags; - private final WireBytes.Tags clientTagBytes; - private Map contributions; + + /** + * Forwarded tag key to every capability that selected it. Empty until {@link #forwardedBy}: a + * client tag with no recorded selector is shown to nobody. + */ + private Map> forwardOwners = Map.of(); + private Map hookContributions; /** A hook's validated, immutable tags with their exact escaped size, computed once. */ @@ -75,7 +80,6 @@ public OutboundMessage( if (key.startsWith("+")) forwarded.put(key, value); }); this.forwardedClientTags = Map.copyOf(forwarded); - this.clientTagBytes = WireBytes.tags(forwardedClientTags); } public String senderPresentedForm() { @@ -110,32 +114,32 @@ long componentBytes() { return componentBytes; } - Map forwardedClientTags() { - return forwardedClientTags; - } - - WireBytes.Tags clientTagBytes() { - return clientTagBytes; - } - - synchronized WireBytes.Tags contributionBytes(CapabilityExtension capability) { - return legacyContribution(capability, LEGACY_DISPATCHER).bytes(); + /** + * Records which capabilities selected each forwarded client tag, so {@link #forwardedFor} shows a + * tag only to recipients of one of its selectors. Call once, before the message is shared. + */ + OutboundMessage forwardedBy(Map> ownersByKey) { + Map> copy = new java.util.LinkedHashMap<>(); + ownersByKey.forEach((key, owners) -> copy.put(key, Set.copyOf(owners))); + this.forwardOwners = Map.copyOf(copy); + return this; } /** - * The legacy provider's tags, evaluated once per envelope so enqueue can budget them. A throw, a - * {@code null} or an oversized map is contained by {@code dispatcher}: an empty contribution. + * The forwarded client tags a recipient with {@code negotiated} capabilities may see: those for + * which it negotiated at least one selecting capability. It fails closed: a tag whose selector + * was never recorded, as for an envelope built outside {@code MessageDelivery}, is not shown. */ - synchronized Contribution legacyContribution( - CapabilityExtension capability, HookDispatcher dispatcher) { - if (contributions == null) contributions = new IdentityHashMap<>(); - return contributions.computeIfAbsent( - capability, - key -> { - Map tags = - dispatcher.legacyOutboundTags(key.id(), () -> key.contributeTags(this)); - return new Contribution(tags, WireBytes.tags(tags)); + Map forwardedFor(Set negotiated) { + Map visible = new java.util.LinkedHashMap<>(); + forwardedClientTags.forEach( + (key, value) -> { + if (!java.util.Collections.disjoint( + forwardOwners.getOrDefault(key, Set.of()), negotiated)) { + visible.put(key, value); + } }); + return visible; } /** diff --git a/jircd-core/src/main/java/net/jircd/core/session/SessionWriter.java b/jircd-core/src/main/java/net/jircd/core/session/SessionWriter.java index db9b8ab..f724e7d 100644 --- a/jircd-core/src/main/java/net/jircd/core/session/SessionWriter.java +++ b/jircd-core/src/main/java/net/jircd/core/session/SessionWriter.java @@ -19,8 +19,6 @@ import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.time.Duration; -import java.util.ArrayList; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.UUID; @@ -31,7 +29,8 @@ import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; import net.jircd.core.config.ServerConfiguration; -import net.jircd.protocol.CapabilityName; +import net.jircd.core.extension.capability.OutputGroup; +import net.jircd.core.extension.capability.RequestContext; import net.jircd.protocol.Command; import net.jircd.protocol.Message; import net.jircd.protocol.MessageSerializer; @@ -181,40 +180,53 @@ private void enqueueEntry(Entry entry) { } } - private static final String BATCH_TAG_KEY = "batch"; - /** - * Wraps {@code members} in {@code BATCH +ref}/{@code BATCH -ref} framing with a {@code batch=ref} - * tag merged onto each member, but only if this session negotiated both {@code batch} and {@code - * message-tags} — otherwise every member is sent via {@link #enqueueRaw} completely unmodified - * (011-away-notify-batch FR-007 through FR-011, research.md Decision 2). {@code ref} is a fresh - * {@link UUID} generated per call — collision-safe without any shared/synchronized state - * (research.md Decision 3). The open/close {@code BATCH} lines carry no prefix, the same - * precedent {@code KillCommandHandler}'s {@code ERROR} line already establishes for a - * server-framing line with no natural sender identity. + * Enqueues a finite group of {@code members}, framed for this recipient by whichever provider + * owns group framing (or sent plain when none applies to it), as one queue entry: every frame, + * including any closer, is admitted or none is, so an opener can never be queued without its + * closer and concurrent groups never interleave. The group must fit this queue; one that cannot + * overflows this recipient, like any other output it cannot hold. Nothing here keeps a provider + * callback for the writer thread: the plan is serialized now. */ - public void enqueueBatch(String type, List typeParams, List members) { - if (!session.negotiatedCapabilities().contains(CapabilityName.BATCH) - || !session.negotiatedCapabilities().contains(CapabilityName.MESSAGE_TAGS)) { - for (Message member : members) { - enqueueRaw(member); - } + public void enqueueGroup(String type, List typeParams, List members) { + enqueueGroup(type, typeParams, members, RequestContext.of(0, Map.of())); + } + + /** As {@link #enqueueGroup(String, List, List)}, for the request {@code context} it answers. */ + public void enqueueGroup( + String type, List typeParams, List members, RequestContext context) { + if (closing.get() || overflowed.get()) { return; } - String ref = UUID.randomUUID().toString(); - List openParams = new ArrayList<>(); - openParams.add("+" + ref); - openParams.add(type); - openParams.addAll(typeParams); - enqueueRaw(new Message(Map.of(), null, null, "BATCH", List.copyOf(openParams))); + long memberBytes = 0; for (Message member : members) { - Map tags = new LinkedHashMap<>(member.tags()); - tags.put(BATCH_TAG_KEY, ref); - enqueueRaw( - new Message( - tags, member.prefix(), member.command(), member.rawCommand(), member.params())); + memberBytes = Math.addExact(memberBytes, WireBytes.raw(member)); + } + if (memberBytes > limitBytes) { + // Larger than the whole queue: reject before allocating any transformed copy. + enqueueEntry(new Entry(memberBytes, null, null)); + return; + } + // The plan's own bound is left open on purpose: a provider's valid framing that is merely too + // large for the queue must overflow this recipient below, not read as a malformed plan and fall + // back to plain members. The plan stays bounded by its frame count and the per-frame limits. + OutputGroup group = + new OutputGroup(UUID.randomUUID().toString(), type, typeParams, members, Long.MAX_VALUE); + List frames = tagRenderer.frame(session, group, context).frames(); + if (frames.isEmpty()) { + return; + } + FrameSequence sequence; + try { + sequence = FrameSequence.of(frames); + } catch (IllegalArgumentException e) { + LOG.warn( + "Dropping an unrepresentable output group for connection {}: {}", + session.connectionId(), + e.getMessage()); + return; } - enqueueRaw(new Message(Map.of(), null, null, "BATCH", List.of("-" + ref))); + enqueueEntry(new Entry(sequence.bytes(), sequence, null)); } private void handleOverflow() { diff --git a/jircd-core/src/main/java/net/jircd/core/session/TagRenderer.java b/jircd-core/src/main/java/net/jircd/core/session/TagRenderer.java index b49d4df..f739354 100644 --- a/jircd-core/src/main/java/net/jircd/core/session/TagRenderer.java +++ b/jircd-core/src/main/java/net/jircd/core/session/TagRenderer.java @@ -16,13 +16,15 @@ package net.jircd.core.session; import java.util.Map; +import net.jircd.core.extension.capability.FramingPlan; +import net.jircd.core.extension.capability.OutputGroup; +import net.jircd.core.extension.capability.RequestContext; /** * Produces the message-tags a recipient should see on a delivered {@link OutboundMessage}, * live-checked against that recipient's currently negotiated-and-enabled capabilities (research.md - * "Message fan-out concurrency model"). The Foundational default renders no tags at all; {@code - * jircd-capabilities/*} wires the real {@code message-tags}/{@code server-time} decoration in - * (T092). + * "Message fan-out concurrency model"). The default renders no tags at all and frames a group as + * plain members; {@link CapabilityTagRenderer} asks the capability providers that own each. */ @FunctionalInterface public interface TagRenderer { @@ -60,4 +62,13 @@ default PreparedTags prepare(ClientSession recipient, OutboundMessage message) { } Map render(ClientSession recipient, OutboundMessage message); + + /** + * How {@code group} is framed for {@code recipient}: the complete ordered messages, including any + * opener and closer. The default sends the members plain; capability renderers ask the provider + * that owns group framing. A plan returned here is complete: it is queued whole or not at all. + */ + default FramingPlan frame(ClientSession recipient, OutputGroup group, RequestContext context) { + return FramingPlan.plain(group); + } } diff --git a/jircd-core/src/main/java/net/jircd/core/session/command/AwayCommandHandler.java b/jircd-core/src/main/java/net/jircd/core/session/command/AwayCommandHandler.java index d651219..4e80a0a 100644 --- a/jircd-core/src/main/java/net/jircd/core/session/command/AwayCommandHandler.java +++ b/jircd-core/src/main/java/net/jircd/core/session/command/AwayCommandHandler.java @@ -16,23 +16,17 @@ package net.jircd.core.session.command; import java.util.List; -import java.util.Map; -import java.util.Set; import java.util.function.Supplier; import net.jircd.core.extension.ExtensionRegistry; import net.jircd.core.session.ClientSession; -import net.jircd.core.session.PresentedIdentity; -import net.jircd.core.session.Recipients; -import net.jircd.protocol.CapabilityName; -import net.jircd.protocol.Command; import net.jircd.protocol.Message; import net.jircd.protocol.NumericReply; /** * {@code AWAY} — sets or clears the sender's away status. A reason given while already away * replaces the previous one in place, confirmed the same way as setting it for the first time; no - * parameter clears it. Every successful change also broadcasts an {@code AWAY} notice to - * channel-mates who negotiated the {@code away-notify} capability. + * parameter clears it. Every successful change is published as a committed {@code AWAY_CHANGED} + * event; which peers are told about it, and how, is up to the capability providers that listen. */ public final class AwayCommandHandler implements CommandHandler { @@ -50,6 +44,7 @@ public void handle(ClientSession session, Message message) { // An empty trailing argument clears away status the same // way a fully-absent one already does, rather than setting an empty-string away reason. if (message.params().isEmpty() || message.params().getFirst().isEmpty()) { + String previous = session.awayReason(); session.setAwayReason(null); Replies.sendFitted( session, @@ -57,31 +52,18 @@ public void handle(ClientSession session, Message message) { NumericReply.RPL_UNAWAY, List.of(), "You are no longer marked as being away"); - broadcastAwayChange(session, null); + extensionRegistry.committedEvents().awayChanged(session, nullToEmpty(previous), ""); return; } String reason = message.params().getFirst(); + String previous = session.awayReason(); session.setAwayReason(reason); Replies.sendFitted( session, server, NumericReply.RPL_NOWAWAY, List.of(), "You have been marked as being away"); - broadcastAwayChange(session, reason); + extensionRegistry.committedEvents().awayChanged(session, nullToEmpty(previous), reason); } - /** - * Fans out to every channel-mate opted into {@code away-notify}, deduplicated to one notice per - * recipient regardless of how many channels they share with {@code session}. The sender is - * excluded, unlike {@code NICK}, whose notice also goes to the changing client itself. - */ - private void broadcastAwayChange(ClientSession session, String reason) { - Set recipients = - Recipients.sharedChannelNeighbors( - session, false, m -> m.negotiatedCapabilities().contains(CapabilityName.AWAY_NOTIFY)); - if (recipients.isEmpty()) { - return; - } - String prefix = PresentedIdentity.presentedForm(session, extensionRegistry); - List params = reason != null ? List.of(reason) : List.of(); - Message notice = new Message(Map.of(), prefix, Command.AWAY, "AWAY", params); - Recipients.deliver(recipients, notice); + private static String nullToEmpty(String value) { + return value == null ? "" : value; } } diff --git a/jircd-core/src/main/java/net/jircd/core/session/command/JoinCommandHandler.java b/jircd-core/src/main/java/net/jircd/core/session/command/JoinCommandHandler.java index 8a91143..0a9d527 100644 --- a/jircd-core/src/main/java/net/jircd/core/session/command/JoinCommandHandler.java +++ b/jircd-core/src/main/java/net/jircd/core/session/command/JoinCommandHandler.java @@ -24,9 +24,10 @@ import net.jircd.core.session.ChannelRegistry; import net.jircd.core.session.ClientSession; import net.jircd.core.session.CoreChannelModes; +import net.jircd.core.session.MemberFacts; +import net.jircd.core.session.MemberReplies; import net.jircd.core.session.PresentedIdentity; import net.jircd.core.session.Recipients; -import net.jircd.protocol.CapabilityName; import net.jircd.protocol.ChannelName; import net.jircd.protocol.Command; import net.jircd.protocol.Hostmask; @@ -140,6 +141,9 @@ private void joinOne(ClientSession session, String name, String suppliedKey) { Message joinNotification = new Message(Map.of(), presentedForm, Command.JOIN, "JOIN", List.of(channel.name())); Recipients.deliver(result.recipients(), joinNotification); + extensionRegistry + .committedEvents() + .joined(session, presentedForm, channel.name(), result.recipients()); Channel.Topic topic = channel.topicState(); if (topic != null) { @@ -154,7 +158,7 @@ private void joinOne(ClientSession session, String name, String suppliedKey) { Long.toString(topic.setAt())); } - sendNamesReply(session, channel, serverName.get(), result.memberListing()); + sendNamesReply(extensionRegistry, session, channel, serverName.get(), result.members()); } /** The pre-lock displayed identity, or the runtime failure raised while resolving it. */ @@ -215,11 +219,35 @@ private static AdmissionFailure admissionFailure( return null; } - public static void sendNamesReply(ClientSession requester, Channel channel, String serverName) { - sendNamesReply(requester, channel, serverName, listingFor(requester, channel)); + /** + * Sends the channel's current members, rendered for {@code requester}, then the closing numeric. + */ + public static void sendNamesReply( + ExtensionRegistry extensionRegistry, + ClientSession requester, + Channel channel, + String serverName) { + sendNamesReply(extensionRegistry, requester, channel, serverName, channel.memberFacts()); } - /** Sends a membership snapshot captured by a join transition, followed by its closing numeric. */ + /** + * Sends member facts captured by a join transition, rendered for {@code requester}, followed by + * the closing numeric. + */ + public static void sendNamesReply( + ExtensionRegistry extensionRegistry, + ClientSession requester, + Channel channel, + String serverName, + List members) { + sendNamesReply( + requester, + channel, + serverName, + new MemberReplies(extensionRegistry).namesTokens(requester, members)); + } + + /** Sends already rendered listing tokens, followed by the closing numeric. */ public static void sendNamesReply( ClientSession requester, Channel channel, String serverName, List memberListing) { sendNamesLine(requester, channel, serverName, memberListing); @@ -237,14 +265,16 @@ public static void sendNamesReply( * NamesCommandHandler}) can call this once per visible channel and send exactly one closing reply * of its own afterward, instead of one per channel. */ - public static void sendNamesLine(ClientSession requester, Channel channel, String serverName) { - sendNamesLine(requester, channel, serverName, listingFor(requester, channel)); - } - - /** The listing in the form the requester negotiated: every prefix with {@code multi-prefix}. */ - private static List listingFor(ClientSession requester, Channel channel) { - return channel.memberListing( - requester.negotiatedCapabilities().contains(CapabilityName.MULTI_PREFIX)); + public static void sendNamesLine( + ExtensionRegistry extensionRegistry, + ClientSession requester, + Channel channel, + String serverName) { + sendNamesLine( + requester, + channel, + serverName, + new MemberReplies(extensionRegistry).namesTokens(requester, channel.memberFacts())); } private static void sendNamesLine( diff --git a/jircd-core/src/main/java/net/jircd/core/session/command/MessageCommandHandler.java b/jircd-core/src/main/java/net/jircd/core/session/command/MessageCommandHandler.java index 399a025..b50e6bb 100644 --- a/jircd-core/src/main/java/net/jircd/core/session/command/MessageCommandHandler.java +++ b/jircd-core/src/main/java/net/jircd/core/session/command/MessageCommandHandler.java @@ -15,33 +15,24 @@ */ package net.jircd.core.session.command; -import java.util.Collection; -import java.util.LinkedHashSet; import java.util.List; -import java.util.Set; import java.util.function.Supplier; import net.jircd.core.extension.ExtensionRegistry; -import net.jircd.core.session.Channel; import net.jircd.core.session.ChannelRegistry; import net.jircd.core.session.ClientSession; -import net.jircd.core.session.CoreChannelModes; +import net.jircd.core.session.MessageDelivery; import net.jircd.core.session.NicknameRegistry; -import net.jircd.core.session.OutboundMessage; -import net.jircd.core.session.PresentedIdentity; -import net.jircd.protocol.Hostmask; import net.jircd.protocol.NumericReply; /** - * {@code PRIVMSG}/{@code NOTICE} — channel and direct messaging (FR-004, FR-005). Builds one shared - * {@link OutboundMessage} and enqueues it onto every recipient's {@code SessionWriter} — this - * handler applies no capability-dependent formatting itself; that happens per-recipient at drain - * time. + * {@code PRIVMSG}/{@code NOTICE} — channel and direct messaging (FR-004, FR-005). Target + * resolution, the send gates and recipient selection are {@link MessageDelivery}'s; this handler + * applies no capability-dependent formatting itself, which happens per recipient when the message + * is queued. */ public final class MessageCommandHandler implements CommandHandler { - private final ChannelRegistry channelRegistry; - private final NicknameRegistry nicknameRegistry; - private final ExtensionRegistry extensionRegistry; + private final MessageDelivery delivery; private final Supplier serverName; private final boolean notice; @@ -51,9 +42,8 @@ public MessageCommandHandler( ExtensionRegistry extensionRegistry, Supplier serverName, boolean notice) { - this.channelRegistry = channelRegistry; - this.nicknameRegistry = nicknameRegistry; - this.extensionRegistry = extensionRegistry; + this.delivery = + new MessageDelivery(channelRegistry, nicknameRegistry, extensionRegistry, serverName); this.serverName = serverName; this.notice = notice; } @@ -84,15 +74,7 @@ public void handle(ClientSession session, net.jircd.protocol.Message message) { return; } - Set recipients = - resolveRecipients( - channelRegistry, - nicknameRegistry, - extensionRegistry, - serverName, - session, - target, - !notice); + MessageDelivery.AuthorizedTarget recipients = delivery.authorize(session, target, !notice); if (recipients.isEmpty()) { return; } @@ -110,117 +92,10 @@ public void handle(ClientSession session, net.jircd.protocol.Message message) { } } - String presentedForm = PresentedIdentity.presentedForm(session, extensionRegistry); - // 005-fix-batch-conformance FR-010 — pass the sender's own tags through, the same way - // TagmsgCommandHandler already does; CapabilityTagRenderer merges these into the + // 005-fix-batch-conformance FR-010 — the sender's own client-only tags are offered to the + // providers that forward them; CapabilityTagRenderer merges the selected ones into the // per-recipient rendered map alongside server-contributed ones (msgid/time). - OutboundMessage outbound = - OutboundMessage.now(presentedForm, commandName, target, body, message.tags()); - for (ClientSession recipient : - deliveryTargets( - recipients, session, target, includeSenderInFanOut(extensionRegistry, session))) { - if (recipient.writer() != null) { - recipient.writer().enqueue(outbound); - } - } - } - - /** - * Who receives a fan-out: a direct target always gets it (even when it is the sender itself, - * exactly once); a channel's own member set drops the sender unless {@code echo-message} wants it - * included; and a direct message to someone else additionally echoes to the sender only under - * {@code echo-message}. Shared by {@code PRIVMSG}/{@code NOTICE} and {@code TAGMSG}. - */ - static Collection deliveryTargets( - Set recipients, ClientSession sender, String target, boolean echoToSender) { - Set targets = new LinkedHashSet<>(recipients); - if (target.startsWith("#")) { - if (!echoToSender) { - targets.remove(sender); - } - } else if (echoToSender) { - targets.add(sender); // a direct recipients set is Set.of(target); no-op for a self-target - } - return targets; - } - - /** - * Resolves {@code target} into its recipient set for {@code PRIVMSG}/{@code NOTICE}/{@code - * TAGMSG} alike (002-extended-irc-commands FR-022, research.md "TAGMSG delivery reuse"): a - * channel name must exist and pass the {@code SEND} gate below, a nickname must be connected. - * Returns an empty set on failure, having already sent the appropriate error to {@code session} - * when {@code reportErrors} is {@code true} ({@code NOTICE}-style silence otherwise). - */ - static Set resolveRecipients( - ChannelRegistry channelRegistry, - NicknameRegistry nicknameRegistry, - ExtensionRegistry extensionRegistry, - Supplier serverName, - ClientSession session, - String target, - boolean reportErrors) { - if (target.startsWith("#")) { - var channel = channelRegistry.lookup(target); - if (channel.isEmpty() || !passesSendGate(channel.get(), session, extensionRegistry)) { - if (reportErrors) { - Replies.sendFitted( - session, - serverName.get(), - NumericReply.ERR_CANNOTSENDTOCHAN, - List.of(target), - "Cannot send to channel"); - } - return Set.of(); - } - return channel.get().members(); - } - var recipient = nicknameRegistry.lookup(target); - if (recipient.isEmpty()) { - if (reportErrors) { - Replies.sendFitted( - session, - serverName.get(), - NumericReply.ERR_NOSUCHNICK, - List.of(target), - "No such nick/channel"); - } - return Set.of(); - } - return Set.of(recipient.get()); - } - - /** Whether {@code echo-message} (Story 2) wants {@code session} included in its own fan-out. */ - static boolean includeSenderInFanOut(ExtensionRegistry extensionRegistry, ClientSession session) { - return extensionRegistry.enabled().stream() - .filter(net.jircd.core.extension.CapabilityExtension.class::isInstance) - .map(net.jircd.core.extension.CapabilityExtension.class::cast) - .anyMatch(capability -> capability.includeSenderInFanOut(session)); - } - - /** - * The {@code SEND}-gate check point FR-043 requires: every currently-recognized flag whose {@code - * gates} includes {@code SEND}, checked independently — {@code members-only} requires membership; - * {@code moderated} requires operator or voice (FR-045); {@code ban-mask} requires neither the - * sender's presented nor real identity to match any active ban (FR-062, dual-matched to resist - * {@code cloak} evasion) — muting a matched sender without removing them from members. - */ - private static boolean passesSendGate( - Channel channel, ClientSession session, ExtensionRegistry extensionRegistry) { - if (channel.activeModes().contains(CoreChannelModes.MEMBERS_ONLY) - && !channel.members().contains(session)) { - return false; - } - if (channel.activeModes().contains(CoreChannelModes.MODERATED) - && !channel.operators().contains(session) - && !channel.voiced().contains(session)) { - return false; - } - if (!channel.bans().isEmpty()) { - String presented = PresentedIdentity.presentedForm(session, extensionRegistry); - String real = Hostmask.format(session.nickname(), session.ident(), session.realHostname()); - return !channel.matchesMask(CoreChannelModes.BAN_MASK, presented, real) - || channel.matchesMask(CoreChannelModes.BAN_EXCEPTION, presented, real); - } - return true; + delivery.deliver( + session, commandName, target, body, message.tags(), recipients, recipient -> true); } } diff --git a/jircd-core/src/main/java/net/jircd/core/session/command/NamesCommandHandler.java b/jircd-core/src/main/java/net/jircd/core/session/command/NamesCommandHandler.java index f3e2013..75ee180 100644 --- a/jircd-core/src/main/java/net/jircd/core/session/command/NamesCommandHandler.java +++ b/jircd-core/src/main/java/net/jircd/core/session/command/NamesCommandHandler.java @@ -56,7 +56,7 @@ public void handle(ClientSession session, Message message) { if (message.params().isEmpty()) { for (var channel : channelRegistry.all()) { if (!ChannelVisibility.isHiddenFrom(channel, session, extensionRegistry)) { - JoinCommandHandler.sendNamesLine(session, channel, serverName.get()); + JoinCommandHandler.sendNamesLine(extensionRegistry, session, channel, serverName.get()); } } Replies.sendFitted( @@ -83,6 +83,6 @@ public void handle(ClientSession session, Message message) { "End of /NAMES list"); return; } - JoinCommandHandler.sendNamesReply(session, found.get(), serverName.get()); + JoinCommandHandler.sendNamesReply(extensionRegistry, session, found.get(), serverName.get()); } } diff --git a/jircd-core/src/main/java/net/jircd/core/session/command/NickCommandHandler.java b/jircd-core/src/main/java/net/jircd/core/session/command/NickCommandHandler.java index 45618ca..28484be 100644 --- a/jircd-core/src/main/java/net/jircd/core/session/command/NickCommandHandler.java +++ b/jircd-core/src/main/java/net/jircd/core/session/command/NickCommandHandler.java @@ -114,6 +114,7 @@ public void handle(ClientSession session, Message message) { if (isChange) { broadcastNickChange(session, oldHostmask, requested); + extensionRegistry.committedEvents().nickChanged(session, oldHostmask, previous, requested); } } diff --git a/jircd-core/src/main/java/net/jircd/core/session/command/PartCommandHandler.java b/jircd-core/src/main/java/net/jircd/core/session/command/PartCommandHandler.java index 039f0b6..c878385 100644 --- a/jircd-core/src/main/java/net/jircd/core/session/command/PartCommandHandler.java +++ b/jircd-core/src/main/java/net/jircd/core/session/command/PartCommandHandler.java @@ -90,5 +90,13 @@ public void handle(ClientSession session, Message message) { reason != null ? List.of(channel.name(), reason) : List.of(channel.name()); Message partNotification = new Message(Map.of(), presentedForm, Command.PART, "PART", params); Recipients.deliver(departed.get().recipients(), partNotification); + extensionRegistry + .committedEvents() + .parted( + session, + presentedForm, + channel.name(), + reason == null ? "" : reason, + departed.get().recipients()); } } diff --git a/jircd-core/src/main/java/net/jircd/core/session/command/TagmsgCommandHandler.java b/jircd-core/src/main/java/net/jircd/core/session/command/TagmsgCommandHandler.java deleted file mode 100644 index 3cce461..0000000 --- a/jircd-core/src/main/java/net/jircd/core/session/command/TagmsgCommandHandler.java +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright 2026 Guillermo Castro - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - * implied. See the License for the specific language governing - * permissions and limitations under the License. - */ -package net.jircd.core.session.command; - -import java.util.List; -import java.util.Set; -import java.util.function.Supplier; -import net.jircd.core.extension.ExtensionRegistry; -import net.jircd.core.session.ChannelRegistry; -import net.jircd.core.session.ClientSession; -import net.jircd.core.session.NicknameRegistry; -import net.jircd.core.session.OutboundMessage; -import net.jircd.core.session.PresentedIdentity; -import net.jircd.protocol.CapabilityName; -import net.jircd.protocol.Message; -import net.jircd.protocol.NumericReply; - -/** - * {@code TAGMSG} — a tag-only message, no text body (002-extended-irc-commands FR-020 through - * FR-023, research.md "TAGMSG delivery reuse"): reuses {@link MessageCommandHandler}'s target - * resolution verbatim, then fans out to recipients that have {@code message-tags} negotiated only — - * a client without it has nothing to render from a message with no body. - */ -public final class TagmsgCommandHandler implements CommandHandler { - - private static final String COMMAND_NAME = "TAGMSG"; - - private final ChannelRegistry channelRegistry; - private final NicknameRegistry nicknameRegistry; - private final ExtensionRegistry extensionRegistry; - private final Supplier serverName; - - public TagmsgCommandHandler( - ChannelRegistry channelRegistry, - NicknameRegistry nicknameRegistry, - ExtensionRegistry extensionRegistry, - Supplier serverName) { - this.channelRegistry = channelRegistry; - this.nicknameRegistry = nicknameRegistry; - this.extensionRegistry = extensionRegistry; - this.serverName = serverName; - } - - @Override - public void handle(ClientSession session, Message message) { - if (message.params().isEmpty()) { - Replies.sendFitted( - session, - serverName.get(), - NumericReply.ERR_NEEDMOREPARAMS, - List.of(COMMAND_NAME), - "Not enough parameters"); - return; - } - if (message.tags().isEmpty()) { - Replies.sendFitted( - session, - serverName.get(), - NumericReply.ERR_UNKNOWNCOMMAND, - List.of(COMMAND_NAME), - "Malformed message"); - return; - } - String target = message.params().getFirst(); - Set recipients = - MessageCommandHandler.resolveRecipients( - channelRegistry, - nicknameRegistry, - extensionRegistry, - serverName, - session, - target, - true); - if (recipients.isEmpty()) { - return; - } - - String presentedForm = PresentedIdentity.presentedForm(session, extensionRegistry); - OutboundMessage outbound = - OutboundMessage.now(presentedForm, COMMAND_NAME, target, null, message.tags()); - boolean echoToSender = MessageCommandHandler.includeSenderInFanOut(extensionRegistry, session); - for (ClientSession recipient : - MessageCommandHandler.deliveryTargets(recipients, session, target, echoToSender)) { - if (!recipient.negotiatedCapabilities().contains(CapabilityName.MESSAGE_TAGS)) { - continue; // FR-021: nothing to render for a client without message-tags negotiated - } - if (recipient.writer() != null) { - recipient.writer().enqueue(outbound); - } - } - } -} diff --git a/jircd-core/src/main/java/net/jircd/core/session/command/WhoCommandHandler.java b/jircd-core/src/main/java/net/jircd/core/session/command/WhoCommandHandler.java index d025d13..c78b4b3 100644 --- a/jircd-core/src/main/java/net/jircd/core/session/command/WhoCommandHandler.java +++ b/jircd-core/src/main/java/net/jircd/core/session/command/WhoCommandHandler.java @@ -26,10 +26,10 @@ import net.jircd.core.session.ChannelRegistry; import net.jircd.core.session.ChannelVisibility; import net.jircd.core.session.ClientSession; +import net.jircd.core.session.MemberReplies; import net.jircd.core.session.NicknameRegistry; import net.jircd.core.session.PresentedIdentity; import net.jircd.core.session.UserMode; -import net.jircd.protocol.CapabilityName; import net.jircd.protocol.Message; import net.jircd.protocol.NumericReply; @@ -52,6 +52,7 @@ public final class WhoCommandHandler implements CommandHandler { private final ExtensionRegistry extensionRegistry; private final Supplier serverName; private final BooleanSupplier whoMaskEnabled; + private final MemberReplies memberReplies; public WhoCommandHandler( ChannelRegistry channelRegistry, @@ -64,6 +65,7 @@ public WhoCommandHandler( this.extensionRegistry = extensionRegistry; this.serverName = serverName; this.whoMaskEnabled = whoMaskEnabled; + this.memberReplies = new MemberReplies(extensionRegistry); } @Override @@ -147,9 +149,7 @@ private void sendWhoReply( flags.append('*'); } if (channel != null) { - flags.append( - channel.statusPrefixes( - target, requester.negotiatedCapabilities().contains(CapabilityName.MULTI_PREFIX))); + flags.append(memberReplies.whoStatus(requester, channel.memberFacts(target))); } Replies.sendFitted( diff --git a/jircd-core/src/test/java/net/jircd/core/config/CapabilityReplyBudgetTest.java b/jircd-core/src/test/java/net/jircd/core/config/CapabilityReplyBudgetTest.java index f05bf16..c0d51ea 100644 --- a/jircd-core/src/test/java/net/jircd/core/config/CapabilityReplyBudgetTest.java +++ b/jircd-core/src/test/java/net/jircd/core/config/CapabilityReplyBudgetTest.java @@ -61,17 +61,20 @@ void coreOnlyHighestStatusReservesOneStatusByte() { } @Test - void twoSymbolStatusReproducesTheCurrentSafeEnvelope() { - ReplyBudgetCatalog legacy = ReplyBudgetCatalog.legacyCompatible(); + void aTwoSymbolStatusProviderReservesTheOperatorAndVoiceSymbols() { + ReplyBudgetCatalog legacy = catalog("multi-prefix", ReplySlot.STATUS, 2); assertThat(legacy.worstCaseBytes(ReplyLayout.NAMES_MEMBER, LIMITS)) .isEqualTo(S + 2 * 9 + 50 + 15); int u = ServerConfiguration.IDENT_MAX_BYTES; int h = ServerConfiguration.PRESENTED_HOST_MAX_BYTES; - // WHO with F = 2 + P = 4 is exactly the previously hard-coded assumption. + // WHO with F = 2 + P = 4 for the two-symbol status. assertThat(legacy.worstCaseBytes(ReplyLayout.WHO_ENTRY, LIMITS)) .isEqualTo(2 * S + 2 * 9 + 50 + u + h + 22); - assertThat(HostmaskReplyBudget.worstCaseBytes(S, 9, 50)) + assertThat(HostmaskReplyBudget.worstCaseBytes(S, 9, 50, legacy)) .containsEntry("352 (WHO)", 2 * S + 2 * 9 + 50 + u + h + 22); + // With no provider the reserved width is core's single highest-status byte. + assertThat(HostmaskReplyBudget.worstCaseBytes(S, 9, 50)) + .containsEntry("352 (WHO)", 2 * S + 2 * 9 + 50 + u + h + 21); } @Test @@ -176,12 +179,13 @@ void namesFitComputationReportsTheRemainingChannelBudget() { assertThatThrownBy( () -> ConfigurationLoader.validateNamesBudget( - "test.jircd.local", 216, 51, ReplyBudgetCatalog.legacyCompatible())) + "test.jircd.local", 216, 51, catalog("multi-prefix", ReplySlot.STATUS, 2))) .isInstanceOf(ConfigurationException.class) .hasMessageContaining("353=514") .hasMessageContaining("budget="); Map names = - HostmaskReplyBudget.namesWorstCaseBytes(16, 215, 51, ReplyBudgetCatalog.legacyCompatible()); + HostmaskReplyBudget.namesWorstCaseBytes( + 16, 215, 51, catalog("multi-prefix", ReplySlot.STATUS, 2)); assertThat(names).containsEntry("353", 512); } diff --git a/jircd-core/src/test/java/net/jircd/core/config/ReplyBudgetConfigurationTest.java b/jircd-core/src/test/java/net/jircd/core/config/ReplyBudgetConfigurationTest.java index 96aea8a..2f21246 100644 --- a/jircd-core/src/test/java/net/jircd/core/config/ReplyBudgetConfigurationTest.java +++ b/jircd-core/src/test/java/net/jircd/core/config/ReplyBudgetConfigurationTest.java @@ -24,15 +24,25 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; import java.util.Set; import net.jircd.core.extension.ExtensionRegistry; +import net.jircd.core.extension.capability.ReplyBudgetCatalog; +import net.jircd.core.extension.capability.ReplyContribution; +import net.jircd.core.extension.capability.ReplySlot; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; class ReplyBudgetConfigurationTest { @TempDir Path directory; - private final ConfigurationLoader loader = new ConfigurationLoader(Set.of(), Set.of()); + /** What the installed multi-prefix provider declares: the operator and voice symbols. */ + private static final ReplyBudgetCatalog TWO_SYMBOL_STATUS = + new ReplyBudgetCatalog( + List.of(new ReplyContribution("multi-prefix", ReplySlot.STATUS, limits -> 2))); + + private final ConfigurationLoader loader = + new ConfigurationLoader(Set.of(), Set.of(), () -> TWO_SYMBOL_STATUS); private ServerConfiguration load(String yaml) throws ConfigurationException { return loader.load(new ByteArrayInputStream(yaml.getBytes(StandardCharsets.UTF_8))); @@ -70,8 +80,11 @@ void checks366IndependentlyAtItsBoundary() throws Exception { @Test void accepts353ExactlyAtTheConservativeBoundary() throws Exception { - ConfigurationLoader.validateNamesBudget("test.jircd.local", 215, 51); - assertThatThrownBy(() -> ConfigurationLoader.validateNamesBudget("test.jircd.local", 216, 51)) + ConfigurationLoader.validateNamesBudget("test.jircd.local", 215, 51, TWO_SYMBOL_STATUS); + assertThatThrownBy( + () -> + ConfigurationLoader.validateNamesBudget( + "test.jircd.local", 216, 51, TWO_SYMBOL_STATUS)) .isInstanceOf(ConfigurationException.class) .hasMessageContaining("353=514"); } diff --git a/jircd-core/src/test/java/net/jircd/core/extension/capability/CapabilityCompositionTest.java b/jircd-core/src/test/java/net/jircd/core/extension/capability/CapabilityCompositionTest.java new file mode 100644 index 0000000..f12b42e --- /dev/null +++ b/jircd-core/src/test/java/net/jircd/core/extension/capability/CapabilityCompositionTest.java @@ -0,0 +1,258 @@ +/* + * Copyright 2026 Guillermo Castro + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package net.jircd.core.extension.capability; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import net.jircd.core.capability.Capability; +import net.jircd.core.extension.AbstractCapabilityExtension; +import net.jircd.core.extension.ExtensionRegistry; +import net.jircd.core.session.CapabilityTagRenderer; +import net.jircd.core.session.Channel; +import net.jircd.core.session.ChannelRegistry; +import net.jircd.core.session.ClientSession; +import net.jircd.core.session.MemberReplies; +import net.jircd.core.session.MessageDelivery; +import net.jircd.core.session.NicknameRegistry; +import net.jircd.core.session.RateLimitBucket; +import net.jircd.core.session.SessionWriter; +import net.jircd.core.session.command.AwayCommandHandler; +import net.jircd.protocol.Command; +import net.jircd.protocol.Message; +import net.jircd.protocol.MessageParser; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * A capability that exists nowhere in core, built only from the registrations the six bundled + * modules already use, composes with them without a change to core: a new capability name needs no + * new branch. The test provider stands in for the four features the design maps onto this SPI. + * + *

Where each future feature attaches, and the domain work that stays outside this change: + * + * + * + * + * + * + * + * + * + *
Seams of the features this SPI is designed to carry
FeatureSeam exercised hereRemaining domain work
account-notifycommitted event plus the event's audience and recipient + * negotiation (an {@code ACCOUNT_CHANGED} kind added where account state commits)account and authentication state, and the real publisher; no event is faked
chathistoryprovider command, finite group with a session prerequisite, + * historical envelopestorage, query, authorization, paging and the history batch + * type; never an unbounded stream
labeled-response / labelinbound tag into a bounded request context, a + * sender-copy role that sees it, group framingthe label wire rules, one + * acknowledgement when an echo is the response, no labels on unsolicited peer traffic
userhost-in-namesNAMES identity slot, independent of the status slot, with + * its own byte boundthe {@code !ident@host} wire form and its privacy and bound + * policy
+ */ +class CapabilityCompositionTest { + + /** A made-up capability using events, an identity slot, request context, roles and groups. */ + private static final class Experimental extends AbstractCapabilityExtension { + final AtomicReference contextSeenByRoles = new AtomicReference<>(); + + Experimental() { + super("experimental"); + } + + @Override + public void register(CapabilityRegistrar registrar) { + // account-notify seam: a committed event answered with a notice to the audience. + registrar.onEvent( + EventKind.AWAY_CHANGED, + event -> + List.of( + new EventIntent( + new Message( + Map.of(), + event.subject(), + Command.NOTICE, + "NOTICE", + List.of("x", "seen")), + event.audience().peers()))); + // userhost-in-names seam: its own slot, composing with another provider's status slot. + registrar.onNamesIdentity( + limits -> 200, + member -> member.nickname() + "!" + member.ident() + "@" + member.displayHost()); + // label seam: client tag into bounded context, then a role decision that can see it. + registrar.onInboundTags( + (tags, context) -> + new InboundTagResult( + Map.of(), + tags.tags().containsKey("label") + ? Map.of("experimental/label", tags.tags().get("label")) + : Map.of())); + registrar.onRecipients( + (intent, context) -> { + contextSeenByRoles.set(context); + return context.attributes().containsKey("experimental/label") + ? Set.of(DeliveryRole.SENDER_COPY) + : Set.of(); + }); + // chathistory seam: a finite group framed only when its prerequisite is also negotiated. + registrar.onOutputGroup( + Set.of("tags"), + (group, context) -> { + List frames = new ArrayList<>(); + frames.add(new Message(Map.of(), null, null, "BATCH", List.of("+h", group.type()))); + frames.addAll(group.members()); + frames.add(new Message(Map.of(), null, null, "BATCH", List.of("-h"))); + return new FramingPlan(frames); + }); + } + } + + /** The status slot, from a second provider that knows nothing of the first. */ + private static final class AllStatuses extends AbstractCapabilityExtension { + AllStatuses() { + super("all-statuses"); + } + + @Override + public void register(CapabilityRegistrar registrar) { + registrar.onMemberStatus(limits -> 2, member -> String.join("", member.heldStatuses())); + } + } + + private final NicknameRegistry nicknames = new NicknameRegistry(); + private final ChannelRegistry channels = new ChannelRegistry(); + private final ExtensionRegistry extensions = new ExtensionRegistry(); + private final Map outputs = new HashMap<>(); + private final List writers = new ArrayList<>(); + private final Experimental experimental = new Experimental(); + + CapabilityCompositionTest() { + extensions.register(experimental); + extensions.enable("experimental"); + extensions.register(new AllStatuses()); + extensions.enable("all-statuses"); + } + + @AfterEach + void closeWriters() { + writers.forEach(SessionWriter::close); + } + + private ClientSession user(String nick, String... negotiated) { + ClientSession session = new ClientSession(nick, "host.example", RateLimitBucket.withDefaults()); + session.setNickname(nick); + session.setIdent("ident"); + session.replaceNegotiatedCapabilities(List.of(negotiated)); + nicknames.claim(nick, session); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + outputs.put(session, out); + @SuppressWarnings("PMD.CloseResource") // closed by closeWriters() + SessionWriter writer = new SessionWriter(session, out, new CapabilityTagRenderer(extensions)); + writers.add(writer); + session.attachWriter(writer); + return session; + } + + private String received(ClientSession session) { + writers.forEach(SessionWriter::close); + return outputs.get(session).toString(StandardCharsets.UTF_8); + } + + @Test + void aCommittedEventReachesOnlyPeersThatNegotiatedTheNewCapability() throws Exception { + ClientSession alice = user("alice"); + ClientSession listening = user("listening", "experimental"); + ClientSession deaf = user("deaf"); + for (ClientSession member : List.of(alice, listening, deaf)) { + channels.join("#c", member, ch -> true); + } + + new AwayCommandHandler(() -> "server", extensions) + .handle(alice, MessageParser.parse("AWAY :x")); + + assertThat(received(listening)).contains("NOTICE x seen"); + assertThat(received(deaf)).doesNotContain("seen"); + } + + @Test + void anIdentitySlotAndAStatusSlotFromDifferentProvidersCompose() { + ClientSession op = user("op"); + Channel channel = channels.join("#c", op, ch -> true).orElseThrow().channel(); + channel.grantVoice(op); + ClientSession requester = user("requester", "experimental", "all-statuses"); + + assertThat(new MemberReplies(extensions).namesTokens(requester, channel.memberFacts())) + .containsExactly("@+op!ident@host.example"); + } + + @Test + void aRequestContextCarriesAClientTagToTheRoleDecisionWithinItsBounds() { + ClientSession sender = user("sender", "experimental"); + ClientSession peer = user("peer"); + MessageDelivery delivery = new MessageDelivery(channels, nicknames, extensions, () -> "server"); + + MessageDelivery.Delivery labeled = + delivery.deliver( + sender, + "PRIVMSG", + "peer", + "hi", + Map.of("label", "abc"), + delivery.authorize(sender, "peer", true), + r -> true); + MessageDelivery.Delivery unlabeled = + delivery.deliver( + sender, + "PRIVMSG", + "peer", + "hi", + Map.of(), + delivery.authorize(sender, "peer", true), + r -> true); + + assertThat(labeled.context().attributes()).containsEntry("experimental/label", "abc"); + assertThat(labeled.recipients().get(sender)).containsExactly(DeliveryRole.SENDER_COPY); + assertThat(unlabeled.recipients()).doesNotContainKey(sender); + // The correlation never travels to the peer's copy of the message. + assertThat(received(peer)).doesNotContain("abc"); + } + + @Test + void aFiniteGroupIsFramedOnlyWhenItsPrerequisiteIsAlsoNegotiated() { + ClientSession both = user("both", "experimental", "tags"); + ClientSession ownerOnly = user("owner-only", "experimental"); + Message member = + new Message(Map.of(), "s!i@h", Command.PRIVMSG, "PRIVMSG", List.of("#c", "page")); + + both.writer().enqueueGroup("history", List.of(), List.of(member)); + ownerOnly.writer().enqueueGroup("history", List.of(), List.of(member)); + + assertThat(received(both)).contains("BATCH +h history").contains("BATCH -h"); + assertThat(received(ownerOnly)).doesNotContain("BATCH"); + } + + @Test + void theMadeUpCapabilityNeededNoChangeToCoreAndOffersOnlyItsOwnName() { + assertThat(extensions.offeredCapabilities()) + .extracting(Capability::name) + .containsExactlyInAnyOrder("experimental", "all-statuses"); + } +} diff --git a/jircd-core/src/test/java/net/jircd/core/extension/capability/CapabilityDispatchTest.java b/jircd-core/src/test/java/net/jircd/core/extension/capability/CapabilityDispatchTest.java index 6dd9264..fb8c1ba 100644 --- a/jircd-core/src/test/java/net/jircd/core/extension/capability/CapabilityDispatchTest.java +++ b/jircd-core/src/test/java/net/jircd/core/extension/capability/CapabilityDispatchTest.java @@ -672,26 +672,4 @@ public int size() { assertThat(dispatcher.event(snapshot, event(listening))).hasSize(1); assertThat(faults).isEmpty(); } - - // ---- the temporary legacy tag path is contained like a hook - - @Test - void aLegacyContributionThatThrowsIsNullOrOversizedIsDroppedAndReported() { - assertThat( - dispatcher.legacyOutboundTags( - "old", - () -> { - throw new IllegalStateException("boom"); - })) - .isEmpty(); - assertThat(dispatcher.legacyOutboundTags("old", () -> null)).isEmpty(); - assertThat(dispatcher.legacyOutboundTags("old", () -> Map.of("a", "x".repeat(4100)))).isEmpty(); - assertThat(dispatcher.legacyOutboundTags("old", () -> Map.of("a", "v"))) - .containsExactly(Map.entry("a", "v")); - assertThat(faults) - .containsExactly( - new Fault("old", HookKind.OUTBOUND_TAGS, FaultCategory.EXCEPTION), - new Fault("old", HookKind.OUTBOUND_TAGS, FaultCategory.NULL_RESULT), - new Fault("old", HookKind.OUTBOUND_TAGS, FaultCategory.INVALID_RESULT)); - } } diff --git a/jircd-core/src/test/java/net/jircd/core/session/CapabilityTagRendererBudgetTest.java b/jircd-core/src/test/java/net/jircd/core/session/CapabilityTagRendererBudgetTest.java index bdc95a9..bcef2f3 100644 --- a/jircd-core/src/test/java/net/jircd/core/session/CapabilityTagRendererBudgetTest.java +++ b/jircd-core/src/test/java/net/jircd/core/session/CapabilityTagRendererBudgetTest.java @@ -21,10 +21,7 @@ import java.io.ByteArrayOutputStream; import java.nio.charset.StandardCharsets; import java.util.Map; -import net.jircd.core.capability.Capability; -import net.jircd.core.extension.CapabilityExtension; import net.jircd.core.extension.ExtensionRegistry; -import net.jircd.core.extension.ServerContext; import net.jircd.protocol.CapabilityName; import net.jircd.protocol.Command; import net.jircd.protocol.Message; @@ -34,34 +31,9 @@ class CapabilityTagRendererBudgetTest { private static CapabilityTagRenderer renderer(Map serverTags) { ExtensionRegistry registry = new ExtensionRegistry(); - registry.register( - new CapabilityExtension() { - @Override - public String id() { - return "test-tags"; - } - - @Override - public void start(ServerContext context) { - // This tag-only test extension has no resources to initialize. - } - - @Override - public void stop() { - // This tag-only test extension has no resources to release. - } - - @Override - public Capability providedCapability() { - return new Capability("test-tags"); - } - - @Override - public Map contributeTags(OutboundMessage message) { - return serverTags; - } - }); + registry.register(new TagProvider("test-tags", serverTags)); registry.enable("test-tags"); + ClientTagForwarder.enableIn(registry); return new CapabilityTagRenderer(registry); } @@ -76,8 +48,10 @@ private static ClientSession recipient() { @Test void clientAndServerOriginsMayEachUse4094DataBytes() { OutboundMessage message = - OutboundMessage.now( - "sender!ident@host", "PRIVMSG", "bob", "hello", Map.of("+c", "x".repeat(4091))); + ForwardedTags.selectedBy( + "message-tags", + OutboundMessage.now( + "sender!ident@host", "PRIVMSG", "bob", "hello", Map.of("+c", "x".repeat(4091)))); Map tags = renderer(Map.of("s", "y".repeat(4092))).render(recipient(), message); assertThat(MessageSerializer.tagDataBytes(tags)).isEqualTo(8189); } @@ -85,20 +59,22 @@ void clientAndServerOriginsMayEachUse4094DataBytes() { @Test void oversizedClientDataIsRejectedAfterEscaping() { OutboundMessage message = - OutboundMessage.now( - "sender!ident@host", "PRIVMSG", "bob", "hello", Map.of("+c", " ".repeat(2046))); + ForwardedTags.selectedBy( + "message-tags", + OutboundMessage.now( + "sender!ident@host", "PRIVMSG", "bob", "hello", Map.of("+c", " ".repeat(2046)))); assertThatThrownBy(() -> renderer(Map.of()).render(recipient(), message)) .isInstanceOf(MessageSerializer.MessageTooLongException.class); } @Test - void anOversizedLegacyServerContributionIsOmittedAtPreparationNotTheMessage() { + void anOversizedServerContributionIsOmittedAtPreparationNotTheMessage() { OutboundMessage message = OutboundMessage.now("sender!ident@host", "PRIVMSG", "bob", "hello"); assertThat(renderer(Map.of("s", "x".repeat(4093))).render(recipient(), message)).isEmpty(); } @Test - void anOversizedLegacyContributionDoesNotDropTheMessageOrStopSubsequentWrites() { + void anOversizedContributionDoesNotDropTheMessageOrStopSubsequentWrites() { ByteArrayOutputStream out = new ByteArrayOutputStream(); try (SessionWriter writer = new SessionWriter(recipient(), out, renderer(Map.of("s", "x".repeat(4093))))) { diff --git a/jircd-core/src/test/java/net/jircd/core/session/CapabilityTagRendererMigrationTest.java b/jircd-core/src/test/java/net/jircd/core/session/CapabilityTagRendererMigrationTest.java index fde9a73..99b0c09 100644 --- a/jircd-core/src/test/java/net/jircd/core/session/CapabilityTagRendererMigrationTest.java +++ b/jircd-core/src/test/java/net/jircd/core/session/CapabilityTagRendererMigrationTest.java @@ -22,25 +22,21 @@ import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; import net.jircd.core.capability.Capability; +import net.jircd.core.extension.AbstractCapabilityExtension; import net.jircd.core.extension.CapabilityExtension; import net.jircd.core.extension.ExtensionRegistry; import net.jircd.core.extension.ServerContext; import net.jircd.core.extension.capability.CapabilityRegistrar; import org.junit.jupiter.api.Test; -/** - * Migrated providers publish typed tag hooks and are never also asked through the legacy method. - */ +/** Providers publish typed tag hooks: evaluated once per envelope, shared, contained on a fault. */ class CapabilityTagRendererMigrationTest { private static class Provider implements CapabilityExtension { final AtomicInteger hookCalls = new AtomicInteger(); - final AtomicInteger legacyCalls = new AtomicInteger(); - final boolean migrated; final boolean failing; - Provider(boolean migrated, boolean failing) { - this.migrated = migrated; + Provider(boolean failing) { this.failing = failing; } @@ -66,7 +62,6 @@ public Capability providedCapability() { @Override public void register(CapabilityRegistrar registrar) { - if (!migrated) return; registrar.onOutboundTags( Set.of("t"), 16, @@ -76,12 +71,6 @@ public void register(CapabilityRegistrar registrar) { return Map.of("t", "hook"); }); } - - @Override - public Map contributeTags(OutboundMessage message) { - legacyCalls.incrementAndGet(); - return Map.of("t", "legacy"); - } } private static ClientSession recipient(String name) { @@ -94,12 +83,13 @@ private static ExtensionRegistry registryWith(Provider provider) { ExtensionRegistry registry = new ExtensionRegistry(); registry.register(provider); registry.enable("tagger"); + ClientTagForwarder.enableIn(registry); return registry; } @Test - void migratedProviderIsEvaluatedOncePerEnvelopeAndNeverThroughTheLegacyMethod() { - Provider provider = new Provider(true, false); + void aProviderIsEvaluatedOncePerEnvelopeAndNeverAtDrain() { + Provider provider = new Provider(false); CapabilityTagRenderer renderer = new CapabilityTagRenderer(registryWith(provider)); OutboundMessage envelope = OutboundMessage.now("n!u@h", "PRIVMSG", "#c", "hi"); for (int i = 0; i < 5; i++) { @@ -108,33 +98,35 @@ void migratedProviderIsEvaluatedOncePerEnvelopeAndNeverThroughTheLegacyMethod() assertThat(prepared.tags().get()).containsExactly(Map.entry("t", "hook")); } assertThat(provider.hookCalls).hasValue(1); - assertThat(provider.legacyCalls).hasValue(0); } @Test - void unmigratedProviderKeepsTheLegacyPath() { - Provider provider = new Provider(false, false); + void aFaultingHookContributesNothingAndLeavesOtherOutputIntact() { + Provider provider = new Provider(true); CapabilityTagRenderer renderer = new CapabilityTagRenderer(registryWith(provider)); - OutboundMessage envelope = OutboundMessage.now("n!u@h", "PRIVMSG", "#c", "hi"); - assertThat(renderer.render(recipient("r"), envelope)).containsExactly(Map.entry("t", "legacy")); - assertThat(provider.hookCalls).hasValue(0); + OutboundMessage envelope = + ForwardedTags.selectedBy( + "message-tags", + OutboundMessage.now("n!u@h", "PRIVMSG", "#c", "hi", Map.of("+keep", "me"))); + ClientSession session = recipient("r"); + session.replaceNegotiatedCapabilities(List.of("tagger", "message-tags")); + assertThat(renderer.render(session, envelope)).containsExactly(Map.entry("+keep", "me")); } @Test - void aFaultingHookContributesNothingAndLeavesOtherOutputIntact() { - Provider provider = new Provider(true, true); - CapabilityTagRenderer renderer = new CapabilityTagRenderer(registryWith(provider)); + void clientTagsWithNoRecordedSelectorAreNotForwardedToAnyone() { + CapabilityTagRenderer renderer = new CapabilityTagRenderer(registryWith(new Provider(false))); OutboundMessage envelope = OutboundMessage.now("n!u@h", "PRIVMSG", "#c", "hi", Map.of("+keep", "me")); ClientSession session = recipient("r"); session.replaceNegotiatedCapabilities(List.of("tagger", "message-tags")); - assertThat(renderer.render(session, envelope)).containsExactly(Map.entry("+keep", "me")); - assertThat(provider.legacyCalls).hasValue(0); + + assertThat(renderer.render(session, envelope)).doesNotContainKey("+keep"); } @Test void disablingWithdrawsNewCapturesButCapturedWorkKeepsItsTags() { - Provider provider = new Provider(true, false); + Provider provider = new Provider(false); ExtensionRegistry registry = registryWith(provider); CapabilityTagRenderer renderer = new CapabilityTagRenderer(registry); OutboundMessage envelope = OutboundMessage.now("n!u@h", "PRIVMSG", "#c", "hi"); @@ -168,7 +160,7 @@ void hooksThatAreIndividuallyValidButOverflowTogetherLoseTheLaterOneNotTheMessag } private static CapabilityExtension bulky(String id, String key) { - return new Provider(true, false) { + return new Provider(false) { @Override public String id() { return id; @@ -187,30 +179,14 @@ public void register(CapabilityRegistrar registrar) { } @Test - void aTypedContributionThatOverflowsWithALegacyOneIsDroppedNotTheMessage() { + void aContributionThatOverflowsAlongsideAnotherIsDroppedNotTheMessage() { ExtensionRegistry registry = new ExtensionRegistry(); - registry.register( - new Provider(false, false) { - @Override - public String id() { - return "legacy"; - } - - @Override - public Capability providedCapability() { - return new Capability("legacy"); - } - - @Override - public Map contributeTags(OutboundMessage message) { - return Map.of("msgid", "m".repeat(2000)); - } - }); - registry.register(bulky("typed", "t")); - registry.enable("legacy"); - registry.enable("typed"); + registry.register(typed("first", "msgid", () -> Map.of("msgid", "m".repeat(2000)))); + registry.register(bulky("second", "t")); + registry.enable("first"); + registry.enable("second"); ClientSession session = new ClientSession("r", "host", RateLimitBucket.withDefaults()); - session.replaceNegotiatedCapabilities(List.of("legacy", "typed")); + session.replaceNegotiatedCapabilities(List.of("first", "second")); java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); try (SessionWriter writer = new SessionWriter(session, out, new CapabilityTagRenderer(registry), 1 << 20)) { @@ -222,21 +198,13 @@ public Map contributeTags(OutboundMessage message) { .doesNotContain("t=x"); } - private static Provider legacy(String id, java.util.function.Supplier> tags) { - return new Provider(false, false) { - @Override - public String id() { - return id; - } - - @Override - public Capability providedCapability() { - return new Capability(id); - } - + /** A provider whose single owned tag comes from {@code tags}, declared at the widest bound. */ + private static AbstractCapabilityExtension typed( + String id, String key, java.util.function.Supplier> tags) { + return new AbstractCapabilityExtension(id) { @Override - public Map contributeTags(OutboundMessage message) { - return tags.get(); + public void register(CapabilityRegistrar registrar) { + registrar.onOutboundTags(Set.of(key), 4094, message -> tags.get()); } }; } @@ -253,16 +221,17 @@ private static String wireOf(ExtensionRegistry registry, List negotiated } @Test - void aLegacyProviderThatThrowsOrReturnsNullContributesNothingAndTheMessageStillGoesOut() { + void aProviderThatThrowsOrReturnsNullContributesNothingAndTheMessageStillGoesOut() { ExtensionRegistry registry = new ExtensionRegistry(); registry.register( - legacy( + typed( "boom", + "a", () -> { throw new IllegalStateException("boom"); })); - registry.register(legacy("nul", () -> null)); - registry.register(legacy("fine", () -> Map.of("msgid", "ok"))); + registry.register(typed("nul", "b", () -> null)); + registry.register(typed("fine", "msgid", () -> Map.of("msgid", "ok"))); registry.enable("boom"); registry.enable("nul"); registry.enable("fine"); @@ -271,11 +240,11 @@ void aLegacyProviderThatThrowsOrReturnsNullContributesNothingAndTheMessageStillG } @Test - void oversizedLegacyTagsAreOmittedAtEnqueueNotAtDrainSoTheMessageIsNotDropped() { + void oversizedTagsAreOmittedAtEnqueueNotAtDrainSoTheMessageIsNotDropped() { ExtensionRegistry registry = new ExtensionRegistry(); - registry.register(legacy("huge", () -> Map.of("msgid", "m".repeat(4100)))); - registry.register(legacy("one", () -> Map.of("a", "x".repeat(2500)))); - registry.register(legacy("two", () -> Map.of("b", "y".repeat(2500)))); + registry.register(typed("huge", "msgid", () -> Map.of("msgid", "m".repeat(4100)))); + registry.register(typed("one", "a", () -> Map.of("a", "x".repeat(2500)))); + registry.register(typed("two", "b", () -> Map.of("b", "y".repeat(2500)))); registry.enable("huge"); registry.enable("one"); registry.enable("two"); @@ -286,24 +255,14 @@ void oversizedLegacyTagsAreOmittedAtEnqueueNotAtDrainSoTheMessageIsNotDropped() } @Test - void legacySelectionFollowsTheCapturedSnapshotNotTheLiveEnabledState() throws Exception { + void selectionFollowsTheCapturedSnapshotNotTheLiveEnabledState() throws Exception { java.util.concurrent.CountDownLatch stopping = new java.util.concurrent.CountDownLatch(1); java.util.concurrent.CountDownLatch release = new java.util.concurrent.CountDownLatch(1); - Provider slow = - new Provider(false, false) { - @Override - public String id() { - return "slow"; - } - - @Override - public Capability providedCapability() { - return new Capability("slow"); - } - + AbstractCapabilityExtension slow = + new AbstractCapabilityExtension("slow") { @Override - public Map contributeTags(OutboundMessage message) { - return Map.of("t", "legacy"); + public void register(CapabilityRegistrar registrar) { + registrar.onOutboundTags(Set.of("t"), 16, message -> Map.of("t", "v")); } @Override diff --git a/jircd-core/src/test/java/net/jircd/core/session/ChannelRegistryTest.java b/jircd-core/src/test/java/net/jircd/core/session/ChannelRegistryTest.java index 63b0f12..3e50e6f 100644 --- a/jircd-core/src/test/java/net/jircd/core/session/ChannelRegistryTest.java +++ b/jircd-core/src/test/java/net/jircd/core/session/ChannelRegistryTest.java @@ -283,6 +283,16 @@ void twoKickHandlersForOneTargetBroadcastOnlyOneDeparture() throws Exception { assertThat(replies.toString(StandardCharsets.UTF_8)).contains(" 441 operator target "); } + /** The ordinary rendering of a join's member facts: nickname with the highest status. */ + private static List listing(JoinResult result) { + return result.members().stream() + .map( + fact -> + (fact.heldStatuses().isEmpty() ? "" : fact.heldStatuses().getFirst()) + + fact.member().nickname()) + .toList(); + } + @Test void joinCapturesRecipientsAndNamesAndSkipsAdmissionForAnExistingMember() { ChannelRegistry registry = new ChannelRegistry(); @@ -293,11 +303,11 @@ void joinCapturesRecipientsAndNamesAndSkipsAdmissionForAnExistingMember() { assertThat(first.grantedOperator()).isTrue(); assertThat(first.alreadyMember()).isFalse(); assertThat(first.recipients()).containsExactly(alice); - assertThat(first.memberListing()).containsExactly("@alice"); + assertThat(listing(first)).containsExactly("@alice"); assertThat(second.channel()).isSameAs(first.channel()); assertThat(second.grantedOperator()).isFalse(); assertThat(second.recipients()).containsExactlyInAnyOrder(alice, bob); - assertThat(second.memberListing()).containsExactlyInAnyOrder("@alice", "bob"); + assertThat(listing(second)).containsExactlyInAnyOrder("@alice", "bob"); JoinResult repeated = registry .join( @@ -312,9 +322,9 @@ void joinCapturesRecipientsAndNamesAndSkipsAdmissionForAnExistingMember() { assertThat(repeated.recipients()).containsExactlyInAnyOrder(alice, bob); assertThat(alice.channelMemberships()).containsExactly(first.channel()); Set joinRecipients = first.recipients(); - List joinListing = first.memberListing(); + List joinMembers = first.members(); assertThatThrownBy(joinRecipients::clear).isInstanceOf(UnsupportedOperationException.class); - assertThatThrownBy(joinListing::clear).isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(joinMembers::clear).isInstanceOf(UnsupportedOperationException.class); } @Test @@ -624,7 +634,7 @@ void joinerKickedBeforeTheSnapshotIsStillARecipient() throws Exception { } JoinResult result = join.get(5, TimeUnit.SECONDS).orElseThrow(); assertThat(result.recipients()).contains(joiner, operator); - assertThat(result.memberListing()).containsExactly("@operator"); + assertThat(listing(result)).containsExactly("@operator"); } } diff --git a/jircd-core/src/test/java/net/jircd/core/session/ChannelTest.java b/jircd-core/src/test/java/net/jircd/core/session/ChannelTest.java index 863adcf..5cbcc06 100644 --- a/jircd-core/src/test/java/net/jircd/core/session/ChannelTest.java +++ b/jircd-core/src/test/java/net/jircd/core/session/ChannelTest.java @@ -17,6 +17,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.groups.Tuple.tuple; import java.time.Instant; import java.util.List; @@ -119,7 +120,7 @@ void memberListingUsesOperatorBeforeVoiceAndListsEachMemberOnceInIterationOrder( } @Test - void multiPrefixListingHoldsEveryStatusInPrefixOrder() { + void memberFactsHoldEveryStatusInPrefixOrderWithoutAnyCapabilityFlag() { Channel channel = new Channel("#lobby"); ClientSession both = member("both"); ClientSession voiced = member("voiced"); @@ -131,11 +132,15 @@ void multiPrefixListingHoldsEveryStatusInPrefixOrder() { channel.grantVoice(voiced); channel.grantOperator(operator); - assertThat(channel.memberListing(true)) - .containsExactlyInAnyOrder("@+both", "+voiced", "plain", "@operator"); - assertThat(channel.memberListing(false)) + assertThat(channel.memberFacts()) + .extracting(fact -> fact.member().nickname(), MemberFacts::heldStatuses) + .containsExactlyInAnyOrder( + tuple("both", List.of("@", "+")), + tuple("voiced", List.of("+")), + tuple("plain", List.of()), + tuple("operator", List.of("@"))); + assertThat(channel.memberListing()) .containsExactlyInAnyOrder("@both", "+voiced", "plain", "@operator"); - assertThat(channel.memberListing()).isEqualTo(channel.memberListing(false)); } /** @@ -146,7 +151,7 @@ void multiPrefixListingHoldsEveryStatusInPrefixOrder() { * appeared in the reads made. */ @Test - void multiPrefixNeverShowsAStatusCombinationTheMemberDidNotHold() throws Exception { + void heldStatusesNeverShowACombinationTheMemberDidNotHold() throws Exception { Channel channel = new Channel("#lobby"); ClientSession anchor = member("anchor"); ClientSession flipper = member("flipper"); @@ -166,9 +171,11 @@ void multiPrefixNeverShowsAStatusCombinationTheMemberDidNotHold() throws Excepti } }); try { + int impossible = 0; for (int i = 0; i < 2_000_000; i++) { - assertThat(channel.statusPrefixes(flipper, true)).isNotEqualTo("@+"); + if (channel.heldStatuses(flipper).size() > 1) impossible++; } + assertThat(impossible).isZero(); } finally { running.set(false); writer.get(5, TimeUnit.SECONDS); @@ -177,10 +184,10 @@ void multiPrefixNeverShowsAStatusCombinationTheMemberDidNotHold() throws Excepti } @Test - void statusPrefixesIsEmptyForANonMember() { + void heldStatusesIsEmptyForANonMember() { Channel channel = new Channel("#lobby"); - assertThat(channel.statusPrefixes(member("stranger"), true)).isEmpty(); + assertThat(channel.heldStatuses(member("stranger"))).isEmpty(); } @Test diff --git a/jircd-core/src/test/java/net/jircd/core/session/ClientTagForwarder.java b/jircd-core/src/test/java/net/jircd/core/session/ClientTagForwarder.java new file mode 100644 index 0000000..d1d7a35 --- /dev/null +++ b/jircd-core/src/test/java/net/jircd/core/session/ClientTagForwarder.java @@ -0,0 +1,51 @@ +/* + * Copyright 2026 Guillermo Castro + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package net.jircd.core.session; + +import java.util.LinkedHashMap; +import java.util.Map; +import net.jircd.core.extension.AbstractCapabilityExtension; +import net.jircd.core.extension.capability.CapabilityRegistrar; +import net.jircd.core.extension.capability.InboundTagResult; + +/** + * Stand-in for message-tags, which lives in a module core cannot depend on: forwards every + * client-only tag to recipients that negotiated {@code message-tags}. + */ +final class ClientTagForwarder extends AbstractCapabilityExtension { + ClientTagForwarder() { + super("message-tags"); + } + + @Override + public void register(CapabilityRegistrar registrar) { + registrar.onInboundTags( + (tags, context) -> { + Map forwarded = new LinkedHashMap<>(); + tags.tags() + .forEach( + (key, value) -> { + if (key.startsWith("+")) forwarded.put(key, value); + }); + return new InboundTagResult(forwarded, Map.of()); + }); + } + + static void enableIn(net.jircd.core.extension.ExtensionRegistry registry) { + registry.register(new ClientTagForwarder()); + registry.enable("message-tags"); + } +} diff --git a/jircd-core/src/test/java/net/jircd/core/session/CommittedEventTest.java b/jircd-core/src/test/java/net/jircd/core/session/CommittedEventTest.java new file mode 100644 index 0000000..5eae151 --- /dev/null +++ b/jircd-core/src/test/java/net/jircd/core/session/CommittedEventTest.java @@ -0,0 +1,461 @@ +/* + * Copyright 2026 Guillermo Castro + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package net.jircd.core.session; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import net.jircd.core.extension.AbstractCapabilityExtension; +import net.jircd.core.extension.ExtensionRegistry; +import net.jircd.core.extension.HostnameDisplayExtension; +import net.jircd.core.extension.ServerExtension; +import net.jircd.core.extension.capability.CapabilityRegistrar; +import net.jircd.core.extension.capability.CommittedEvent; +import net.jircd.core.extension.capability.EventIntent; +import net.jircd.core.extension.capability.EventKind; +import net.jircd.core.session.command.AwayCommandHandler; +import net.jircd.core.session.command.JoinCommandHandler; +import net.jircd.core.session.command.NickCommandHandler; +import net.jircd.core.session.command.PartCommandHandler; +import net.jircd.core.session.command.RegistrationCompletion; +import net.jircd.protocol.Command; +import net.jircd.protocol.Message; +import net.jircd.protocol.MessageParser; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** Committed facts reach listeners once per accepted transition, after the state has changed. */ +class CommittedEventTest { + + /** Records every event, and optionally throws or answers with a notice to the whole audience. */ + private static final class Spy extends AbstractCapabilityExtension { + final List events = Collections.synchronizedList(new ArrayList<>()); + final Map> audiences = new ConcurrentHashMap<>(); + final boolean failing; + final AtomicInteger calls = new AtomicInteger(); + + Spy(String id, boolean failing) { + super(id); + this.failing = failing; + } + + @Override + public void register(CapabilityRegistrar registrar) { + for (EventKind kind : EventKind.values()) { + registrar.onEvent( + kind, + event -> { + calls.incrementAndGet(); + events.add(event); + audiences.put(event, event.audience().peers()); + if (failing) throw new IllegalStateException("listener fault"); + return List.of(); + }); + } + } + } + + private final NicknameRegistry nicknames = new NicknameRegistry(); + private final ChannelRegistry channels = new ChannelRegistry(); + private final ExtensionRegistry extensions = new ExtensionRegistry(); + private final List writers = new ArrayList<>(); + private final Map outputs = new HashMap<>(); + private final JoinCommandHandler join = + new JoinCommandHandler(channels, extensions, () -> "server", () -> 50); + private final PartCommandHandler part = + new PartCommandHandler(channels, extensions, () -> "server"); + private final AwayCommandHandler away = new AwayCommandHandler(() -> "server", extensions); + private final NickCommandHandler nick = + new NickCommandHandler( + nicknames, + () -> "server", + () -> 30, + new RegistrationCompletion( + () -> "server", "test", Instant.now(), extensions, new UserStatistics()), + extensions); + private final DisconnectCleanup cleanup = + new DisconnectCleanup(nicknames, channels, new WhowasHistory(() -> 10), extensions); + private Spy spy; + + @BeforeEach + void listen() { + spy = new Spy("spy", false); + extensions.register(spy); + extensions.enable("spy"); + } + + @AfterEach + void closeWriters() { + writers.forEach(SessionWriter::close); + } + + private ClientSession user(String nick) { + ClientSession session = new ClientSession(nick, "host.example", RateLimitBucket.withDefaults()); + session.setNickname(nick); + session.setIdent("ident"); + session.setRealname(nick); + assertThat(session.lifecycle().completeRegistration()).isTrue(); + nicknames.claim(nick, session); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + outputs.put(session, out); + @SuppressWarnings("PMD.CloseResource") // closed by closeWriters() + SessionWriter writer = new SessionWriter(session, out, TagRenderer.NONE); + writers.add(writer); + session.attachWriter(writer); + return session; + } + + private void run( + net.jircd.core.session.command.CommandHandler handler, ClientSession s, String line) + throws Exception { + handler.handle(s, MessageParser.parse(line)); + } + + private List events(EventKind kind) { + synchronized (spy.events) { + return spy.events.stream().filter(e -> e.kind() == kind).toList(); + } + } + + // --- AWAY --- + + @Test + void everyAcceptedAwayChangeIsAnEventIncludingARepeatedReason() throws Exception { + ClientSession alice = user("alice"); + + run(away, alice, "AWAY :lunch"); + run(away, alice, "AWAY :lunch"); + run(away, alice, "AWAY :dinner"); + run(away, alice, "AWAY"); + + List awayEvents = events(EventKind.AWAY_CHANGED); + assertThat(awayEvents) + .extracting(CommittedEvent::before, CommittedEvent::after) + .containsExactly( + org.assertj.core.groups.Tuple.tuple("", "lunch"), + org.assertj.core.groups.Tuple.tuple("lunch", "lunch"), + org.assertj.core.groups.Tuple.tuple("lunch", "dinner"), + org.assertj.core.groups.Tuple.tuple("dinner", "")); + assertThat(awayEvents) + .allSatisfy(e -> assertThat(e.subject()).isEqualTo("alice!ident@host.example")); + assertThat(awayEvents).extracting(CommittedEvent::operationId).doesNotHaveDuplicates(); + } + + @Test + void theAudienceIsSharedChannelPeersOnceEachWithoutTheSubject() throws Exception { + ClientSession alice = user("alice"); + ClientSession bob = user("bob"); + ClientSession carol = user("carol"); + for (String channel : List.of("#a", "#b")) { + run(join, alice, "JOIN " + channel); + run(join, bob, "JOIN " + channel); + } + run(join, carol, "JOIN #b"); + run(join, user("loner"), "JOIN #c"); + + run(away, alice, "AWAY :x"); + + CommittedEvent event = events(EventKind.AWAY_CHANGED).getFirst(); + assertThat(spy.audiences.get(event)).containsExactlyInAnyOrder(bob, carol); + } + + @Test + void theSubjectIsThePresentedIdentityNotTheRealHostname() throws Exception { + extensions.register(new TestCloak()); + extensions.enable("test-cloak"); + ClientSession alice = user("alice"); + + run(away, alice, "AWAY :x"); + + assertThat(events(EventKind.AWAY_CHANGED).getFirst().subject()) + .isEqualTo("alice!ident@cloak.example"); + } + + // --- JOIN / PART --- + + @Test + void aJoinIsAnEventWithItsMembersButARepeatedJoinIsNot() throws Exception { + ClientSession alice = user("alice"); + ClientSession bob = user("bob"); + run(join, alice, "JOIN #c"); + + run(join, bob, "JOIN #c"); + run(join, bob, "JOIN #c"); + + List joins = events(EventKind.JOINED); + assertThat(joins).hasSize(2); + CommittedEvent second = joins.get(1); + assertThat(second.subject()).isEqualTo("bob!ident@host.example"); + assertThat(second.after()).isEqualTo("#c"); + assertThat(spy.audiences.get(second)).containsExactly(alice); + } + + @Test + void aRejectedJoinPublishesNothing() throws Exception { + ClientSession alice = user("alice"); + ClientSession bob = user("bob"); + run(join, alice, "JOIN #c"); + channels.lookup("#c").orElseThrow().setKey("secret"); + + run(join, bob, "JOIN #c wrong"); + + assertThat(events(EventKind.JOINED)).hasSize(1); + } + + @Test + void aPartCarriesTheAudienceCapturedBeforeTheDeparture() throws Exception { + ClientSession alice = user("alice"); + ClientSession bob = user("bob"); + run(join, alice, "JOIN #c"); + run(join, bob, "JOIN #c"); + + run(part, bob, "PART #c :bye"); + run(part, bob, "PART #c :again"); + + List parts = events(EventKind.PARTED); + assertThat(parts).hasSize(1); + assertThat(parts.getFirst().before()).isEqualTo("#c"); + assertThat(parts.getFirst().after()).isEqualTo("bye"); + assertThat(spy.audiences.get(parts.getFirst())).containsExactly(alice); + assertThat(channels.lookup("#c").orElseThrow().members()).doesNotContain(bob); + } + + // --- NICK --- + + @Test + void aNickChangeCarriesTheOldIdentityAndThePeers() throws Exception { + ClientSession alice = user("alice"); + ClientSession bob = user("bob"); + run(join, alice, "JOIN #c"); + run(join, bob, "JOIN #c"); + + run(nick, alice, "NICK alicia"); + run(nick, alice, "NICK alicia"); // not a change + run(nick, alice, "NICK bob"); // rejected: in use + + List nicks = events(EventKind.NICK_CHANGED); + assertThat(nicks).hasSize(1); + CommittedEvent change = nicks.getFirst(); + assertThat(change.subject()).isEqualTo("alice!ident@host.example"); + assertThat(change.before()).isEqualTo("alice"); + assertThat(change.after()).isEqualTo("alicia"); + assertThat(spy.audiences.get(change)).containsExactly(bob); + assertThat(alice.nickname()).isEqualTo("alicia"); + } + + // --- QUIT --- + + @Test + void aQuitIsPublishedOnceWithTheNeighborsAndCleanupIsIdempotent() throws Exception { + ClientSession alice = user("alice"); + ClientSession bob = user("bob"); + run(join, alice, "JOIN #c"); + run(join, alice, "JOIN #d"); + run(join, bob, "JOIN #c"); + run(join, bob, "JOIN #d"); + + cleanup.cleanup(bob, "Quit: gone"); + cleanup.cleanup(bob, "second trigger"); + + List quits = events(EventKind.QUIT); + assertThat(quits).hasSize(1); + assertThat(quits.getFirst().subject()).isEqualTo("bob!ident@host.example"); + assertThat(quits.getFirst().after()).isEqualTo("Quit: gone"); + assertThat(spy.audiences.get(quits.getFirst())).containsExactly(alice); + } + + @Test + void aSessionThatNeverRegisteredAnIdentityPublishesNoQuit() { + ClientSession anonymous = + new ClientSession("anon", "host.example", RateLimitBucket.withDefaults()); + + cleanup.cleanup(anonymous, "timeout"); + + assertThat(events(EventKind.QUIT)).isEmpty(); + } + + // --- listener faults --- + + @Test + void committedStateStandsAndLaterEventsStillFlowAfterAListenerFault() throws Exception { + extensions.disable("spy"); + Spy faulty = new Spy("faulty", true); + extensions.register(faulty); + extensions.enable("faulty"); + spy = faulty; + ClientSession alice = user("alice"); + ClientSession bob = user("bob"); + + run(join, alice, "JOIN #c"); + run(join, bob, "JOIN #c"); + run(away, alice, "AWAY :x"); + run(nick, alice, "NICK alicia"); + cleanup.cleanup(bob, "bye"); + + assertThat(channels.lookup("#c").orElseThrow().members()).containsExactly(alice); + assertThat(alice.awayReason()).isEqualTo("x"); + assertThat(alice.nickname()).isEqualTo("alicia"); + assertThat(nicknames.lookup("bob")).isEmpty(); + assertThat(faulty.calls.get()).isGreaterThanOrEqualTo(5); + } + + @Test + void anUnlistenedKindResolvesNeitherTheSubjectNorTheAudience() { + extensions.disable("spy"); + AbstractCapabilityExtension awayOnly = + new AbstractCapabilityExtension("only-away") { + @Override + public void register(CapabilityRegistrar registrar) { + registrar.onEvent(EventKind.AWAY_CHANGED, event -> List.of()); + } + }; + extensions.register(awayOnly); + extensions.enable("only-away"); + AtomicInteger resolved = new AtomicInteger(); + + extensions + .committedEvents() + .publish( + EventKind.JOINED, + () -> { + resolved.incrementAndGet(); + return "alice!ident@host.example"; + }, + "", + "#c", + () -> { + resolved.incrementAndGet(); + return List.of(); + }); + + assertThat(resolved).hasValue(0); + } + + @Test + void aFailingPresentedIdentityNeverCostsTheCommandItsRemainingReplies() throws Exception { + extensions.register(new ThrowingCloak()); + extensions.enable("throwing-cloak"); + ClientSession alice = user("alice"); + + // The confirmation precedes the event and the state is set before it: neither is lost. + run(away, alice, "AWAY :x"); + + assertThat(alice.awayReason()).isEqualTo("x"); + writers.forEach(SessionWriter::close); + assertThat(outputs.get(alice).toString(StandardCharsets.UTF_8)).contains(" 306 "); + assertThat(events(EventKind.AWAY_CHANGED)).isEmpty(); + } + + // --- listener-driven delivery --- + + @Test + void aListenerIntentIsDeliveredOnlyToItsPermittedPeers() throws Exception { + extensions.disable("spy"); + AbstractCapabilityExtension notifier = + new AbstractCapabilityExtension("notifier") { + @Override + public void register(CapabilityRegistrar registrar) { + registrar.onEvent( + EventKind.AWAY_CHANGED, + event -> + List.of( + new EventIntent( + new Message( + Map.of(), event.subject(), Command.AWAY, "AWAY", List.of("seen")), + event.audience().peers()))); + } + }; + extensions.register(notifier); + extensions.enable("notifier"); + ClientSession alice = user("alice"); + ClientSession listening = user("listening"); + ClientSession deaf = user("deaf"); + listening.replaceNegotiatedCapabilities(List.of("notifier")); + for (ClientSession member : List.of(alice, listening, deaf)) run(join, member, "JOIN #c"); + + run(away, alice, "AWAY :x"); + writers.forEach(SessionWriter::close); + + assertThat(outputs.get(listening).toString(StandardCharsets.UTF_8)) + .contains(":alice!ident@host.example AWAY seen\r\n"); + assertThat(outputs.get(deaf).toString(StandardCharsets.UTF_8)).doesNotContain(" AWAY "); + assertThat(outputs.get(alice).toString(StandardCharsets.UTF_8)).doesNotContain(" AWAY seen"); + } + + private static final class ThrowingCloak implements ServerExtension, HostnameDisplayExtension { + @Override + public String id() { + return "throwing-cloak"; + } + + @Override + public String extensionPoint() { + return "hostname-display"; + } + + @Override + public String display(String realHostname) { + throw new IllegalStateException("cloak failure"); + } + + @Override + public void start(net.jircd.core.extension.ServerContext context) { + // No resources. + } + + @Override + public void stop() { + // No resources. + } + } + + private static final class TestCloak implements ServerExtension, HostnameDisplayExtension { + @Override + public String id() { + return "test-cloak"; + } + + @Override + public String extensionPoint() { + return "hostname-display"; + } + + @Override + public String display(String realHostname) { + return "cloak.example"; + } + + @Override + public void start(net.jircd.core.extension.ServerContext context) { + // No resources. + } + + @Override + public void stop() { + // No resources. + } + } +} diff --git a/jircd-core/src/test/java/net/jircd/core/session/ForwardedTags.java b/jircd-core/src/test/java/net/jircd/core/session/ForwardedTags.java new file mode 100644 index 0000000..957f8bb --- /dev/null +++ b/jircd-core/src/test/java/net/jircd/core/session/ForwardedTags.java @@ -0,0 +1,33 @@ +/* + * Copyright 2026 Guillermo Castro + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package net.jircd.core.session; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +/** Test helper: records {@code owner} as the selector of every client-only tag of an envelope. */ +final class ForwardedTags { + private ForwardedTags() {} + + static OutboundMessage selectedBy(String owner, OutboundMessage message) { + Map> owners = new LinkedHashMap<>(); + message.clientTags().keySet().stream() + .filter(key -> key.startsWith("+")) + .forEach(key -> owners.put(key, Set.of(owner))); + return message.forwardedBy(owners); + } +} diff --git a/jircd-core/src/test/java/net/jircd/core/session/MemberReplyRenderingTest.java b/jircd-core/src/test/java/net/jircd/core/session/MemberReplyRenderingTest.java new file mode 100644 index 0000000..818412d --- /dev/null +++ b/jircd-core/src/test/java/net/jircd/core/session/MemberReplyRenderingTest.java @@ -0,0 +1,200 @@ +/* + * Copyright 2026 Guillermo Castro + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package net.jircd.core.session; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Set; +import net.jircd.core.extension.AbstractCapabilityExtension; +import net.jircd.core.extension.ExtensionRegistry; +import net.jircd.core.extension.capability.CapabilityRegistrar; +import net.jircd.core.extension.capability.MemberSnapshot; +import org.junit.jupiter.api.Test; + +/** Member facts carry no capability flag; one rendering service applies the requester's hooks. */ +class MemberReplyRenderingTest { + + /** Replaces the status slot with every held symbol, as multi-prefix does. */ + private static final class AllStatuses extends AbstractCapabilityExtension { + AllStatuses() { + super("all-statuses"); + } + + @Override + public void register(CapabilityRegistrar registrar) { + registrar.onMemberStatus(limits -> 2, member -> String.join("", member.heldStatuses())); + } + } + + /** Replaces the NAMES identity with a full hostmask, independently of the status slot. */ + private static final class FullIdentity extends AbstractCapabilityExtension { + FullIdentity() { + super("full-identity"); + } + + @Override + public void register(CapabilityRegistrar registrar) { + registrar.onNamesIdentity( + limits -> 200, + member -> member.nickname() + "!" + member.ident() + "@" + member.displayHost()); + } + } + + private final ExtensionRegistry extensions = new ExtensionRegistry(); + private final MemberReplies replies = new MemberReplies(extensions); + private final Channel channel = new Channel("#c"); + + private static ClientSession user(String nick) { + ClientSession session = new ClientSession(nick, "host.example", RateLimitBucket.withDefaults()); + session.setNickname(nick); + session.setIdent("ident"); + return session; + } + + private ClientSession requester(String... negotiated) { + ClientSession requester = user("requester"); + requester.replaceNegotiatedCapabilities(List.of(negotiated)); + return requester; + } + + private Channel populated() { + ClientSession op = user("op"); + ClientSession both = user("both"); + ClientSession voice = user("voice"); + ClientSession plain = user("plain"); + for (ClientSession member : List.of(op, both, voice, plain)) { + channel.addMember(member); + member.addMembership(channel); + } + channel.grantOperator(both); + channel.grantVoice(both); + channel.grantVoice(voice); + return channel; + } + + @Test + void factsListEveryHeldStatusHighestFirstWithoutAnyCapabilityFlag() { + populated(); + + assertThat(channel.memberFacts()) + .extracting(fact -> fact.member().nickname(), MemberFacts::heldStatuses) + .containsExactlyInAnyOrder( + org.assertj.core.groups.Tuple.tuple("op", List.of("@")), + org.assertj.core.groups.Tuple.tuple("both", List.of("@", "+")), + org.assertj.core.groups.Tuple.tuple("voice", List.of("+")), + org.assertj.core.groups.Tuple.tuple("plain", List.of())); + } + + @Test + void withoutAProviderEveryRequesterGetsTheHighestStatusOnly() { + populated(); + + assertThat(replies.namesTokens(requester("multi-prefix"), channel.memberFacts())) + .containsExactlyInAnyOrder("@op", "@both", "+voice", "plain"); + assertThat(channel.memberListing()) + .containsExactlyInAnyOrder("@op", "@both", "+voice", "plain"); + } + + @Test + void aStatusProviderReplacesTheSlotOnlyForRequestersThatNegotiatedIt() { + populated(); + extensions.register(new AllStatuses()); + extensions.enable("all-statuses"); + + assertThat(replies.namesTokens(requester("all-statuses"), channel.memberFacts())) + .containsExactlyInAnyOrder("@op", "@+both", "+voice", "plain"); + assertThat(replies.namesTokens(requester(), channel.memberFacts())) + .containsExactlyInAnyOrder("@op", "@both", "+voice", "plain"); + } + + @Test + void whoFlagsAppendTheStatusSlotOnly() { + populated(); + extensions.register(new AllStatuses()); + extensions.enable("all-statuses"); + ClientSession both = channel.findMember("both").orElseThrow(); + + assertThat(replies.whoStatus(requester("all-statuses"), channel.memberFacts(both))) + .isEqualTo("@+"); + assertThat(replies.whoStatus(requester(), channel.memberFacts(both))).isEqualTo("@"); + assertThat(replies.whoStatus(requester(), channel.memberFacts(user("stranger")))).isEmpty(); + } + + @Test + void identityAndStatusSlotsComposeIndependently() { + populated(); + extensions.register(new AllStatuses()); + extensions.enable("all-statuses"); + extensions.register(new FullIdentity()); + extensions.enable("full-identity"); + + assertThat( + replies.namesTokens(requester("all-statuses", "full-identity"), channel.memberFacts())) + .containsExactlyInAnyOrder( + "@op!ident@host.example", + "@+both!ident@host.example", + "+voice!ident@host.example", + "plain!ident@host.example"); + assertThat(replies.namesTokens(requester("full-identity"), channel.memberFacts())) + .containsExactlyInAnyOrder( + "@op!ident@host.example", + "@both!ident@host.example", + "+voice!ident@host.example", + "plain!ident@host.example"); + } + + @Test + void aProviderThatFaultsLeavesTheCoreDefault() { + populated(); + extensions.register( + new AbstractCapabilityExtension("broken") { + @Override + public void register(CapabilityRegistrar registrar) { + registrar.onMemberStatus(limits -> 2, member -> "#"); + } + }); + extensions.enable("broken"); + + assertThat(replies.namesTokens(requester("broken"), channel.memberFacts())) + .containsExactlyInAnyOrder("@op", "@both", "+voice", "plain"); + } + + @Test + void identityFactsAreOnlyResolvedWhenAProviderNeedsThem() { + populated(); + java.util.concurrent.atomic.AtomicReference seen = + new java.util.concurrent.atomic.AtomicReference<>(); + extensions.register( + new AbstractCapabilityExtension("peek") { + @Override + public void register(CapabilityRegistrar registrar) { + registrar.onMemberStatus( + limits -> 2, + member -> { + seen.set(member); + return member.highestStatus(); + }); + } + }); + extensions.enable("peek"); + + replies.namesTokens(requester("peek"), channel.memberFacts()); + + assertThat(seen.get().displayHost()).isEmpty(); + assertThat(Set.of(seen.get().nickname())).isNotEmpty(); + } +} diff --git a/jircd-core/src/test/java/net/jircd/core/session/MessageDeliveryTest.java b/jircd-core/src/test/java/net/jircd/core/session/MessageDeliveryTest.java new file mode 100644 index 0000000..b584c52 --- /dev/null +++ b/jircd-core/src/test/java/net/jircd/core/session/MessageDeliveryTest.java @@ -0,0 +1,558 @@ +/* + * Copyright 2026 Guillermo Castro + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package net.jircd.core.session; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import net.jircd.core.extension.AbstractCapabilityExtension; +import net.jircd.core.extension.ExtensionRegistry; +import net.jircd.core.extension.capability.CapabilityRegistrar; +import net.jircd.core.extension.capability.DeliveryRole; +import net.jircd.core.extension.capability.InboundTagResult; +import net.jircd.core.extension.capability.RequestContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** The shared target and send gates, role merging and per-envelope identity of every message. */ +class MessageDeliveryTest { + + /** Sender-copy and request-correlation stand-in for providers that live outside core. */ + private static final class Provider extends AbstractCapabilityExtension { + final AtomicInteger recipientCalls = new AtomicInteger(); + final AtomicInteger inboundCalls = new AtomicInteger(); + final AtomicReference seenContext = new AtomicReference<>(); + + Provider() { + super("copy"); + } + + @Override + public void register(CapabilityRegistrar registrar) { + registrar.onInboundTags( + (tags, context) -> { + inboundCalls.incrementAndGet(); + Map forwarded = new LinkedHashMap<>(); + tags.tags().entrySet().stream() + .filter(e -> e.getKey().startsWith("+")) + .forEach(e -> forwarded.put(e.getKey(), e.getValue())); + return new InboundTagResult(forwarded, Map.of("copy/label", "L1")); + }); + registrar.onRecipients( + (intent, context) -> { + recipientCalls.incrementAndGet(); + seenContext.set(context); + return Set.of(DeliveryRole.SENDER_COPY); + }); + registrar.onOutboundTags( + Set.of("msgid"), 42, message -> Map.of("msgid", message.messageId().toString())); + registrar.onOutboundTags( + Set.of("time"), 64, message -> Map.of("time", message.sentAt().toString())); + } + } + + private final NicknameRegistry nicknames = new NicknameRegistry(); + private final ChannelRegistry channels = new ChannelRegistry(); + private final ExtensionRegistry extensions = new ExtensionRegistry(); + private final Map outputs = new LinkedHashMap<>(); + private final List writers = new ArrayList<>(); + private MessageDelivery delivery; + + @BeforeEach + void setUp() { + delivery = new MessageDelivery(channels, nicknames, extensions, () -> "test.jircd.local"); + } + + @AfterEach + void closeWriters() { + writers.forEach(SessionWriter::close); + } + + private ClientSession user(String nick, String... negotiated) { + ClientSession session = new ClientSession(nick, "host.example", RateLimitBucket.withDefaults()); + session.setNickname(nick); + session.setIdent("ident"); + session.replaceNegotiatedCapabilities(List.of(negotiated)); + nicknames.claim(nick, session); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + outputs.put(session, out); + @SuppressWarnings("PMD.CloseResource") // closed by closeWriters() + SessionWriter writer = new SessionWriter(session, out, new CapabilityTagRenderer(extensions)); + writers.add(writer); + session.attachWriter(writer); + return session; + } + + private void join(ClientSession session, String channel) { + channels.join(channel, session, ch -> true); + } + + private String received(ClientSession session) { + writers.forEach(SessionWriter::close); + return outputs.get(session).toString(StandardCharsets.UTF_8); + } + + private void enable(Provider provider) { + extensions.register(provider); + extensions.enable(provider.id()); + } + + // --- target and send gates --- + + @Test + void anUnknownNicknameIsReportedUnlessTheCommandIsSilent() { + ClientSession alice = user("alice"); + + assertThat(delivery.authorize(alice, "ghost", true)).isEmpty(); + assertThat(delivery.authorize(alice, "ghost", false)).isEmpty(); + + assertThat(received(alice)).contains(" 401 alice ghost ").hasLineCount(1); + } + + @Test + void aMissingChannelIsReportedAsCannotSend() { + ClientSession alice = user("alice"); + + assertThat(delivery.authorize(alice, "#nowhere", true)).isEmpty(); + + assertThat(received(alice)).contains(" 404 alice #nowhere "); + } + + @Test + void membersOnlyAndModeratedGatesBlockOutsidersAndUnvoicedMembers() { + ClientSession op = user("op"); + ClientSession outsider = user("outsider"); + ClientSession member = user("member"); + join(op, "#c"); + join(member, "#c"); + Channel channel = channels.lookup("#c").orElseThrow(); + channel.setMode(CoreChannelModes.MEMBERS_ONLY); + + assertThat(delivery.authorize(outsider, "#c", false)).isEmpty(); + assertThat(delivery.authorize(member, "#c", false)).contains(op, member); + + channel.setMode(CoreChannelModes.MODERATED); + assertThat(delivery.authorize(member, "#c", false)).isEmpty(); + assertThat(delivery.authorize(op, "#c", false)).contains(op, member); + channel.grantVoice(member); + assertThat(delivery.authorize(member, "#c", false)).contains(op, member); + } + + @Test + void aBanOnThePresentedIdentityMutesTheSenderUnlessExcepted() { + ClientSession op = user("op"); + ClientSession banned = user("banned"); + join(op, "#c"); + join(banned, "#c"); + Channel channel = channels.lookup("#c").orElseThrow(); + channel.addBan(new BanEntry("banned!*@*", "op", Instant.EPOCH)); + + assertThat(delivery.authorize(banned, "#c", false)).isEmpty(); + assertThat(delivery.authorize(op, "#c", false)).contains(banned); + assertThat(received(banned)).isEmpty(); + } + + // --- authorization cannot be bypassed --- + + @Test + void anAuthorizationForOneTargetCannotDeliverToAnotherOrFromAnotherSender() { + ClientSession alice = user("alice"); + ClientSession bob = user("bob"); + ClientSession mallory = user("mallory"); + join(alice, "#c"); + join(bob, "#c"); + MessageDelivery.AuthorizedTarget forChannel = delivery.authorize(alice, "#c", true); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> + delivery.deliver( + alice, "PRIVMSG", "#does-not-exist", "hi", Map.of(), forChannel, r -> true)) + .isInstanceOf(IllegalArgumentException.class); + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> delivery.deliver(mallory, "PRIVMSG", "#c", "hi", Map.of(), forChannel, r -> true)) + .isInstanceOf(IllegalArgumentException.class); + + assertThat(received(bob)).isEmpty(); + assertThat(received(mallory)).isEmpty(); + } + + @Test + void anAuthorizationFromAnotherDeliveryServiceIsRefused() { + ClientSession alice = user("alice"); + ClientSession bob = user("bob"); + MessageDelivery other = new MessageDelivery(channels, nicknames, extensions, () -> "s"); + + MessageDelivery.AuthorizedTarget foreign = other.authorize(alice, "bob", true); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> delivery.deliver(alice, "PRIVMSG", "bob", "hi", Map.of(), foreign, r -> true)) + .isInstanceOf(IllegalArgumentException.class); + assertThat(received(bob)).isEmpty(); + } + + @Test + void aFailedAuthorizationDeliversNothingEvenWhenDeliveredAnyway() { + ClientSession alice = user("alice"); + ClientSession bob = user("bob"); + MessageDelivery.AuthorizedTarget denied = delivery.authorize(alice, "#does-not-exist", false); + + MessageDelivery.Delivery sent = + delivery.deliver(alice, "PRIVMSG", "#does-not-exist", "hi", Map.of(), denied, r -> true); + + assertThat(denied).isEmpty(); + assertThat(sent.recipients()).isEmpty(); + assertThat(received(bob)).isEmpty(); + } + + // --- recipients and roles --- + + @Test + void aChannelMessageSkipsTheSenderByDefault() { + ClientSession alice = user("alice"); + ClientSession bob = user("bob"); + join(alice, "#c"); + join(bob, "#c"); + + MessageDelivery.Delivery sent = + delivery.deliver( + alice, + "PRIVMSG", + "#c", + "hi", + Map.of(), + delivery.authorize(alice, "#c", true), + r -> true); + + assertThat(sent.recipients()).containsOnlyKeys(bob); + assertThat(sent.recipients().get(bob)).containsExactly(DeliveryRole.ORDINARY_TARGET); + assertThat(received(alice)).isEmpty(); + assertThat(received(bob)).isEqualTo(":alice!ident@host.example PRIVMSG #c hi\r\n"); + } + + @Test + void aDirectMessageToTheSenderIsDeliveredExactlyOnceWithOrWithoutSenderCopy() { + enable(new Provider()); + ClientSession plain = user("plain"); + ClientSession echo = user("echo", "copy"); + + delivery.deliver( + plain, + "PRIVMSG", + "plain", + "me", + Map.of(), + delivery.authorize(plain, "plain", true), + r -> true); + delivery.deliver( + echo, "PRIVMSG", "echo", "me", Map.of(), delivery.authorize(echo, "echo", true), r -> true); + + assertThat(received(plain).lines()).hasSize(1); + assertThat(received(echo).lines()).hasSize(1); + } + + @Test + void senderCopyMergesWithTheOrdinaryRoleAndIsOmittedWithoutTheOwnerNegotiated() { + Provider provider = new Provider(); + enable(provider); + ClientSession echoing = user("echoing", "copy"); + ClientSession silent = user("silent"); + ClientSession bob = user("bob"); + join(echoing, "#c"); + join(silent, "#c"); + join(bob, "#c"); + + MessageDelivery.Delivery withCopy = + delivery.deliver( + echoing, + "PRIVMSG", + "#c", + "a", + Map.of(), + delivery.authorize(echoing, "#c", true), + r -> true); + MessageDelivery.Delivery without = + delivery.deliver( + silent, + "PRIVMSG", + "#c", + "b", + Map.of(), + delivery.authorize(silent, "#c", true), + r -> true); + + assertThat(withCopy.recipients().get(echoing)).containsExactly(DeliveryRole.SENDER_COPY); + assertThat(withCopy.recipients().get(bob)).containsExactly(DeliveryRole.ORDINARY_TARGET); + assertThat(without.recipients()).doesNotContainKey(silent); + assertThat(received(echoing).lines()).hasSize(2); // its own copy and silent's message + } + + @Test + void aDirectMessageToSomeoneElseIsCopiedToTheSenderOnlyWithTheOwner() { + enable(new Provider()); + ClientSession echoing = user("echoing", "copy"); + ClientSession bob = user("bob"); + + MessageDelivery.Delivery sent = + delivery.deliver( + echoing, + "NOTICE", + "bob", + "x", + Map.of(), + delivery.authorize(echoing, "bob", true), + r -> true); + + assertThat(sent.recipients()).containsOnlyKeys(bob, echoing); + assertThat(sent.recipients().get(echoing)).containsExactly(DeliveryRole.SENDER_COPY); + } + + @Test + void theRecipientGateAppliesToTheSenderCopyToo() { + enable(new Provider()); + ClientSession echoing = user("echoing", "copy"); + ClientSession tagged = user("tagged", "tagged"); + join(echoing, "#c"); + join(tagged, "#c"); + + MessageDelivery.Delivery sent = + delivery.deliver( + echoing, + "TAGMSG", + "#c", + null, + Map.of("+x", "y"), + delivery.authorize(echoing, "#c", true), + r -> r.negotiatedCapabilities().contains("tagged")); + + assertThat(sent.recipients()).containsOnlyKeys(tagged); + } + + // --- envelope identity and request correlation --- + + @Test + void everyRecipientSeesTheSameEnvelopeIdentityAndTheHooksRunOncePerDelivery() { + Provider provider = new Provider(); + enable(provider); + ClientSession sender = user("sender", "copy"); + List peers = new ArrayList<>(); + join(sender, "#c"); + for (int i = 0; i < 20; i++) { + ClientSession peer = user("peer" + i, "copy"); + peers.add(peer); + join(peer, "#c"); + } + + MessageDelivery.Delivery sent = + delivery.deliver( + sender, + "PRIVMSG", + "#c", + "hello", + Map.of(), + delivery.authorize(sender, "#c", true), + r -> true); + + String msgid = sent.envelope().messageId().toString(); + String time = sent.envelope().sentAt().toString(); + assertThat(peers) + .isNotEmpty() + .allSatisfy( + peer -> assertThat(received(peer)).contains("msgid=" + msgid).contains("time=" + time)); + assertThat(received(sender)).contains("msgid=" + msgid); + assertThat(provider.inboundCalls).hasValue(1); + assertThat(provider.recipientCalls).hasValue(1); + } + + @Test + void requestContextReachesTheRecipientHookButNeverTheEnvelopeOrPeers() { + Provider provider = new Provider(); + enable(provider); + ClientSession sender = user("sender", "copy"); + ClientSession peer = user("peer", "copy"); + join(sender, "#c"); + join(peer, "#c"); + + MessageDelivery.Delivery sent = + delivery.deliver( + sender, + "PRIVMSG", + "#c", + "hi", + Map.of(), + delivery.authorize(sender, "#c", true), + r -> true); + + assertThat(provider.seenContext.get()).isSameAs(sent.context()); + assertThat(sent.context().attributes()).containsEntry("copy/label", "L1"); + assertThat(received(peer)).doesNotContain("L1").doesNotContain("label"); + assertThat(sent.envelope().clientTags()).doesNotContainKey("copy/label"); + } + + @Test + void eachRequestGetsItsOwnContextId() { + ClientSession alice = user("alice"); + ClientSession bob = user("bob"); + + long first = + delivery + .deliver( + alice, + "PRIVMSG", + "bob", + "1", + Map.of(), + delivery.authorize(alice, "bob", true), + r -> true) + .context() + .requestId(); + long second = + delivery + .deliver( + alice, + "PRIVMSG", + "bob", + "2", + Map.of(), + delivery.authorize(alice, "bob", true), + r -> true) + .context() + .requestId(); + + assertThat(second).isGreaterThan(first); + assertThat(bob).isNotNull(); + } + + @Test + void withoutAnInboundTagProviderNoClientTagIsForwarded() { + ClientSession alice = user("alice"); + user("bob", "message-tags"); + + MessageDelivery.Delivery sent = + delivery.deliver( + alice, + "PRIVMSG", + "bob", + "hi", + Map.of("+typing", "active"), + delivery.authorize(alice, "bob", true), + r -> true); + + assertThat(sent.envelope().clientTags()).isEmpty(); + } + + /** Selects client-only tags, as message-tags does; named so a recipient can negotiate it. */ + private static final class Forwarder extends AbstractCapabilityExtension { + Forwarder() { + super("fwd"); + } + + @Override + public void register(CapabilityRegistrar registrar) { + registrar.onInboundTags( + (tags, context) -> + new InboundTagResult(Map.of("+private", tags.tags().get("+private")), Map.of())); + } + } + + /** A second inbound-tag provider that selects nothing and only records context. */ + private static final class ContextOnly extends AbstractCapabilityExtension { + ContextOnly() { + super("ctx"); + } + + @Override + public void register(CapabilityRegistrar registrar) { + registrar.onInboundTags( + (tags, context) -> new InboundTagResult(Map.of(), Map.of("ctx/x", "1"))); + } + } + + /** Selects the same tag as {@link Forwarder}, so two capabilities own one key. */ + private static final class SecondForwarder extends AbstractCapabilityExtension { + SecondForwarder() { + super("fwd2"); + } + + @Override + public void register(CapabilityRegistrar registrar) { + registrar.onInboundTags( + (tags, context) -> + new InboundTagResult(Map.of("+private", tags.tags().get("+private")), Map.of())); + } + } + + @Test + void aTagSelectedByTwoCapabilitiesReachesRecipientsOfEitherOne() { + extensions.register(new Forwarder()); + extensions.enable("fwd"); + extensions.register(new SecondForwarder()); + extensions.enable("fwd2"); + ClientSession sender = user("sender"); + ClientSession first = user("first", "fwd"); + ClientSession second = user("second", "fwd2"); + ClientSession neither = user("neither"); + for (ClientSession member : List.of(sender, first, second, neither)) join(member, "#c"); + + delivery.deliver( + sender, + "PRIVMSG", + "#c", + "hi", + Map.of("+private", "secret"), + delivery.authorize(sender, "#c", true), + r -> true); + + assertThat(received(first)).contains("+private=secret"); + assertThat(received(second)).contains("+private=secret"); + assertThat(received(neither)).doesNotContain("private"); + } + + @Test + void forwardedClientTagsGoOnlyToRecipientsOfTheProviderThatSelectedThem() { + extensions.register(new Forwarder()); + extensions.enable("fwd"); + extensions.register(new ContextOnly()); + extensions.enable("ctx"); + ClientSession sender = user("sender"); + ClientSession owner = user("owner", "fwd"); + ClientSession other = user("other", "ctx"); + join(sender, "#c"); + join(owner, "#c"); + join(other, "#c"); + + delivery.deliver( + sender, + "PRIVMSG", + "#c", + "hi", + Map.of("+private", "secret"), + delivery.authorize(sender, "#c", true), + r -> true); + + assertThat(received(owner)).contains("+private=secret"); + assertThat(received(other)).doesNotContain("private").doesNotContain("secret"); + } +} diff --git a/jircd-core/src/test/java/net/jircd/core/session/SelfDirectMessageTest.java b/jircd-core/src/test/java/net/jircd/core/session/SelfDirectMessageTest.java index 947c414..9f5bdf0 100644 --- a/jircd-core/src/test/java/net/jircd/core/session/SelfDirectMessageTest.java +++ b/jircd-core/src/test/java/net/jircd/core/session/SelfDirectMessageTest.java @@ -19,8 +19,11 @@ import java.io.ByteArrayOutputStream; import java.nio.charset.StandardCharsets; +import java.util.Set; import net.jircd.core.extension.AbstractCapabilityExtension; import net.jircd.core.extension.ExtensionRegistry; +import net.jircd.core.extension.capability.CapabilityRegistrar; +import net.jircd.core.extension.capability.DeliveryRole; import net.jircd.core.session.command.MessageCommandHandler; import net.jircd.protocol.MessageParser; import org.junit.jupiter.params.ParameterizedTest; @@ -36,8 +39,8 @@ private static final class EchoStub extends AbstractCapabilityExtension { } @Override - public boolean includeSenderInFanOut(ClientSession sender) { - return sender.negotiatedCapabilities().contains("echo-message"); + public void register(CapabilityRegistrar registrar) { + registrar.onRecipients((intent, context) -> Set.of(DeliveryRole.SENDER_COPY)); } } diff --git a/jircd-core/src/test/java/net/jircd/core/session/SelfDirectTagmsgTest.java b/jircd-core/src/test/java/net/jircd/core/session/SelfDirectTagmsgTest.java deleted file mode 100644 index d0e07d2..0000000 --- a/jircd-core/src/test/java/net/jircd/core/session/SelfDirectTagmsgTest.java +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Copyright 2026 Guillermo Castro - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - * implied. See the License for the specific language governing - * permissions and limitations under the License. - */ -package net.jircd.core.session; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.io.ByteArrayOutputStream; -import java.nio.charset.StandardCharsets; -import net.jircd.core.extension.AbstractCapabilityExtension; -import net.jircd.core.extension.ExtensionRegistry; -import net.jircd.core.session.command.TagmsgCommandHandler; -import net.jircd.protocol.MessageParser; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -/** Issue #18 follow-up: direct TAGMSG delivery and echo follow the same rules as PRIVMSG. */ -class SelfDirectTagmsgTest { - - private static final class EchoStub extends AbstractCapabilityExtension { - EchoStub() { - super("echo-message"); - } - - @Override - public boolean includeSenderInFanOut(ClientSession sender) { - return sender.negotiatedCapabilities().contains("echo-message"); - } - } - - private static final class Fixture implements AutoCloseable { - final ClientSession alice = session("alice", "a"); - final ClientSession bob = session("bob", "b"); - final ByteArrayOutputStream aliceOut = new ByteArrayOutputStream(); - final ByteArrayOutputStream bobOut = new ByteArrayOutputStream(); - final SessionWriter aliceWriter; - final SessionWriter bobWriter; - final TagmsgCommandHandler handler; - - Fixture(boolean echo) { - NicknameRegistry nicknames = new NicknameRegistry(); - nicknames.claim("alice", alice); - nicknames.claim("bob", bob); - ExtensionRegistry extensions = new ExtensionRegistry(); - extensions.register(new EchoStub()); - extensions.enable("echo-message"); - if (echo) { - alice.negotiatedCapabilities().add("echo-message"); - } - aliceWriter = new SessionWriter(alice, aliceOut, TagRenderer.NONE); - bobWriter = new SessionWriter(bob, bobOut, TagRenderer.NONE); - alice.attachWriter(aliceWriter); - bob.attachWriter(bobWriter); - handler = - new TagmsgCommandHandler( - new ChannelRegistry(), nicknames, extensions, () -> "test.jircd.local"); - } - - private static ClientSession session(String nick, String id) { - ClientSession s = new ClientSession(id, "host.example", RateLimitBucket.withDefaults()); - s.setNickname(nick); - s.setIdent("ident"); - s.negotiatedCapabilities().add("message-tags"); - return s; - } - - void send(String target) throws Exception { - handler.handle(alice, MessageParser.parse("@+typing=active TAGMSG " + target)); - aliceWriter.close(); - bobWriter.close(); - } - - String alice() { - return aliceOut.toString(StandardCharsets.UTF_8); - } - - String bob() { - return bobOut.toString(StandardCharsets.UTF_8); - } - - @Override - public void close() throws Exception { - aliceWriter.close(); - bobWriter.close(); - } - } - - @ParameterizedTest - @ValueSource(booleans = {false, true}) - void selfTargetedTagmsgIsDeliveredExactlyOnce(boolean echo) throws Exception { - try (Fixture f = new Fixture(echo)) { - f.send("alice"); - assertThat(f.alice().lines()).hasSize(1); - assertThat(f.alice()).contains("TAGMSG alice"); - } - } - - @Test - void directTagmsgIsEchoedOnceWhenEchoMessageNegotiated() throws Exception { - try (Fixture f = new Fixture(true)) { - f.send("bob"); - assertThat(f.bob().lines()).hasSize(1); - assertThat(f.alice().lines()).hasSize(1); - assertThat(f.alice()).contains("TAGMSG bob"); - } - } - - @Test - void directTagmsgIsNotEchoedWithoutEchoMessage() throws Exception { - try (Fixture f = new Fixture(false)) { - f.send("bob"); - assertThat(f.bob().lines()).hasSize(1); - assertThat(f.alice()).isEmpty(); - } - } - - @Test - void echoRequiresMessageTagsOnTheSender() throws Exception { - try (Fixture f = new Fixture(true)) { - f.alice.negotiatedCapabilities().remove("message-tags"); - f.send("bob"); - assertThat(f.bob().lines()).hasSize(1); - assertThat(f.alice()).isEmpty(); - } - } -} diff --git a/jircd-core/src/test/java/net/jircd/core/session/SessionWriterBatchTest.java b/jircd-core/src/test/java/net/jircd/core/session/SessionWriterBatchTest.java deleted file mode 100644 index ae4e84e..0000000 --- a/jircd-core/src/test/java/net/jircd/core/session/SessionWriterBatchTest.java +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Copyright 2026 Guillermo Castro - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - * implied. See the License for the specific language governing - * permissions and limitations under the License. - */ -package net.jircd.core.session; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.io.ByteArrayOutputStream; -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.Map; -import net.jircd.protocol.CapabilityName; -import net.jircd.protocol.Command; -import net.jircd.protocol.Message; -import org.junit.jupiter.api.Test; - -/** 011-away-notify-batch User Story 2: {@link SessionWriter#enqueueBatch}. */ -class SessionWriterBatchTest { - - private static ClientSession newSession() { - return new ClientSession("c1", "host.example", RateLimitBucket.withDefaults()); - } - - private static Message privmsg(String target, String body) { - return new Message( - Map.of(), "sender!ident@host", Command.PRIVMSG, "PRIVMSG", List.of(target, body)); - } - - private static String[] lines(ByteArrayOutputStream out) { - String text = out.toString(StandardCharsets.UTF_8); - return text.isEmpty() ? new String[0] : text.split("\r\n"); - } - - private static String tagValue(String line, String key) { - assertThat(line).startsWith("@"); - String tagSection = line.substring(1, line.indexOf(' ')); - for (String tag : tagSection.split(";")) { - int eq = tag.indexOf('='); - if (eq > 0 && tag.substring(0, eq).equals(key)) { - return tag.substring(eq + 1); - } - } - throw new AssertionError("Tag '" + key + "' not found in: " + line); - } - - @Test - void twoConcurrentBatchesAreDistinctAndCorrectlyFramed() throws Exception { - ClientSession session = newSession(); - session.negotiatedCapabilities().add(CapabilityName.BATCH); - session.negotiatedCapabilities().add(CapabilityName.MESSAGE_TAGS); - ByteArrayOutputStream out = new ByteArrayOutputStream(); - - try (SessionWriter writer = new SessionWriter(session, out, TagRenderer.NONE)) { - writer.enqueueBatch( - "type-a", List.of(), List.of(privmsg("#chan", "a1"), privmsg("#chan", "a2"))); - writer.enqueueBatch("type-b", List.of("param1"), List.of(privmsg("#chan", "b1"))); - } - - String[] lines = lines(out); - assertThat(lines).hasSize(7); - - // Group A: open, two tagged members, close. - assertThat(lines[0]).startsWith("BATCH +").contains(" type-a"); - int prefixLenA = "BATCH +".length(); - String refA = lines[0].substring(prefixLenA, lines[0].indexOf(' ', prefixLenA)); - assertThat(tagValue(lines[1], "batch")).isEqualTo(refA); - assertThat(lines[1]).contains("a1"); - assertThat(tagValue(lines[2], "batch")).isEqualTo(refA); - assertThat(lines[2]).contains("a2"); - assertThat(lines[3]).isEqualTo("BATCH -" + refA); - - // Group B: open (with its type-param), one tagged member, close. - assertThat(lines[4]).startsWith("BATCH +").contains(" type-b param1"); - int prefixLenB = "BATCH +".length(); - String refB = lines[4].substring(prefixLenB, lines[4].indexOf(' ', prefixLenB)); - assertThat(tagValue(lines[5], "batch")).isEqualTo(refB); - assertThat(lines[5]).contains("b1"); - assertThat(lines[6]).isEqualTo("BATCH -" + refB); - - // Group boundary count check above already required exactly 7 lines total. - assertThat(refA).isNotEqualTo(refB); - } - - @Test - void recipientLackingEitherCapabilityGetsMembersCompletelyUnwrapped() throws Exception { - ClientSession neitherCapability = newSession(); - ByteArrayOutputStream outNeither = new ByteArrayOutputStream(); - try (SessionWriter writer = - new SessionWriter(neitherCapability, outNeither, TagRenderer.NONE)) { - writer.enqueueBatch("type-a", List.of(), List.of(privmsg("#chan", "hello"))); - } - String[] neitherLines = lines(outNeither); - assertThat(neitherLines).hasSize(1); - assertThat(neitherLines[0]).doesNotStartWith("@").doesNotContain("BATCH").contains("hello"); - - ClientSession batchOnly = newSession(); - batchOnly.negotiatedCapabilities().add(CapabilityName.BATCH); - ByteArrayOutputStream outBatchOnly = new ByteArrayOutputStream(); - try (SessionWriter writer = new SessionWriter(batchOnly, outBatchOnly, TagRenderer.NONE)) { - writer.enqueueBatch("type-a", List.of(), List.of(privmsg("#chan", "hello"))); - } - String[] batchOnlyLines = lines(outBatchOnly); - assertThat(batchOnlyLines).hasSize(1); - assertThat(batchOnlyLines[0]).doesNotStartWith("@").doesNotContain("BATCH").contains("hello"); - } -} diff --git a/jircd-core/src/test/java/net/jircd/core/session/SessionWriterByteLimitTest.java b/jircd-core/src/test/java/net/jircd/core/session/SessionWriterByteLimitTest.java index b04a47f..5d55f86 100644 --- a/jircd-core/src/test/java/net/jircd/core/session/SessionWriterByteLimitTest.java +++ b/jircd-core/src/test/java/net/jircd/core/session/SessionWriterByteLimitTest.java @@ -25,12 +25,14 @@ import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.Executors; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import net.jircd.core.extension.AbstractCapabilityExtension; import net.jircd.core.extension.ExtensionRegistry; +import net.jircd.core.extension.capability.CapabilityRegistrar; import net.jircd.protocol.Command; import net.jircd.protocol.Message; import org.junit.jupiter.api.Test; @@ -76,7 +78,12 @@ void contributionMetadataDoesNotChangeMessageValueEquality() { first.clientTags(), first.sentAt(), first.messageId()); - first.contributionBytes(capability("large", Map.of("s", "x".repeat(4092)))); + ExtensionRegistry registry = new ExtensionRegistry(); + registry.register(capability("large", Map.of("s", "x".repeat(4092)))); + registry.enable("large"); + ClientSession recipient = session(); + recipient.negotiatedCapabilities().add("large"); + new CapabilityTagRenderer(registry).prepare(recipient, first); assertThat(first) .isEqualTo(equal) .hasSameHashCodeAs(equal) @@ -162,12 +169,14 @@ void chargesMatchRecipientWireBytesAndFilterClientTags(int capabilities) throws if ((capabilities & 1) != 0) recipient.negotiatedCapabilities().add("message-tags"); if ((capabilities & 2) != 0) recipient.negotiatedCapabilities().add("server-time"); OutboundMessage message = - OutboundMessage.now( - "n!u@h", - "PRIVMSG", - "#é", - "€ body", - Map.of("+test", "é; \\\r\n", "+empty", "", "hidden", "filtered")); + ForwardedTags.selectedBy( + "message-tags", + OutboundMessage.now( + "n!u@h", + "PRIVMSG", + "#é", + "€ body", + Map.of("+test", "é; \\\r\n", "+empty", "", "hidden", "filtered"))); assertCharge(recipient, new CapabilityTagRenderer(registry), message, true); } @@ -187,11 +196,13 @@ void legalLargeServerContributionIsChargedInFull() throws Exception { } @Test - void overlappingLargeContributionsStillAdmitTheLegalLineAtTheFloor() throws Exception { + void manyLargeContributionsStillAdmitTheLegalLineAtTheFloor() throws Exception { + // Each owner has its own key, so together they overflow one origin: the later ones are dropped + // at enqueue and the charge never exceeds what the queue can hold. ExtensionRegistry registry = new ExtensionRegistry(); ClientSession recipient = session(); for (int i = 0; i < 8; i++) { - var contributor = capability("overlap" + i, Map.of("s", "x".repeat(4092))); + var contributor = capability("overlap" + i, Map.of("s" + i, "x".repeat(4090))); registry.register(contributor); registry.enable(contributor.id()); recipient.negotiatedCapabilities().add(contributor.id()); @@ -204,10 +215,10 @@ void overlappingLargeContributionsStillAdmitTheLegalLineAtTheFloor() throws Exce } @Test - void collisionsAndTruncatedTextHaveConservativeCharges() throws Exception { + void sameNamedClientAndServerTagsAndTruncatedTextHaveConservativeCharges() throws Exception { ExtensionRegistry registry = new ExtensionRegistry(); - var first = capability("message-tags", Map.of("+same", "one")); - var second = capability("server-time", Map.of("+same", "two")); + var first = capability("message-tags", Map.of("same", "one")); + var second = capability("server-time", Map.of("time", "two")); registry.register(first); registry.enable(first.id()); registry.register(second); @@ -217,7 +228,10 @@ void collisionsAndTruncatedTextHaveConservativeCharges() throws Exception { assertCharge( recipient, new CapabilityTagRenderer(registry), - OutboundMessage.now("n!u@h", "PRIVMSG", "#c", "é".repeat(600), Map.of("+same", "client")), + ForwardedTags.selectedBy( + "message-tags", + OutboundMessage.now( + "n!u@h", "PRIVMSG", "#c", "é".repeat(600), Map.of("+same", "client"))), false); } @@ -230,8 +244,10 @@ void maximumLegalLineFitsTheFloor() throws Exception { ClientSession recipient = session(); recipient.negotiatedCapabilities().add("message-tags"); OutboundMessage message = - OutboundMessage.now( - "n!u@h", "PRIVMSG", "#c", " ".repeat(491), Map.of("+c", "x".repeat(4091))); + ForwardedTags.selectedBy( + "message-tags", + OutboundMessage.now( + "n!u@h", "PRIVMSG", "#c", " ".repeat(491), Map.of("+c", "x".repeat(4091)))); var renderer = new CapabilityTagRenderer(registry); assertCharge(recipient, renderer, message, true); } @@ -242,9 +258,14 @@ void contributionsAreMemoizedPerMessageAndCapabilityIdentity() throws Exception var capability = new AbstractCapabilityExtension("message-tags") { @Override - public Map contributeTags(OutboundMessage message) { - calls.incrementAndGet(); - return Map.of("msgid", message.messageId().toString()); + public void register(CapabilityRegistrar registrar) { + registrar.onOutboundTags( + Set.of("msgid"), + 42, + message -> { + calls.incrementAndGet(); + return Map.of("msgid", message.messageId().toString()); + }); } }; ExtensionRegistry registry = new ExtensionRegistry(); @@ -267,9 +288,14 @@ public Map contributeTags(OutboundMessage message) { var different = new AbstractCapabilityExtension("message-tags") { @Override - public Map contributeTags(OutboundMessage input) { - calls.incrementAndGet(); - return Map.of("msgid", "different"); + public void register(CapabilityRegistrar registrar) { + registrar.onOutboundTags( + Set.of("msgid"), + 42, + input -> { + calls.incrementAndGet(); + return Map.of("msgid", "different"); + }); } }; other.register(different); @@ -406,12 +432,7 @@ void rawBroadcastReservesTheFullWireChargeForEveryRecipient() throws Exception { } private static AbstractCapabilityExtension capability(String id, Map tags) { - return new AbstractCapabilityExtension(id) { - @Override - public Map contributeTags(OutboundMessage message) { - return tags; - } - }; + return new TagProvider(id, tags); } private static void assertCharge( diff --git a/jircd-core/src/test/java/net/jircd/core/session/SessionWriterGroupTest.java b/jircd-core/src/test/java/net/jircd/core/session/SessionWriterGroupTest.java new file mode 100644 index 0000000..b71bae7 --- /dev/null +++ b/jircd-core/src/test/java/net/jircd/core/session/SessionWriterGroupTest.java @@ -0,0 +1,235 @@ +/* + * Copyright 2026 Guillermo Castro + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package net.jircd.core.session; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.jircd.core.extension.AbstractCapabilityExtension; +import net.jircd.core.extension.ExtensionRegistry; +import net.jircd.core.extension.capability.CapabilityRegistrar; +import net.jircd.core.extension.capability.FramingPlan; +import net.jircd.core.extension.capability.OutputGroup; +import net.jircd.protocol.Command; +import net.jircd.protocol.Message; +import org.junit.jupiter.api.Test; + +/** + * {@link SessionWriter#enqueueGroup}: generic framing, with a stub shaper standing in for batch. + */ +class SessionWriterGroupTest { + + /** + * Frames a group with BEGIN/END lines, only for recipients that negotiated "framer" and "tags". + */ + private static final class Framer extends AbstractCapabilityExtension { + final List seen = new ArrayList<>(); + + Framer() { + super("framer"); + } + + @Override + public void register(CapabilityRegistrar registrar) { + registrar.onOutputGroup( + Set.of("tags"), + (group, context) -> { + seen.add(group); + List frames = new ArrayList<>(); + frames.add(new Message(Map.of(), null, null, "BATCH", List.of("+g", group.type()))); + group.members().forEach(frames::add); + frames.add(new Message(Map.of(), null, null, "BATCH", List.of("-g"))); + return new FramingPlan(frames); + }); + } + } + + private static Message privmsg(String body) { + return new Message( + Map.of(), "sender!ident@host", Command.PRIVMSG, "PRIVMSG", List.of("#chan", body)); + } + + private static ClientSession session(String... negotiated) { + ClientSession session = new ClientSession("c1", "host.example", RateLimitBucket.withDefaults()); + session.replaceNegotiatedCapabilities(List.of(negotiated)); + return session; + } + + private static ExtensionRegistry registryWithFramer(Framer framer) { + ExtensionRegistry registry = new ExtensionRegistry(); + registry.register(framer); + registry.enable("framer"); + return registry; + } + + private static String[] lines(ByteArrayOutputStream out) { + String text = out.toString(StandardCharsets.UTF_8); + return text.isEmpty() ? new String[0] : text.split("\r\n"); + } + + @Test + void withoutAShaperTheMembersAreSentPlain() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (SessionWriter writer = new SessionWriter(session(), out, TagRenderer.NONE)) { + writer.enqueueGroup("type", List.of(), List.of(privmsg("a"), privmsg("b"))); + } + assertThat(lines(out)) + .containsExactly( + ":sender!ident@host PRIVMSG #chan a", ":sender!ident@host PRIVMSG #chan b"); + } + + @Test + void aValidPlanIsAppendedAsOneRunWithItsOpenerAndCloser() { + Framer framer = new Framer(); + ExtensionRegistry registry = registryWithFramer(framer); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (SessionWriter writer = + new SessionWriter(session("framer", "tags"), out, new CapabilityTagRenderer(registry))) { + writer.enqueueGroup("type-a", List.of("p"), List.of(privmsg("a1"))); + } + assertThat(lines(out)) + .containsExactly("BATCH +g type-a", ":sender!ident@host PRIVMSG #chan a1", "BATCH -g"); + assertThat(framer.seen).hasSize(1); + assertThat(framer.seen.getFirst().typeParams()).containsExactly("p"); + } + + @Test + void aMissingOwnerOrPrerequisiteMeansPlainMembersAndNoShaperCall() { + Framer framer = new Framer(); + ExtensionRegistry registry = registryWithFramer(framer); + for (ClientSession recipient : List.of(session(), session("framer"), session("tags"))) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (SessionWriter writer = + new SessionWriter(recipient, out, new CapabilityTagRenderer(registry))) { + writer.enqueueGroup("type", List.of(), List.of(privmsg("x"))); + } + assertThat(lines(out)).containsExactly(":sender!ident@host PRIVMSG #chan x"); + } + assertThat(framer.seen).isEmpty(); + } + + @Test + void anEmptyGroupWithoutFramingSendsNothing() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (SessionWriter writer = new SessionWriter(session(), out, TagRenderer.NONE)) { + writer.enqueueGroup("type", List.of(), List.of()); + } + assertThat(lines(out)).isEmpty(); + } + + @Test + void framingOverheadThatExceedsTheQueueOverflowsTheRecipientInsteadOfFallingBackToPlain() { + Framer framer = new Framer(); + ExtensionRegistry registry = registryWithFramer(framer); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + // The plain member is 37 bytes with CRLF; the framed group adds 25 more, so 50 holds only + // plain. + try (SessionWriter writer = + new SessionWriter( + session("framer", "tags"), out, new CapabilityTagRenderer(registry), 50)) { + writer.enqueueGroup("type", List.of(), List.of(privmsg("x"))); + } + String written = out.toString(StandardCharsets.UTF_8); + assertThat(written) + .doesNotContain("PRIVMSG") + .doesNotContain("BATCH") + .contains("SendQ exceeded"); + } + + @Test + void aGroupWithAnUnrepresentableMemberIsDroppedWholeAndLaterOutputStillFlows() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + Message injected = + new Message(Map.of(), "s!i@h", Command.PRIVMSG, "PRIVMSG", List.of("#chan", "a\r\nQUIT")); + try (SessionWriter writer = new SessionWriter(session(), out, TagRenderer.NONE)) { + writer.enqueueGroup("type", List.of(), List.of(privmsg("ok"), injected)); + writer.enqueueGroup("type", List.of(), List.of(privmsg("later"))); + } + assertThat(lines(out)).containsExactly(":sender!ident@host PRIVMSG #chan later"); + } + + @Test + void aGroupThatCannotFitTheQueueIsRejectedWholeAndNeverPartlyWritten() { + Framer framer = new Framer(); + ExtensionRegistry registry = registryWithFramer(framer); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ClientSession recipient = session("framer", "tags"); + List members = new ArrayList<>(); + for (int i = 0; i < 20; i++) members.add(privmsg("x".repeat(100))); + // Room for a few members but not the whole group plus its opener and closer. + try (SessionWriter writer = + new SessionWriter(recipient, out, new CapabilityTagRenderer(registry), 600)) { + writer.enqueueGroup("type", List.of(), members); + } + String written = out.toString(StandardCharsets.UTF_8); + assertThat(written) + .doesNotContain("BATCH +g") + .doesNotContain("PRIVMSG") + .contains("SendQ exceeded"); + } + + @Test + void anAdmittedGroupKeepsItsCloserWhenTheShaperIsWithdrawnBeforeTheDrain() { + Framer framer = new Framer(); + ExtensionRegistry registry = registryWithFramer(framer); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (SessionWriter writer = + new SessionWriter(session("framer", "tags"), out, new CapabilityTagRenderer(registry))) { + writer.enqueueGroup("type", List.of(), List.of(privmsg("a"))); + registry.disable("framer"); + } + String[] lines = lines(out); + assertThat(lines).hasSize(3).endsWith("BATCH -g"); + } + + @Test + void concurrentGroupsToOneRecipientNeverInterleave() throws Exception { + Framer framer = new Framer(); + ExtensionRegistry registry = registryWithFramer(framer); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (SessionWriter writer = + new SessionWriter( + session("framer", "tags"), out, new CapabilityTagRenderer(registry), 1 << 20)) { + List threads = new ArrayList<>(); + for (int t = 0; t < 8; t++) { + int id = t; + threads.add( + Thread.ofVirtual() + .start( + () -> { + for (int i = 0; i < 25; i++) { + writer.enqueueGroup( + "t" + id, List.of(), List.of(privmsg("a"), privmsg("b"), privmsg("c"))); + } + })); + } + for (Thread thread : threads) thread.join(); + } + String[] lines = lines(out); + assertThat(lines).hasSize(8 * 25 * 5); + for (int i = 0; i < lines.length; i += 5) { + assertThat(lines[i]).startsWith("BATCH +g t"); + assertThat(lines[i + 1]).contains("PRIVMSG #chan a"); + assertThat(lines[i + 3]).contains("PRIVMSG #chan c"); + assertThat(lines[i + 4]).isEqualTo("BATCH -g"); + } + } +} diff --git a/jircd-core/src/test/java/net/jircd/core/session/TagProvider.java b/jircd-core/src/test/java/net/jircd/core/session/TagProvider.java new file mode 100644 index 0000000..62bbb78 --- /dev/null +++ b/jircd-core/src/test/java/net/jircd/core/session/TagProvider.java @@ -0,0 +1,42 @@ +/* + * Copyright 2026 Guillermo Castro + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package net.jircd.core.session; + +import java.util.Map; +import net.jircd.core.extension.AbstractCapabilityExtension; +import net.jircd.core.extension.capability.CapabilityRegistrar; +import net.jircd.protocol.MessageSerializer; + +/** A capability that contributes a fixed set of server tags, declared at their exact size. */ +final class TagProvider extends AbstractCapabilityExtension { + private final Map tags; + + TagProvider(String id, Map tags) { + super(id); + this.tags = Map.copyOf(tags); + } + + @Override + public void register(CapabilityRegistrar registrar) { + if (tags.isEmpty()) return; // a provider with nothing to contribute registers nothing + registrar.onOutboundTags( + tags.keySet(), + Math.min( + MessageSerializer.MAX_TAG_DATA_BYTES_PER_ORIGIN, + Math.max(1, MessageSerializer.tagDataBytes(tags))), + message -> tags); + } +} diff --git a/jircd-core/src/test/java/net/jircd/core/session/command/HostmaskReplyFitTest.java b/jircd-core/src/test/java/net/jircd/core/session/command/HostmaskReplyFitTest.java index cfa5e0a..6267953 100644 --- a/jircd-core/src/test/java/net/jircd/core/session/command/HostmaskReplyFitTest.java +++ b/jircd-core/src/test/java/net/jircd/core/session/command/HostmaskReplyFitTest.java @@ -130,6 +130,7 @@ void theModelEqualsTheRealSerializedLineForEveryReply(int nick, int chan) throws ExtensionRegistry extensions = new ExtensionRegistry(); extensions.register(new LongHost()); extensions.enable("long-host"); + MultiPrefixStub.enableIn(extensions); ByteArrayOutputStream ownerOut = new ByteArrayOutputStream(); ByteArrayOutputStream askerOut = new ByteArrayOutputStream(); ClientSession owner = user("o".repeat(nick), ownerOut, ""); @@ -169,7 +170,9 @@ void theModelEqualsTheRealSerializedLineForEveryReply(int nick, int chan) throws asker.writer().close(); owner.writer().close(); - Map worst = HostmaskReplyBudget.worstCaseBytes(serverBytes, nick, chan); + Map worst = + HostmaskReplyBudget.worstCaseBytes( + serverBytes, nick, chan, extensions.capabilitySnapshot().replyBudgetCatalog()); List sent = lines(askerOut); assertThat(wireBytes(only(sent, "333"))) .as(only(sent, "333")) diff --git a/jircd-core/src/test/java/net/jircd/core/session/command/MultiPrefixReplyTest.java b/jircd-core/src/test/java/net/jircd/core/session/command/MultiPrefixReplyTest.java index 267a228..c782a85 100644 --- a/jircd-core/src/test/java/net/jircd/core/session/command/MultiPrefixReplyTest.java +++ b/jircd-core/src/test/java/net/jircd/core/session/command/MultiPrefixReplyTest.java @@ -45,6 +45,7 @@ class MultiPrefixReplyTest { channels.join("#c", boss, ignored -> true).orElseThrow().channel(); MultiPrefixReplyTest() { + MultiPrefixStub.enableIn(extensions); // boss is the first joiner, so already an operator; plain stays unprivileged. channel.grantVoice(boss); channels.join("#c", plain, ignored -> true); diff --git a/jircd-core/src/test/java/net/jircd/core/session/command/MultiPrefixStub.java b/jircd-core/src/test/java/net/jircd/core/session/command/MultiPrefixStub.java new file mode 100644 index 0000000..b0e1685 --- /dev/null +++ b/jircd-core/src/test/java/net/jircd/core/session/command/MultiPrefixStub.java @@ -0,0 +1,40 @@ +/* + * Copyright 2026 Guillermo Castro + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package net.jircd.core.session.command; + +import net.jircd.core.extension.AbstractCapabilityExtension; +import net.jircd.core.extension.ExtensionRegistry; +import net.jircd.core.extension.capability.CapabilityRegistrar; + +/** + * Stand-in for the multi-prefix module, which core cannot depend on: lists every held status in the + * status slot of a requester that negotiated it. + */ +final class MultiPrefixStub extends AbstractCapabilityExtension { + MultiPrefixStub() { + super("multi-prefix"); + } + + @Override + public void register(CapabilityRegistrar registrar) { + registrar.onMemberStatus(limits -> 2, member -> String.join("", member.heldStatuses())); + } + + static void enableIn(ExtensionRegistry registry) { + registry.register(new MultiPrefixStub()); + registry.enable("multi-prefix"); + } +} diff --git a/jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityGroupTest.java b/jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityGroupTest.java new file mode 100644 index 0000000..7eb1a41 --- /dev/null +++ b/jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityGroupTest.java @@ -0,0 +1,172 @@ +/* + * Copyright 2026 Guillermo Castro + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package net.jircd.integration; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import net.jircd.core.session.ClientSession; +import net.jircd.protocol.Command; +import net.jircd.protocol.Message; +import org.junit.jupiter.api.Test; + +/** + * A registered finite-group probe over a real socket: core's generic group admission with the batch + * provider discovered from the classpath. The probe enqueues a finite group on a live session's + * writer, the way a future history or label feature would, so the framing seen here is the + * provider's and the admission is core's. + */ +class CapabilityGroupTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private static final String BATCH_AND_TAGS_YAML = + """ + capabilities: + batch: enabled + message-tags: enabled + """; + + private static Message privmsg(String body) { + return new Message( + Map.of(), "bot!bot@host", Command.PRIVMSG, "PRIVMSG", List.of("#probe", body)); + } + + private static RawIrcClient client(TestServer server, String nick, String caps) throws Exception { + RawIrcClient client = RawIrcClient.connectPlaintext(server.plaintextPort()); + if (!caps.isEmpty()) { + client.send("CAP REQ :" + caps); + client.readUntil("CAP * ACK", TIMEOUT); + client.send("CAP END"); + } + client.registerAndAwaitWelcome(nick, nick); + return client; + } + + private static ClientSession live(TestServer server, String nick) { + return server.application.nicknameRegistry().lookup(nick).orElseThrow(); + } + + /** Everything the client was sent for the probe, ending at its own barrier. */ + private static List probe(RawIrcClient client) throws Exception { + List lines = new ArrayList<>(client.readThroughPong(TIMEOUT)); + return lines.stream().filter(l -> l.contains("PRIVMSG #probe") || l.contains("BATCH")).toList(); + } + + @Test + void aBatchAndMessageTagsClientReceivesTheGroupFramedAndTagged() throws Exception { + try (TestServer server = TestServer.start(BATCH_AND_TAGS_YAML); + RawIrcClient alice = client(server, "alice", "batch message-tags")) { + live(server, "alice") + .writer() + .enqueueGroup("probe", List.of("p1"), List.of(privmsg("one"), privmsg("two"))); + + List lines = probe(alice); + + assertThat(lines).hasSize(4); + assertThat(lines.get(0)).startsWith("BATCH +").endsWith(" probe p1"); + String ref = lines.get(0).substring("BATCH +".length(), lines.get(0).indexOf(' ', 7)); + assertThat(lines.get(1)).startsWith("@batch=" + ref).contains("PRIVMSG #probe one"); + assertThat(lines.get(2)).startsWith("@batch=" + ref).contains("PRIVMSG #probe two"); + assertThat(lines.get(3)).isEqualTo("BATCH -" + ref); + } + } + + @Test + void aBatchOnlyClientReceivesPlainMembersAndStillNegotiated() throws Exception { + try (TestServer server = TestServer.start(BATCH_AND_TAGS_YAML); + RawIrcClient bob = client(server, "bob", "batch")) { + live(server, "bob") + .writer() + .enqueueGroup("probe", List.of(), List.of(privmsg("one"), privmsg("two"))); + + List lines = probe(bob); + + assertThat(lines) + .containsExactly(":bot!bot@host PRIVMSG #probe one", ":bot!bot@host PRIVMSG #probe two"); + } + } + + @Test + void aClientWithNeitherCapabilityReceivesPlainMembers() throws Exception { + try (TestServer server = TestServer.start(BATCH_AND_TAGS_YAML); + RawIrcClient carol = client(server, "carol", "")) { + live(server, "carol").writer().enqueueGroup("probe", List.of(), List.of(privmsg("one"))); + + assertThat(probe(carol)).containsExactly(":bot!bot@host PRIVMSG #probe one"); + } + } + + @Test + void anEmptyGroupIsAnOpenerAndACloserForABatchClient() throws Exception { + try (TestServer server = TestServer.start(BATCH_AND_TAGS_YAML); + RawIrcClient dave = client(server, "dave", "batch message-tags")) { + live(server, "dave").writer().enqueueGroup("empty", List.of(), List.of()); + + List lines = probe(dave); + + assertThat(lines).hasSize(2); + assertThat(lines.get(0)).startsWith("BATCH +").endsWith(" empty"); + assertThat(lines.get(1)) + .isEqualTo("BATCH -" + lines.get(0).substring(7, lines.get(0).indexOf(' ', 7))); + } + } + + @Test + void twoGroupsToOneClientNeverInterleaveAndUseDistinctReferences() throws Exception { + try (TestServer server = TestServer.start(BATCH_AND_TAGS_YAML); + RawIrcClient erin = client(server, "erin", "batch message-tags")) { + ClientSession session = live(server, "erin"); + Thread first = + Thread.ofVirtual() + .start( + () -> { + for (int i = 0; i < 20; i++) { + session + .writer() + .enqueueGroup("a", List.of(), List.of(privmsg("a1"), privmsg("a2"))); + } + }); + for (int i = 0; i < 20; i++) { + session.writer().enqueueGroup("b", List.of(), List.of(privmsg("b1"), privmsg("b2"))); + } + first.join(); + + List lines = probe(erin); + + assertThat(lines).hasSize(40 * 4); + for (int i = 0; i < lines.size(); i += 4) { + String ref = lines.get(i).substring(7, lines.get(i).indexOf(' ', 7)); + assertThat(lines.get(i + 1)).startsWith("@batch=" + ref); + assertThat(lines.get(i + 2)).startsWith("@batch=" + ref); + assertThat(lines.get(i + 3)).isEqualTo("BATCH -" + ref); + } + } + } + + @Test + void withTheProviderNotEnabledGroupsAreSentPlain() throws Exception { + try (TestServer server = TestServer.start("capabilities:\n message-tags: enabled\n"); + RawIrcClient frank = client(server, "frank", "message-tags")) { + live(server, "frank").writer().enqueueGroup("probe", List.of(), List.of(privmsg("one"))); + + assertThat(probe(frank)).containsExactly(":bot!bot@host PRIVMSG #probe one"); + } + } +} diff --git a/jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityProviderAbsenceTest.java b/jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityProviderAbsenceTest.java new file mode 100644 index 0000000..1ad34e1 --- /dev/null +++ b/jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityProviderAbsenceTest.java @@ -0,0 +1,275 @@ +/* + * Copyright 2026 Guillermo Castro + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package net.jircd.integration; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Enumeration; +import java.util.List; +import java.util.Set; +import net.jircd.core.extension.CapabilityExtension; +import net.jircd.core.session.ClientSession; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Each of the six capability providers, absent from an isolated catalog, takes its offer and its + * behavior with it, and nothing else. The catalog is the ServiceLoader view a server starts with: a + * class loader that hides one provider class from the {@code CapabilityExtension} service file. No + * jar or class file is touched or deleted, and the desired configuration enables only providers + * that are present, as an administrator's would. + */ +class CapabilityProviderAbsenceTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private static final List PROVIDERS = + List.of( + "message-tags", "server-time", "echo-message", "away-notify", "batch", "multi-prefix"); + + @TempDir Path directory; + + /** A class loader whose view of the capability service file lacks one provider class. */ + private static final class WithoutProvider extends ClassLoader { + private final String hiddenClassSuffix; + private final Path scratch; + + WithoutProvider(String hiddenClassSuffix, Path scratch) { + super(CapabilityProviderAbsenceTest.class.getClassLoader()); + this.hiddenClassSuffix = hiddenClassSuffix; + this.scratch = scratch; + } + + @Override + public Enumeration getResources(String name) throws IOException { + Enumeration original = super.getResources(name); + if (!name.equals("META-INF/services/" + CapabilityExtension.class.getName())) { + return original; + } + List filtered = new ArrayList<>(); + for (URL url : Collections.list(original)) { + List kept; + try (var in = url.openStream()) { + kept = + new String(in.readAllBytes(), StandardCharsets.UTF_8) + .lines() + .filter(line -> !line.trim().endsWith(hiddenClassSuffix)) + .toList(); + } + Path file = Files.createTempFile(scratch, "services-", ".txt"); + Files.write(file, kept); + filtered.add(file.toUri().toURL()); + } + return Collections.enumeration(filtered); + } + } + + private static String providerClass(String capability) { + return switch (capability) { + case "message-tags" -> "MessageTagsExtension"; + case "server-time" -> "ServerTimeExtension"; + case "echo-message" -> "EchoMessageExtension"; + case "away-notify" -> "AwayNotifyExtension"; + case "batch" -> "BatchExtension"; + case "multi-prefix" -> "MultiPrefixExtension"; + default -> throw new IllegalArgumentException(capability); + }; + } + + /** The configuration an administrator would write for the providers still installed. */ + private static String yamlWithout(String absent) { + StringBuilder yaml = new StringBuilder("capabilities:\n"); + for (String provider : PROVIDERS) { + if (!provider.equals(absent)) yaml.append(" ").append(provider).append(": enabled\n"); + } + return yaml.toString(); + } + + private TestServer startWithout(String absent) throws Exception { + Thread thread = Thread.currentThread(); + ClassLoader original = thread.getContextClassLoader(); + thread.setContextClassLoader(new WithoutProvider(providerClass(absent), directory)); + try { + return TestServer.start(yamlWithout(absent)); + } finally { + thread.setContextClassLoader(original); + } + } + + private static Set offered(RawIrcClient client) throws Exception { + client.send("CAP LS 302"); + String response = client.readUntil("CAP * LS", TIMEOUT); + String caps = response.substring(response.indexOf("LS :") + "LS :".length()).trim(); + return caps.isEmpty() ? Set.of() : Set.of(caps.split(" ")); + } + + private static RawIrcClient registered(TestServer server, String nick, String caps) + throws Exception { + RawIrcClient client = RawIrcClient.connectPlaintext(server.plaintextPort()); + if (!caps.isEmpty()) { + client.send("CAP REQ :" + caps); + client.readUntil("CAP * ACK", TIMEOUT); + client.send("CAP END"); + } + client.registerAndAwaitWelcome(nick, nick); + return client; + } + + @Test + void theFullCatalogOffersAllSixAndTheBaselineIsWhatTheAbsenceTestsSubtract() throws Exception { + try (TestServer server = TestServer.start(yamlWithout("none")); + RawIrcClient client = RawIrcClient.connectPlaintext(server.plaintextPort())) { + assertThat(offered(client)).containsExactlyInAnyOrderElementsOf(PROVIDERS); + } + } + + @Test + void aMissingProviderIsNotOfferedAndCannotBeRequested() throws Exception { + for (String absent : PROVIDERS) { + try (TestServer server = startWithout(absent); + RawIrcClient client = RawIrcClient.connectPlaintext(server.plaintextPort())) { + assertThat(offered(client)) + .as("offers without %s", absent) + .doesNotContain(absent) + .containsExactlyInAnyOrderElementsOf( + PROVIDERS.stream().filter(p -> !p.equals(absent)).toList()); + client.send("CAP REQ :" + absent); + assertThat(client.readUntil("CAP * NAK", TIMEOUT)).contains(absent); + } + } + } + + @Test + void withoutMessageTagsTagmsgIsUnknownAndNoMsgidOrClientTagIsSent() throws Exception { + try (TestServer server = startWithout("message-tags"); + RawIrcClient alice = registered(server, "alice", "server-time echo-message"); + RawIrcClient bob = registered(server, "bob", "server-time")) { + alice.send("@+typing=active TAGMSG bob"); + assertThat(alice.readUntil("421", TIMEOUT)).contains("TAGMSG"); + + alice.send("@+typing=active PRIVMSG bob :hello"); + String line = bob.readUntil("hello", TIMEOUT); + assertThat(line).contains("time=").doesNotContain("msgid=").doesNotContain("+typing"); + } + } + + @Test + void withoutServerTimeNoTimeTagIsSentButMessageTagsStillWork() throws Exception { + try (TestServer server = startWithout("server-time"); + RawIrcClient alice = registered(server, "alice", ""); + RawIrcClient bob = registered(server, "bob", "message-tags")) { + alice.send("PRIVMSG bob :hello"); + assertThat(bob.readUntil("hello", TIMEOUT)).contains("msgid=").doesNotContain("time="); + } + } + + @Test + void withoutEchoMessageTheSenderGetsNoCopy() throws Exception { + try (TestServer server = startWithout("echo-message"); + RawIrcClient alice = registered(server, "alice", "message-tags"); + RawIrcClient bob = registered(server, "bob", "")) { + alice.send("PRIVMSG bob :hello"); + bob.readUntil("hello", TIMEOUT); + assertThat(alice.readThroughPong(TIMEOUT)).noneMatch(l -> l.contains("PRIVMSG bob")); + } + } + + @Test + void withoutAwayNotifyAwayWorksButNoNoticeIsSent() throws Exception { + try (TestServer server = startWithout("away-notify"); + RawIrcClient alice = registered(server, "alice", ""); + RawIrcClient bob = registered(server, "bob", "")) { + alice.send("JOIN #lobby"); + alice.readUntil("353", TIMEOUT); + bob.send("JOIN #lobby"); + bob.readUntil("353", TIMEOUT); + alice.send("AWAY :lunch"); + assertThat(alice.readUntil("306", TIMEOUT)).contains("marked as being away"); + assertThat(bob.readThroughPong(TIMEOUT)).noneMatch(l -> l.contains(" AWAY")); + assertThat(server.application.nicknameRegistry().lookup("alice").orElseThrow().awayReason()) + .isEqualTo("lunch"); + } + } + + @Test + void withoutMultiPrefixNamesAndWhoShowTheHighestStatusOnly() throws Exception { + try (TestServer server = startWithout("multi-prefix"); + RawIrcClient alice = registered(server, "alice", ""); + RawIrcClient bob = registered(server, "bob", "")) { + alice.send("JOIN #lobby"); + alice.readUntil("353", TIMEOUT); + bob.send("JOIN #lobby"); + bob.readUntil("353", TIMEOUT); + alice.send("MODE #lobby +v bob"); + alice.readUntil("MODE", TIMEOUT); + alice.send("MODE #lobby +v alice"); + alice.readUntil("MODE", TIMEOUT); + alice.send("NAMES #lobby"); + assertThat(alice.readUntil("353", TIMEOUT)).contains("@alice").doesNotContain("@+alice"); + alice.send("WHO #lobby"); + assertThat(alice.readUntil("352", TIMEOUT)).doesNotContain("@+"); + } + } + + @Test + void withoutBatchGroupsAreSentPlainAndNothingElseChanges() throws Exception { + try (TestServer server = startWithout("batch"); + RawIrcClient alice = registered(server, "alice", "message-tags")) { + ClientSession session = server.application.nicknameRegistry().lookup("alice").orElseThrow(); + session + .writer() + .enqueueGroup( + "probe", + List.of(), + List.of( + new net.jircd.protocol.Message( + java.util.Map.of(), + "bot!bot@host", + net.jircd.protocol.Command.PRIVMSG, + "PRIVMSG", + List.of("alice", "one")))); + List lines = alice.readThroughPong(TIMEOUT); + assertThat(lines).anyMatch(l -> l.contains("PRIVMSG alice one")); + assertThat(lines).noneMatch(l -> l.contains("BATCH")); + } + } + + @Test + void theUnrelatedProvidersAndCoreKeepWorkingWithAnyOneMissing() throws Exception { + for (String absent : PROVIDERS) { + try (TestServer server = startWithout(absent); + RawIrcClient alice = registered(server, "alice", ""); + RawIrcClient bob = registered(server, "bob", "")) { + alice.send("JOIN #core"); + alice.readUntil("353", TIMEOUT); + bob.send("JOIN #core"); + bob.readUntil("353", TIMEOUT); + alice.send("PRIVMSG #core :still works without " + absent); + assertThat(bob.readUntil("still works", TIMEOUT)).contains("PRIVMSG #core"); + alice.send("NICK alicia"); + assertThat(bob.readUntil("NICK", TIMEOUT)).contains("alicia"); + } + } + } +} diff --git a/jircd-protocol/src/main/java/net/jircd/protocol/CapabilityName.java b/jircd-protocol/src/main/java/net/jircd/protocol/CapabilityName.java index 75c6020..27606e7 100644 --- a/jircd-protocol/src/main/java/net/jircd/protocol/CapabilityName.java +++ b/jircd-protocol/src/main/java/net/jircd/protocol/CapabilityName.java @@ -16,14 +16,11 @@ package net.jircd.protocol; /** - * IRCv3 capability names this server implements — the one shared source of truth for a wire value - * that both server-side code and a future client both need: each {@code jircd-capabilities/*} - * module's own {@code Extension} class exposes its id via one of these constants (rather than its - * own private literal), and {@code jircd-core} command handlers that must check a recipient's - * negotiated set reference the same constant directly — {@code jircd-core} cannot depend on {@code - * jircd-capabilities} (wrong dependency direction), but both already depend on this module, so - * routing through here removes the duplication instead of requiring every caller to re-declare its - * own copy of the string. + * IRCv3 capability names this server implements — the shared vocabulary of the wire value, so each + * {@code jircd-capabilities/*} module's own {@code Extension} class exposes its id through one of + * these constants rather than its own private literal, and clients and tests refer to the same + * spelling. Names only: {@code jircd-core} never branches on a concrete capability name; each + * module owns its behavior through the typed registrations of the capability SPI. */ public final class CapabilityName { diff --git a/jircd-server-extensions/admin/src/main/java/net/jircd/serverextensions/admin/SajoinCommandHandler.java b/jircd-server-extensions/admin/src/main/java/net/jircd/serverextensions/admin/SajoinCommandHandler.java index e2ea62b..4e4c342 100644 --- a/jircd-server-extensions/admin/src/main/java/net/jircd/serverextensions/admin/SajoinCommandHandler.java +++ b/jircd-server-extensions/admin/src/main/java/net/jircd/serverextensions/admin/SajoinCommandHandler.java @@ -88,7 +88,13 @@ public void handle(ClientSession session, Message message) { Message joinNotification = new Message(Map.of(), presentedForm, Command.JOIN, "JOIN", List.of(channel.name())); Recipients.deliver(result.recipients(), joinNotification); + if (!result.alreadyMember()) { + extensionRegistry + .committedEvents() + .joined(session, presentedForm, channel.name(), result.recipients()); + } - JoinCommandHandler.sendNamesReply(session, channel, serverName.get(), result.memberListing()); + JoinCommandHandler.sendNamesReply( + extensionRegistry, session, channel, serverName.get(), result.members()); } } diff --git a/jircd-server/src/main/java/net/jircd/server/JircdServerApplication.java b/jircd-server/src/main/java/net/jircd/server/JircdServerApplication.java index 191f537..47c686c 100644 --- a/jircd-server/src/main/java/net/jircd/server/JircdServerApplication.java +++ b/jircd-server/src/main/java/net/jircd/server/JircdServerApplication.java @@ -53,7 +53,6 @@ import net.jircd.core.session.command.PartCommandHandler; import net.jircd.core.session.command.PingPongCommandHandler; import net.jircd.core.session.command.QuitCommandHandler; -import net.jircd.core.session.command.TagmsgCommandHandler; import net.jircd.core.session.command.TimeCommandHandler; import net.jircd.core.session.command.TopicCommandHandler; import net.jircd.core.session.command.UserCommandHandler; @@ -266,8 +265,8 @@ private void registerStory1Handlers() { } /** - * VERSION/TIME/LUSERS/AWAY/WHOWAS/TAGMSG (002-extended-irc-commands), plus MOTD, ISON, ADMIN, - * STATS and HELP/HELPOP. + * VERSION/TIME/LUSERS/AWAY/WHOWAS (002-extended-irc-commands), plus MOTD, ISON, ADMIN, STATS and + * HELP/HELPOP. */ private void registerExtendedCommandHandlers(net.jircd.core.session.command.MotdService motd) { connectionHandler.registerHandler( @@ -310,10 +309,6 @@ private void registerExtendedCommandHandlers(net.jircd.core.session.command.Motd Command.AWAY, new AwayCommandHandler(() -> serverName, extensionRegistry)); connectionHandler.registerHandler( Command.WHOWAS, new WhowasCommandHandler(whowasHistory, () -> serverName)); - connectionHandler.registerHandler( - Command.TAGMSG, - new TagmsgCommandHandler( - channelRegistry, nicknameRegistry, extensionRegistry, () -> serverName)); } /** {@code KICK}/channel-{@code MODE}/{@code INVITE} (Story 5, FR-013/FR-014/FR-064/FR-065). */ diff --git a/specs/014-capability-plugin-spi/tasks.md b/specs/014-capability-plugin-spi/tasks.md index 78fec99..51abec1 100644 --- a/specs/014-capability-plugin-spi/tasks.md +++ b/specs/014-capability-plugin-spi/tasks.md @@ -40,46 +40,46 @@ ### Shared delivery and message-tags -- [ ] T017 [PR101] [US1] Add `jircd-core/src/test/java/net/jircd/core/session/MessageDeliveryTest.java` for shared target/send gates, bans/privacy/NOTICE silence, channel sender exclusion/direct self exactly once, merged roles and “unsolicited peer traffic does not inherit request correlation”; assert stable envelope id/time and no registry scan per recipient. -- [ ] T018 [PR101] [US1] Extract `MessageDelivery.java`, immutable DeliveryIntent/RequestContext under `jircd-core/src/main/java/net/jircd/core/session/` from `command/MessageCommandHandler.java`; preserve core authorization/identity, recipient+envelope dedup, typed sender/recipient prerequisites, bounded provider-namespaced context and captured immutable inputs without lifecycle retry. -- [ ] T019 [PR101] [US1] Add `MessageTagsExtensionTest.java`/`TagmsgCommandHandlerTest.java` under `jircd-capabilities/message-tags/src/test/java/net/jircd/capabilities/messagetags/` for client-only normalization/forged keys, sender without negotiation where permitted, exact shared msgid, TAGMSG recipient/send/error behavior and malformed/oversize tag containment. -- [ ] T020 [PR101] [US1] Migrate `jircd-capabilities/message-tags/src/main/java/net/jircd/capabilities/messagetags/MessageTagsExtension.java` to inbound/msgid/recipient/command registrations; move core `session/command/TagmsgCommandHandler.java` into this module using MessageDelivery, remove MESSAGE_TAGS behavior from `CapabilityTagRenderer.java` and hard-wiring from `jircd-server/src/main/java/net/jircd/server/JircdServerApplication.java`. -- [ ] T021 [PR101] [US1] Run module/core tests and unchanged `TagmsgCommandTest.java`, `Story2MessageIdTest.java` and `ConnectionAndCapPrecisionTest.java` in `jircd-integration-tests/src/test/java/net/jircd/integration/`; record green checkpoint/fixture hashes in `specs/014-capability-plugin-spi/validation/pr-101/migrations.md` before echo-message. +- [x] T017 [PR101] [US1] Add `jircd-core/src/test/java/net/jircd/core/session/MessageDeliveryTest.java` for shared target/send gates, bans/privacy/NOTICE silence, channel sender exclusion/direct self exactly once, merged roles and “unsolicited peer traffic does not inherit request correlation”; assert stable envelope id/time and no registry scan per recipient. +- [x] T018 [PR101] [US1] Extract `MessageDelivery.java`, immutable DeliveryIntent/RequestContext under `jircd-core/src/main/java/net/jircd/core/session/` from `command/MessageCommandHandler.java`; preserve core authorization/identity, recipient+envelope dedup, typed sender/recipient prerequisites, bounded provider-namespaced context and captured immutable inputs without lifecycle retry. +- [x] T019 [PR101] [US1] Add `MessageTagsExtensionTest.java`/`TagmsgCommandHandlerTest.java` under `jircd-capabilities/message-tags/src/test/java/net/jircd/capabilities/messagetags/` for client-only normalization/forged keys, sender without negotiation where permitted, exact shared msgid, TAGMSG recipient/send/error behavior and malformed/oversize tag containment. +- [x] T020 [PR101] [US1] Migrate `jircd-capabilities/message-tags/src/main/java/net/jircd/capabilities/messagetags/MessageTagsExtension.java` to inbound/msgid/recipient/command registrations; move core `session/command/TagmsgCommandHandler.java` into this module using MessageDelivery, remove MESSAGE_TAGS behavior from `CapabilityTagRenderer.java` and hard-wiring from `jircd-server/src/main/java/net/jircd/server/JircdServerApplication.java`. +- [x] T021 [PR101] [US1] Run module/core tests and unchanged `TagmsgCommandTest.java`, `Story2MessageIdTest.java` and `ConnectionAndCapPrecisionTest.java` in `jircd-integration-tests/src/test/java/net/jircd/integration/`; record green checkpoint/fixture hashes in `specs/014-capability-plugin-spi/validation/pr-101/migrations.md` before echo-message. ### echo-message -- [ ] T022 [PR101] [US1] Add `jircd-capabilities/echo-message/src/test/java/net/jircd/capabilities/echomessage/EchoMessageExtensionTest.java` for sender-copy policy on direct/channel/self PRIVMSG/NOTICE/TAGMSG, role merge/no duplicate recipient, immutable sender negotiation and request context propagation. -- [ ] T023 [PR101] [US1] Migrate `jircd-capabilities/echo-message/src/main/java/net/jircd/capabilities/echomessage/EchoMessageExtension.java` to typed sender policy; remove provider scanning/echo assumptions from `jircd-core/src/main/java/net/jircd/core/session/command/MessageCommandHandler.java`; module TAGMSG shares the generic role pipeline. -- [ ] T024 [PR101] [US1] Run provider/core self-direct tests and unchanged `jircd-integration-tests/src/test/java/net/jircd/integration/DirectMessageGuaranteesTest.java`/`Story2NegotiatedDeliveryTest.java`; append green checkpoint to `specs/014-capability-plugin-spi/validation/pr-101/migrations.md` before away-notify. +- [x] T022 [PR101] [US1] Add `jircd-capabilities/echo-message/src/test/java/net/jircd/capabilities/echomessage/EchoMessageExtensionTest.java` for sender-copy policy on direct/channel/self PRIVMSG/NOTICE/TAGMSG, role merge/no duplicate recipient, immutable sender negotiation and request context propagation. +- [x] T023 [PR101] [US1] Migrate `jircd-capabilities/echo-message/src/main/java/net/jircd/capabilities/echomessage/EchoMessageExtension.java` to typed sender policy; remove provider scanning/echo assumptions from `jircd-core/src/main/java/net/jircd/core/session/command/MessageCommandHandler.java`; module TAGMSG shares the generic role pipeline. +- [x] T024 [PR101] [US1] Run provider/core self-direct tests and unchanged `jircd-integration-tests/src/test/java/net/jircd/integration/DirectMessageGuaranteesTest.java`/`Story2NegotiatedDeliveryTest.java`; append green checkpoint to `specs/014-capability-plugin-spi/validation/pr-101/migrations.md` before away-notify. ### Events and away-notify -- [ ] T025 [PR101] [US1] Add `jircd-core/src/test/java/net/jircd/core/session/CommittedEventTest.java` and `jircd-capabilities/away-notify/src/test/java/net/jircd/capabilities/awaynotify/AwayNotifyExtensionTest.java` for successful/rejected/no-op away/join/part/quit/nick, accepted repeated AWAY, old NICK identity/pre-departure audience, idempotent cleanup, shared-peer dedup/self exclusion/cloak and committed state after a listener fault. -- [ ] T026 [PR101] [US1] Publish generic committed facts after locks from core `session/ChannelRegistry.java`, `DisconnectCleanup.java`, `command/AwayCommandHandler.java`, `NickCommandHandler.java`, `JoinCommandHandler.java` and `PartCommandHandler.java`, including reused admin SAJOIN/KILL outcomes; move AWAY peer selection/message behavior into `jircd-capabilities/away-notify/src/main/java/net/jircd/capabilities/awaynotify/AwayNotifyExtension.java`, retaining core away state/confirmations. -- [ ] T027 [PR101] [US1] Run event/module tests and unchanged `jircd-integration-tests/src/test/java/net/jircd/integration/AwayNotifyTest.java`/`AwayStatusTest.java`/`DepartureRecipientsTest.java`; append green checkpoint to `specs/014-capability-plugin-spi/validation/pr-101/migrations.md` before multi-prefix. +- [x] T025 [PR101] [US1] Add `jircd-core/src/test/java/net/jircd/core/session/CommittedEventTest.java` and `jircd-capabilities/away-notify/src/test/java/net/jircd/capabilities/awaynotify/AwayNotifyExtensionTest.java` for successful/rejected/no-op away/join/part/quit/nick, accepted repeated AWAY, old NICK identity/pre-departure audience, idempotent cleanup, shared-peer dedup/self exclusion/cloak and committed state after a listener fault. +- [x] T026 [PR101] [US1] Publish generic committed facts after locks from core `session/ChannelRegistry.java`, `DisconnectCleanup.java`, `command/AwayCommandHandler.java`, `NickCommandHandler.java`, `JoinCommandHandler.java` and `PartCommandHandler.java`, including reused admin SAJOIN/KILL outcomes; move AWAY peer selection/message behavior into `jircd-capabilities/away-notify/src/main/java/net/jircd/capabilities/awaynotify/AwayNotifyExtension.java`, retaining core away state/confirmations. +- [x] T027 [PR101] [US1] Run event/module tests and unchanged `jircd-integration-tests/src/test/java/net/jircd/integration/AwayNotifyTest.java`/`AwayStatusTest.java`/`DepartureRecipientsTest.java`; append green checkpoint to `specs/014-capability-plugin-spi/validation/pr-101/migrations.md` before multi-prefix. ### multi-prefix -- [ ] T028 [PR101] [US1] Add `jircd-core/src/test/java/net/jircd/core/session/MemberReplyRenderingTest.java` and `jircd-capabilities/multi-prefix/src/test/java/net/jircd/capabilities/multiprefix/MultiPrefixExtensionTest.java` for member facts with no multi-prefix flag, consistently ordered held statuses, core highest default, independent identity-slot composition and complete fitted tokens/WHO flags. -- [ ] T029 [PR101] [US1] Migrate `jircd-capabilities/multi-prefix/src/main/java/net/jircd/capabilities/multiprefix/MultiPrefixExtension.java` to status/bound hooks; replace capability-shaped listing/booleans in core Channel/ChannelRegistry/JoinCommandHandler/WhoCommandHandler and admin SajoinCommandHandler with generic snapshots/rendering, and remove hidden fixed capability widths from `jircd-core/src/main/java/net/jircd/core/config/ConfigurationLoader.java`/`HostmaskReplyBudget.java`. -- [ ] T030 [PR101] [US1] Run member/module/budget tests and unchanged `MultiPrefixTest.java`, `NamesByteBudgetTest.java`, `Story7WhoChannelTest.java` and `Story6SamodeTest.java` under `jircd-integration-tests/src/test/java/net/jircd/integration/`; append green checkpoint to `specs/014-capability-plugin-spi/validation/pr-101/migrations.md` before batch. +- [x] T028 [PR101] [US1] Add `jircd-core/src/test/java/net/jircd/core/session/MemberReplyRenderingTest.java` and `jircd-capabilities/multi-prefix/src/test/java/net/jircd/capabilities/multiprefix/MultiPrefixExtensionTest.java` for member facts with no multi-prefix flag, consistently ordered held statuses, core highest default, independent identity-slot composition and complete fitted tokens/WHO flags. +- [x] T029 [PR101] [US1] Migrate `jircd-capabilities/multi-prefix/src/main/java/net/jircd/capabilities/multiprefix/MultiPrefixExtension.java` to status/bound hooks; replace capability-shaped listing/booleans in core Channel/ChannelRegistry/JoinCommandHandler/WhoCommandHandler and admin SajoinCommandHandler with generic snapshots/rendering, and remove hidden fixed capability widths from `jircd-core/src/main/java/net/jircd/core/config/ConfigurationLoader.java`/`HostmaskReplyBudget.java`. +- [x] T030 [PR101] [US1] Run member/module/budget tests and unchanged `MultiPrefixTest.java`, `NamesByteBudgetTest.java`, `Story7WhoChannelTest.java` and `Story6SamodeTest.java` under `jircd-integration-tests/src/test/java/net/jircd/integration/`; append green checkpoint to `specs/014-capability-plugin-spi/validation/pr-101/migrations.md` before batch. ### batch -- [ ] T031 [PR101] [US1] Add `jircd-capabilities/batch/src/test/java/net/jircd/capabilities/batch/BatchExtensionTest.java` plus a registered finite-group socket probe in `jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityGroupTest.java`; assert both/either/missing prerequisite fallback, prefixless framing, valid unique ASCII refs, empty/concurrent/nested bounded groups, full reservation and retained close on admitted groups. -- [ ] T032 [PR101] [US1] Implement group shaping in `jircd-capabilities/batch/src/main/java/net/jircd/capabilities/batch/BatchExtension.java`; move ref/BATCH/tag/gating out of core `session/SessionWriter.java`, migrate enqueueBatch callers/unit fixtures to generic enqueueGroup and validated framed/plain alternatives with whole-queue append; core expresses prerequisites generically, and #97 adds the atomic current-state choice during concurrent transitions. -- [ ] T033 [PR101] [US1] Run batch/provider/generic writer/socket group tests and unchanged integration suite; append green checkpoint to `specs/014-capability-plugin-spi/validation/pr-101/migrations.md`; verify batch-only still negotiates/offers and delivers plain members, with server-time independent of message-tags. +- [x] T031 [PR101] [US1] Add `jircd-capabilities/batch/src/test/java/net/jircd/capabilities/batch/BatchExtensionTest.java` plus a registered finite-group socket probe in `jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityGroupTest.java`; assert both/either/missing prerequisite fallback, prefixless framing, valid unique ASCII refs, empty/concurrent/nested bounded groups, full reservation and retained close on admitted groups. +- [x] T032 [PR101] [US1] Implement group shaping in `jircd-capabilities/batch/src/main/java/net/jircd/capabilities/batch/BatchExtension.java`; move ref/BATCH/tag/gating out of core `session/SessionWriter.java`, migrate enqueueBatch callers/unit fixtures to generic enqueueGroup and validated framed/plain alternatives with whole-queue append; core expresses prerequisites generically, and #97 adds the atomic current-state choice during concurrent transitions. +- [x] T033 [PR101] [US1] Run batch/provider/generic writer/socket group tests and unchanged integration suite; append green checkpoint to `specs/014-capability-plugin-spi/validation/pr-101/migrations.md`; verify batch-only still negotiates/offers and delivers plain members, with server-time independent of message-tags. ### Composition, absence and cleanup -- [ ] T034 [PR101] [US3] Add `jircd-core/src/test/java/net/jircd/core/extension/capability/CapabilityCompositionTest.java` for a test-only capability using existing committed events, identity/status slots, bounded RequestContext/roles and finite groups without a core name change; map account-notify/chathistory/label/userhost seams and remaining domain work without implementing those future features. -- [ ] T035 [PR101] [US1] Add `jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityProviderAbsenceTest.java` using isolated catalogs/classpaths with matching desired config; test all six missing providers' offer AND behavior absence, core/unrelated behavior and no destructive JAR deletion. #97 extends this test to assert core cap-notify remains when any/all providers are absent. -- [ ] T036 [PR101] [US1] Remove contributeTags/includeSenderInFanOut, temporary bridge, stale Javadoc and all dead capability behavior from `jircd-core/src/main/java/` and `jircd-server/src/main/java/net/jircd/server/JircdServerApplication.java`; audit every research inventory site and literal/CapabilityName alias, leaving only CAP infrastructure and generic core behavior. -- [ ] T037 [PR101] [US1] Migrate affected core/provider unit API fixtures under `jircd-core/src/test/java/` and `jircd-capabilities/`; document public hook/ServerContext/member/group API migration at definitions and `README.md`, with the unreleased `0.1.0-SNAPSHOT` first-release version signal. Keep existing integration sources unchanged. -- [ ] T038 [PR101] Update ownership/extension claims in `specs/001-ircv3-server/data-model.md`, its `contracts/irc-protocol-commands.md` and `specs/011-away-notify-batch/contracts/away-notify-and-batch.md` to match module-owned behavior and generic reply/group rules; do not claim runtime #97/#102 mechanics are already implemented. -- [ ] T039 [PR101] Run full baseline/candidate irctest with T002's identical frozen harness/config and `--timeout=60 --timeout-method=signal`; record case-level pass/fail/skip comparison and zero new failures/skips under `specs/014-capability-plugin-spi/validation/pr-101/`, retaining unrelated baseline failures and the full unchanged integration fixture proof. -- [ ] T040 [PR101] Run applicable existing tagged fan-out/delivery/JOIN loads, record comparisons and resolve `.github/REVIEW_CHECKLIST.md`/other-vendor/SonarCloud findings as available in `specs/014-capability-plugin-spi/validation/pr-101/review.md`; verify docs/API/zero-branch inventory and all five migration checkpoints before final build. -- [ ] T041 [PR101] Finish #101 with `./gradlew spotlessApply` then `./gradlew build` green, including static/coverage gates and no aggregate coverage decrease; record in `specs/014-capability-plugin-spi/validation/pr-101/results.md`. Checkpoint: all six modules own behavior, legacy hooks gone, unchanged existing integration suite and irctest evidence recorded. +- [x] T034 [PR101] [US3] Add `jircd-core/src/test/java/net/jircd/core/extension/capability/CapabilityCompositionTest.java` for a test-only capability using existing committed events, identity/status slots, bounded RequestContext/roles and finite groups without a core name change; map account-notify/chathistory/label/userhost seams and remaining domain work without implementing those future features. +- [x] T035 [PR101] [US1] Add `jircd-integration-tests/src/test/java/net/jircd/integration/CapabilityProviderAbsenceTest.java` using isolated catalogs/classpaths with matching desired config; test all six missing providers' offer AND behavior absence, core/unrelated behavior and no destructive JAR deletion. #97 extends this test to assert core cap-notify remains when any/all providers are absent. +- [x] T036 [PR101] [US1] Remove contributeTags/includeSenderInFanOut, temporary bridge, stale Javadoc and all dead capability behavior from `jircd-core/src/main/java/` and `jircd-server/src/main/java/net/jircd/server/JircdServerApplication.java`; audit every research inventory site and literal/CapabilityName alias, leaving only CAP infrastructure and generic core behavior. +- [x] T037 [PR101] [US1] Migrate affected core/provider unit API fixtures under `jircd-core/src/test/java/` and `jircd-capabilities/`; document public hook/ServerContext/member/group API migration at definitions and `README.md`, with the unreleased `0.1.0-SNAPSHOT` first-release version signal. Keep existing integration sources unchanged. +- [x] T038 [PR101] Update ownership/extension claims in `specs/001-ircv3-server/data-model.md`, its `contracts/irc-protocol-commands.md` and `specs/011-away-notify-batch/contracts/away-notify-and-batch.md` to match module-owned behavior and generic reply/group rules; do not claim runtime #97/#102 mechanics are already implemented. +- [x] T039 [PR101] Run full baseline/candidate irctest with T002's identical frozen harness/config and `--timeout=60 --timeout-method=signal`; record case-level pass/fail/skip comparison and zero new failures/skips under `specs/014-capability-plugin-spi/validation/pr-101/`, retaining unrelated baseline failures and the full unchanged integration fixture proof. +- [x] T040 [PR101] Run applicable existing tagged fan-out/delivery/JOIN loads, record comparisons and resolve `.github/REVIEW_CHECKLIST.md`/other-vendor/SonarCloud findings as available in `specs/014-capability-plugin-spi/validation/pr-101/review.md`; verify docs/API/zero-branch inventory and all five migration checkpoints before final build. +- [x] T041 [PR101] Finish #101 with `./gradlew spotlessApply` then `./gradlew build` green, including static/coverage gates and no aggregate coverage decrease; record in `specs/014-capability-plugin-spi/validation/pr-101/results.md`. Checkpoint: all six modules own behavior, legacy hooks gone, unchanged existing integration suite and irctest evidence recorded. ## PR 3 — #97: Runtime lifecycle + advertised cap-notify (T042–T054) diff --git a/specs/014-capability-plugin-spi/validation/pr-101/irctest.md b/specs/014-capability-plugin-spi/validation/pr-101/irctest.md new file mode 100644 index 0000000..02ede14 --- /dev/null +++ b/specs/014-capability-plugin-spi/validation/pr-101/irctest.md @@ -0,0 +1,32 @@ +# irctest comparison (T039) + +Run by the coordinator on 2026-10-23 with the harness frozen in [T002](../pr-100/irctest-environment.md). Before the runs, the harness matched every recorded value: HEAD `76b91582`, sha256 of `irctest/controllers/jircd.py` `a9c9ba8d…94`, `requirements.txt` `ac78ec82…3d`, `pyproject.toml` `c08f9d76…a2`, `git diff` `d78cc79c…9b`. Nothing in the harness was modified. + +| Item | Value | +| --- | --- | +| Baseline | main `fc3aeab` (after #104), detached worktree, `:jircd-server:installDist` | +| Candidate | this branch's working tree after review round 2, `:jircd-server:installDist` | +| JDK | Temurin 25.0.4+7 (`JIRCD_JAVA_HOME`) for both | +| Command | `.venv/bin/python -m pytest -p no:cacheprovider --controller irctest.controllers.jircd -m 'not deprecated and not Ergo and not Sable and not services' --timeout=60 --timeout-method=signal -q -rfEs --junitxml=` | + +## Results + +| Revision | Passed | Skipped | Failed | Deselected | Time | +| --- | --- | --- | --- | --- | --- | +| Baseline (main) | 315 | 155 | 4 | 186 | 3m37s | +| Candidate | 315 | 155 | 4 | 186 | 3m33s | + +A case-level comparison of the two JUnit XML reports (classname + test name → pass/fail/skip) finds **no differences**: no new failures, no new skips, no newly passing cases. The candidate was also run once on the round-1 code, with the same result. + +## Baseline failures (unchanged, unrelated) + +All four are in `irctest/server_tests/cap.py::CapTestCase` and fail identically on both revisions: + +- `testReqOne`, `testReqOneThenOne`, `testReqPostRegistration`: `CAP REQ :multi-prefix` gets `NAK`. +- `testReqTwo`: `CAP REQ :multi-prefix userhost-in-names` gets `NAK`. + +multi-prefix was added in #99, after the frozen controller patch. The controller's server config doesn't enable it, so the server doesn't offer it. userhost-in-names isn't implemented. Changing the frozen harness is out of scope for this comparison. + +## Integration fixture proof + +`git diff --name-status origin/main -- jircd-integration-tests` lists no modified or deleted files. The only additions are `CapabilityGroupTest` and `CapabilityProviderAbsenceTest` (see [migrations.md](migrations.md)). diff --git a/specs/014-capability-plugin-spi/validation/pr-101/migrations.md b/specs/014-capability-plugin-spi/validation/pr-101/migrations.md new file mode 100644 index 0000000..f2c8f13 --- /dev/null +++ b/specs/014-capability-plugin-spi/validation/pr-101/migrations.md @@ -0,0 +1,55 @@ +# PR 2 (#101) migration checkpoints (T021, T024, T027, T030, T033) + +Recorded on the uncommitted working tree of branch `014-capability-migrations` (base `fc3aeab`), JDK Temurin 25, Gradle 9.8.1. Each section was written when its checkpoint was green, before the next migration started. + +Fixture proof used at every checkpoint: the sorted per-file `sha256` listing of `jircd-integration-tests/src/test` hashes to `ba2b56c66a7acd6bcd833be0a4ae0c50e07f837871c593133713932d85b8b474` (117 files), identical to [pr-100/integration-fixtures.md](../pr-100/integration-fixtures.md), and `git status --short jircd-integration-tests` lists nothing. The only integration source this PR adds are the two new files the tasks ask for (`CapabilityGroupTest`, `CapabilityProviderAbsenceTest`), recorded in `results.md`. + +## 1. message-tags (T017–T021) + +| Check | Result | +| --- | --- | +| `./gradlew :jircd-core:test :jircd-capabilities:message-tags:test` | green (`MessageDeliveryTest`, `MessageTagsExtensionTest`, `TagmsgCommandHandlerTest` and the existing core suite) | +| `./gradlew :jircd-integration-tests:test --tests '*TagmsgCommandTest' --tests '*Story2MessageIdTest' --tests '*ConnectionAndCapPrecisionTest'` | green: 13 tests, 0 failed | +| Integration fixture hash | `ba2b56c6…8b474`, 117 files, unchanged | + +What moved: `MessageDelivery` (core) owns target resolution, send gates, the envelope and role selection. `MessageTagsExtension` owns the client-only-tag selection (inbound hook), `msgid` (outbound hook) and `TAGMSG` (provider command, `TagmsgCommandHandler` moved out of core). Core's `CapabilityTagRenderer` forwards a selected client tag to a recipient that negotiated the owner of an inbound-tag hook; it no longer names `message-tags`. The echo-message scan in `MessageDelivery` is a temporary bridge removed in the next migration. + +## 2. echo-message (T022–T024) + +| Check | Result | +| --- | --- | +| `./gradlew :jircd-core:test :jircd-capabilities:echo-message:test :jircd-capabilities:message-tags:test` | green (`EchoMessageExtensionTest`: 13 tests; `SelfDirectMessageTest` now stages a recipient hook instead of `includeSenderInFanOut`) | +| `./gradlew :jircd-integration-tests:test --tests '*DirectMessageGuaranteesTest' --tests '*Story2NegotiatedDeliveryTest'` (plus the message-tags set again) | green: 2 + 1 tests (and `TagmsgCommandTest`, `Story2MessageIdTest`), 0 failed | +| Integration fixture hash | `ba2b56c6…8b474`, 117 files, unchanged | + +What moved: `EchoMessageExtension` registers a recipient hook returning `SENDER_COPY`. Core applies it only when the sender negotiated the owner, merges it with the ordinary role (a self-target is delivered once) and applies the recipient rule of the command to the copy (TAGMSG still needs `message-tags` on the copy). The temporary `includeSenderInFanOut` scan in `MessageDelivery` is gone. + +## 3. away-notify and committed events (T025–T027) + +| Check | Result | +| --- | --- | +| `./gradlew :jircd-core:test :jircd-capabilities:away-notify:test` | green (`CommittedEventTest`: 13 tests (at the end of the PR; 12 when this checkpoint was taken), `AwayNotifyExtensionTest`: 7 tests) | +| `./gradlew :jircd-integration-tests:test --tests '*AwayNotifyTest' --tests '*AwayStatusTest' --tests '*DepartureRecipientsTest'` (plus `Story6SajoinTest`, `SajoinRepeatTest`, `KillCommandTest`, `NickBroadcastTest`, `QuitNeighborsTest`) | green: 6 + 5 + 1 tests, 0 failed | +| Integration fixture hash | `ba2b56c6…8b474`, 117 files, unchanged | + +What moved: core publishes `AWAY_CHANGED`, `JOINED`, `PARTED`, `QUIT` and `NICK_CHANGED` through `CommittedEvents` after the transition, with no monitor held, from `AwayCommandHandler`, `JoinCommandHandler`, `PartCommandHandler`, `NickCommandHandler`, `DisconnectCleanup` (which every QUIT, KILL, timeout and sendq path funnels through) and the admin `SajoinCommandHandler`. A kind nobody listens for resolves neither the presented identity nor the audience. `AwayNotifyExtension` owns the AWAY notice: it answers the event with one notice for the audience, and core narrows it to peers that negotiated `away-notify`. Core keeps the away state and the 305/306 confirmations. + +## 4. multi-prefix (T028–T030) + +| Check | Result | +| --- | --- | +| `./gradlew :jircd-core:test :jircd-capabilities:multi-prefix:test :jircd-server-extensions:admin:test` | green (core: 793 tests, 0 failed; `MemberReplyRenderingTest` 7 tests, `MultiPrefixExtensionTest` 5 tests) | +| `./gradlew :jircd-integration-tests:test --tests '*MultiPrefixTest' --tests '*NamesByteBudgetTest' --tests '*Story7WhoChannelTest' --tests '*Story6SamodeTest'` (plus `ReplyBudgetReloadTest`, `BareNamesTest`, `Story1NamesTest`, `Story6SajoinTest`, `Story5NamesPrefixTest`, `Story4InvalidConfigTest`, `Story4LengthLimitConfigTest`) | green: 2 + 11 + 2 + 2 tests, 0 failed | +| Integration fixture hash | `ba2b56c6…8b474`, 117 files, unchanged | + +What moved: `Channel` no longer takes a multi-prefix flag; it exposes `memberFacts()` (the session plus every held status, highest first, read as one consistent view) and keeps `memberListing()` as the highest-status form integration tests use. `JoinResult` carries unformatted `MemberFacts`; the new `MemberReplies` renders NAMES tokens and channel-WHO status for the requester through the status and NAMES-identity slots, resolving identity fields only when a provider needs them. JOIN, NAMES (targeted and bare), the admin SAJOIN and WHO all use it. `MultiPrefixExtension` replaces the status slot with all held symbols and declares its own 2-byte bound; the fixed 2-byte "legacy" width is gone from `ReplyBudgetCatalog`, `ConfigurationLoader` and `HostmaskReplyBudget`, so a server without the provider reserves only core's single highest-status byte. With the module on the classpath (every shipped build) the reserved envelope is unchanged: the same limits are accepted and rejected. + +## 5. batch (T031–T033) + +| Check | Result | +| --- | --- | +| `./gradlew :jircd-core:test :jircd-capabilities:{batch,multi-prefix,away-notify,echo-message,message-tags,server-time}:test :jircd-server-extensions:admin:test` | green (`BatchExtensionTest` 11 tests, `SessionWriterGroupTest` 7 tests at this checkpoint, 8 at the end; the old `SessionWriterBatchTest` is replaced by them) | +| `./gradlew :jircd-integration-tests:test` (whole suite, load tests excluded) | green: 302 tests, 0 failed, 0 skipped (includes the 6 new `CapabilityGroupTest` socket probes) | +| Integration fixture hash | the 117 tracked files hash to `ba2b56c6…8b474` again; the only change under `jircd-integration-tests` is the new untracked `CapabilityGroupTest.java` | + +What moved: `SessionWriter.enqueueBatch` is gone. `SessionWriter.enqueueGroup(type, params, members[, context])` builds a finite `OutputGroup`, asks the renderer (`TagRenderer.frame`, implemented by `CapabilityTagRenderer` through the hook dispatcher) for the recipient's framing plan, and queues the validated frames as one `FrameSequence` entry: all frames or none, so an opener never lacks its closer, concurrent groups never interleave, and a group larger than the queue overflows only that recipient before any transformed copy is allocated. `BatchExtension` owns the reference, the prefixless `BATCH` lines and the `batch` tag, with `message-tags` as a session prerequisite: `batch` alone is still offered, acknowledged and inert, and plain members are sent. A member that already carries a `batch` tag keeps it (nesting). Core does not choose the framing again at admission: that atomic current-prerequisite choice under concurrent capability changes is the later runtime-lifecycle change (#97), which this PR does not claim. diff --git a/specs/014-capability-plugin-spi/validation/pr-101/results.md b/specs/014-capability-plugin-spi/validation/pr-101/results.md new file mode 100644 index 0000000..0e7b1b1 --- /dev/null +++ b/specs/014-capability-plugin-spi/validation/pr-101/results.md @@ -0,0 +1,86 @@ +# PR 2 (#101) results (T037, T041) + +Recorded on the uncommitted working tree of branch `014-capability-migrations` (base `fc3aeab`, main after #104), JDK Temurin 25.0.4+7, Gradle 9.8.1, Apple M4 Pro. Per-checkpoint evidence is in [migrations.md](migrations.md), the self-check in [review.md](review.md). irctest (T039), SonarCloud and the other-vendor review were run by the coordinator: [irctest.md](irctest.md), [review.md](review.md). + +## Final build (T041) + +| Command | Outcome | +| --- | --- | +| `./gradlew spotlessApply` | applied, then clean | +| `./gradlew build --rerun-tasks` | **BUILD SUCCESSFUL** (1 m 36 s, rerun after review round 2): 1254 tests in the module XML reports, 0 failed, 0 skipped; Spotless, SpotBugs (main and test), PMD (main and test) and `testCodeCoverageVerification` all green | + +Two earlier plain builds failed before this one and were fixed, not retried: `pmdTest` flagged `CloseResource` on test writers closed by an `@AfterEach` (suppressed with that reason at the six declarations) and `spotbugsTest` flagged `USO_UNSAFE_OBJECT_SYNCHRONIZATION` on a `synchronized` HashMap in `CommittedEventTest` (now a `ConcurrentHashMap`). + +| Module | Tests | +| --- | --- | +| jircd-protocol | 58 | +| jircd-core | 810 | +| jircd-capabilities: away-notify / batch / echo-message / message-tags / multi-prefix / server-time | 7 / 11 / 13 / 13 / 5 / 6 | +| jircd-server-extensions:admin | 20 | +| jircd-integration-tests | 311 (302 existing and group probes, plus the 9 of `CapabilityProviderAbsenceTest`) | + +### Coverage (aggregate over every module with `src/main/java`) + +Report: `build/reports/jacoco/testCodeCoverageReport/` (`html/`, `testCodeCoverageReport.xml`). + +| | Covered | Floor | After #100 | Result | +| --- | --- | --- | --- | --- | +| Line | **95.56%** (5744 of 6011) | 85% | 94.95% | pass, higher | +| Branch | **88.61%** (2341 of 2642) | 75% | 87.30% | pass, higher | + +By package: `net.jircd.core.session` lines 1709 of 1746, branches 665 of 748; `net.jircd.core.extension.capability` lines 618 of 639, branches 314 of 354; each capability module's `main` code is fully covered by line (`message-tags` 51/51, `batch` 19/19, `away-notify` 10/10, `echo-message` 4/4, `multi-prefix` 5/5). + +## Existing integration fixtures unchanged (acceptance) + +`git status --short jircd-integration-tests` lists only the two new files the tasks ask for, `CapabilityGroupTest.java` (T031) and `CapabilityProviderAbsenceTest.java` (T035). The 117 tracked files under `jircd-integration-tests/src/test` hash to `ba2b56c66a7acd6bcd833be0a4ae0c50e07f837871c593133713932d85b8b474` (sorted `shasum -a 256` listing), identical to [pr-100/integration-fixtures.md](../pr-100/integration-fixtures.md), and `git diff HEAD -- jircd-integration-tests` is empty. The full existing socket suite passed against the migrated modules at every checkpoint and in the final build. + +## Tests added or changed (T017–T037) + +New (tests were written before the production code of each migration; I did not record a separate red run for each, but `MessageTagsExtension`/`TagmsgCommandHandler` did not compile and the echo-message tests failed on the unmigrated module before T023): + +| Class (module) | Tests | Covers | +| --- | --- | --- | +| `MessageDeliveryTest` (core) | 13 | T017: send gates, bans, silent NOTICE, sender exclusion, direct self exactly once, role merging, one hook call per delivery, stable msgid/time, request context never reaching peers | +| `MessageTagsExtensionTest`, `TagmsgCommandHandlerTest` (message-tags) | 5 + 8 | T019: client-only tag selection and forged keys, msgid, TAGMSG recipient/send/error rules, oversize tags | +| `EchoMessageExtensionTest` (echo-message) | 13 | T022: sender copy on direct, channel and self PRIVMSG/NOTICE/TAGMSG, role merge, central sender gate | +| `CommittedEventTest` (core) | 13 | T025: every event kind, accepted-versus-rejected/no-op, repeated AWAY, old NICK identity, pre-departure audience, idempotent QUIT, listener and identity faults, unlistened kind costs nothing | +| `AwayNotifyExtensionTest` (away-notify) | 7 | T025: notice content, dedup, self exclusion, cloak, provider absent | +| `MemberReplyRenderingTest` (core), `MultiPrefixExtensionTest` (multi-prefix) | 7 + 5 | T028: facts without a flag, consistent status order, core highest default, independent slots, declared bound | +| `BatchExtensionTest` (batch), `SessionWriterGroupTest` (core), `CapabilityGroupTest` (integration) | 11 + 8 + 6 | T031: both/either/missing prerequisite, prefixless framing, unique ASCII references, empty/concurrent/nested groups, whole-group reservation, retained closer | +| `CapabilityCompositionTest` (core) | 5 | T034: a made-up capability from events, slots, request context, roles and groups | +| `CapabilityProviderAbsenceTest` (integration) | 9 | T035: each of the six providers hidden from the service file | + +Changed unit fixtures (T037): `SelfDirectMessageTest` (recipient hook instead of `includeSenderInFanOut`), `ChannelTest` and `ChannelRegistryTest` (member facts; the 2 000 000-iteration status stress test now counts impossible reads and asserts once, because AssertJ per iteration made it take minutes), `ReplyBudgetConfigurationTest` and `CapabilityReplyBudgetTest` (explicit two-symbol catalog in place of the removed legacy one), `CapabilityTagRendererBudgetTest`, `CapabilityTagRendererMigrationTest`, `SessionWriterByteLimitTest` and `CapabilityDispatchTest` (typed hooks in place of `contributeTags` and `legacyOutboundTags`), `HostmaskReplyFitTest` and `MultiPrefixReplyTest` (a multi-prefix stand-in provider), `ServerTimeExtensionTest` (drops the check of the removed method). Removed: `SelfDirectTagmsgTest` (now `TagmsgCommandHandlerTest`), `SessionWriterBatchTest` (now `BatchExtensionTest` and `SessionWriterGroupTest`), `MessageTagsExtensionPurityTest` (now `MessageTagsExtensionTest`). Two old scenarios cannot exist any more and were reshaped: overlapping server tags from different providers (a tag key now has exactly one owner, rejected at publication) and a provider contributing a `+` key. + +## Load comparison (T040) + +`./gradlew loadTest` ran twice on main (`fc3aeab`, a temporary worktree, removed afterwards) and twice on this tree, interleaved, with nothing else running. All 8 load tests passed every time. + +| Load test | main run 1 / run 2 | this tree run 1 / run 2 | +| --- | --- | --- | +| ExceptionModes join, p50 (ms) | 0.596, 0.144 / 0.630, 0.242 | 2.247, 0.251 / 0.682, 0.260 | +| ConcurrentConnectionScale p50 / p99 (ms) | 0.983 / 1.455; 0.716 / 1.737 | 0.902 / 1.641; 1.053 / 1.499 | +| JoinStorm (2000 clients) storm ms; quiet p50 / p99 (ms) | 2204; 33.9 / 119.4 · 2313; 27.9 / 82.9 | 2208; 26.5 / 156.5 · 2244; 31.5 / 171.6 | +| SingleWaveJoinStorm ms; overflow disconnects | 1924; 0 · 1951; 0 | 1956; 0 · 1942; 0 | +| Story1DeliveryLatency (50 messages) p50 / p99 (ms) | 0.524 / 0.845; 0.562 / 0.803 | 0.554 / 2.300; 0.578 / 2.397 | + +Story1 alone, three more runs each, same order (p50 / p99 ms): main 1.369 / 1.903, 1.224 / 1.863, 1.413 / 2.165; this tree 1.432 / 2.303, 1.361 / 5.196, 1.384 / 1.957. + +**Reading:** no meaningful regression. Storm times, overflow counts and the JOIN-storm quiet-channel latency are within run-to-run noise (storm ≈ 2.2 s against the 15 s budget, quiet p99 well under 500 ms). Story1's p99 is the maximum of 50 samples; in the interleaved runs this tree's was about 1.5 ms higher than main's, but the three isolated repeats overlap (1.96–5.20 ms against 1.86–2.17 ms) and the p50 is equal, so I read it as a single slow first delivery plus noise, not a systematic cost; all of it is three orders of magnitude under the 1 s budget. I did not profile it. The dedicated final load measurement belongs to the later change (#102). + +## Deviations from tasks.md and why + +1. **`DeliveryIntent`/`RequestContext` (T018):** the tasks say to add them under `session/`; #100 already defined both in `net.jircd.core.extension.capability`, and `MessageDelivery` uses those rather than a second pair. `MessageDelivery.Delivery` carries the envelope, the context and the roles per recipient. +2. **Forwarding client tags (T020):** instead of a name check, `CapabilityTagRenderer` forwards the tags an inbound-tag hook selected to a recipient that negotiated the owner of such a hook (`RuntimeSnapshot.forwardsClientTags`). Same wire behavior, no capability name in core. +3. **`TAGMSG` without the module (T020):** it is now an unknown command (`421`) when `message-tags` is absent or disabled; core used to register it unconditionally (recipients without the capability received nothing). The provider-absence test pins this. The command still works for a sender without `message-tags` when the module is enabled, as before. +4. **Event audience (T025/T026):** the audience excludes the subject, so "self exclusion" is the core's, not each listener's. A QUIT for a session that never had a nickname, ident and hostname publishes nothing, matching the existing QUIT notice. +5. **Reply API (T029):** `Channel.memberListing()` (no argument) and `JoinCommandHandler.sendNamesReply(requester, channel, server, List)` are kept, because existing integration tests call them; the other `sendNames*` overloads now take the `ExtensionRegistry` first. `ReplyBudgetCatalog.legacyCompatible()` is removed; the multi-prefix module declares the 2 bytes itself. +6. **Groups (T032):** `enqueueGroup` queues a group as one entry, so a plain (unframed) group is also admitted whole where the old `enqueueBatch` queued members one by one; a member that already carries a `batch` tag keeps it (the old code overwrote it), which is what makes nesting possible. The "current prerequisite choice at admission under the administrative mutex" is the later runtime-lifecycle change (#97): framing is chosen when the group is prepared, and no document claims otherwise. +7. **Socket probe (T031):** `CapabilityGroupTest` enqueues the group on a live session's writer from the test thread rather than through a registered probe command, so no test extension is added to the shared integration classpath (it would have appeared in every test's extension listing). +8. **`CommittedEvents.publish` contains `RuntimeException`:** not in the tasks. A failure resolving the presented identity or the audience would otherwise cost a JOIN its NAMES reply or an AWAY its remaining replies after the state had committed. +9. **T039 (irctest):** run by the coordinator; no case-level differences from main. See [irctest.md](irctest.md). + +## Open questions + +- Whether the `time`/`msgid` tag hooks should one day be memoized on the compiled tag section rather than per hook; not needed for the budgets here. +- `CommittedEvents` logs a contained fault at WARN with the exception type only. The bounded, rate-limited diagnostic path is #102's; until then a persistently failing cloak would log once per event. diff --git a/specs/014-capability-plugin-spi/validation/pr-101/review.md b/specs/014-capability-plugin-spi/validation/pr-101/review.md new file mode 100644 index 0000000..4499286 --- /dev/null +++ b/specs/014-capability-plugin-spi/validation/pr-101/review.md @@ -0,0 +1,70 @@ +# Review record (T040) + +**Status:** the implementer's self-check, the other-vendor review (Codex GPT-6.1-Sol, round 1: three P2) and SonarCloud round 1 (14 test-code issues) are recorded below, all fixed. irctest (T039) shows no case-level differences from main: [irctest.md](irctest.md). + +## Other-vendor review: Codex GPT-6.1-Sol, round 1 + +| # | Finding | Test added | Fix | +| --- | --- | --- | --- | +| 1 | P2: client-tag forwarding lost provider ownership; a recipient that negotiated only a second inbound-tag provider received the tags message-tags selected | `MessageDeliveryTest.forwardedClientTagsGoOnlyToRecipientsOfTheProviderThatSelectedThem` (two providers; failed before the fix) | `HookDispatcher.inboundSelection` records which capability selected each forwarded key; `MessageDelivery` stores it on the envelope (`OutboundMessage.forwardedBy`), and `CapabilityTagRenderer` shows a recipient only the tags `forwardedFor(its negotiated set)` returns. A tag with no recorded selector (an envelope built outside `MessageDelivery`) keeps the earlier "any inbound-tag owner negotiated" rule | +| 2 | P2: framing overhead turned an oversized group into plain delivery, because the queue limit was the plan's byte bound | `SessionWriterGroupTest.framingOverheadThatExceedsTheQueueOverflowsTheRecipientInsteadOfFallingBackToPlain` (plain 37 bytes fits a 50-byte queue, framed 62 does not) | the group handed to the dispatcher no longer carries the queue size as its plan bound, so a well-formed plan is never "invalid" for being large; `enqueueGroup` admits the serialized sequence whole and the existing overflow path closes only that recipient, with no partial group. A plan stays bounded by its frame count and the per-frame limits, and the member bytes were already checked against the queue | +| 3 | P2: `deliver` accepted any recipient set, bypassing target and send gates | `MessageDeliveryTest`: `anAuthorizationForOneTargetCannotDeliverToAnotherOrFromAnotherSender`, `anAuthorizationFromAnotherDeliveryServiceIsRefused`, `aFailedAuthorizationDeliversNothingEvenWhenDeliveredAnyway` | `authorize` returns an opaque `MessageDelivery.AuthorizedTarget` (private constructor, bound to its issuing service, sender and target, iterable over its recipients); `deliver` takes nothing else and throws `IllegalArgumentException` for a mismatch, and an empty authorization delivers nothing, not even a sender copy. A caller can no longer supply its own peer set | + +## Other-vendor review: Codex GPT-6.1-Sol, round 2 + +Round 1's three fixes were verified by the reviewer. New findings: + +| # | Finding | Test | Fix | +| --- | --- | --- | --- | +| 1 | P2: when two inbound hooks selected the same tag key, the later selector replaced the earlier, so a recipient of only the first lost the tag | `MessageDeliveryTest.aTagSelectedByTwoCapabilitiesReachesRecipientsOfEitherOne`; `MessageTagsExtensionTest.theSelectionRecordsMessageTagsAsTheOwnerOfEachForwardedTag` | `InboundSelection.ownersByKey` maps each key to the set of every selecting capability, and `OutboundMessage.forwardedFor` shows the tag to a recipient that negotiated any of them | +| 2 | P3: the overflow regression test called `awaitTermination()` with no bound and would hang if framing fell back to plain | the same test, `SessionWriterGroupTest.framingOverheadThatExceedsTheQueue...` | the unbounded wait is gone (also from `aGroupThatCannotFitTheQueue...`): the try-with-resources close drains with the writer's own 2 s bound, then the assertions run, so a regression fails instead of hanging | +| 3 | unowned forwarded tags fell back to the old "any inbound-tag provider negotiated" rule for envelopes built outside `MessageDelivery` | `CapabilityTagRendererMigrationTest.clientTagsWithNoRecordedSelectorAreNotForwardedToAnyone` | `forwardedFor` fails closed: a client tag with no recorded selector is shown to nobody. `RuntimeSnapshot.forwardsClientTags` is deleted. The `OutboundMessage` class Javadoc documents it. The older renderer and byte-limit tests that relied on the fallback now declare their selector through the test helper `ForwardedTags.selectedBy("message-tags", ...)` | + +## SonarCloud round 1 (14 issues, all test code) + +All fixed: S6126 text/`String.join` instead of concatenation (`AwayNotifyExtensionTest`); S1130 unused `throws Exception` removed (`BatchExtensionTest` x5, `TagmsgCommandHandlerTest`, `CommittedEventTest`); S1612 method reference `Capability::name` (`BatchExtensionTest`, `CapabilityCompositionTest`); S1640 `EnumMap` for the command table (`TagmsgCommandHandlerTest`); S5853 chained assertions (`TagmsgCommandHandlerTest`, `SessionWriterGroupTest`); S1117 local `away` renamed `awayEvents` (`CommittedEventTest`). + +## SonarCloud round 2 (2 test-code issues) + +Both fixed: S5778 in `MessageDeliveryTest.anAuthorizationFromAnotherDeliveryServiceIsRefused` (the foreign authorization is built before the `assertThatThrownBy` lambda, which now holds the single call that must throw); S5853 in `SessionWriterGroupTest.aGroupThatCannotFitTheQueueIsRejectedWholeAndNeverPartlyWritten` (one assertion chain). + +## Self-check against REVIEW_CHECKLIST.md + +| Area | Result | +| --- | --- | +| Sonar S1181 | the new code catches `RuntimeException` at provider boundaries only; no `Throwable`/`Error` catch. `CommittedEvents.publish` contains a failed presented-identity or audience resolution so a committed command still sends its remaining replies (`CommittedEventTest.aFailingPresentedIdentityNeverCostsTheCommandItsRemainingReplies`) | +| S7467 / S1186 / S2445 / S3077 | unused catch parameters are `_`; the only empty-bodied methods are commented test stand-ins; no new `synchronized` on a non-private monitor; the `volatile` fields I first wrote in `MessageTagsExtension` became an `AtomicReference` | +| S6878 / S6916 / S6880 / S3776 | no new method exceeds the complexity limit; `MessageDelivery.deliver` is the longest and has straight-line steps (capture, inbound tags, roles, enqueue) | +| S1192 | the repeated `"BATCH"`, `"batch"`, `"msgid"` literals are constants in the modules that own them | +| Tests (S5778 / S2925 / S5853 / S5841 / S3415) | no `Thread.sleep`; concurrency tests join threads and read from a barrier or the writer's close; the nested-lambda `assertThatThrownBy` cases build their arguments first; `assertThat(...)` carries the computed value. I did not exhaustively chase S5778 in the older test files I only adapted | +| Failure and cleanup paths | a provider command that is installed is removed on disable by handler identity (existing); `MessageTagsExtension.stop` clears its context so a handler racing disable fails with `IllegalStateException`, which the command guard contains and reports; the group path closes nothing it did not open (`FrameSequence` is plain data) | +| Concurrency and reload | `MessageDelivery` and `MemberReplies` each capture one `RuntimeSnapshot` per call and run every hook once outside every monitor; `CommittedEvents` is called after the transition with no registry or channel monitor held; concurrent groups never interleave because a group is one queue entry (`SessionWriterGroupTest.concurrentGroupsToOneRecipientNeverInterleave`, `CapabilityGroupTest`). Choosing the framing again at admission, under concurrent capability changes, is the later runtime-lifecycle change and is not claimed | +| Protocol size limits | a group larger than the queue overflows that recipient before any transformed copy exists; a framed group is validated against its byte bound by the dispatcher and serialized exactly once more for the queue; NAMES/WHO bounds still go through the one `ReplyLayout` arithmetic, now with `multi-prefix` declaring its own 2 bytes (`MultiPrefixExtensionTest.reservesTheWidthOfAllStatusesInNamesAndWhoLayouts`); the old fixed width is gone | +| Hostile or oversized input | client tags are still size-checked by the parser and `FrameSyntax`; an over-budget forwarded tag set is dropped for that recipient at enqueue and later messages still flow (`TagmsgCommandHandlerTest.anOversizeClientTagSetIsDroppedForTheRecipientWithoutBreakingLaterMessages`); inbound-tag hooks run once per message, not per recipient; identity fields are resolved only when a provider uses them, so a plain NAMES does no hostname work per member | +| Extension boundaries | every hook result still goes through `HookDispatcher`; an event hook fault leaves the committed state (`CommittedEventTest`); a status hook returning foreign symbols falls back to the highest status (`MemberReplyRenderingTest.aProviderThatFaultsLeavesTheCoreDefault`) | +| Validation edge cases | empty group, empty audience, a repeated identical AWAY reason (still an event), a repeated JOIN/PART and a NICK to the same name (no event), a nested group whose member already carries a `batch` tag (kept) are each tested | +| Rejected requests change nothing | a rejected JOIN (wrong key), NICK (in use or unchanged), PART of a channel not joined and a TAGMSG to an unknown target publish no event and send no copy | +| Counters and accounting | group and message bytes are charged and released through the existing `enqueueEntry` path; an over-queue group is rejected before it is charged | +| Security | no new logging of client content: the only new WARN names the connection id and the exception message for an unrepresentable trusted group | +| Determinism | no locale-dependent formatting added | +| Public API compatibility | breaking changes are listed with their replacements in the README migration table and the Javadoc at each definition; `JoinCommandHandler.sendNamesReply(requester, channel, server, List)` and `Channel.memberListing()` keep their signatures because the existing integration tests call them. `0.1.0-SNAPSHOT` is unreleased, so no version bump | +| Tests that can pass while the code is wrong | assertions check exact wire lines and exact role sets; the absence test hides a real provider class from the service file (so the offer and the behavior are checked against a server that really lacks it) and a control test proves the same loader hides nothing when asked to hide nothing; the one-hook-call-per-delivery counters prove no per-recipient scan | +| Docs and contracts | README SPI section and migration table; `package-info`; Javadoc on `MessageDelivery`, `CommittedEvents`, `MemberReplies`, `SessionWriter.enqueueGroup`, `TagRenderer.frame`; `specs/001-ircv3-server/data-model.md`, `.../contracts/irc-protocol-commands.md` and `specs/011-away-notify-batch/contracts/away-notify-and-batch.md` now describe module-owned behavior and say what the runtime-lifecycle change still owns | + +## Zero-branch inventory (research.md, "Complete current core behavior inventory") + +| Site | Result | +| --- | --- | +| `session/CapabilityTagRenderer` | forwards a selected client tag to a recipient that negotiated the owner of an inbound-tag hook; no capability name | +| `session/command/TagmsgCommandHandler` | moved to the `message-tags` module | +| `session/command/AwayCommandHandler` | publishes `AWAY_CHANGED`; the notice is `away-notify`'s | +| `session/SessionWriter` | `enqueueBatch` removed; `enqueueGroup` is generic | +| `session/ChannelRegistry`, `command/JoinCommandHandler`, `command/WhoCommandHandler`, `session/Channel`, admin `SajoinCommandHandler` | member facts and `MemberReplies`; no capability flag | +| `session/command/MessageCommandHandler` | `MessageDelivery`; no provider scan | +| `session/OutboundMessage` | only typed-hook contributions are memoized | +| `JircdServerApplication` | no `TAGMSG` registration | +| `CapabilityExtension` | `contributeTags` and `includeSenderInFanOut` removed | +| `HookDispatcher`, `RuntimeSnapshot` | the legacy tag path and `hasHooks` removed | +| `ConfigurationLoader`, `HostmaskReplyBudget`, `ReplyBudgetCatalog` | the fixed 2-byte "legacy" status width removed | + +A grep of `jircd-core/src/main`, `jircd-server/src/main` and the server extensions for the six capability names, `CapabilityName` and the removed methods finds only Javadoc that mentions tags generically and the capability SPI's own `capabilityName` plumbing. The one deliberate literal left in core is the reserved `batch` tag key in `CapabilityRegistrar` (`BATCH_TAG`): it is a protocol-level reservation that stops an ordinary tag contributor from claiming the key the batch group shaper owns, as the SPI contract states, and it names no behavior. From 64c12a951da138a17774a777e27620b9d5d3b7ed Mon Sep 17 00:00:00 2001 From: Guillermo Castro Date: Fri, 23 Oct 2026 16:26:03 -0600 Subject: [PATCH 2/4] Describe module-owned capability behaviour in the 001 and 011 contracts The data model and protocol contracts still described capability behaviour as core's. Point them at the owning modules and the generic reply and output-group rules, without claiming the runtime lifecycle (#97) or fault isolation (#102) that later PRs add. Refs #101 Co-Authored-By: Claude Sonnet 5.5 --- .../contracts/irc-protocol-commands.md | 21 +++++++++++-- specs/001-ircv3-server/data-model.md | 21 ++++++++----- .../contracts/away-notify-and-batch.md | 31 ++++++++++++++----- 3 files changed, 55 insertions(+), 18 deletions(-) diff --git a/specs/001-ircv3-server/contracts/irc-protocol-commands.md b/specs/001-ircv3-server/contracts/irc-protocol-commands.md index 991fa39..e6f02ba 100644 --- a/specs/001-ircv3-server/contracts/irc-protocol-commands.md +++ b/specs/001-ircv3-server/contracts/irc-protocol-commands.md @@ -196,6 +196,17 @@ defined, the same class of gap the nickname/channel grammars closed for "Message identifiers"). Unlike `server-time`'s `time` tag, `msgid` isn't gated by a second, separately-declinable capability — there is no way to negotiate `message-tags` without also getting `msgid`. +- **Ownership** (014-capability-plugin-spi): each capability module owns its behavior; core + keeps `CAP` negotiation, tag syntax and size validation, authorized delivery and byte + budgets. `message-tags` selects the client-only (`+`) tags to forward, contributes `msgid` + and owns `TAGMSG`; `server-time` contributes `time` independently of `message-tags`; + `echo-message` asks for the sender copy through a recipient role that core merges with the + ordinary one, so a self-targeted message is still delivered once; `away-notify`, `batch` and + `multi-prefix` are described in their own contract notes. Removing a module from the + classpath removes both its `CAP LS` offer and its behavior; core and the other modules are + unaffected. How a capability change is applied to already-connected sessions at runtime is + specified separately (014-capability-plugin-spi, runtime lifecycle) and is not part of this + ownership change. - `005`'s token set (FR-055) is deliberately minimal: every token restates a value this specification already committed to elsewhere (casemapping, grammar limits, mode prefixes, UTF-8 enforcement), none @@ -322,8 +333,12 @@ was previously missing and needed its own definition (FR-048). today's single highest prefix (`@nick`, `H@`). The choice follows the client receiving the reply, never the member listed, and applies to `NAMES` (targeted and bare), the `353` sent on `JOIN`/`SAJOIN`, and the - channel-scoped `WHO`. Core behavior gated on the capability id; the - `jircd-capabilities/multi-prefix` module only registers the capability. + channel-scoped `WHO`. Ownership (014-capability-plugin-spi): core keeps the + member facts — every held status, highest first in `PREFIX` order — and renders the + highest status by default; the `jircd-capabilities/multi-prefix` module replaces the status + slot of the `353` token and the `352` flags with all of them for a requester that negotiated + it, and declares the two-symbol bound the reply budget reserves. Without the module every + requester sees the highest status only. ### User Queries (Story 7) @@ -677,7 +692,7 @@ any given server to implement it. | `HELP`, `HELPOP` | Modern IRC | **Implemented** (issue #77) — a short built-in topic list: `704`, `705`…, `706` with the topic as subject (`index` for a bare `HELP`), `524 ERR_HELPNOTFOUND` for an unknown topic. Both commands behave identically | | `CAP` | IRCv3 | **Implemented** — see "Connection Registration" above | | `AUTHENTICATE` | IRCv3 (SASL) | Recognized only — deferred with Story 3 (FR-009/FR-010); MUST NOT be offered as a capability (contracts note above) | -| `TAGMSG` | IRCv3 | Recognized only — no tag-only-message feature in this release, though `message-tags` (FR-025) itself is implemented for regular messages | +| `TAGMSG` | IRCv3 | **Implemented** by the `message-tags` module (002-extended-irc-commands FR-020–FR-023; ownership per 014-capability-plugin-spi) — a tag-only message that reuses core's target resolution and send gates and reaches only recipients that negotiated `message-tags`. Without that module the command is unknown (`421`) | | `EXTENSION` | This project | **Implemented** — see "Administration" above; not an RFC 1459/2812 command | | `WHOHOST` | This project | **Implemented** — see "Administration" above; not an RFC 1459/2812 command | | `SAJOIN` | Common IRCd convention | **Implemented** — see "Administration" above; not an RFC 1459/2812 command, but a well-known real-world administrator-override convention (research.md "Administrator channel override"), reused rather than inventing a project-specific name | diff --git a/specs/001-ircv3-server/data-model.md b/specs/001-ircv3-server/data-model.md index ee9c847..d3ec491 100644 --- a/specs/001-ircv3-server/data-model.md +++ b/specs/001-ircv3-server/data-model.md @@ -159,11 +159,14 @@ a channel a `PRIVMSG` was sent to). **Validation rules**: `OutboundMessage` MUST NOT contain a `message-tags` prefix, a `time` tag, a `msgid` tag, or any other capability-dependent -decoration — those are computed by each recipient's own writer thread at -drain time (research.md "Message fan-out concurrency model"), by -re-checking that recipient's `ClientSession.negotiatedCapabilities` -against the live state of the corresponding `CapabilityExtension` -(`Capability` validation rules, above). `messageId` itself is NOT +decoration. Each recipient's tags are selected when the message is queued +(research.md "Message fan-out concurrency model"): the providers' typed +outbound-tag hooks contribute them from this immutable message, evaluated once +per envelope and shared as validated immutable results, for the providers that +recipient's `ClientSession.negotiatedCapabilities` names +(014-capability-plugin-spi); the drain only renders that captured selection. +The client-only tags forwarded are those the `message-tags` provider's +inbound-tag hook selected at construction. `messageId` itself is NOT capability-dependent — it's generated unconditionally for every `OutboundMessage`, the same as `sentAt` — only whether it's *rendered as a tag* depends on the recipient's negotiated capabilities; the id's @@ -613,7 +616,7 @@ independent of that extension. | Field | Type | Notes | |---|---|---| -| `name` | string | One of `message-tags`, `server-time`, `echo-message` for this release (FR-025). | +| `name` | string | One of `message-tags`, `server-time`, `echo-message`, `away-notify`, `batch`, `multi-prefix` (FR-025, extended by 011-away-notify-batch and issue #96). | | `available` | boolean | Derived from whether the owning `CapabilityExtension` is currently `ENABLED` (FR-007, FR-025). | **Validation rules**: A capability's `available` state MUST be sourced from @@ -642,7 +645,9 @@ split is a compiler-enforced domain distinction, not just documentation: - **`CapabilityExtension`**: also provides exactly one `Capability` and is therefore visible to clients via `CAP LS`. Lives under `jircd-capabilities/`. This release's set: `message-tags`, `server-time`, - `echo-message`. + `echo-message`, `away-notify`, `batch`, `multi-prefix`. Each owns its behavior through typed + hook registrations (014-capability-plugin-spi); core keeps only `CAP` negotiation and the + generic mechanisms those hooks plug into, with no branch on a capability's name. - **`ServerExtension`**: no client-negotiable `Capability`; administrator- only, a client never learns it exists. Lives under `jircd-server-extensions/`. This release's set: `cloak` (FR-031), @@ -662,7 +667,7 @@ extension-provided behavior (FR-020). | Field | Type | Notes | |---|---|---| -| `id` | string | Stable identifier used in Server Configuration (FR-012). This release's full set: `message-tags`, `server-time`, `echo-message` (`CapabilityExtension`), `cloak`, `admin` (`ServerExtension`) — one Gradle subproject each. | +| `id` | string | Stable identifier used in Server Configuration (FR-012). This release's full set: `message-tags`, `server-time`, `echo-message`, `away-notify`, `batch`, `multi-prefix` (`CapabilityExtension`), `cloak`, `admin` (`ServerExtension`) — one Gradle subproject each. | | `state` | enum: `ENABLED`, `DISABLED`, `FAILED` | `FAILED` = failed to start/errored at runtime without affecting other extensions (FR-020). | | `providedCapability` | `Capability` name, 0..1 | Present only for `CapabilityExtension`; absent for `ServerExtension` (e.g., cloak, admin). | | `extensionPoint` | string, 0..1 | Set only for extensions that supply a value or decision core code consumes rather than just adding a capability (e.g., `cloak` claims `hostname-display`). `null` for extensions with no such claim. `connection-admission` (FR-066, research.md "Connection-admission extension point") is a second recognized point — unclaimed by any extension in this release, since the future "G-line"-style connection-blocking extension it's reserved for isn't built yet; `jircd-core` still calls out to it on every accepted connection, always permitting the connection through while nothing claims it, the same default-permissive fallback `hostname-display` has when `cloak` is disabled. | diff --git a/specs/011-away-notify-batch/contracts/away-notify-and-batch.md b/specs/011-away-notify-batch/contracts/away-notify-and-batch.md index d0aeabe..b7bafa5 100644 --- a/specs/011-away-notify-batch/contracts/away-notify-and-batch.md +++ b/specs/011-away-notify-batch/contracts/away-notify-and-batch.md @@ -30,8 +30,15 @@ client→server in this direction): - The changed user's own confirmation reply (`306 RPL_NOWAWAY` / `305 RPL_UNAWAY`) is unaffected by this feature — it is sent to the changed user regardless of whether they or - anyone else has negotiated `away-notify` (unchanged, pre-existing `AwayCommandHandler` - behavior). + anyone else has negotiated `away-notify` (core's `AwayCommandHandler` keeps the away state + and these confirmations; the notice to peers belongs to the `away-notify` module, see below). +- **Ownership** (014-capability-plugin-spi): core publishes a committed `AWAY_CHANGED` event + after the away state changes and with no registry or channel monitor held — for every accepted + `AWAY`, including a repeated identical reason. The `away-notify` module answers it with the + notice above addressed to the event's audience: the deduplicated peers who share a channel + with the user, never the user itself. Core narrows the notice to recipients that negotiated + the capability and delivers it; a listener fault never undoes the away state. Without the + module the state and confirmations are unchanged and no notice is sent. - A recipient who has not negotiated `away-notify` observes zero behavior change from this feature — the underlying `AWAY` command's existing behavior for the sender themselves, and for `WHOIS`/`WHO`/`PRIVMSG`'s existing away-reply behavior, is entirely unchanged (FR-005). @@ -62,8 +69,8 @@ BATCH + [ ...] BATCH - ``` -The open/close `BATCH` lines carry no prefix — `SessionWriter`, which owns `enqueueBatch`, has -no server-name dependency to draw one from, and a prefix-less framing line has established +The open/close `BATCH` lines carry no prefix — the `batch` module builds them, it has no +server-name dependency to draw one from, and a prefix-less framing line has established precedent in this codebase (`KillCommandHandler`'s `ERROR` line uses the same `null` prefix for the same reason: a server-framing line with no natural sender identity). @@ -79,9 +86,19 @@ the same reason: a server-framing line with no natural sender identity). `batch` and `message-tags` — a recipient with `batch` alone still receives the member messages, but completely unwrapped, with no tag and no framing (research.md, Decision 2). This is stricter than the bare wire format above might suggest, and is a deliberate - defensive choice: `SessionWriter.enqueueRaw`'s tag serialization applies zero capability - gating on its own, so a caller/tag combination that reached an unprepared client once already - in this codebase (005-fix-batch-conformance FR-010) must not be possible to reproduce here. + defensive choice: the writer's tag serialization applies zero capability gating on its own, + so a caller/tag combination that reached an unprepared client once already in this codebase + (005-fix-batch-conformance FR-010) must not be possible to reproduce here. `message-tags` is + a session prerequisite of the `batch` group shaper, not a hard dependency: `batch` alone is + still offered and acknowledged, and simply gets plain members. +- **Ownership** (014-capability-plugin-spi): the `batch` module owns the reference, the `BATCH` + lines and the `batch` tag. Core's `SessionWriter.enqueueGroup` takes a finite group, asks the + owning provider for a framing plan for that recipient, validates it, and queues every frame — + opener and closer included — as one entry that is admitted whole or not at all (a group that + cannot fit overflows that recipient only). A member that already carries a `batch` tag keeps + it, which is how a caller nests groups. Core currently chooses the framing when the group is + prepared; choosing it again at admission, under concurrent capability changes, belongs to the + later runtime-lifecycle change and is not provided here. - Two or more concurrent groups delivered to the same recipient use distinct reference tags and are never ambiguous about which member belongs to which group (FR-009). - This feature ships the mechanism only — no currently-shipped command routes any of its From 8c5af7e94de52b5b41576a61d6a40087b708d9ee Mon Sep 17 00:00:00 2001 From: Guillermo Castro Date: Fri, 23 Oct 2026 18:52:30 -0600 Subject: [PATCH 3/4] Publish KICK as a committed event and document event ordering (#106) Sourcery noted that a successful KICK removed a member without publishing anything, so providers listening for membership changes missed kicks. KICK now publishes EventKind.KICKED after the transition, carrying the kicker as the event's actor. The victim's identity is captured before the removal (RFC1459 folding), so a racing NICK can't rename the event; if the capture fails, the KICK still proceeds and only the event is skipped. Sourcery also asked about ordering between committed events. The guarantee is now stated where it holds: events a session causes with its own commands are published in the order it issued them. Events another session causes about it, and events about different subjects, have no relative order. Refs #101 Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- .../extension/capability/CommittedEvent.java | 21 +- .../core/extension/capability/EventKind.java | 12 +- .../jircd/core/session/CommittedEvents.java | 47 +++- .../session/command/KickCommandHandler.java | 55 ++++- .../core/session/CommittedEventTest.java | 91 +++++++ .../command/KickEventIdentityTest.java | 223 ++++++++++++++++++ .../contracts/capability-spi.md | 2 +- specs/014-capability-plugin-spi/data-model.md | 2 +- .../validation/pr-101/review.md | 13 + 10 files changed, 460 insertions(+), 8 deletions(-) create mode 100644 jircd-core/src/test/java/net/jircd/core/session/command/KickEventIdentityTest.java diff --git a/README.md b/README.md index 85ae3d9..69e9364 100644 --- a/README.md +++ b/README.md @@ -336,7 +336,7 @@ behaviour, so removing a module from the classpath removes its offer and its beh | `onOutboundTags(keys, maxBytes, hook)` | Owned server tags for recipients that negotiated the capability; pure in the immutable message, validated against the declared escaped-byte bound | `server-time` (`time`), `message-tags` (`msgid`) | | `onInboundTags` | Selects the client-only (`+`) tags to forward and provider-scoped request context; a recipient that negotiated the owner sees the forwarded tags | `message-tags` | | `onRecipients` | Asks for a `SENDER_COPY` role for the sender of a permitted `PRIVMSG`/`NOTICE`/`TAGMSG`; core merges roles so a recipient gets the message once | `echo-message` | -| `onEvent(kind, hook)` | Reacts to a committed `AWAY_CHANGED`, `JOINED`, `PARTED`, `QUIT` or `NICK_CHANGED` with delivery intents for peers drawn from the event's audience; recipients are narrowed to those that negotiated the capability | `away-notify` | +| `onEvent(kind, hook)` | Reacts to a committed `AWAY_CHANGED`, `JOINED`, `PARTED`, `KICKED`, `QUIT` or `NICK_CHANGED` with delivery intents for peers drawn from the event's audience; recipients are narrowed to those that negotiated the capability | `away-notify` | | `onMemberStatus`, `onNamesIdentity`, `onReplyField` | Bounded decorations of schema-declared reply slots (the status of a NAMES token or channel WHO entry, the NAMES identity) | `multi-prefix` (status) | | `onOutputGroup(prerequisites, hook)` | Framing of a finite group; the group is validated and queued whole, or sent plain when the owner or a prerequisite was not negotiated | `batch` | | `command` | Provider-owned command; it can never replace a command core or another extension handles, and is removed (answered as unknown) when the provider is withdrawn | `message-tags` (`TAGMSG`) | diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/CommittedEvent.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/CommittedEvent.java index 2baba3f..2f16701 100644 --- a/jircd-core/src/main/java/net/jircd/core/extension/capability/CommittedEvent.java +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/CommittedEvent.java @@ -19,7 +19,11 @@ /** * An immutable fact about a state transition that has already committed. {@code before}/{@code - * after} are empty strings when the transition has no such value. + * after} are empty strings when the transition has no such value, and so is {@code actor} when the + * subject caused the transition itself. + * + *

Only events caused by one session's own commands are ordered, in the order it issued them; see + * {@link EventKind}. */ public record CommittedEvent( EventKind kind, @@ -27,7 +31,8 @@ public record CommittedEvent( String subject, String before, String after, - Audience audience) { + Audience audience, + String actor) { public CommittedEvent { Objects.requireNonNull(kind); @@ -35,5 +40,17 @@ public record CommittedEvent( Objects.requireNonNull(before); Objects.requireNonNull(after); Objects.requireNonNull(audience); + Objects.requireNonNull(actor); + } + + /** An event the subject caused itself. */ + public CommittedEvent( + EventKind kind, + long operationId, + String subject, + String before, + String after, + Audience audience) { + this(kind, operationId, subject, before, after, audience, ""); } } diff --git a/jircd-core/src/main/java/net/jircd/core/extension/capability/EventKind.java b/jircd-core/src/main/java/net/jircd/core/extension/capability/EventKind.java index 6040248..5c55e0b 100644 --- a/jircd-core/src/main/java/net/jircd/core/extension/capability/EventKind.java +++ b/jircd-core/src/main/java/net/jircd/core/extension/capability/EventKind.java @@ -15,11 +15,21 @@ */ package net.jircd.core.extension.capability; -/** Committed state transitions a capability may observe. */ +/** + * Committed state transitions a capability may observe. + * + *

Ordering: events caused by one session's own commands are published in the order that session + * issued them, because its commands run serially on its connection thread and each event is + * published after its transition. An event another session causes about it ({@link #KICKED}) has no + * order relative to those, and nothing is ordered across subjects; {@code operationId} is not a + * global sequence, and a listener must not infer one. + */ public enum EventKind { AWAY_CHANGED, JOINED, PARTED, + /** A channel operator removed {@code subject}; {@code actor} is the kicker. */ + KICKED, QUIT, NICK_CHANGED } diff --git a/jircd-core/src/main/java/net/jircd/core/session/CommittedEvents.java b/jircd-core/src/main/java/net/jircd/core/session/CommittedEvents.java index de6928c..6e12c48 100644 --- a/jircd-core/src/main/java/net/jircd/core/session/CommittedEvents.java +++ b/jircd-core/src/main/java/net/jircd/core/session/CommittedEvents.java @@ -41,6 +41,11 @@ * resolved and the audience is traversed only on demand, at most once per event, so an event costs * nothing when no capability wants it. A rejected or no-op operation publishes nothing; the caller * decides what counts as a transition. + * + *

Ordering: events caused by one session's own commands are published in the order that session + * issued them (its commands run serially). An event another session causes about it, such as {@code + * KICKED}, has no order relative to those, and nothing is ordered across subjects; no lock or + * global sequence is kept for it. */ public final class CommittedEvents { private static final Logger LOG = LoggerFactory.getLogger(CommittedEvents.class); @@ -64,6 +69,16 @@ public void publish( String before, String after, Supplier> audience) { + publish(kind, subject, () -> "", before, after, audience); + } + + private void publish( + EventKind kind, + Supplier subject, + Supplier actor, + String before, + String after, + Supplier> audience) { RuntimeSnapshot snapshot = extensionRegistry.capabilitySnapshot(); if (!listened(snapshot, kind)) return; try { @@ -74,7 +89,8 @@ public void publish( subject.get(), before, after, - new Audience(() -> List.copyOf(audience.get()))); + new Audience(() -> List.copyOf(audience.get())), + actor.get()); for (EventIntent intent : extensionRegistry.hookDispatcher().event(snapshot, event)) { Recipients.deliver(intent.recipients(), intent.message()); } @@ -86,6 +102,11 @@ public void publish( } } + /** Whether some enabled provider listens for {@code kind}. */ + public boolean listensTo(EventKind kind) { + return listened(extensionRegistry.capabilitySnapshot(), kind); + } + private static boolean listened(RuntimeSnapshot snapshot, EventKind kind) { for (HookRegistration registration : snapshot.hooks(HookKind.EVENT)) { if (registration.eventKind() == kind) return true; @@ -132,6 +153,30 @@ public void parted( () -> recipients.stream().filter(member -> member != session).toList()); } + /** + * {@code kicked} was removed from {@code channel} by the session presented as {@code + * kickerPresentedForm}; {@code recipients} were captured before it left. The kicked member is the + * subject and is not in the audience. {@code kickedPresentedForm} is the identity captured when + * the kick was prepared; it is never resolved again here, because the session may have changed + * nickname since, so a caller without one publishes nothing. + */ + public void kicked( + String kickerPresentedForm, + ClientSession kicked, + String kickedPresentedForm, + String channel, + String reason, + Collection recipients) { + Objects.requireNonNull(kickedPresentedForm); + publish( + EventKind.KICKED, + () -> kickedPresentedForm, + () -> kickerPresentedForm, + channel, + reason, + () -> recipients.stream().filter(member -> member != kicked).toList()); + } + /** {@code session} disconnected; {@code neighbors} were captured as its memberships went. */ public void quit( ClientSession session, diff --git a/jircd-core/src/main/java/net/jircd/core/session/command/KickCommandHandler.java b/jircd-core/src/main/java/net/jircd/core/session/command/KickCommandHandler.java index e42c383..19bca03 100644 --- a/jircd-core/src/main/java/net/jircd/core/session/command/KickCommandHandler.java +++ b/jircd-core/src/main/java/net/jircd/core/session/command/KickCommandHandler.java @@ -19,6 +19,7 @@ import java.util.Map; import java.util.function.Supplier; import net.jircd.core.extension.ExtensionRegistry; +import net.jircd.core.extension.capability.EventKind; import net.jircd.core.session.Channel; import net.jircd.core.session.ChannelRegistry; import net.jircd.core.session.ClientSession; @@ -30,23 +31,27 @@ import net.jircd.protocol.Command; import net.jircd.protocol.Message; import net.jircd.protocol.NumericReply; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * {@code KICK} — operator-only channel member removal. Always available, never gated by extension * toggling. */ public final class KickCommandHandler implements CommandHandler { + private static final Logger LOG = LoggerFactory.getLogger(KickCommandHandler.class); private final ChannelRegistry channelRegistry; private final ExtensionRegistry extensionRegistry; private final Supplier serverName; private final Runnable beforeTransition; + private final Runnable afterTransition; public KickCommandHandler( ChannelRegistry channelRegistry, ExtensionRegistry extensionRegistry, Supplier serverName) { - this(channelRegistry, extensionRegistry, serverName, () -> {}); + this(channelRegistry, extensionRegistry, serverName, () -> {}, () -> {}); } /** Test-only seam: runs after the pre-checks and prefix preparation, before the transition. */ @@ -55,6 +60,21 @@ public KickCommandHandler( ExtensionRegistry extensionRegistry, Supplier serverName, Runnable beforeTransition) { + this(channelRegistry, extensionRegistry, serverName, beforeTransition, () -> {}); + } + + /** + * Test-only seams: {@code beforeTransition} runs after the pre-checks and prefix preparation, + * before the transition; {@code afterTransition} runs once a kick has committed, before its + * notification and committed event. + */ + KickCommandHandler( + ChannelRegistry channelRegistry, + ExtensionRegistry extensionRegistry, + Supplier serverName, + Runnable beforeTransition, + Runnable afterTransition) { + this.afterTransition = afterTransition; this.channelRegistry = channelRegistry; this.extensionRegistry = extensionRegistry; this.serverName = serverName; @@ -100,6 +120,25 @@ public void handle(ClientSession session, Message message) { // the target a member. The registry still decides authoritatively, and a request that turns // out invalid discards the prefix. String presentedForm = PresentedIdentity.presentedForm(session, extensionRegistry); + // The kicked member's identity for the committed event is captured here too, while it is the + // one the KICK names: a NICK of the target racing the kick must not rename the event. It is + // never resolved again later, so a capture that fails or finds nobody skips the event. + boolean listened = extensionRegistry.committedEvents().listensTo(EventKind.KICKED); + ClientSession candidate = null; + String candidateForm = null; + String captureFailure = "target not found by name"; + if (listened) { + candidate = channel.findMember(targetNickname).orElse(null); + if (candidate != null) { + try { + candidateForm = PresentedIdentity.presentedForm(candidate, extensionRegistry); + } catch (RuntimeException e) { + // Only the optional event capture failed: the KICK itself must still go through. Only + // the type is kept for the log; the message may carry extension or client data. + captureFailure = e.getClass().getSimpleName(); + } + } + } beforeTransition.run(); switch (channelRegistry.kick(channel, session, targetNickname)) { case KickOutcome.NotOperator() -> rejectNotOperator(session, channel, channelName); @@ -111,9 +150,23 @@ public void handle(ClientSession session, Message message) { List.of(targetNickname), "They aren't on that channel"); case KickOutcome.Kicked(Departure departure) -> { + afterTransition.run(); List params = List.of(channel.name(), departure.departed().nickname(), reason); Message notification = new Message(Map.of(), presentedForm, Command.KICK, "KICK", params); Recipients.deliver(departure.recipients(), notification); + if (listened && candidateForm != null && departure.departed() == candidate) { + extensionRegistry + .committedEvents() + .kicked( + presentedForm, + departure.departed(), + candidateForm, + channel.name(), + reason, + departure.recipients()); + } else if (listened) { + LOG.warn("Committed KICKED event not published: {}", captureFailure); + } } } } diff --git a/jircd-core/src/test/java/net/jircd/core/session/CommittedEventTest.java b/jircd-core/src/test/java/net/jircd/core/session/CommittedEventTest.java index 5eae151..4b05f34 100644 --- a/jircd-core/src/test/java/net/jircd/core/session/CommittedEventTest.java +++ b/jircd-core/src/test/java/net/jircd/core/session/CommittedEventTest.java @@ -37,6 +37,7 @@ import net.jircd.core.extension.capability.EventKind; import net.jircd.core.session.command.AwayCommandHandler; import net.jircd.core.session.command.JoinCommandHandler; +import net.jircd.core.session.command.KickCommandHandler; import net.jircd.core.session.command.NickCommandHandler; import net.jircd.core.session.command.PartCommandHandler; import net.jircd.core.session.command.RegistrationCompletion; @@ -87,6 +88,8 @@ public void register(CapabilityRegistrar registrar) { new JoinCommandHandler(channels, extensions, () -> "server", () -> 50); private final PartCommandHandler part = new PartCommandHandler(channels, extensions, () -> "server"); + private final KickCommandHandler kick = + new KickCommandHandler(channels, extensions, () -> "server"); private final AwayCommandHandler away = new AwayCommandHandler(() -> "server", extensions); private final NickCommandHandler nick = new NickCommandHandler( @@ -243,6 +246,94 @@ void aPartCarriesTheAudienceCapturedBeforeTheDeparture() throws Exception { assertThat(channels.lookup("#c").orElseThrow().members()).doesNotContain(bob); } + // --- KICK --- + + @Test + void aKickCarriesTheKickedIdentityTheKickerTheReasonAndThePreRemovalAudience() throws Exception { + ClientSession alice = user("alice"); + ClientSession bob = user("bob"); + ClientSession carol = user("carol"); + run(join, alice, "JOIN #c"); + run(join, bob, "JOIN #c"); + run(join, carol, "JOIN #c"); + + run(kick, alice, "KICK #c bob :out"); + + List kicks = events(EventKind.KICKED); + assertThat(kicks).hasSize(1); + CommittedEvent kicked = kicks.getFirst(); + assertThat(kicked.subject()).isEqualTo("bob!ident@host.example"); + assertThat(kicked.actor()).isEqualTo("alice!ident@host.example"); + assertThat(kicked.before()).isEqualTo("#c"); + assertThat(kicked.after()).isEqualTo("out"); + assertThat(spy.audiences.get(kicked)).containsExactlyInAnyOrder(alice, carol); + assertThat(channels.lookup("#c").orElseThrow().members()).doesNotContain(bob); + } + + @Test + void aKickWithoutACommentReportsTheKickersNicknameAsTheReason() throws Exception { + ClientSession alice = user("alice"); + ClientSession bob = user("bob"); + run(join, alice, "JOIN #c"); + run(join, bob, "JOIN #c"); + + run(kick, alice, "KICK #c bob"); + + assertThat(events(EventKind.KICKED)).extracting(CommittedEvent::after).containsExactly("alice"); + } + + @Test + void aRejectedKickPublishesNothing() throws Exception { + ClientSession alice = user("alice"); + ClientSession bob = user("bob"); + ClientSession carol = user("carol"); + run(join, alice, "JOIN #c"); + run(join, bob, "JOIN #c"); + + run(kick, bob, "KICK #c alice"); // not an operator + run(kick, carol, "KICK #c alice"); // not a member + run(kick, alice, "KICK #c carol"); // target not on the channel + run(kick, alice, "KICK #nowhere bob"); // no such channel + run(kick, alice, "KICK #c"); // not enough parameters + + assertThat(events(EventKind.KICKED)).isEmpty(); + assertThat(channels.lookup("#c").orElseThrow().members()).contains(alice, bob); + } + + // --- ordering --- + + @Test + void eventsASessionCausesItselfArePublishedInTheOrderItIssuedThem() throws Exception { + ClientSession alice = user("alice"); + ClientSession bob = user("bob"); + run(join, alice, "JOIN #c"); + + run(join, bob, "JOIN #c"); + run(part, bob, "PART #c"); + run(join, bob, "JOIN #c"); + run(nick, bob, "NICK robert"); + cleanup.cleanup(bob, "gone"); + + // Only the events bob's own commands caused; one that another session causes (KICKED) has no + // order relative to these. + List bobs; + synchronized (spy.events) { + bobs = + spy.events.stream() + .filter(e -> e.subject().startsWith("bob!") || e.subject().startsWith("robert!")) + .toList(); + } + assertThat(bobs) + .extracting(CommittedEvent::kind) + .containsExactly( + EventKind.JOINED, + EventKind.PARTED, + EventKind.JOINED, + EventKind.NICK_CHANGED, + EventKind.QUIT); + assertThat(bobs).extracting(CommittedEvent::operationId).isSorted(); + } + // --- NICK --- @Test diff --git a/jircd-core/src/test/java/net/jircd/core/session/command/KickEventIdentityTest.java b/jircd-core/src/test/java/net/jircd/core/session/command/KickEventIdentityTest.java new file mode 100644 index 0000000..47c36fd --- /dev/null +++ b/jircd-core/src/test/java/net/jircd/core/session/command/KickEventIdentityTest.java @@ -0,0 +1,223 @@ +/* + * Copyright 2026 Guillermo Castro + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + * implied. See the License for the specific language governing + * permissions and limitations under the License. + */ +package net.jircd.core.session.command; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import net.jircd.core.extension.AbstractCapabilityExtension; +import net.jircd.core.extension.ExtensionRegistry; +import net.jircd.core.extension.HostnameDisplayExtension; +import net.jircd.core.extension.ServerContext; +import net.jircd.core.extension.ServerExtension; +import net.jircd.core.extension.capability.CapabilityRegistrar; +import net.jircd.core.extension.capability.CommittedEvent; +import net.jircd.core.extension.capability.EventKind; +import net.jircd.core.session.ChannelRegistry; +import net.jircd.core.session.ClientSession; +import net.jircd.core.session.RateLimitBucket; +import net.jircd.core.session.SessionWriter; +import net.jircd.core.session.TagRenderer; +import net.jircd.protocol.MessageParser; +import org.junit.jupiter.api.Test; + +/** The KICKED event names the member by the identity the KICK named, not by a later NICK. */ +class KickEventIdentityTest { + + private static ClientSession user(String nick) { + ClientSession session = new ClientSession(nick, "host.example", RateLimitBucket.withDefaults()); + session.setNickname(nick); + session.setIdent("ident"); + session.setRealname(nick); + return session; + } + + private final ChannelRegistry channels = new ChannelRegistry(); + private final ExtensionRegistry extensions = new ExtensionRegistry(); + private final List events = Collections.synchronizedList(new ArrayList<>()); + + private void listenForKicks() { + extensions.register( + new AbstractCapabilityExtension("spy") { + @Override + public void register(CapabilityRegistrar registrar) { + registrar.onEvent( + EventKind.KICKED, + event -> { + events.add(event); + return List.of(); + }); + } + }); + extensions.enable("spy"); + } + + @Test + void aNickChangeBetweenCommitAndPublicationDoesNotRenameTheKickedSubject() throws Exception { + listenForKicks(); + ClientSession alice = user("alice"); + ClientSession bob = user("bob"); + channels.join("#c", alice, ignored -> true); + channels.join("#c", bob, ignored -> true); + // The kick has committed; bob's own NICK runs before the event is published. + KickCommandHandler kick = + new KickCommandHandler( + channels, extensions, () -> "server", () -> {}, () -> bob.setNickname("robert")); + + kick.handle(alice, MessageParser.parse("KICK #c bob :out")); + + assertThat(events).hasSize(1); + assertThat(events.getFirst().subject()).isEqualTo("bob!ident@host.example"); + assertThat(bob.nickname()).isEqualTo("robert"); + } + + @Test + void anRfc1459EquivalentSpellingOfTheTargetStillCapturesItsIdentity() throws Exception { + listenForKicks(); + ClientSession alice = user("alice"); + ClientSession bob = user("[bob]"); + channels.join("#c", alice, ignored -> true); + channels.join("#c", bob, ignored -> true); + KickCommandHandler kick = + new KickCommandHandler( + channels, extensions, () -> "server", () -> {}, () -> bob.setNickname("robert")); + + kick.handle(alice, MessageParser.parse("KICK #c {bob} :out")); + + assertThat(events).hasSize(1); + assertThat(events.getFirst().subject()).isEqualTo("[bob]!ident@host.example"); + } + + @Test + void aFailingDisplayForTheVictimOnlyNeverAbortsTheKick() throws Exception { + listenForKicks(); + extensions.register(new VictimHostFails()); + extensions.enable("victim-host-fails"); + ClientSession alice = user("alice"); + ClientSession bob = new ClientSession("bob", "victim.example", RateLimitBucket.withDefaults()); + bob.setNickname("bob"); + bob.setIdent("ident"); + channels.join("#c", alice, ignored -> true); + channels.join("#c", bob, ignored -> true); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + String written; + try (SessionWriter writer = new SessionWriter(alice, out, TagRenderer.NONE)) { + alice.attachWriter(writer); + + new KickCommandHandler(channels, extensions, () -> "server") + .handle(alice, MessageParser.parse("KICK #c bob :out")); + } finally { + written = out.toString(StandardCharsets.UTF_8); + } + + assertThat(channels.lookup("#c").orElseThrow().members()).containsExactly(alice); + assertThat(written).contains("KICK #c bob out"); + assertThat(events).isEmpty(); + } + + @Test + void aCaptureThatFailsOnceNeverLetsALaterNickChangeNameTheEvent() throws Exception { + listenForKicks(); + AtomicInteger lookups = new AtomicInteger(); + extensions.register(new FirstVictimLookupFails(lookups)); + extensions.enable("first-lookup-fails"); + ClientSession alice = user("alice"); + ClientSession bob = new ClientSession("bob", "victim.example", RateLimitBucket.withDefaults()); + bob.setNickname("bob"); + bob.setIdent("ident"); + channels.join("#c", alice, ignored -> true); + channels.join("#c", bob, ignored -> true); + KickCommandHandler kick = + new KickCommandHandler( + channels, extensions, () -> "server", () -> {}, () -> bob.setNickname("robert")); + + kick.handle(alice, MessageParser.parse("KICK #c bob :out")); + + assertThat(channels.lookup("#c").orElseThrow().members()).containsExactly(alice); + assertThat(lookups).hasValue(1); // never asked again after the failed capture + assertThat(events).isEmpty(); + } + + private static final class FirstVictimLookupFails + implements ServerExtension, HostnameDisplayExtension { + private final AtomicInteger victimLookups; + + FirstVictimLookupFails(AtomicInteger victimLookups) { + this.victimLookups = victimLookups; + } + + @Override + public String id() { + return "first-lookup-fails"; + } + + @Override + public String extensionPoint() { + return "hostname-display"; + } + + @Override + public void start(ServerContext context) { + // Stateless test extension: nothing to set up. + } + + @Override + public void stop() { + // Stateless test extension: nothing to release. + } + + @Override + public String display(String hostname) { + if ("victim.example".equals(hostname) && victimLookups.getAndIncrement() == 0) { + throw new IllegalStateException("display failed"); + } + return hostname; + } + } + + private static final class VictimHostFails implements ServerExtension, HostnameDisplayExtension { + @Override + public String id() { + return "victim-host-fails"; + } + + @Override + public String extensionPoint() { + return "hostname-display"; + } + + @Override + public void start(ServerContext context) { + // Stateless test extension: nothing to set up. + } + + @Override + public void stop() { + // Stateless test extension: nothing to release. + } + + @Override + public String display(String hostname) { + if ("victim.example".equals(hostname)) throw new IllegalStateException("display failed"); + return hostname; + } + } +} diff --git a/specs/014-capability-plugin-spi/contracts/capability-spi.md b/specs/014-capability-plugin-spi/contracts/capability-spi.md index 0a3b9f6..217ab6e 100644 --- a/specs/014-capability-plugin-spi/contracts/capability-spi.md +++ b/specs/014-capability-plugin-spi/contracts/capability-spi.md @@ -28,7 +28,7 @@ Core MessageParser/serializer continue to recognize tag syntax and wire tokens e ## Event contract -Event kinds implemented now: `AWAY_CHANGED`, `JOINED`, `PARTED`, `QUIT`, `NICK_CHANGED`. A future account implementation adds `ACCOUNT_CHANGED` at the actual account-state commit boundary; do not emit fake account events or build an account subsystem here. +Event kinds implemented now: `AWAY_CHANGED`, `JOINED`, `PARTED`, `KICKED`, `QUIT`, `NICK_CHANGED`. A future account implementation adds `ACCOUNT_CHANGED` at the actual account-state commit boundary; do not emit fake account events or build an account subsystem here. `KICKED` carries the kicked member's presented identity as subject, the channel as `before`, the reason as `after`, the kicker's presented identity as `actor` and the pre-removal audience without the kicked member. The kicked identity is captured when the KICK is prepared and never resolved again, so a later NICK of the target does not rename the event; if the capture fails or finds no member, the KICK proceeds and `KICKED` is not published. Ordering guarantee: events caused by one session's own commands are published in the order that session issued them (its commands run serially); an event another session causes about it (`KICKED`) has no order relative to those, nothing is ordered across subjects, and `operationId` is not a global sequence. - Emit after a successful state transition, once; no event for rejected/no-op operations. Existing AWAY semantics treat every accepted set/change/clear as the trigger, even if the supplied reason is repeated; preserve current behavior. - AWAY includes old/new reason and presented subject identity; JOIN includes committed join/member facts; PART/QUIT include pre-removal audience/identity; NICK includes old presented identity and new nickname. QUIT cleanup is idempotent. diff --git a/specs/014-capability-plugin-spi/data-model.md b/specs/014-capability-plugin-spi/data-model.md index 0b7d127..536d7c4 100644 --- a/specs/014-capability-plugin-spi/data-model.md +++ b/specs/014-capability-plugin-spi/data-model.md @@ -38,7 +38,7 @@ Disable/failure removes withdrawn names physically. Enable does not restore them ## CommittedEvent, Audience and MemberSnapshot -CommittedEvent holds kind, logical operation id, immutable subject before/after facts and permitted audience. Publish exactly once after a successful core transition, retaining existing repeated accepted-AWAY semantics. No event for rejected/no-op JOIN/nick or duplicate cleanup. +CommittedEvent holds kind, logical operation id, immutable subject before/after facts, an optional actor (the kicker for `KICKED`) and permitted audience. Ordering: events caused by one session's own commands are published in the order that session issued them (its commands run serially); an event another session causes about it (`KICKED`) has no order relative to those, nothing is ordered across subjects, and `operationId` is not a global sequence. Publish exactly once after a successful core transition, retaining existing repeated accepted-AWAY semantics. No event for rejected/no-op JOIN/nick or duplicate cleanup. Audience deduplicates permitted peers with explicit self policy and no global-session scan. Capture departure peers before removal. MemberSnapshot contains permitted nickname/ident/display-host, consistently ordered held statuses and generic reply facts; it contains no multi-prefix flag. Across members preserve existing weak snapshot semantics; core default is highest status + nickname. Release domain locks before any hook. diff --git a/specs/014-capability-plugin-spi/validation/pr-101/review.md b/specs/014-capability-plugin-spi/validation/pr-101/review.md index 4499286..c14924e 100644 --- a/specs/014-capability-plugin-spi/validation/pr-101/review.md +++ b/specs/014-capability-plugin-spi/validation/pr-101/review.md @@ -28,6 +28,19 @@ All fixed: S6126 text/`String.join` instead of concatenation (`AwayNotifyExtensi Both fixed: S5778 in `MessageDeliveryTest.anAuthorizationFromAnotherDeliveryServiceIsRefused` (the foreign authorization is built before the `assertThatThrownBy` lambda, which now holds the single call that must throw); S5853 in `SessionWriterGroupTest.aGroupThatCannotFitTheQueueIsRejectedWholeAndNeverPartlyWritten` (one assertion chain). +## PR #106 Sourcery + +Four comments (#0 to #4); #3 and #4 are fixed, #0 to #2 declined. Codex follow-up (two P2) on the fixes is recorded at the end of this section. + +- **#4 KICK published no committed event (fixed).** Added `EventKind.KICKED`, published by `KickCommandHandler` from the successful `Kicked` branch only, after the membership transition and outside every lock. Subject is the kicked member's presented identity, `before` the channel, `after` the reason, new `CommittedEvent.actor` the kicker's presented identity, audience the pre-removal recipients without the kicked member. KICK is the only removal that reuses `ChannelRegistry.kick`; no other command in core or the bundled extensions removes a member this way. Tests in `CommittedEventTest`: `aKickCarriesTheKickedIdentityTheKickerTheReasonAndThePreRemovalAudience`, `aKickWithoutACommentReportsTheKickersNicknameAsTheReason`, `aRejectedKickPublishesNothing` (non-operator, non-member, absent target, no such channel, missing parameters). +- **#3 No global order across sessions (documented, not changed).** `EventKind`, `CommittedEvent` and `CommittedEvents` Javadoc, the capability-spi contract and data-model.md state the ordering guarantee (narrowed by the Codex follow-up below). Test: `eventsASessionCausesItselfArePublishedInTheOrderItIssuedThem`. +- **#0, #1, #2 (declined).** #0: a hung trusted hook is out of scope per runtime-lifecycle.md. #1: TAGMSG sender negotiation is unchanged from main. #2: AWAY runs only on the session's own connection thread. +- **Codex follow-up P2 1: per-subject order was over-claimed (documentation narrowed, no locking).** A KICK runs on the kicker's thread, so two kicks of one member by different users, or a KICK racing the member's own NICK or JOIN, can publish out of transition order. The guarantee is now: events caused by one session's own commands are published in the order that session issued them; an event another session causes about it (`KICKED`) has no order relative to those, and nothing is ordered across subjects. Narrowed in `EventKind`, `CommittedEvent`, `CommittedEvents`, the contract and data-model.md. The sequential test is renamed to assert only the self-caused events (join, part, join, nick, quit), excluding `KICKED`. +- **Codex follow-up P2 2: the KICKED subject was read late from the mutable session (fixed).** `KickCommandHandler` now resolves the target's presented identity before the transition, outside every lock, when a provider listens for `KICKED` (`CommittedEvents.listensTo`), and passes it into `CommittedEvents.kicked`; if the removed member is not the one found by name beforehand, it falls back to resolving at publication. No hostname or cloak callback runs under a membership lock. Regression test `KickEventIdentityTest.aNickChangeBetweenCommitAndPublicationDoesNotRenameTheKickedSubject` uses a test-only after-commit seam to change the nick between commit and publication; it is synchronous, so it needs no waits or cleanup. +- **Codex follow-up 2, P2 1: the capture used a different name match than the registry (fixed).** `memberNamed` used `equalsIgnoreCase`, so `KICK #c {bob}` removed `[bob]` but captured no identity. It now uses `Channel.findMember`, the same RFC1459 folding the registry uses. Test: `KickEventIdentityTest.anRfc1459EquivalentSpellingOfTheTargetStillCapturesItsIdentity`. +- **Codex follow-up 2, P2 2: the optional capture could abort a KICK (fixed).** A hostname-display extension throwing for the victim, with a `KICKED` listener present, failed the command before the transition. The capture is now contained: on `RuntimeException` the KICK proceeds, the type alone is logged, and (superseded by follow-up 3) the event resolved the identity at publication. Test: `KickEventIdentityTest.aFailingDisplayForTheVictimOnlyNeverAbortsTheKick` (victim host only; the KICK succeeds and is notified, no event, nothing escapes). The kicker's own prefix failure still aborts before the transition, unchanged. `CommittedEvents.kicked` keeps the Sonar S1172 change (no kicker parameter). +- **Codex follow-up 3, P2: the late-resolution fallback reintroduced the rename (fixed).** When the pre-transition capture failed or found no member, `CommittedEvents.kicked` re-resolved the identity from the mutable session at publication, so a transient display failure plus a later NICK named the new nickname. The fallback is gone: `kicked` requires a captured identity, and `KickCommandHandler` skips `KICKED` (the KICK itself proceeds) when none was captured, logging once with the failure type or "target not found by name". This supersedes the fallback described in follow-up 2. Test: `KickEventIdentityTest.aCaptureThatFailsOnceNeverLetsALaterNickChangeNameTheEvent` (display fails on the first victim lookup only, with the after-commit rename seam: the KICK succeeds and no event is published). `aFailingDisplayForTheVictimOnlyNeverAbortsTheKick` still holds. + ## Self-check against REVIEW_CHECKLIST.md | Area | Result | From 653ffcf7aaa1ebad9eaecbbac7629c0e10a477cd Mon Sep 17 00:00:00 2001 From: Guillermo Castro Date: Fri, 23 Oct 2026 19:01:06 -0600 Subject: [PATCH 4/4] Record test and coverage numbers after the PR #106 fix round The KICKED event and ordering tests added 8 jircd-core tests, so the recorded build figures were stale. Refs #101 Co-Authored-By: Claude Opus 5.5 --- .../validation/pr-101/results.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/specs/014-capability-plugin-spi/validation/pr-101/results.md b/specs/014-capability-plugin-spi/validation/pr-101/results.md index 0e7b1b1..3ef4b29 100644 --- a/specs/014-capability-plugin-spi/validation/pr-101/results.md +++ b/specs/014-capability-plugin-spi/validation/pr-101/results.md @@ -7,14 +7,14 @@ Recorded on the uncommitted working tree of branch `014-capability-migrations` ( | Command | Outcome | | --- | --- | | `./gradlew spotlessApply` | applied, then clean | -| `./gradlew build --rerun-tasks` | **BUILD SUCCESSFUL** (1 m 36 s, rerun after review round 2): 1254 tests in the module XML reports, 0 failed, 0 skipped; Spotless, SpotBugs (main and test), PMD (main and test) and `testCodeCoverageVerification` all green | +| `./gradlew build --rerun-tasks` | **BUILD SUCCESSFUL** (1 m 37 s with `testCodeCoverageReport`, rerun after the PR #106 Sourcery round): 1262 tests in the module XML reports, 0 failed, 0 skipped; Spotless, SpotBugs (main and test), PMD (main and test) and `testCodeCoverageVerification` all green | Two earlier plain builds failed before this one and were fixed, not retried: `pmdTest` flagged `CloseResource` on test writers closed by an `@AfterEach` (suppressed with that reason at the six declarations) and `spotbugsTest` flagged `USO_UNSAFE_OBJECT_SYNCHRONIZATION` on a `synchronized` HashMap in `CommittedEventTest` (now a `ConcurrentHashMap`). | Module | Tests | | --- | --- | | jircd-protocol | 58 | -| jircd-core | 810 | +| jircd-core | 818 | | jircd-capabilities: away-notify / batch / echo-message / message-tags / multi-prefix / server-time | 7 / 11 / 13 / 13 / 5 / 6 | | jircd-server-extensions:admin | 20 | | jircd-integration-tests | 311 (302 existing and group probes, plus the 9 of `CapabilityProviderAbsenceTest`) | @@ -25,8 +25,8 @@ Report: `build/reports/jacoco/testCodeCoverageReport/` (`html/`, `testCodeCovera | | Covered | Floor | After #100 | Result | | --- | --- | --- | --- | --- | -| Line | **95.56%** (5744 of 6011) | 85% | 94.95% | pass, higher | -| Branch | **88.61%** (2341 of 2642) | 75% | 87.30% | pass, higher | +| Line | **95.72%** (5791 of 6050) | 85% | 94.95% | pass, higher | +| Branch | **88.70%** (2356 of 2656) | 75% | 87.30% | pass, higher | By package: `net.jircd.core.session` lines 1709 of 1746, branches 665 of 748; `net.jircd.core.extension.capability` lines 618 of 639, branches 314 of 354; each capability module's `main` code is fully covered by line (`message-tags` 51/51, `batch` 19/19, `away-notify` 10/10, `echo-message` 4/4, `multi-prefix` 5/5).