Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions jircd-capabilities/multi-prefix/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
dependencies {
implementation(project(":jircd-protocol"))
implementation(project(":jircd-core"))
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
net.jircd.capabilities.multiprefix.MultiPrefixExtension
Original file line number Diff line number Diff line change
Expand Up @@ -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="
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {}

Expand Down
95 changes: 72 additions & 23 deletions jircd-core/src/main/java/net/jircd/core/session/Channel.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
*
* <p>The collection accessors return unmodifiable views. Membership changes arrive only through
* {@link ChannelRegistry} transitions.
Expand All @@ -60,6 +62,13 @@ public final class Channel {
private final Set<ClientSession> operatorsView = Collections.unmodifiableSet(operators);
private final Set<ClientSession> voiced = ConcurrentHashMap.newKeySet();
private final Set<ClientSession> 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<ChannelMode> activeModes = ConcurrentHashMap.newKeySet();
private final Set<ChannelMode> activeModesView = Collections.unmodifiableSet(activeModes);
private final List<BanEntry> bans = new CopyOnWriteArrayList<>();
Expand Down Expand Up @@ -103,23 +112,52 @@ public Set<ClientSession> 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<String> 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<String> memberListing(boolean multiPrefix) {
List<String> 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<ClientSession> operators() {
return operatorsView;
}
Expand Down Expand Up @@ -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;
}

Expand All @@ -265,27 +299,27 @@ 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. */
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. */
Expand Down Expand Up @@ -355,16 +389,31 @@ synchronized boolean addMember(ClientSession session) {
boolean wasEmpty = members.isEmpty();
members.add(session);
if (wasEmpty) {
operators.add(session);
updateHolders(operators, session, true);
}
return wasEmpty;
}

/** 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<ClientSession> holders, ClientSession member, boolean add) {
privilegeVersion.incrementAndGet();
try {
return add ? holders.add(member) : holders.remove(member);
} finally {
privilegeVersion.incrementAndGet();
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -106,7 +107,9 @@ public Optional<JoinResult> join(
}
}
joinSnapshotHook.accept(channel);
List<String> listing = channel.memberListing();
List<String> listing =
channel.memberListing(
session.negotiatedCapabilities().contains(CapabilityName.MULTI_PREFIX));
Set<ClientSession> recipients = new LinkedHashSet<>(channel.members());
recipients.add(session);
return Optional.of(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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. */
Expand All @@ -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<String> listingFor(ClientSession requester, Channel channel) {
return channel.memberListing(
requester.negotiatedCapabilities().contains(CapabilityName.MULTI_PREFIX));
}

private static void sendNamesLine(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading