diff --git a/AGENTS.md b/AGENTS.md index ba38ce4..e755761 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ IRCv3 server in Java 25 (Gradle toolchain, multi-module). The project rules live ## Modules -`jircd-protocol` (wire format) → `jircd-core` → `jircd-capabilities:*` (message-tags, server-time, echo-message, away-notify, batch) and `jircd-server-extensions:*` (cloak, admin) → `jircd-server` (app, `JircdServerApplication`). `jircd-integration-tests` holds the end-to-end tests over real sockets. +`jircd-protocol` (wire format) → `jircd-core` → `jircd-capabilities:*` (message-tags, server-time, echo-message, away-notify, batch, multi-prefix) and `jircd-server-extensions:*` (cloak, admin) → `jircd-server` (app, `JircdServerApplication`). `jircd-integration-tests` holds the end-to-end tests over real sockets. ## Commands @@ -31,7 +31,7 @@ PRs get reviewed by SonarCloud (quality gate plus non-gating issues, all of whic - Check the diff against [`.github/REVIEW_CHECKLIST.md`](.github/REVIEW_CHECKLIST.md): the Sonar rules this repo keeps hitting, and what Sourcery keeps finding (failure and cleanup paths, concurrency and reload, protocol size limits, hostile input, extension boundaries, validation edge cases, rejected requests, tests that pass while the code is wrong, and docs claims). - Local Sonar analysis, for maintainers with a SonarCloud token in `systemProp.sonar.token` in `~/.gradle/gradle.properties` (never read or print that file): - Run it only on trusted code: our own worktree branches. The build runs the branch's Gradle logic with the token available, so never run it on a fork's or an untrusted branch. - - `./gradlew build sonar --no-parallel -Dsonar.qualitygate.wait=true -Dsonar.branch.name="$(git branch --show-current)"` uploads a short-lived branch analysis with the PR gate's rules. `sonar.qualitygate.wait` blocks until SonarCloud has processed it, so the next query doesn't read the previous analysis, and fails the task if the gate fails. `--no-parallel` matches CI. + - `./gradlew build sonar --no-parallel -Dsonar.qualitygate.wait=true -Dsonar.scm.disabled=true -Dsonar.branch.name="$(git branch --show-current)"` uploads a short-lived branch analysis with the PR gate's rules. `sonar.scm.disabled` is required: SonarCloud judges new code from git blame, so without it uncommitted, staged and untracked changes report no issues (SonarCloud still leaves out issues already on main). `sonar.qualitygate.wait` blocks until SonarCloud has processed it, so the next query doesn't read the previous analysis, and fails the task if the gate fails. `--no-parallel` matches CI. - Then read every open issue on the branch: `curl -s "https://sonarcloud.io/api/issues/search?componentKeys=jircd_jircd-server&branch=$(git branch --show-current)&resolved=false&ps=500"`, and page further if `total` exceeds 500. Fix them before opening the PR. - Contributors without a token skip this step; SonarCloud then reports on the PR (or not at all for a fork's PR). - The SonarQube CLI's `sonar analyze` can't stand in: its code analysis returns 403 on this plan, so it only scans secrets. diff --git a/README.md b/README.md index 737e7f7..b112b9b 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,8 @@ Planned for the first release: - Real-time connection registration, channel messaging, and moderation (classic first-join-gets-operator model) - IRCv3 capability negotiation, with `message-tags`, `server-time`, and - `echo-message` as the initial capability set + `echo-message` as the initial capability set; `away-notify`, `batch` and + `multi-prefix` are available too - Runtime-toggleable extensions, configurable via file or in-band IRC administrative commands - Standard `nickname!ident@hostname` identity presentation, with an @@ -60,13 +61,13 @@ 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 + 14 <= 512` (353) and `S + N + Cmax + 29 <= 512` (366) +`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 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 bytes and a presented host of up to 63 bytes (see the contract below); the widest -is WHO's 352, `2S + 2N + Cmax + 27 + 63 + 21 <= 512`. A host an extension returns +is WHO's 352, `2S + 2N + Cmax + 27 + 63 + 22 <= 512`. A host an extension returns is truncated to 63 bytes. USERHOST entries split across several 302 lines, so the budget covers one entry (`S + 2N + 27 + 63 + 13`). NAMES never splits or truncates member identities. A grandfathered item that diff --git a/jircd-capabilities/multi-prefix/build.gradle.kts b/jircd-capabilities/multi-prefix/build.gradle.kts new file mode 100644 index 0000000..f522a60 --- /dev/null +++ b/jircd-capabilities/multi-prefix/build.gradle.kts @@ -0,0 +1,4 @@ +dependencies { + implementation(project(":jircd-protocol")) + implementation(project(":jircd-core")) +} 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 new file mode 100644 index 0000000..e2d2bea --- /dev/null +++ b/jircd-capabilities/multi-prefix/src/main/java/net/jircd/capabilities/multiprefix/MultiPrefixExtension.java @@ -0,0 +1,36 @@ +/* + * 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 net.jircd.core.extension.AbstractCapabilityExtension; +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. + */ +public final class MultiPrefixExtension extends AbstractCapabilityExtension { + + public static final String CAPABILITY_ID = CapabilityName.MULTI_PREFIX; + + public MultiPrefixExtension() { + super(CAPABILITY_ID); + } +} diff --git a/jircd-capabilities/multi-prefix/src/main/resources/META-INF/services/net.jircd.core.extension.CapabilityExtension b/jircd-capabilities/multi-prefix/src/main/resources/META-INF/services/net.jircd.core.extension.CapabilityExtension new file mode 100644 index 0000000..62e65b9 --- /dev/null +++ b/jircd-capabilities/multi-prefix/src/main/resources/META-INF/services/net.jircd.core.extension.CapabilityExtension @@ -0,0 +1 @@ +net.jircd.capabilities.multiprefix.MultiPrefixExtension 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 4e72454..3a9d873 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 @@ -291,13 +291,13 @@ static void validateNamesBudget( String serverName, int nicknameMaxLength, int channelNameMaxLength) throws ConfigurationException { int serverBytes = MessageSerializer.utf8Bytes(serverName); - int namesBytes = serverBytes + 2 * nicknameMaxLength + channelNameMaxLength + 14; + int namesBytes = serverBytes + 2 * nicknameMaxLength + channelNameMaxLength + 15; int endBytes = serverBytes + nicknameMaxLength + channelNameMaxLength + 29; int maximum = MessageSerializer.MAX_COMMAND_BYTES; if (namesBytes > maximum || endBytes > maximum) { int channelBudget = Math.min( - maximum - serverBytes - 2 * nicknameMaxLength - 14, + maximum - serverBytes - 2 * nicknameMaxLength - 15, maximum - serverBytes - nicknameMaxLength - 29); throw new ConfigurationException( "serverName (S=" 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 a493848..b0d0a13 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 @@ -36,8 +36,8 @@ public final class HostmaskReplyBudget { /** Epoch seconds are 10 digits until the year 2286. */ private static final int TIMESTAMP_BYTES = 10; - /** A WHO flags field at its widest: away, operator and channel status. */ - private static final int WHO_FLAGS_BYTES = 3; + /** A WHO flags field at its widest: away, operator and both channel statuses (multi-prefix). */ + private static final int WHO_FLAGS_BYTES = 4; private HostmaskReplyBudget() {} 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 82c6d67..9a7683d 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 @@ -24,6 +24,7 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Supplier; import net.jircd.protocol.NickMask; @@ -42,8 +43,9 @@ * 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, and a - * prefix may come from two separate reads of the operator and voice sets. + * 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. * *

The collection accessors return unmodifiable views. Membership changes arrive only through * {@link ChannelRegistry} transitions. @@ -60,6 +62,13 @@ public final class Channel { private final Set operatorsView = Collections.unmodifiableSet(operators); private final Set voiced = ConcurrentHashMap.newKeySet(); private final Set voicedView = Collections.unmodifiableSet(voiced); + + /** + * Odd while a privilege update is in progress; changes with every update. Written under the + * monitor. + */ + private final AtomicLong privilegeVersion = new AtomicLong(); + private final Set activeModes = ConcurrentHashMap.newKeySet(); private final Set activeModesView = Collections.unmodifiableSet(activeModes); private final List bans = new CopyOnWriteArrayList<>(); @@ -103,23 +112,52 @@ public Set members() { * 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, and each prefix comes from two separate reads, so a concurrent privilege change - * can yield a prefix the member never held. + * may not appear. A member's prefix is read consistently, as in {@link #statusPrefixes}. */ public List memberListing() { + return memberListing(false); + } + + /** + * 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. + */ + public List memberListing(boolean multiPrefix) { List listing = new ArrayList<>(); for (ClientSession member : members) { - String status = ""; - if (operators.contains(member)) { - status = "@"; - } else if (voiced.contains(member)) { - status = "+"; - } - listing.add(status + member.nickname()); + listing.add(statusPrefixes(member, multiPrefix) + member.nickname()); } return listing; } + /** + * 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. + */ + public String statusPrefixes(ClientSession member, boolean multiPrefix) { + boolean operator; + boolean voice; + while (true) { + long before = privilegeVersion.get(); + operator = operators.contains(member); + voice = voiced.contains(member); + if ((before & 1) == 0 && privilegeVersion.get() == before) { + break; + } + Thread.onSpinWait(); + } + if (operator) { + return multiPrefix && voice ? "@+" : "@"; + } + return voice ? "+" : ""; + } + public Set operators() { return operatorsView; } @@ -252,11 +290,7 @@ public synchronized boolean setMemberPrivilege( case OPERATOR -> operators; case VOICE -> voiced; }; - if (granted) { - holders.add(member); - } else { - holders.remove(member); - } + updateHolders(holders, member, granted); return true; } @@ -265,13 +299,13 @@ public synchronized boolean grantOperator(ClientSession member) { if (!members.contains(member)) { return false; } - operators.add(member); + updateHolders(operators, member, true); return true; } /** Removes operator status, returning whether it was present. */ public synchronized boolean revokeOperator(ClientSession member) { - return operators.remove(member); + return updateHolders(operators, member, false); } /** Grants voice to a current member; returns false only for a non-member. */ @@ -279,13 +313,13 @@ public synchronized boolean grantVoice(ClientSession member) { if (!members.contains(member)) { return false; } - voiced.add(member); + updateHolders(voiced, member, true); return true; } /** Removes voice, returning whether it was present. */ public synchronized boolean revokeVoice(ClientSession member) { - return voiced.remove(member); + return updateHolders(voiced, member, false); } /** Activates a mode, clearing PRIVATE for SECRET and SECRET for PRIVATE in the same step. */ @@ -355,7 +389,7 @@ synchronized boolean addMember(ClientSession session) { boolean wasEmpty = members.isEmpty(); members.add(session); if (wasEmpty) { - operators.add(session); + updateHolders(operators, session, true); } return wasEmpty; } @@ -363,8 +397,23 @@ synchronized boolean addMember(ClientSession session) { /** Removes a departing session from membership, operator, and voice state together. */ synchronized void removeMember(ClientSession session) { members.remove(session); - operators.remove(session); - voiced.remove(session); + privilegeVersion.incrementAndGet(); + try { + operators.remove(session); + voiced.remove(session); + } finally { + privilegeVersion.incrementAndGet(); + } + } + + /** Adds to or removes from a privilege set, bracketed for {@link #statusPrefixes}' retry. */ + private boolean updateHolders(Set holders, ClientSession member, boolean add) { + privilegeVersion.incrementAndGet(); + try { + return add ? holders.add(member) : holders.remove(member); + } finally { + privilegeVersion.incrementAndGet(); + } } /** 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 5d5e7fe..6cfbfbd 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,6 +25,7 @@ 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. @@ -106,7 +107,9 @@ public Optional join( } } joinSnapshotHook.accept(channel); - List listing = channel.memberListing(); + List listing = + channel.memberListing( + session.negotiatedCapabilities().contains(CapabilityName.MULTI_PREFIX)); Set recipients = new LinkedHashSet<>(channel.members()); recipients.add(session); return Optional.of( 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 2f4de6f..8a91143 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 @@ -26,6 +26,7 @@ import net.jircd.core.session.CoreChannelModes; 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; @@ -215,7 +216,7 @@ private static AdmissionFailure admissionFailure( } public static void sendNamesReply(ClientSession requester, Channel channel, String serverName) { - sendNamesReply(requester, channel, serverName, channel.memberListing()); + sendNamesReply(requester, channel, serverName, listingFor(requester, channel)); } /** Sends a membership snapshot captured by a join transition, followed by its closing numeric. */ @@ -237,7 +238,13 @@ public static void sendNamesReply( * of its own afterward, instead of one per channel. */ public static void sendNamesLine(ClientSession requester, Channel channel, String serverName) { - sendNamesLine(requester, channel, serverName, channel.memberListing()); + 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)); } private static void sendNamesLine( 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 549c966..d025d13 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 @@ -29,6 +29,7 @@ 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; @@ -146,11 +147,9 @@ private void sendWhoReply( flags.append('*'); } if (channel != null) { - if (channel.operators().contains(target)) { - flags.append('@'); - } else if (channel.voiced().contains(target)) { - flags.append('+'); - } + flags.append( + channel.statusPrefixes( + target, requester.negotiatedCapabilities().contains(CapabilityName.MULTI_PREFIX))); } Replies.sendFitted( 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 71d2ad0..96aea8a 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 @@ -48,8 +48,8 @@ void rejects353OverflowWithComputedBudgetAndKeys() { .hasMessageContaining("serverName") .hasMessageContaining("nicknameMaxLength") .hasMessageContaining("channelNameMaxLength") - .hasMessageContaining("353=514") - .hasMessageContaining("budget=48") + .hasMessageContaining("353=515") + .hasMessageContaining("budget=47") .hasMessageContaining("512"); } @@ -70,13 +70,16 @@ void checks366IndependentlyAtItsBoundary() throws Exception { @Test void accepts353ExactlyAtTheConservativeBoundary() throws Exception { - ConfigurationLoader.validateNamesBudget("test.jircd.local", 216, 50); + ConfigurationLoader.validateNamesBudget("test.jircd.local", 215, 51); + assertThatThrownBy(() -> ConfigurationLoader.validateNamesBudget("test.jircd.local", 216, 51)) + .isInstanceOf(ConfigurationException.class) + .hasMessageContaining("353=514"); } @Test void rejectsTheIssue85ServerNameThatOverflowsAHostmaskReply() { - // 388-byte server name, 30-byte nicks, 50-byte channel: 333 is 569 bytes with a 21-byte cloak. - String server = "s".repeat(384) + ".net"; + // 387-byte server name, 30-byte nicks, 50-byte channel: 333 is 568 bytes with a 21-byte cloak. + String server = "s".repeat(383) + ".net"; assertThatThrownBy(() -> load("serverName: " + server + "\nnicknameMaxLength: 30\n")) .isInstanceOf(ConfigurationException.class) .hasMessageContaining("serverName") @@ -90,12 +93,12 @@ void rejectsTheIssue85ServerNameThatOverflowsAHostmaskReply() { @Test void hostmaskBudgetAcceptsTheWidestServerNameAndRejectsOneByteMore() throws Exception { - // WHO carries the server name twice: 2S + 2N + C + 27 + 63 + 21 <= 512 -> S <= 166 at 9/50. + // WHO carries the server name twice: 2S + 2N + C + 27 + 63 + 22 <= 512 -> S <= 166 at 9/50. String widest = "s".repeat(162) + ".net"; assertThat(load("serverName: " + widest + "\n").serverName()).isEqualTo(widest); assertThatThrownBy(() -> load("serverName: s" + widest + "\n")) .isInstanceOf(ConfigurationException.class) - .hasMessageContaining("352 (WHO)=513"); + .hasMessageContaining("352 (WHO)=514"); } @Test 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 c9775b5..63b0f12 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 @@ -24,6 +24,7 @@ import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.concurrent.Callable; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; @@ -633,6 +634,8 @@ void memberListingDoesNotWaitForTheChannelMonitor() throws Exception { ChannelRegistry registry = new ChannelRegistry(); Channel channel = registry.join("#c", newSession("alice"), ignored -> true).orElseThrow().channel(); + // Typed so the reference resolves to the no-argument overload, not Runnable's submit. + Callable> listing = channel::memberListing; CountDownLatch holding = new CountDownLatch(1); CountDownLatch release = new CountDownLatch(1); try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { @@ -647,7 +650,7 @@ void memberListingDoesNotWaitForTheChannelMonitor() throws Exception { }); try { assertThat(holding.await(5, TimeUnit.SECONDS)).isTrue(); - assertThat(executor.submit(channel::memberListing)) + assertThat(executor.submit(listing)) .succeedsWithin(Duration.ofSeconds(2)) .isEqualTo(List.of("@alice")); } finally { 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 f083adb..863adcf 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 @@ -24,6 +24,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; @@ -117,6 +118,71 @@ void memberListingUsesOperatorBeforeVoiceAndListsEachMemberOnceInIterationOrder( .toList()); } + @Test + void multiPrefixListingHoldsEveryStatusInPrefixOrder() { + Channel channel = new Channel("#lobby"); + ClientSession both = member("both"); + ClientSession voiced = member("voiced"); + ClientSession plain = member("plain"); + ClientSession operator = member("operator"); + // The first joiner becomes operator, so "both" starts as one and gains voice below. + List.of(both, voiced, plain, operator).forEach(channel::addMember); + channel.grantVoice(both); + channel.grantVoice(voiced); + channel.grantOperator(operator); + + assertThat(channel.memberListing(true)) + .containsExactlyInAnyOrder("@+both", "+voiced", "plain", "@operator"); + assertThat(channel.memberListing(false)) + .containsExactlyInAnyOrder("@both", "+voiced", "plain", "@operator"); + assertThat(channel.memberListing()).isEqualTo(channel.memberListing(false)); + } + + /** + * The member only ever holds operator or voice, never both: each flip de-ops then voices, or + * de-voices then ops. A reader that read the two sets separately could see the old operator and + * the new voice and render "@+". The overlap is timing dependent, so this is a stress test: it + * failed on the unversioned two-read implementation, and passing proves no impossible state + * appeared in the reads made. + */ + @Test + void multiPrefixNeverShowsAStatusCombinationTheMemberDidNotHold() throws Exception { + Channel channel = new Channel("#lobby"); + ClientSession anchor = member("anchor"); + ClientSession flipper = member("flipper"); + channel.addMember(anchor); + channel.addMember(flipper); + channel.grantOperator(flipper); + AtomicBoolean running = new AtomicBoolean(true); + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var writer = + executor.submit( + () -> { + while (running.get()) { + channel.revokeOperator(flipper); + channel.grantVoice(flipper); + channel.revokeVoice(flipper); + channel.grantOperator(flipper); + } + }); + try { + for (int i = 0; i < 2_000_000; i++) { + assertThat(channel.statusPrefixes(flipper, true)).isNotEqualTo("@+"); + } + } finally { + running.set(false); + writer.get(5, TimeUnit.SECONDS); + } + } + } + + @Test + void statusPrefixesIsEmptyForANonMember() { + Channel channel = new Channel("#lobby"); + + assertThat(channel.statusPrefixes(member("stranger"), true)).isEmpty(); + } + @Test void emptyChannelHasEmptyMemberListing() { assertThat(new Channel("#empty").memberListing()).isEmpty(); 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 2652636..cfa5e0a 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 @@ -36,6 +36,7 @@ import net.jircd.core.session.TagRenderer; import net.jircd.core.session.WhowasEntry; import net.jircd.core.session.WhowasHistory; +import net.jircd.protocol.CapabilityName; import net.jircd.protocol.MessageParser; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -141,6 +142,10 @@ void theModelEqualsTheRealSerializedLineForEveryReply(int nick, int chan) throws assertThat(nicknames.claim(asker.nickname(), asker)).isTrue(); channels.join(channelName, owner, ignored -> true); channels.join(channelName, asker, ignored -> true); + // The widest 352 flags are away, operator, @ and + : the owner is voiced as well as the first + // joiner, and the asker negotiated multi-prefix. + channels.lookup(channelName).orElseThrow().grantVoice(owner); + asker.replaceNegotiatedCapabilities(List.of(CapabilityName.MULTI_PREFIX)); String presentedHost = "h".repeat(ServerConfiguration.PRESENTED_HOST_MAX_BYTES); WhowasHistory history = new WhowasHistory(() -> 10); history.record( 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 new file mode 100644 index 0000000..267a228 --- /dev/null +++ b/jircd-core/src/test/java/net/jircd/core/session/command/MultiPrefixReplyTest.java @@ -0,0 +1,179 @@ +/* + * 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.util.Arrays; +import java.util.List; +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.protocol.CapabilityName; +import net.jircd.protocol.Message; +import org.junit.jupiter.api.Test; + +/** + * {@code multi-prefix}: 353 and 352 list every status the member holds, in ISUPPORT PREFIX order, + * only to a requester that negotiated it. The choice follows the requester, not the member listed. + */ +class MultiPrefixReplyTest { + + private static final String SERVER = "irc.test"; + + private final HandlerHarness harness = new HandlerHarness(); + private final ChannelRegistry channels = new ChannelRegistry(); + private final ExtensionRegistry extensions = new ExtensionRegistry(); + private final ClientSession boss = harness.register("boss"); + private final ClientSession plain = harness.register("plain"); + private final ClientSession viewer = harness.register("viewer"); + private final Channel channel = + channels.join("#c", boss, ignored -> true).orElseThrow().channel(); + + MultiPrefixReplyTest() { + // boss is the first joiner, so already an operator; plain stays unprivileged. + channel.grantVoice(boss); + channels.join("#c", plain, ignored -> true); + channels.join("#c", viewer, ignored -> true); + channel.grantVoice(viewer); + } + + private static void negotiateMultiPrefix(ClientSession session) { + session.replaceNegotiatedCapabilities(List.of(CapabilityName.MULTI_PREFIX)); + } + + private List names(ClientSession requester) throws Exception { + return HandlerHarness.run( + new NamesCommandHandler(channels, extensions, () -> SERVER), requester, "NAMES #c"); + } + + private List who(ClientSession requester) throws Exception { + return HandlerHarness.run( + new WhoCommandHandler(channels, harness.nicknames, extensions, () -> SERVER, () -> true), + requester, + "WHO #c"); + } + + private static List listed(List replies, String requester) { + Message namreply = replies.stream().filter(m -> "353".equals(m.rawCommand())).findFirst().get(); + assertThat(namreply.params().subList(0, 3)).containsExactly(requester, "=", "#c"); + return Arrays.asList(namreply.params().get(3).split(" ")); + } + + private static String whoFlags(List replies, String nick) { + List match = + replies.stream() + .filter(m -> "352".equals(m.rawCommand()) && nick.equals(m.params().get(5))) + .toList(); + assertThat(match).hasSize(1); + return match.getFirst().params().get(6); + } + + @Test + void namesWithoutTheCapabilityKeepsOnlyTheHighestPrefix() throws Exception { + assertThat(listed(names(viewer), "viewer")) + .containsExactlyInAnyOrder("@boss", "plain", "+viewer"); + } + + @Test + void namesWithTheCapabilityListsEveryPrefixOperatorFirst() throws Exception { + negotiateMultiPrefix(viewer); + + assertThat(listed(names(viewer), "viewer")) + .containsExactlyInAnyOrder("@+boss", "plain", "+viewer"); + } + + @Test + void namesFollowsTheRequesterNotTheMemberListed() throws Exception { + negotiateMultiPrefix(boss); + + // boss negotiated it; viewer did not, so viewer still sees one prefix for boss. + assertThat(listed(names(viewer), "viewer")).contains("@boss"); + assertThat(listed(names(boss), "boss")).contains("@+boss"); + } + + @Test + void bareNamesAppliesTheCapabilityToo() throws Exception { + negotiateMultiPrefix(viewer); + + List replies = + HandlerHarness.run( + new NamesCommandHandler(channels, extensions, () -> SERVER), viewer, "NAMES"); + + assertThat(listed(replies, "viewer")).contains("@+boss"); + } + + @Test + void joinListsEveryPrefixToAJoinerWithTheCapability() throws Exception { + ClientSession joiner = harness.register("joiner"); + negotiateMultiPrefix(joiner); + + List replies = + HandlerHarness.run( + new JoinCommandHandler(channels, extensions, () -> SERVER, () -> 50), + joiner, + "JOIN #c"); + + Message namreply = replies.stream().filter(m -> "353".equals(m.rawCommand())).findFirst().get(); + assertThat(namreply.params().subList(0, 3)).containsExactly("joiner", "=", "#c"); + assertThat(Arrays.asList(namreply.params().get(3).split(" "))) + .containsExactlyInAnyOrder("@+boss", "plain", "+viewer", "joiner"); + } + + @Test + void joinKeepsTheSinglePrefixForAJoinerWithoutTheCapability() throws Exception { + ClientSession joiner = harness.register("joiner"); + + List replies = + HandlerHarness.run( + new JoinCommandHandler(channels, extensions, () -> SERVER, () -> 50), + joiner, + "JOIN #c"); + + Message namreply = replies.stream().filter(m -> "353".equals(m.rawCommand())).findFirst().get(); + assertThat(Arrays.asList(namreply.params().get(3).split(" "))) + .containsExactlyInAnyOrder("@boss", "plain", "+viewer", "joiner"); + } + + @Test + void whoWithoutTheCapabilityFlagsOnlyTheHighestPrefix() throws Exception { + List replies = who(viewer); + + assertThat(whoFlags(replies, "boss")).isEqualTo("H@"); + assertThat(whoFlags(replies, "plain")).isEqualTo("H"); + assertThat(whoFlags(replies, "viewer")).isEqualTo("H+"); + } + + @Test + void whoWithTheCapabilityFlagsEveryPrefixOperatorFirst() throws Exception { + negotiateMultiPrefix(viewer); + + List replies = who(viewer); + + assertThat(whoFlags(replies, "boss")).isEqualTo("H@+"); + assertThat(whoFlags(replies, "plain")).isEqualTo("H"); + assertThat(whoFlags(replies, "viewer")).isEqualTo("H+"); + } + + @Test + void whoPlacesTheExtraPrefixAfterTheOperatorFlagAndAwayMarker() throws Exception { + negotiateMultiPrefix(viewer); + boss.setAwayReason("gone"); + + assertThat(whoFlags(who(viewer), "boss")).isEqualTo("G@+"); + } +} diff --git a/jircd-integration-tests/src/test/java/net/jircd/integration/MultiPrefixTest.java b/jircd-integration-tests/src/test/java/net/jircd/integration/MultiPrefixTest.java new file mode 100644 index 0000000..c1d3f29 --- /dev/null +++ b/jircd-integration-tests/src/test/java/net/jircd/integration/MultiPrefixTest.java @@ -0,0 +1,112 @@ +/* + * 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.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import net.jircd.protocol.Message; +import net.jircd.protocol.MessageParser; +import org.junit.jupiter.api.Test; + +/** The {@code multi-prefix} capability over real sockets: 353 on JOIN and NAMES, and 352 on WHO. */ +class MultiPrefixTest { + + private static final Duration WAIT = Duration.ofSeconds(5); + private static final String YAML = "capabilities:\n multi-prefix: enabled\n"; + + private static void register(RawIrcClient client, String nick, boolean multiPrefix) + throws Exception { + if (multiPrefix) { + client.send("CAP REQ :multi-prefix"); + assertThat(client.readUntil("CAP * ACK", WAIT)).endsWith(" multi-prefix"); + client.send("CAP END"); + } + client.registerAndAwaitWelcome(nick, nick); + } + + /** The 353 member list as a set of tokens, since members are listed in no fixed order. */ + private static List members(RawIrcClient client, String nick) throws Exception { + List params = read(client, "353").params(); + assertThat(params.subList(0, 3)).containsExactly(nick, "=", "#lobby"); + return Arrays.asList(params.get(3).split(" ")); + } + + private static Message read(RawIrcClient client, String code) throws Exception { + return MessageParser.parse(client.readUntil(code, WAIT)); + } + + @Test + void capLsOffersMultiPrefixAndReqIsAcked() throws Exception { + try (TestServer server = TestServer.start(YAML); + RawIrcClient client = RawIrcClient.connectPlaintext(server.plaintextPort())) { + client.send("CAP LS 302"); + assertThat(client.readUntil("CAP * LS", WAIT)).contains("multi-prefix"); + client.send("CAP REQ :multi-prefix"); + assertThat(client.readUntil("CAP * ACK", WAIT)).endsWith(" multi-prefix"); + } + } + + @Test + void opAndVoiceMemberIsListedWithBothPrefixesOnlyToAClientThatNegotiatedIt() throws Exception { + try (TestServer server = TestServer.start(YAML); + RawIrcClient alice = RawIrcClient.connectPlaintext(server.plaintextPort()); + RawIrcClient bob = RawIrcClient.connectPlaintext(server.plaintextPort()); + RawIrcClient carol = RawIrcClient.connectPlaintext(server.plaintextPort())) { + register(alice, "alice", false); + alice.send("JOIN #lobby"); + read(alice, "366"); + // alice is the first joiner, hence operator; voicing herself makes her hold both. + alice.send("MODE #lobby +v alice"); + alice.readUntil("MODE #lobby +v alice", WAIT); + + register(bob, "bob", true); + bob.send("JOIN #lobby"); + assertThat(members(bob, "bob")).containsExactlyInAnyOrder("@+alice", "bob"); + + register(carol, "carol", false); + carol.send("JOIN #lobby"); + assertThat(members(carol, "carol")).containsExactlyInAnyOrder("@alice", "bob", "carol"); + + bob.send("NAMES #lobby"); + assertThat(members(bob, "bob")).containsExactlyInAnyOrder("@+alice", "bob", "carol"); + carol.send("NAMES #lobby"); + assertThat(members(carol, "carol")).containsExactlyInAnyOrder("@alice", "bob", "carol"); + + bob.send("WHO #lobby"); + assertThat(whoFlags(bob)) + .containsExactlyInAnyOrderEntriesOf(Map.of("alice", "H@+", "bob", "H", "carol", "H")); + carol.send("WHO #lobby"); + assertThat(whoFlags(carol)) + .containsExactlyInAnyOrderEntriesOf(Map.of("alice", "H@", "bob", "H", "carol", "H")); + } + } + + /** The flags field of each 352 by nickname, up to the closing 315. */ + private static Map whoFlags(RawIrcClient client) throws Exception { + Map flags = new HashMap<>(); + for (int i = 0; i < 3; i++) { + List params = read(client, "352").params(); + flags.put(params.get(5), params.get(6)); + } + read(client, "315"); + return flags; + } +} diff --git a/jircd-integration-tests/src/test/java/net/jircd/integration/ReplyBudgetReloadTest.java b/jircd-integration-tests/src/test/java/net/jircd/integration/ReplyBudgetReloadTest.java index ada7492..850d784 100644 --- a/jircd-integration-tests/src/test/java/net/jircd/integration/ReplyBudgetReloadTest.java +++ b/jircd-integration-tests/src/test/java/net/jircd/integration/ReplyBudgetReloadTest.java @@ -50,7 +50,7 @@ void loweredNicknameLimitGrandfathersMembersAndTheGuardHandlesNewLargerChannels( member.readUntil("366", TIMEOUT); Files.writeString( server.configPath, - TestServer.baseYaml() + "nicknameMaxLength: 9\nchannelNameMaxLength: 351\n"); + TestServer.baseYaml() + "nicknameMaxLength: 9\nchannelNameMaxLength: 350\n"); server.application.reloader().reload(); assertThat(server.application.nicknameRegistry().lookup(nickname)).isPresent(); assertThat(server.application.channelRegistry().lookup("#old").orElseThrow().memberListing()) @@ -59,7 +59,7 @@ void loweredNicknameLimitGrandfathersMembersAndTheGuardHandlesNewLargerChannels( assertThat(probe.readUntil("432", TIMEOUT)).contains("Erroneous nickname"); root.send("NICK " + "r".repeat(10)); assertThat(root.readUntil("432", TIMEOUT)).contains("Erroneous nickname"); - String channel = "#" + "c".repeat(350); + String channel = "#" + "c".repeat(349); root.send("JOIN " + channel); root.readUntil("366", TIMEOUT); member.send("JOIN " + channel); @@ -75,7 +75,7 @@ void loweredNicknameLimitGrandfathersMembersAndTheGuardHandlesNewLargerChannels( assertThat(logs.messages()) .singleElement() .asString() - .contains("Dropping oversized listed item", channel, "itemBytes=150", "budget=129"); + .contains("Dropping oversized listed item", channel, "itemBytes=150", "budget=130"); } assertThat(server.application.channelRegistry().lookup(channel).orElseThrow().memberListing()) .contains(nickname, "@root"); @@ -85,11 +85,11 @@ void loweredNicknameLimitGrandfathersMembersAndTheGuardHandlesNewLargerChannels( @Test void loweredChannelLimitKeepsExistingChannelAndBareNamesStillAttemptsItsCloser() throws Exception { - try (TestServer server = TestServer.start("channelNameMaxLength: 351\n"); + try (TestServer server = TestServer.start("channelNameMaxLength: 350\n"); RawIrcClient member = RawIrcClient.connectPlaintext(server.plaintextPort()); RawIrcClient requester = RawIrcClient.connectPlaintext(server.plaintextPort())) { member.registerAndAwaitWelcome("member", "member"); - String channel = "#" + "c".repeat(350); + String channel = "#" + "c".repeat(349); member.send("JOIN " + channel); member.readUntil("366", TIMEOUT); var original = server.application.channelRegistry().lookup(channel).orElseThrow(); @@ -128,7 +128,7 @@ void retainedNickAndChannelHostmaskRepliesFitOrWarnAndCommandsContinue() throws RawIrcClient target = RawIrcClient.connectPlaintext(server.plaintextPort())) { String memberNick = "m".repeat(150); String targetNick = "t".repeat(150); - String channel = "#" + "c".repeat(350); + String channel = "#" + "c".repeat(349); owner.registerAndAwaitWelcome("owner", "owner"); member.registerAndAwaitWelcome(memberNick, "member"); target.registerAndAwaitWelcome(targetNick, "target"); @@ -138,7 +138,7 @@ void retainedNickAndChannelHostmaskRepliesFitOrWarnAndCommandsContinue() throws target.readThroughPong(TIMEOUT); Files.writeString( server.configPath, - TestServer.baseYaml() + "nicknameMaxLength: 9\nchannelNameMaxLength: 351\n"); + TestServer.baseYaml() + "nicknameMaxLength: 9\nchannelNameMaxLength: 350\n"); server.application.reloader().reload(); owner.send("JOIN " + channel); owner.readThroughPong(TIMEOUT); diff --git a/jircd-integration-tests/src/test/java/net/jircd/integration/Story4LengthLimitConfigTest.java b/jircd-integration-tests/src/test/java/net/jircd/integration/Story4LengthLimitConfigTest.java index d269ad6..081bc6a 100644 --- a/jircd-integration-tests/src/test/java/net/jircd/integration/Story4LengthLimitConfigTest.java +++ b/jircd-integration-tests/src/test/java/net/jircd/integration/Story4LengthLimitConfigTest.java @@ -83,7 +83,7 @@ void overCeilingLengthValueRejectedAtStartup() throws Exception { void coupledLengthBudgetsAreRejectedAtStartup() { assertThatThrownBy(() -> startWith("nicknameMaxLength: 217\nchannelNameMaxLength: 50")) .isInstanceOf(ConfigurationException.class) - .hasMessageContaining("353=514"); + .hasMessageContaining("353=515"); } @ParameterizedTest 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 cc08573..75c6020 100644 --- a/jircd-protocol/src/main/java/net/jircd/protocol/CapabilityName.java +++ b/jircd-protocol/src/main/java/net/jircd/protocol/CapabilityName.java @@ -34,4 +34,5 @@ private CapabilityName() {} public static final String ECHO_MESSAGE = "echo-message"; public static final String AWAY_NOTIFY = "away-notify"; public static final String BATCH = "batch"; + public static final String MULTI_PREFIX = "multi-prefix"; } diff --git a/jircd-server/build.gradle.kts b/jircd-server/build.gradle.kts index 8699815..7802d31 100644 --- a/jircd-server/build.gradle.kts +++ b/jircd-server/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { runtimeOnly(project(":jircd-capabilities:echo-message")) runtimeOnly(project(":jircd-capabilities:away-notify")) runtimeOnly(project(":jircd-capabilities:batch")) + runtimeOnly(project(":jircd-capabilities:multi-prefix")) runtimeOnly(project(":jircd-server-extensions:cloak")) runtimeOnly(project(":jircd-server-extensions:admin")) } diff --git a/settings.gradle.kts b/settings.gradle.kts index 935c182..81b9751 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -15,6 +15,7 @@ include( "jircd-capabilities:echo-message", "jircd-capabilities:away-notify", "jircd-capabilities:batch", + "jircd-capabilities:multi-prefix", "jircd-server-extensions", "jircd-server-extensions:cloak", "jircd-server-extensions:admin", diff --git a/specs/001-ircv3-server/contracts/irc-numeric-replies.md b/specs/001-ircv3-server/contracts/irc-numeric-replies.md index 9df51f8..5c49db8 100644 --- a/specs/001-ircv3-server/contracts/irc-numeric-replies.md +++ b/specs/001-ircv3-server/contracts/irc-numeric-replies.md @@ -79,7 +79,7 @@ during that window. | `352` | `RPL_WHOREPLY` | `WHO`, one per (visible) matching user (FR-061) | FR-061 | | `315` | `RPL_ENDOFWHO` | End of a `WHO` reply, including a zero-match one (FR-061) | FR-061 | | `221` | `RPL_UMODEIS` | `MODE ` self-query with no mode string (FR-044) | FR-044 | -| `353` | `RPL_NAMREPLY` | `JOIN`, `NAMES` — nicknames prefixed `@` (operator) or `+` (voiced, non-operator) per irc-protocol-commands.md "Channel Operations" | FR-003, FR-041, FR-045, FR-046 | +| `353` | `RPL_NAMREPLY` | `JOIN`, `NAMES` — nicknames prefixed `@` (operator) or `+` (voiced, non-operator), both (`@+`) for a `multi-prefix` requester, per irc-protocol-commands.md "Channel Operations" | FR-003, FR-041, FR-045, FR-046 | | `366` | `RPL_ENDOFNAMES` | `JOIN`, `NAMES` | FR-003, FR-041 | | `381` | `RPL_YOUREOPER` | Successful `OPER` | FR-034 | | `382` | `RPL_REHASHING` | Successful `REHASH` | FR-011, FR-012 (research.md "Configuration reload mechanism") | diff --git a/specs/001-ircv3-server/contracts/irc-protocol-commands.md b/specs/001-ircv3-server/contracts/irc-protocol-commands.md index 9236dee..991fa39 100644 --- a/specs/001-ircv3-server/contracts/irc-protocol-commands.md +++ b/specs/001-ircv3-server/contracts/irc-protocol-commands.md @@ -185,9 +185,10 @@ defined, the same class of gap the nickname/channel grammars closed for - `CAP` negotiation MUST be usable before `NICK`/`USER` completes registration (FR-006), and registration MUST still succeed for clients that skip `CAP` entirely (FR-008). -- Only `message-tags`, `server-time`, and `echo-message` may appear in the - `CAP LS` response for this release (FR-025); the SASL capability - referenced in the spec is deferred with Story 3 and MUST NOT appear. +- Only `message-tags`, `server-time`, `echo-message`, `away-notify`, `batch` + and `multi-prefix` may appear in the `CAP LS` response (FR-025, extended by + 011-away-notify-batch and issue #96); the SASL capability referenced in the + spec is deferred with Story 3 and MUST NOT appear. - `message-tags` itself now contributes one tag unconditionally: `msgid`, a server-generated unique identifier present on every message relayed to a recipient that has negotiated `message-tags` at all, regardless @@ -310,9 +311,19 @@ was previously missing and needed its own definition (FR-048). correctly enforced server-side: a client showing no visual difference between a voiced and a non-voiced member defeats the point of granting voice at all. A member who is both an operator and voiced is prefixed - `@` only, matching standard client expectations (operator implies the + `@` only for a requester that has not negotiated `multi-prefix` (below), + matching standard client expectations (operator implies the ability to speak; the `+` prefix communicates *additional* information a plain `@` doesn't). +- **`multi-prefix`** (issue #96): to a requester that has negotiated it, `353` + lists every status the member holds, in ISUPPORT `PREFIX` order (`@` before + `+`), so an operator who is also voiced is `@+nick`, and `352`'s `` + flags likewise end in `@+` (`H@+`, `G*@+`). A requester without it gets + 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. ### User Queries (Story 7) diff --git a/specs/001-ircv3-server/contracts/server-configuration.md b/specs/001-ircv3-server/contracts/server-configuration.md index f33ae79..c124aff 100644 --- a/specs/001-ircv3-server/contracts/server-configuration.md +++ b/specs/001-ircv3-server/contracts/server-configuration.md @@ -94,6 +94,7 @@ capabilities: # CapabilityExtension states only (jircd-capabilities/) echo-message: enabled away-notify: enabled # 011-away-notify-batch batch: enabled # 011-away-notify-batch + multi-prefix: enabled # 353/352 list every status prefix a member holds server-extensions: # ServerExtension states only (jircd-server-extensions/) — cloak: disabled # never CAP-negotiated, administrator-only. A @@ -266,15 +267,16 @@ administratorCredentials: - **Coupled NAMES byte bounds (issue #54)** also apply at startup and reload. Let `S` be the resolved server name's UTF-8 byte length, `N` be `nicknameMaxLength`, and `Cmax` be `channelNameMaxLength` used as the channel - UTF-8 byte cap. Both `S + 2N + Cmax + 14 <= 512` (353 RPL_NAMREPLY) and + UTF-8 byte cap. Both `S + 2N + Cmax + 15 <= 512` (353 RPL_NAMREPLY) and `S + N + Cmax + 29 <= 512` (366 RPL_ENDOFNAMES) MUST hold. The first reserves - a trailing colon and one member status character; the two nickname lengths + a trailing colon and two member status characters (an operator who is also + voiced, listed to a `multi-prefix` client); the two nickname lengths cover the recipient and listed member. The second includes the closing numeric's fixed text. An error names `serverName`, `nicknameMaxLength`, and `channelNameMaxLength`, both computed reply sizes, and the channel byte budget - `min(512 - S - 2N - 14, 512 - S - N - 29)`. The limits are rejected, never - silently clamped. For example, `test.jircd.local` is 16 bytes: `N=216, Cmax=50` - exactly fills the conservative 353 budget; `N=217, Cmax=50` requires 514 bytes + `min(512 - S - 2N - 15, 512 - S - N - 29)`. The limits are rejected, never + silently clamped. For example, `test.jircd.local` is 16 bytes: `N=215, Cmax=51` + exactly fills the conservative 353 budget; `N=216, Cmax=51` requires 514 bytes and is rejected. The omitted server name is resolved before validation. `serverName` remains fixed for the lifetime of the running application; reload validates against both the proposed configuration and the running @@ -291,7 +293,7 @@ administratorCredentials: | Reply | Command-section bytes | | --- | --- | - | 352 RPL_WHOREPLY (server name twice, flags `G*@`) | `2S + 2N + Cmax + U + H + 21` | + | 352 RPL_WHOREPLY (server name twice, flags `G*@+`) | `2S + 2N + Cmax + U + H + 22` | | 333 RPL_TOPICWHOTIME (10-digit timestamp) | `S + 2N + Cmax + U + H + 23` | | 311 / 314 WHOIS / WHOWAS user | `S + 2N + U + H + 15` | | 302 RPL_USERHOST (one entry; several entries split across 302 lines) | `S + 2N + U + H + 13` | diff --git a/specs/001-ircv3-server/data-model.md b/specs/001-ircv3-server/data-model.md index cfe250d..ee9c847 100644 --- a/specs/001-ircv3-server/data-model.md +++ b/specs/001-ircv3-server/data-model.md @@ -738,7 +738,7 @@ that isn't a positive integer or exceeds `20` (FR-064), or an combination that cannot fit a complete `353 RPL_NAMREPLY` entry and `366 RPL_ENDOFNAMES` within the 512-byte wire limit — with `S` the UTF-8 byte length of the resolved `serverName` (explicit or defaulted), -`S + 2*nicknameMaxLength + channelNameMaxLength + 14 > 512` for `353` or +`S + 2*nicknameMaxLength + channelNameMaxLength + 15 > 512` for `353` or `S + nicknameMaxLength + channelNameMaxLength + 29 > 512` for `366` — each field may be individually within range and the set still rejected; the message reports both totals and the remaining channel byte budget) MUST be rejected