diff --git a/api/build.gradle.kts b/api/build.gradle.kts index c8afb716..65a03a04 100644 --- a/api/build.gradle.kts +++ b/api/build.gradle.kts @@ -18,7 +18,7 @@ java { } dependencies { - compileOnlyApi(libs.jspecify) + api(libs.jspecify) api(libs.gson) api(libs.guava) diff --git a/api/src/ap/java/com/velocitypowered/api/plugin/ap/PluginAnnotationProcessor.java b/api/src/ap/java/com/velocitypowered/api/plugin/ap/PluginAnnotationProcessor.java index b44f2847..572ed03c 100644 --- a/api/src/ap/java/com/velocitypowered/api/plugin/ap/PluginAnnotationProcessor.java +++ b/api/src/ap/java/com/velocitypowered/api/plugin/ap/PluginAnnotationProcessor.java @@ -97,6 +97,16 @@ public class PluginAnnotationProcessor extends AbstractProcessor { } } + for (String provided : plugin.provides()) { + if (!SerializedPluginDescription.ID_PATTERN.matcher(provided).matches()) { + environment.getMessager().printMessage(Diagnostic.Kind.ERROR, + "Invalid provided ID '" + provided + "' for plugin " + qualifiedName + + ". IDs must start alphabetically, have lowercase alphanumeric characters, and " + + "can contain dashes or underscores."); + return false; + } + } + // All good, generate the velocity-plugin.json. SerializedPluginDescription description = SerializedPluginDescription .from(plugin, qualifiedName.toString()); diff --git a/api/src/ap/java/com/velocitypowered/api/plugin/ap/SerializedPluginDescription.java b/api/src/ap/java/com/velocitypowered/api/plugin/ap/SerializedPluginDescription.java index b712d896..cdcb53b5 100644 --- a/api/src/ap/java/com/velocitypowered/api/plugin/ap/SerializedPluginDescription.java +++ b/api/src/ap/java/com/velocitypowered/api/plugin/ap/SerializedPluginDescription.java @@ -35,11 +35,12 @@ public final class SerializedPluginDescription { private final @Nullable String url; private final @Nullable List authors; private final @Nullable List dependencies; + private final @Nullable List provides; private final String main; private SerializedPluginDescription(String id, String name, String version, String description, String url, - List authors, List dependencies, String main) { + List authors, List dependencies, List provides, String main) { Preconditions.checkNotNull(id, "id"); Preconditions.checkArgument(ID_PATTERN.matcher(id).matches(), "id is not valid"); this.id = id; @@ -50,6 +51,7 @@ public final class SerializedPluginDescription { this.authors = authors == null || authors.isEmpty() ? ImmutableList.of() : authors; this.dependencies = dependencies == null || dependencies.isEmpty() ? ImmutableList.of() : dependencies; + this.provides = provides == null || provides.isEmpty() ? ImmutableList.of() : provides; this.main = Preconditions.checkNotNull(main, "main"); } @@ -61,7 +63,9 @@ public final class SerializedPluginDescription { return new SerializedPluginDescription(plugin.id(), plugin.name(), plugin.version(), plugin.description(), plugin.url(), Arrays.stream(plugin.authors()).filter(author -> !author.isEmpty()) - .collect(Collectors.toList()), dependencies, qualifiedName); + .collect(Collectors.toList()), dependencies, + Arrays.stream(plugin.provides()).filter(provided -> !provided.isEmpty()) + .collect(Collectors.toList()), qualifiedName); } public String getId() { @@ -92,6 +96,10 @@ public final class SerializedPluginDescription { return dependencies == null ? ImmutableList.of() : dependencies; } + public List getProvides() { + return provides == null ? ImmutableList.of() : provides; + } + public String getMain() { return main; } @@ -112,12 +120,13 @@ public final class SerializedPluginDescription { && Objects.equals(url, that.url) && Objects.equals(authors, that.authors) && Objects.equals(dependencies, that.dependencies) + && Objects.equals(provides, that.provides) && Objects.equals(main, that.main); } @Override public int hashCode() { - return Objects.hash(id, name, version, description, url, authors, dependencies); + return Objects.hash(id, name, version, description, url, authors, dependencies, provides); } @Override @@ -130,6 +139,7 @@ public final class SerializedPluginDescription { + ", url='" + url + '\'' + ", authors=" + authors + ", dependencies=" + dependencies + + ", provides=" + provides + ", main='" + main + '\'' + '}'; } diff --git a/api/src/main/java/com/velocitypowered/api/event/player/PlayerClientLoadedWorldEvent.java b/api/src/main/java/com/velocitypowered/api/event/player/PlayerClientLoadedWorldEvent.java new file mode 100644 index 00000000..50fb650c --- /dev/null +++ b/api/src/main/java/com/velocitypowered/api/event/player/PlayerClientLoadedWorldEvent.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2018-2026 Velocity Contributors + * + * The Velocity API is licensed under the terms of the MIT License. For more details, + * reference the LICENSE file in the api top-level directory. + */ + +package com.velocitypowered.api.event.player; + +import com.google.common.annotations.Beta; +import com.google.common.base.Preconditions; +import com.velocitypowered.api.proxy.Player; + +/** + * Called when a player is marked as loaded by the client. + * + *

This event is fired once per {@link com.velocitypowered.api.proxy.ServerConnection} + * when the player explicitly notifies the server after loading the world (closing the downloading terrain screen) + * + * @implNote Unlike Paper this event will not fire due to a timeout nor respawning. + * Though plugins can implement a timeout by scheduling a task in {@link ServerPostConnectEvent} + * and checking {@link com.velocitypowered.api.proxy.ServerConnection#isClientLoaded()}. + * @sinceMinecraft 1.21.4 + * @since 4.1.0 + */ +@Beta +public final class PlayerClientLoadedWorldEvent { + + private final Player player; + + public PlayerClientLoadedWorldEvent(Player player) { + this.player = Preconditions.checkNotNull(player, "player"); + } + + public Player getPlayer() { + return player; + } + + @Override + public String toString() { + return "PlayerClientLoadedWorldEvent{" + + "player=" + player + + '}'; + } +} diff --git a/api/src/main/java/com/velocitypowered/api/plugin/Plugin.java b/api/src/main/java/com/velocitypowered/api/plugin/Plugin.java index d3b458ca..d6c8a4d5 100644 --- a/api/src/main/java/com/velocitypowered/api/plugin/Plugin.java +++ b/api/src/main/java/com/velocitypowered/api/plugin/Plugin.java @@ -72,4 +72,12 @@ public @interface Plugin { * @return the plugin dependencies */ Dependency[] dependencies() default {}; + + /** + * The plugin IDs this plugin "provides" for. Each ID must match + * {@link SerializedPluginDescription#ID_PATTERN_STRING}. + * + * @return the provided IDs + */ + String[] provides() default {}; } diff --git a/api/src/main/java/com/velocitypowered/api/plugin/PluginDescription.java b/api/src/main/java/com/velocitypowered/api/plugin/PluginDescription.java index 540b54ea..67e9b9c6 100644 --- a/api/src/main/java/com/velocitypowered/api/plugin/PluginDescription.java +++ b/api/src/main/java/com/velocitypowered/api/plugin/PluginDescription.java @@ -100,6 +100,16 @@ public interface PluginDescription { return Optional.empty(); } + /** + * Gets a {@link Collection} of the provided IDs of the {@link Plugin} within this container. + * + * @return the provided plugins IDs, can be empty + * @see Plugin#provides() + */ + default Collection getProvidedIds() { + return ImmutableSet.of(); + } + /** * Returns the source the plugin was loaded from. * diff --git a/api/src/main/java/com/velocitypowered/api/proxy/ServerConnection.java b/api/src/main/java/com/velocitypowered/api/proxy/ServerConnection.java index c408e830..d961d4ff 100644 --- a/api/src/main/java/com/velocitypowered/api/proxy/ServerConnection.java +++ b/api/src/main/java/com/velocitypowered/api/proxy/ServerConnection.java @@ -7,6 +7,7 @@ package com.velocitypowered.api.proxy; +import com.google.common.annotations.Beta; import com.velocitypowered.api.proxy.messages.ChannelMessageSink; import com.velocitypowered.api.proxy.messages.ChannelMessageSource; import com.velocitypowered.api.proxy.server.RegisteredServer; @@ -40,6 +41,17 @@ public interface ServerConnection extends ChannelMessageSource, ChannelMessageSi */ ServerInfo getServerInfo(); + /** + * Returns whether the client notified this connection of having loaded the world. + * + * @return true if the client has loaded the world + * @implNote This is purely client-dependent; see {@link com.velocitypowered.api.event.player.PlayerClientLoadedWorldEvent}. + * @sinceMinecraft 1.21.4 + * @since 4.1.0 + */ + @Beta + boolean isClientLoaded(); + /** * Returns the player that this connection is associated with. * diff --git a/gradle.properties b/gradle.properties index 1ccee529..44cdb5d0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,2 +1,2 @@ group=com.velocitypowered -version=4.0.0-SNAPSHOT +version=4.1.0-SNAPSHOT diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f3e5a9ad..9c12ac2d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,24 +2,24 @@ configurate3 = "3.7.3" configurate4 = "4.2.0" flare = "2.0.1" -log4j = "2.26.0" +log4j = "2.26.1" netty = "4.2.16.Final" [plugins] fill = "io.papermc.fill.gradle:1.0.12" -shadow = "com.gradleup.shadow:9.5.1" -spotless = "com.diffplug.spotless:8.2.0" +shadow = "com.gradleup.shadow:9.6.1" +spotless = "com.diffplug.spotless:8.9.0" [libraries] adventure-bom = "net.kyori:adventure-bom:5.2.0" adventure-text-serializer-json-legacy-impl = "net.kyori:adventure-text-serializer-json-legacy-impl:5.2.0" -asm = "org.ow2.asm:asm:9.9.1" +asm = "org.ow2.asm:asm:9.10.1" auto-service = "com.google.auto.service:auto-service:1.1.1" auto-service-annotations = "com.google.auto.service:auto-service-annotations:1.1.1" brigadier = "com.velocitypowered:velocity-brigadier:1.0.0-SNAPSHOT" -bstats = "org.bstats:bstats-base:3.1.0" -caffeine = "com.github.ben-manes.caffeine:caffeine:3.2.3" -checker-qual = "org.checkerframework:checker-qual:3.53.0" +bstats = "org.bstats:bstats-base:3.2.1" +caffeine = "com.github.ben-manes.caffeine:caffeine:3.2.4" +checker-qual = "org.checkerframework:checker-qual:4.2.1" checkstyle = "com.puppycrawl.tools:checkstyle:10.9.3" completablefutures = "com.spotify:completable-futures:0.3.6" configurate3-hocon = { module = "org.spongepowered:configurate-hocon", version.ref = "configurate3" } @@ -29,24 +29,24 @@ configurate4-hocon = { module = "org.spongepowered:configurate-hocon", version.r configurate4-yaml = { module = "org.spongepowered:configurate-yaml", version.ref = "configurate4" } configurate4-gson = { module = "org.spongepowered:configurate-gson", version.ref = "configurate4" } disruptor = "com.lmax:disruptor:4.0.0" -fastutil = "it.unimi.dsi:fastutil:8.5.18" +fastutil = "it.unimi.dsi:fastutil:8.5.19" flare-core = { module = "space.vectrix.flare:flare", version.ref = "flare" } flare-fastutil = { module = "space.vectrix.flare:flare-fastutil", version.ref = "flare" } jline = "org.jline:jline-terminal-ffm:4.3.1" jopt = "net.sf.jopt-simple:jopt-simple:5.0.4" -junit = "org.junit.jupiter:junit-jupiter:6.0.3" -jspecify = "org.jspecify:jspecify:1.0.0" +junit = "org.junit.jupiter:junit-jupiter:6.1.2" +jspecify = "org.jspecify:jspecify:1.0.1" kyori-ansi = "net.kyori:ansi:1.1.1" guava = "com.google.guava:guava:33.6.0-jre" gson = "com.google.code.gson:gson:2.14.0" guice = "com.google.inject:guice:7.0.0" -lmbda = "org.lanternpowered:lmbda:2.0.0" +lmbda = "org.lanternpowered:lmbda:3.0.0" log4j-api = { module = "org.apache.logging.log4j:log4j-api", version.ref = "log4j" } log4j-core = { module = "org.apache.logging.log4j:log4j-core", version.ref = "log4j" } log4j-slf4j-impl = { module = "org.apache.logging.log4j:log4j-slf4j2-impl", version.ref = "log4j" } log4j-iostreams = { module = "org.apache.logging.log4j:log4j-iostreams", version.ref = "log4j" } log4j-jul = { module = "org.apache.logging.log4j:log4j-jul", version.ref = "log4j" } -mockito = "org.mockito:mockito-core:5.22.0" +mockito = "org.mockito:mockito-core:5.23.0" netty-codec = { module = "io.netty:netty-codec", version.ref = "netty" } netty-codec-haproxy = { module = "io.netty:netty-codec-haproxy", version.ref = "netty" } netty-codec-http = { module = "io.netty:netty-codec-http", version.ref = "netty" } @@ -54,10 +54,10 @@ netty-handler = { module = "io.netty:netty-handler", version.ref = "netty" } netty-transport-native-epoll = { module = "io.netty:netty-transport-native-epoll", version.ref = "netty" } netty-transport-native-kqueue = { module = "io.netty:netty-transport-native-kqueue", version.ref = "netty" } netty-transport-native-iouring = { module = "io.netty:netty-transport-native-io_uring", version.ref = "netty" } -nightconfig = "com.electronwill.night-config:toml:3.8.3" -slf4j = "org.slf4j:slf4j-api:2.0.17" -snakeyaml = "org.yaml:snakeyaml:2.5" -spotbugs-annotations = "com.github.spotbugs:spotbugs-annotations:4.9.8" +nightconfig = "com.electronwill.night-config:toml:3.9.0" +slf4j = "org.slf4j:slf4j-api:2.0.18" +snakeyaml = "org.yaml:snakeyaml:2.6" +spotbugs-annotations = "com.github.spotbugs:spotbugs-annotations:4.10.3" terminalconsoleappender = "net.minecrell:terminalconsoleappender:1.3.0" [bundles] diff --git a/native/build-support/build-all-linux-natives.sh b/native/build-support/build-all-linux-natives.sh index 0e182da6..4c2eb3ef 100755 --- a/native/build-support/build-all-linux-natives.sh +++ b/native/build-support/build-all-linux-natives.sh @@ -5,29 +5,44 @@ set -e # make sure we're in the correct directory - the top-level `native` directory cd "$(dirname "$0")/.." || exit 1 +# Use docker by default, falling back to podman. Set CONTAINER_ENGINE to pick one explicitly. +if [ -z "$CONTAINER_ENGINE" ]; then + if command -v docker > /dev/null 2>&1; then + CONTAINER_ENGINE=docker + elif command -v podman > /dev/null 2>&1; then + CONTAINER_ENGINE=podman + else + echo "Neither docker nor podman was found on PATH." >&2 + exit 1 + fi +fi + +echo "Using container engine: $CONTAINER_ENGINE" + ARCHS=(x86_64 aarch64) BASE_DOCKERFILE_VARIANTS=(ubuntu-focal ubuntu-jammy alpine) COMPRESSION_VARIANTS=(ubuntu-focal alpine) +# Build one image per (variant, arch). Passing several --platform flags to a single tagged build +# does not produce a multi-arch tag - only the last architecture keeps the tag, and the run below +# then sees a platform mismatch, treats the image as missing and tries to pull it from a registry. for variant in "${BASE_DOCKERFILE_VARIANTS[@]}"; do - docker_platforms="" for arch in "${ARCHS[@]}"; do - docker_platforms="$docker_platforms --platform linux/${arch}" + echo "Building base build image for $variant on $arch..." + $CONTAINER_ENGINE build -t velocity-native-build:$variant-$arch --platform linux/${arch} \ + -f build-support/$variant.Dockerfile . done - - echo "Building base build image for $variant..." - docker build -t velocity-native-build:$variant $docker_platforms -f build-support/$variant.Dockerfile . done for arch in "${ARCHS[@]}"; do for variant in "${BASE_DOCKERFILE_VARIANTS[@]}"; do echo "Building native crypto for $arch on $variant..." - docker run --rm -v "$(pwd)":/app --platform linux/${arch} velocity-native-build:$variant /bin/bash -c "cd /app && ./build-support/compile-linux-crypto.sh" + $CONTAINER_ENGINE run --rm --pull=never -v "$(pwd)":/app --platform linux/${arch} velocity-native-build:$variant-$arch /bin/bash -c "cd /app && ./build-support/compile-linux-crypto.sh" done for variant in "${COMPRESSION_VARIANTS[@]}"; do echo "Building native compression for $arch on $variant..." - docker run --rm -v "$(pwd)":/app --platform linux/${arch} velocity-native-build:$variant /bin/bash -c "cd /app && ./build-support/compile-linux-compress.sh" + $CONTAINER_ENGINE run --rm --pull=never -v "$(pwd)":/app --platform linux/${arch} velocity-native-build:$variant-$arch /bin/bash -c "cd /app && ./build-support/compile-linux-compress.sh" done -done \ No newline at end of file +done diff --git a/native/src/main/c/jni_zlib_inflate.c b/native/src/main/c/jni_zlib_inflate.c index d9131908..5eb3ca3d 100644 --- a/native/src/main/c/jni_zlib_inflate.c +++ b/native/src/main/c/jni_zlib_inflate.c @@ -34,8 +34,7 @@ Java_com_velocitypowered_natives_compression_NativeZlibInflate_process(JNIEnv *e jlong sourceAddress, jint sourceLength, jlong destinationAddress, - jint destinationLength, - jlong maximumSize) + jint destinationLength) { struct libdeflate_decompressor *decompress = (struct libdeflate_decompressor *) ctx; enum libdeflate_result result = libdeflate_zlib_decompress(decompress, (void *) sourceAddress, diff --git a/native/src/main/java/com/velocitypowered/natives/compression/JavaVelocityCompressor.java b/native/src/main/java/com/velocitypowered/natives/compression/JavaVelocityCompressor.java index b6c3aab4..75fe17c5 100644 --- a/native/src/main/java/com/velocitypowered/natives/compression/JavaVelocityCompressor.java +++ b/native/src/main/java/com/velocitypowered/natives/compression/JavaVelocityCompressor.java @@ -56,24 +56,45 @@ public class JavaVelocityCompressor implements VelocityCompressor { final int origIdx = source.readerIndex(); inflater.setInput(source.nioBuffer()); + int totalProduced = 0; try { final int readable = source.readableBytes(); while (!inflater.finished() && inflater.getBytesRead() < readable) { + if (totalProduced >= uncompressedSize) { + throw new DataFormatException("Decompressed data exceeds the claimed uncompressed size " + + "of " + uncompressedSize + " bytes"); + } + + final int remaining = uncompressedSize - totalProduced; if (!destination.isWritable()) { - destination.ensureWritable(ZLIB_BUFFER_SIZE); + destination.ensureWritable(Math.min(ZLIB_BUFFER_SIZE, remaining)); } ByteBuffer destNioBuf = destination.nioBuffer(destination.writerIndex(), destination.writableBytes()); + + // Never let a single inflate step write past the claimed size + if (destNioBuf.remaining() > remaining) { + destNioBuf.limit(destNioBuf.position() + remaining); + } + int produced = inflater.inflate(destNioBuf); + if (produced == 0 && !inflater.finished()) { + // Output space was available yet the inflater made no progress: the stream is truncated + // or corrupt (this also covers a peer that over-reported the uncompressed size). + throw new DataFormatException("Received a truncated or malformed deflate stream, " + + "expected " + uncompressedSize + " bytes"); + } + totalProduced += produced; destination.writerIndex(destination.writerIndex() + produced); } if (!inflater.finished()) { - throw new DataFormatException("Received a deflate stream that was too large, wanted " - + uncompressedSize); + throw new DataFormatException("Received a truncated or malformed deflate stream, expected " + + uncompressedSize + " bytes"); } - source.readerIndex(origIdx + inflater.getTotalIn()); + + source.readerIndex(origIdx + (int) inflater.getBytesRead()); } finally { inflater.reset(); } @@ -102,7 +123,7 @@ public class JavaVelocityCompressor implements VelocityCompressor { destination.writerIndex(destination.writerIndex() + produced); } - source.readerIndex(origIdx + deflater.getTotalIn()); + source.readerIndex(origIdx + (int) deflater.getBytesRead()); deflater.reset(); } diff --git a/native/src/main/resources/linux_aarch64/velocity-cipher-ossl30x.so b/native/src/main/resources/linux_aarch64/velocity-cipher-ossl30x.so index 814b22a6..24ffa127 100755 Binary files a/native/src/main/resources/linux_aarch64/velocity-cipher-ossl30x.so and b/native/src/main/resources/linux_aarch64/velocity-cipher-ossl30x.so differ diff --git a/native/src/main/resources/linux_x86_64/velocity-cipher-ossl30x.so b/native/src/main/resources/linux_x86_64/velocity-cipher-ossl30x.so index 259b1b3f..1064f83c 100755 Binary files a/native/src/main/resources/linux_x86_64/velocity-cipher-ossl30x.so and b/native/src/main/resources/linux_x86_64/velocity-cipher-ossl30x.so differ diff --git a/native/src/main/resources/macos_arm64/velocity-cipher.dylib b/native/src/main/resources/macos_arm64/velocity-cipher.dylib index 552a6fd4..a3877605 100755 Binary files a/native/src/main/resources/macos_arm64/velocity-cipher.dylib and b/native/src/main/resources/macos_arm64/velocity-cipher.dylib differ diff --git a/native/src/main/resources/macos_arm64/velocity-compress.dylib b/native/src/main/resources/macos_arm64/velocity-compress.dylib index 5ccce7c8..f09f22ec 100755 Binary files a/native/src/main/resources/macos_arm64/velocity-compress.dylib and b/native/src/main/resources/macos_arm64/velocity-compress.dylib differ diff --git a/native/src/test/java/com/velocitypowered/natives/compression/VelocityCompressorTest.java b/native/src/test/java/com/velocitypowered/natives/compression/VelocityCompressorTest.java index a370b918..49d4c46a 100644 --- a/native/src/test/java/com/velocitypowered/natives/compression/VelocityCompressorTest.java +++ b/native/src/test/java/com/velocitypowered/natives/compression/VelocityCompressorTest.java @@ -17,6 +17,7 @@ package com.velocitypowered.natives.compression; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -77,6 +78,83 @@ class VelocityCompressorTest { check(compressor, () -> Unpooled.buffer(TEST_DATA.length + 32)); } + private static final int BOMB_ACTUAL_SIZE = 1 << 20; + private static final int BOMB_LYING_CLAIM = 1024; + + @Test + void javaRejectsUnderReportedUncompressedSize() throws DataFormatException { + VelocityCompressor compressor = JavaVelocityCompressor.FACTORY + .create(Deflater.DEFAULT_COMPRESSION); + DataFormatException ex = assertRejectsDecompressionBomb(compressor); + // The Java compressor's size guard names the claimed size in its message, so operators can + // tell an over-size rejection apart from a genuinely corrupt stream. + assertTrue(ex.getMessage().contains(String.valueOf(BOMB_LYING_CLAIM)), + "rejection must originate from the uncompressed-size guard, got: " + ex.getMessage()); + } + + @Test + @EnabledOnOs({LINUX}) + void nativeRejectsUnderReportedUncompressedSize() throws DataFormatException { + VelocityCompressor compressor = Natives.compress.get().create(Deflater.DEFAULT_COMPRESSION); + if (compressor.preferredBufferType() != BufferPreference.DIRECT_REQUIRED) { + compressor.close(); + fail("Loaded regular compressor"); + } + // libdeflate rejects with its own native-origin message ("uncompressed size is inaccurate"), + // so we only assert the behavioural guarantee here, not the message text. + assertRejectsDecompressionBomb(compressor); + } + + /** + * Asserts that a compressor refuses a decompression bomb: a small, valid deflate stream whose + * claimed uncompressed size is far smaller than what it actually inflates to. Verifies the same + * stream round-trips when the claimed size is honest (proving the rejection is caused by the + * under-reported size, not corrupt input) and that no output is written past the claimed size. + * Closes the compressor before returning the exception thrown by the rejected inflate. + */ + private DataFormatException assertRejectsDecompressionBomb(VelocityCompressor compressor) + throws DataFormatException { + // Direct buffers so this works for the native compressor, which requires them. + ByteBuf source = Unpooled.directBuffer(BOMB_ACTUAL_SIZE); + ByteBuf compressed = Unpooled.directBuffer(); + try { + source.writeZero(BOMB_ACTUAL_SIZE); + compressor.deflate(source, compressed); + final int compressedSize = compressed.readableBytes(); + assertTrue(compressedSize < BOMB_ACTUAL_SIZE / 100, + "sanity: payload really is a decompression bomb (" + compressedSize + " -> " + + BOMB_ACTUAL_SIZE + ")"); + + // Positive control: the compressed stream is perfectly valid and round-trips when the peer + // tells the truth about its uncompressed size. + ByteBuf honest = Unpooled.directBuffer(); + try { + compressor.inflate(compressed.duplicate(), honest, BOMB_ACTUAL_SIZE); + assertEquals(BOMB_ACTUAL_SIZE, honest.readableBytes(), + "valid stream must fully decompress when the claimed size is honest"); + } finally { + honest.release(); + } + + // Attack: same valid stream, but a tiny claimed size. inflate must refuse rather than grow + // the destination without bound. + ByteBuf decompressed = Unpooled.directBuffer(); + try { + DataFormatException ex = assertThrows(DataFormatException.class, + () -> compressor.inflate(compressed.duplicate(), decompressed, BOMB_LYING_CLAIM)); + assertTrue(decompressed.writerIndex() <= BOMB_LYING_CLAIM, + "inflate must not write past the claimed uncompressed size"); + return ex; + } finally { + decompressed.release(); + } + } finally { + source.release(); + compressed.release(); + compressor.close(); + } + } + private void check(VelocityCompressor compressor, Supplier bufSupplier) throws DataFormatException { ByteBuf source = bufSupplier.get(); diff --git a/proxy/build.gradle.kts b/proxy/build.gradle.kts index c1864e34..136f66a7 100644 --- a/proxy/build.gradle.kts +++ b/proxy/build.gradle.kts @@ -33,65 +33,6 @@ tasks { transform(Log4j2PluginsCacheFileTransformer::class.java) - // Exclude all the collection types we don"t intend to use - exclude("it/unimi/dsi/fastutil/booleans/**") - exclude("it/unimi/dsi/fastutil/bytes/**") - exclude("it/unimi/dsi/fastutil/chars/**") - exclude("it/unimi/dsi/fastutil/doubles/**") - exclude("it/unimi/dsi/fastutil/floats/**") - exclude("it/unimi/dsi/fastutil/longs/**") - exclude("it/unimi/dsi/fastutil/shorts/**") - - // Exclude the fastutil IO utilities - we don"t use them. - exclude("it/unimi/dsi/fastutil/io/**") - - // Exclude most of the int types - Object2IntMap have a values() method that returns an - // IntCollection, and we need Int2ObjectMap - exclude("it/unimi/dsi/fastutil/ints/*Int2Boolean*") - exclude("it/unimi/dsi/fastutil/ints/*Int2Byte*") - exclude("it/unimi/dsi/fastutil/ints/*Int2Char*") - exclude("it/unimi/dsi/fastutil/ints/*Int2Double*") - exclude("it/unimi/dsi/fastutil/ints/*Int2Float*") - exclude("it/unimi/dsi/fastutil/ints/*Int2Int*") - exclude("it/unimi/dsi/fastutil/ints/*Int2Long*") - exclude("it/unimi/dsi/fastutil/ints/*Int2Short*") - exclude("it/unimi/dsi/fastutil/ints/*Int2Reference*") - exclude("it/unimi/dsi/fastutil/ints/IntAVL*") - exclude("it/unimi/dsi/fastutil/ints/IntArrayF*") - exclude("it/unimi/dsi/fastutil/ints/IntArrayI*") - exclude("it/unimi/dsi/fastutil/ints/IntArrayL*") - exclude("it/unimi/dsi/fastutil/ints/IntArrayP*") - exclude("it/unimi/dsi/fastutil/ints/IntArraySet*") - exclude("it/unimi/dsi/fastutil/ints/*IntBi*") - exclude("it/unimi/dsi/fastutil/ints/Int*Pair") - exclude("it/unimi/dsi/fastutil/ints/IntLinked*") - exclude("it/unimi/dsi/fastutil/ints/IntList*") - exclude("it/unimi/dsi/fastutil/ints/IntHeap*") - exclude("it/unimi/dsi/fastutil/ints/IntOpen*") - exclude("it/unimi/dsi/fastutil/ints/IntRB*") - exclude("it/unimi/dsi/fastutil/ints/IntSorted*") - exclude("it/unimi/dsi/fastutil/ints/*Priority*") - exclude("it/unimi/dsi/fastutil/ints/*BigList*") - - // Try to exclude everything BUT Object2Int{LinkedOpen,Open,CustomOpen}HashMap - exclude("it/unimi/dsi/fastutil/objects/*ObjectArray*") - exclude("it/unimi/dsi/fastutil/objects/*ObjectAVL*") - exclude("it/unimi/dsi/fastutil/objects/*Object*Big*") - exclude("it/unimi/dsi/fastutil/objects/*Object2Boolean*") - exclude("it/unimi/dsi/fastutil/objects/*Object2Byte*") - exclude("it/unimi/dsi/fastutil/objects/*Object2Char*") - exclude("it/unimi/dsi/fastutil/objects/*Object2Double*") - exclude("it/unimi/dsi/fastutil/objects/*Object2Float*") - exclude("it/unimi/dsi/fastutil/objects/*Object2IntArray*") - exclude("it/unimi/dsi/fastutil/objects/*Object2IntAVL*") - exclude("it/unimi/dsi/fastutil/objects/*Object2IntRB*") - exclude("it/unimi/dsi/fastutil/objects/*Object2Long*") - exclude("it/unimi/dsi/fastutil/objects/*Object2Object*") - exclude("it/unimi/dsi/fastutil/objects/*Object2Reference*") - exclude("it/unimi/dsi/fastutil/objects/*Object2Short*") - exclude("it/unimi/dsi/fastutil/objects/*ObjectRB*") - exclude("it/unimi/dsi/fastutil/objects/*Reference*") - // Exclude Checker Framework annotations exclude("org/checkerframework/checker/**") @@ -129,9 +70,13 @@ fill { build { channel = BuildChannel.STABLE - versionFamily("3.0.0") + versionFamily("4.0.0") version(projectVersion) + if (versionFamily.get().split(".")[0] != projectVersion.split(".")[0]) { + throw IllegalArgumentException("Version family does not match project version") + } + downloads { register("server:default") { file = tasks.shadowJar.flatMap { it.archiveFile } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java b/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java index 83e443e9..7c027dd6 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/VelocityServer.java @@ -221,7 +221,8 @@ public class VelocityServer implements ProxyServer, ForwardingAudience { PluginDescription description = new VelocityPluginDescription( "velocity", version.getName(), version.getVersion(), "The Velocity proxy", version.getName().equals("Velocity") ? VELOCITY_URL : null, - ImmutableList.of(version.getVendor()), Collections.emptyList(), null); + ImmutableList.of(version.getVendor()), Collections.emptyList(), + Collections.emptyList(), null); VelocityPluginContainer container = new VelocityPluginContainer(description); container.setInstance(VelocityVirtualPlugin.INSTANCE); return container; diff --git a/proxy/src/main/java/com/velocitypowered/proxy/command/builtin/VelocityCommand.java b/proxy/src/main/java/com/velocitypowered/proxy/command/builtin/VelocityCommand.java index 562efee4..bebf1c1c 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/command/builtin/VelocityCommand.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/command/builtin/VelocityCommand.java @@ -161,14 +161,17 @@ public final class VelocityCommand { .decoration(TextDecoration.BOLD, true) .color(VELOCITY_COLOR) .append(Component.text() - .content(version.getVersion()) - .decoration(TextDecoration.BOLD, false)) + .content(version.getVersion()) + .decoration(TextDecoration.BOLD, false)) + .hoverEvent(Component.translatable("velocity.command.version-offer-copy-version")) + .clickEvent(ClickEvent.copyToClipboard(version.getName() + " " + + version.getVersion())) .build(); final Component copyright = Component .translatable("velocity.command.version-copyright", Argument.string("vendor", version.getVendor()), - Argument.string("name", version.getName()), - Argument.component("year", Component.text(LocalDate.now().getYear()))); + Argument.string("name", version.getName()), + Argument.component("year", Component.text(LocalDate.now().getYear()))); source.sendMessage(velocity); source.sendMessage(copyright); diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftSessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftSessionHandler.java index d1101d6a..00783dbf 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftSessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/MinecraftSessionHandler.java @@ -53,6 +53,7 @@ import com.velocitypowered.proxy.protocol.packet.ServerLoginPacket; import com.velocitypowered.proxy.protocol.packet.ServerLoginSuccessPacket; import com.velocitypowered.proxy.protocol.packet.ServerboundCookieResponsePacket; import com.velocitypowered.proxy.protocol.packet.ServerboundCustomClickActionPacket; +import com.velocitypowered.proxy.protocol.packet.ServerboundPlayerLoadedPacket; import com.velocitypowered.proxy.protocol.packet.SetCompressionPacket; import com.velocitypowered.proxy.protocol.packet.StatusPingPacket; import com.velocitypowered.proxy.protocol.packet.StatusRequestPacket; @@ -200,6 +201,10 @@ public interface MinecraftSessionHandler { return false; } + default boolean handle(ServerboundPlayerLoadedPacket packet) { + return false; + } + default boolean handle(ServerLoginPacket packet) { return false; } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/BackendPlaySessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/BackendPlaySessionHandler.java index f44859bd..74da2053 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/BackendPlaySessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/BackendPlaySessionHandler.java @@ -296,6 +296,19 @@ public class BackendPlaySessionHandler implements MinecraftSessionHandler { return true; } + // Register and unregister packets are simply forwarded to the client as-is. + if (PluginMessageUtil.isRegister(packet) || PluginMessageUtil.isUnregister(packet)) { + return false; + } + + if (PluginMessageUtil.isMcBrand(packet)) { + PluginMessagePacket rewritten = PluginMessageUtil + .rewriteMinecraftBrand(packet, + server.getVersion(), playerConnection.getProtocolVersion()); + playerConnection.write(rewritten); + return true; + } + if (serverConn.getPhase().handle(serverConn, serverConn.getPlayer(), packet)) { // Handled. return true; diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/ConfigSessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/ConfigSessionHandler.java index 8cc1bf8c..904e848f 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/ConfigSessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/ConfigSessionHandler.java @@ -277,7 +277,6 @@ public class ConfigSessionHandler implements MinecraftSessionHandler { PluginMessageUtil.rewriteMinecraftBrand(packet, server.getVersion(), serverConn.getPlayer().getProtocolVersion())); } else { - byte[] bytes = ByteBufUtil.getBytes(packet.content()); ChannelIdentifier id = this.server.getChannelRegistrar().getFromId(packet.getChannel()); if (id == null) { @@ -287,6 +286,7 @@ public class ConfigSessionHandler implements MinecraftSessionHandler { // Handling this stuff async means that we should probably pause // the connection while we toss this off into another pool + byte[] bytes = ByteBufUtil.getBytes(packet.content()); this.serverConn.getConnection().setAutoReading(false); this.server.getEventManager() .fire(new PluginMessageEvent(serverConn, serverConn.getPlayer(), id, bytes)) diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/VelocityServerConnection.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/VelocityServerConnection.java index c40a7aaa..c8b3c199 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/VelocityServerConnection.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/backend/VelocityServerConnection.java @@ -68,6 +68,7 @@ public class VelocityServerConnection implements MinecraftConnectionAssociation, private final VelocityServer server; private @Nullable MinecraftConnection connection; private boolean hasCompletedJoin = false; + private boolean clientLoaded = false; // 1.21.4+ private boolean gracefulDisconnect = false; private BackendConnectionPhase connectionPhase = BackendConnectionPhases.UNKNOWN; private final Map pendingPings = new HashMap<>(); @@ -317,6 +318,15 @@ public class VelocityServerConnection implements MinecraftConnectionAssociation, } } + public void setClientLoaded(boolean clientLoaded) { + this.clientLoaded = clientLoaded; + } + + @Override + public boolean isClientLoaded() { + return clientLoaded; + } + boolean isGracefulDisconnect() { return gracefulDisconnect; } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientConfigSessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientConfigSessionHandler.java index e4c754f9..b8604103 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientConfigSessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientConfigSessionHandler.java @@ -47,6 +47,7 @@ import com.velocitypowered.proxy.protocol.packet.config.FinishedUpdatePacket; import com.velocitypowered.proxy.protocol.packet.config.KnownPacksPacket; import com.velocitypowered.proxy.protocol.util.PluginMessageUtil; import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufHolder; import io.netty.buffer.ByteBufUtil; import io.netty.buffer.Unpooled; import java.util.concurrent.CompletableFuture; @@ -135,7 +136,6 @@ public class ClientConfigSessionHandler implements MinecraftSessionHandler { } else if (BungeeCordMessageResponder.isBungeeCordMessage(packet)) { return true; } else if (serverConn != null) { - byte[] bytes = ByteBufUtil.getBytes(packet.content()); ChannelIdentifier id = this.server.getChannelRegistrar().getFromId(packet.getChannel()); if (id == null) { @@ -145,6 +145,7 @@ public class ClientConfigSessionHandler implements MinecraftSessionHandler { // Handling this stuff async means that we should probably pause // the connection while we toss this off into another pool + byte[] bytes = ByteBufUtil.getBytes(packet.content()); serverConn.getPlayer().getConnection().setAutoReading(false); this.server.getEventManager() .fire(new PluginMessageEvent(serverConn.getPlayer(), serverConn, id, bytes)) @@ -212,8 +213,9 @@ public class ClientConfigSessionHandler implements MinecraftSessionHandler { @Override public boolean handle(ServerboundCustomClickActionPacket packet) { - if (player.getConnectionInFlight() != null) { - player.getConnectionInFlight().ensureConnected().write(packet.retain()); + VelocityServerConnection serverConnection = player.getConnectionInFlightOrConnectedServer(); + if (serverConnection != null) { + serverConnection.ensureConnected().write(packet.retain()); return true; } @@ -240,8 +242,8 @@ public class ClientConfigSessionHandler implements MinecraftSessionHandler { MinecraftConnection smc = serverConnection.getConnection(); if (smc != null && serverConnection.getPhase().consideredComplete()) { - if (packet instanceof PluginMessagePacket) { - ((PluginMessagePacket) packet).retain(); + if (packet instanceof ByteBufHolder bufHolder) { + bufHolder.retain(); } smc.write(packet); } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientPlaySessionHandler.java b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientPlaySessionHandler.java index a203ddc7..ba3dbc34 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientPlaySessionHandler.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/connection/client/ClientPlaySessionHandler.java @@ -26,6 +26,7 @@ import com.velocitypowered.api.event.player.CookieReceiveEvent; import com.velocitypowered.api.event.player.PlayerChannelRegisterEvent; import com.velocitypowered.api.event.player.PlayerChannelUnregisterEvent; import com.velocitypowered.api.event.player.PlayerClientBrandEvent; +import com.velocitypowered.api.event.player.PlayerClientLoadedWorldEvent; import com.velocitypowered.api.event.player.TabCompleteEvent; import com.velocitypowered.api.event.player.configuration.PlayerEnteredConfigurationEvent; import com.velocitypowered.api.network.ProtocolVersion; @@ -52,6 +53,7 @@ import com.velocitypowered.proxy.protocol.packet.PluginMessagePacket; import com.velocitypowered.proxy.protocol.packet.ResourcePackResponsePacket; import com.velocitypowered.proxy.protocol.packet.RespawnPacket; import com.velocitypowered.proxy.protocol.packet.ServerboundCookieResponsePacket; +import com.velocitypowered.proxy.protocol.packet.ServerboundPlayerLoadedPacket; import com.velocitypowered.proxy.protocol.packet.TabCompleteRequestPacket; import com.velocitypowered.proxy.protocol.packet.TabCompleteResponsePacket; import com.velocitypowered.proxy.protocol.packet.TabCompleteResponsePacket.Offer; @@ -243,6 +245,20 @@ public class ClientPlaySessionHandler implements MinecraftSessionHandler { return true; // will forward onto the server } + @Override + public boolean handle(ServerboundPlayerLoadedPacket packet) { + VelocityServerConnection serverConnection = player.getConnectedServer(); + if (serverConnection == null) { + // No server connection yet, probably transitioning - shouldn't be possible with a vanilla client + return true; + } + if (!serverConnection.isClientLoaded()) { + serverConnection.setClientLoaded(true); + server.getEventManager().fireAndForget(new PlayerClientLoadedWorldEvent(player)); + } + return false; + } + @Override public boolean handle(SessionPlayerCommandPacket packet) { if (player.getCurrentServer().isEmpty()) { diff --git a/proxy/src/main/java/com/velocitypowered/proxy/event/CustomHandlerAdapter.java b/proxy/src/main/java/com/velocitypowered/proxy/event/CustomHandlerAdapter.java index 8b9df6dd..88e731f0 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/event/CustomHandlerAdapter.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/event/CustomHandlerAdapter.java @@ -60,9 +60,7 @@ final class CustomHandlerAdapter { UntargetedEventHandler buildUntargetedHandler(final Method method) throws IllegalAccessException { final MethodHandle methodHandle = methodHandlesLookup.unreflect(method); - final MethodHandles.Lookup defineLookup = MethodHandles.privateLookupIn( - method.getDeclaringClass(), methodHandlesLookup); - final LambdaType lambdaType = functionType.defineClassesWith(defineLookup); + final LambdaType lambdaType = functionType.defineClassesWith(methodHandlesLookup); final F invokeFunction = LambdaFactory.create(lambdaType, methodHandle); final BiFunction handlerFunction = handlerBuilder.apply(invokeFunction); diff --git a/proxy/src/main/java/com/velocitypowered/proxy/event/VelocityEventManager.java b/proxy/src/main/java/com/velocitypowered/proxy/event/VelocityEventManager.java index 9d54b2d0..b56b907c 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/event/VelocityEventManager.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/event/VelocityEventManager.java @@ -243,7 +243,7 @@ public class VelocityEventManager implements EventManager { } else { type = untargetedVoidHandlerType; } - return LambdaFactory.create(type.defineClassesWith(lookup), methodHandle); + return LambdaFactory.create(type.defineClassesWith(methodHandlesLookup), methodHandle); } static final class MethodHandlerInfo { diff --git a/proxy/src/main/java/com/velocitypowered/proxy/plugin/VelocityPluginManager.java b/proxy/src/main/java/com/velocitypowered/proxy/plugin/VelocityPluginManager.java index 6bd0e008..aacbdc49 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/plugin/VelocityPluginManager.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/plugin/VelocityPluginManager.java @@ -46,10 +46,12 @@ import java.util.Collections; import java.util.HashMap; import java.util.IdentityHashMap; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -62,6 +64,7 @@ public class VelocityPluginManager implements PluginManager { private final Map pluginsById = new LinkedHashMap<>(); private final Map pluginInstances = new IdentityHashMap<>(); + private final Set plugins = new LinkedHashSet<>(); private final VelocityServer server; public VelocityPluginManager(VelocityServer server) { @@ -74,7 +77,9 @@ public class VelocityPluginManager implements PluginManager { * @param plugin the plugin to register */ public void registerPlugin(PluginContainer plugin) { + plugins.add(plugin); pluginsById.put(plugin.getDescription().getId(), plugin); + plugin.getDescription().getProvidedIds().forEach(id -> pluginsById.put(id, plugin)); Optional instance = plugin.getInstance(); instance.ifPresent(o -> pluginInstances.put(o, plugin)); } @@ -100,16 +105,34 @@ public class VelocityPluginManager implements PluginManager { try { PluginDescription candidate = loader.loadCandidate(path); - // If we found a duplicate candidate (with the same ID), don't load it. - PluginDescription maybeExistingCandidate = foundCandidates.putIfAbsent( - candidate.getId(), candidate); + // A plugin claims its own ID plus every ID it provides. If any of those are already + // claimed by another candidate, don't load this one. + List claimedIds = new ArrayList<>(candidate.getProvidedIds().size() + 1); + claimedIds.add(candidate.getId()); + claimedIds.addAll(candidate.getProvidedIds()); - if (maybeExistingCandidate != null) { - logger.error("Refusing to load plugin at path {} since we already " - + "loaded a plugin with the same ID {} from {}", + PluginDescription conflict = null; + String conflictingId = null; + for (String id : claimedIds) { + PluginDescription existing = foundCandidates.get(id); + if (existing != null) { + conflict = existing; + conflictingId = id; + break; + } + } + + if (conflict != null) { + logger.error("Refusing to load plugin at path {} since ID {} was already " + + "claimed by a plugin loaded from {}", candidate.getSource().map(Objects::toString).orElse(""), - candidate.getId(), - maybeExistingCandidate.getSource().map(Objects::toString).orElse("")); + conflictingId, + conflict.getSource().map(Objects::toString).orElse("")); + continue; + } + + for (String id : claimedIds) { + foundCandidates.put(id, candidate); } } catch (Throwable e) { logger.error("Unable to load plugin {}", path, e); @@ -122,8 +145,10 @@ public class VelocityPluginManager implements PluginManager { return; } + // foundCandidates indexes each candidate under its ID and any provided IDs, so dedupe before + // sorting to avoid loading a plugin more than once. List sortedPlugins = PluginDependencyUtils.sortCandidates( - new ArrayList<>(foundCandidates.values())); + new ArrayList<>(new LinkedHashSet<>(foundCandidates.values()))); Map loadedCandidates = new HashMap<>(); Map pluginContainers = new LinkedHashMap<>(); @@ -144,6 +169,7 @@ public class VelocityPluginManager implements PluginManager { VelocityPluginContainer container = new VelocityPluginContainer(realPlugin); pluginContainers.put(container, loader.createModule(container)); loadedCandidates.put(realPlugin.getId(), realPlugin); + realPlugin.getProvidedIds().forEach(id -> loadedCandidates.putIfAbsent(id, realPlugin)); } catch (Throwable e) { logger.error("Can't create module for plugin {}", candidate.getId(), e); } @@ -201,7 +227,7 @@ public class VelocityPluginManager implements PluginManager { @Override public Collection getPlugins() { - return Collections.unmodifiableCollection(pluginsById.values()); + return Collections.unmodifiableCollection(plugins); } @Override diff --git a/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/VelocityPluginDescription.java b/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/VelocityPluginDescription.java index cde909ef..1a545ded 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/VelocityPluginDescription.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/VelocityPluginDescription.java @@ -21,6 +21,7 @@ import static com.google.common.base.Preconditions.checkNotNull; import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; import com.google.common.collect.Maps; import com.velocitypowered.api.plugin.PluginDescription; import com.velocitypowered.api.plugin.meta.PluginDependency; @@ -43,6 +44,7 @@ public class VelocityPluginDescription implements PluginDescription { private final @Nullable String url; private final List authors; private final Map dependencies; + private final Collection providedIds; private final Path source; /** @@ -55,11 +57,13 @@ public class VelocityPluginDescription implements PluginDescription { * @param url the website for the plugin * @param authors the authors of this plugin * @param dependencies the dependencies for this plugin + * @param providedIds the IDs this plugin provides for * @param source the original source for the plugin */ public VelocityPluginDescription(String id, @Nullable String name, @Nullable String version, @Nullable String description, @Nullable String url, - @Nullable List authors, Collection dependencies, Path source) { + @Nullable List authors, Collection dependencies, + @Nullable Collection providedIds, Path source) { this.id = checkNotNull(id, "id"); this.name = Strings.emptyToNull(name); this.version = Strings.emptyToNull(version); @@ -67,6 +71,8 @@ public class VelocityPluginDescription implements PluginDescription { this.url = Strings.emptyToNull(url); this.authors = authors == null ? ImmutableList.of() : ImmutableList.copyOf(authors); this.dependencies = Maps.uniqueIndex(dependencies, d -> d == null ? null : d.getId()); + this.providedIds = + providedIds == null ? ImmutableSet.of() : ImmutableSet.copyOf(providedIds); this.source = source; } @@ -110,6 +116,11 @@ public class VelocityPluginDescription implements PluginDescription { return Optional.ofNullable(dependencies.get(id)); } + @Override + public Collection getProvidedIds() { + return providedIds; + } + @Override public Optional getSource() { return Optional.ofNullable(source); @@ -125,6 +136,7 @@ public class VelocityPluginDescription implements PluginDescription { + ", url='" + url + '\'' + ", authors=" + authors + ", dependencies=" + dependencies + + ", providedIds=" + providedIds + ", source=" + source + '}'; } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/java/JavaPluginLoader.java b/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/java/JavaPluginLoader.java index 5b69e49a..cbc4b57f 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/java/JavaPluginLoader.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/java/JavaPluginLoader.java @@ -78,6 +78,14 @@ public class JavaPluginLoader implements PluginLoader { } } + for (String providedId : pd.getProvides()) { + if (!SerializedPluginDescription.ID_PATTERN.matcher(providedId).matches()) { + throw new InvalidPluginException( + "Provided ID '" + providedId + "' for plugin '" + pd.getId() + "' is invalid." + ); + } + } + return createCandidateDescription(pd, source); } @@ -181,6 +189,7 @@ public class JavaPluginLoader implements PluginLoader { description.getUrl(), description.getAuthors(), dependencies, + description.getProvides(), source, description.getMain() ); @@ -197,6 +206,7 @@ public class JavaPluginLoader implements PluginLoader { description.getUrl().orElse(null), description.getAuthors(), description.getDependencies(), + description.getProvidedIds(), description.getSource().orElse(null), mainClass ); diff --git a/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/java/JavaVelocityPluginDescription.java b/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/java/JavaVelocityPluginDescription.java index dfc7c89c..d85be85b 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/java/JavaVelocityPluginDescription.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/java/JavaVelocityPluginDescription.java @@ -32,9 +32,9 @@ class JavaVelocityPluginDescription extends VelocityPluginDescription { JavaVelocityPluginDescription(String id, @Nullable String name, @Nullable String version, @Nullable String description, @Nullable String url, - @Nullable List authors, Collection dependencies, Path source, - Class mainClass) { - super(id, name, version, description, url, authors, dependencies, source); + @Nullable List authors, Collection dependencies, + @Nullable Collection providedIds, Path source, Class mainClass) { + super(id, name, version, description, url, authors, dependencies, providedIds, source); this.mainClass = checkNotNull(mainClass); } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/java/JavaVelocityPluginDescriptionCandidate.java b/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/java/JavaVelocityPluginDescriptionCandidate.java index fb7d9dea..1de30f4d 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/java/JavaVelocityPluginDescriptionCandidate.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/plugin/loader/java/JavaVelocityPluginDescriptionCandidate.java @@ -32,9 +32,9 @@ class JavaVelocityPluginDescriptionCandidate extends VelocityPluginDescription { JavaVelocityPluginDescriptionCandidate(String id, @Nullable String name, @Nullable String version, @Nullable String description, @Nullable String url, - @Nullable List authors, Collection dependencies, Path source, - String mainClass) { - super(id, name, version, description, url, authors, dependencies, source); + @Nullable List authors, Collection dependencies, + @Nullable Collection providedIds, Path source, String mainClass) { + super(id, name, version, description, url, authors, dependencies, providedIds, source); this.mainClass = checkNotNull(mainClass); } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/plugin/util/PluginDependencyUtils.java b/proxy/src/main/java/com/velocitypowered/proxy/plugin/util/PluginDependencyUtils.java index ed2395fa..cc1026a0 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/plugin/util/PluginDependencyUtils.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/plugin/util/PluginDependencyUtils.java @@ -17,7 +17,6 @@ package com.velocitypowered.proxy.plugin.util; -import com.google.common.collect.Maps; import com.google.common.graph.Graph; import com.google.common.graph.GraphBuilder; import com.google.common.graph.MutableGraph; @@ -60,8 +59,19 @@ public class PluginDependencyUtils { .allowsSelfLoops(false) .expectedNodeCount(sortedCandidates.size()) .build(); - Map candidateMap = Maps.uniqueIndex(sortedCandidates, - PluginDescription::getId); + + // Index candidates by their own ID and any IDs they provide, so a dependency can be satisfied + // by a plugin that provides that ID. Real IDs take precedence over provided IDs, and the first + // provider of a given ID wins; upstream loading rejects such conflicts before we get here. + Map candidateMap = new HashMap<>(); + for (PluginDescription description : sortedCandidates) { + candidateMap.putIfAbsent(description.getId(), description); + } + for (PluginDescription description : sortedCandidates) { + for (String provided : description.getProvidedIds()) { + candidateMap.putIfAbsent(provided, description); + } + } for (PluginDescription description : sortedCandidates) { graph.addNode(description); @@ -69,7 +79,8 @@ public class PluginDependencyUtils { for (PluginDependency dependency : description.getDependencies()) { PluginDescription in = candidateMap.get(dependency.getId()); - if (in != null) { + // Guard against self-loops: a plugin may name an ID it itself provides. + if (in != null && !in.equals(description)) { graph.putEdge(description, in); } } diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/StateRegistry.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/StateRegistry.java index f3441c48..a2e0410a 100644 --- a/proxy/src/main/java/com/velocitypowered/proxy/protocol/StateRegistry.java +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/StateRegistry.java @@ -88,6 +88,7 @@ import com.velocitypowered.proxy.protocol.packet.ServerLoginPacket; import com.velocitypowered.proxy.protocol.packet.ServerLoginSuccessPacket; import com.velocitypowered.proxy.protocol.packet.ServerboundCookieResponsePacket; import com.velocitypowered.proxy.protocol.packet.ServerboundCustomClickActionPacket; +import com.velocitypowered.proxy.protocol.packet.ServerboundPlayerLoadedPacket; import com.velocitypowered.proxy.protocol.packet.SetCompressionPacket; import com.velocitypowered.proxy.protocol.packet.StatusPingPacket; import com.velocitypowered.proxy.protocol.packet.StatusRequestPacket; @@ -345,6 +346,12 @@ public enum StateRegistry { map(0x13, MINECRAFT_1_21_2, false), map(0x14, MINECRAFT_1_21_6, false), map(0x15, MINECRAFT_26_1, false)); + serverbound.register( + ServerboundPlayerLoadedPacket.class, + () -> ServerboundPlayerLoadedPacket.INSTANCE, + map(0x2A, MINECRAFT_1_21_4, false), + map(0x2B, MINECRAFT_1_21_6, false), + map(0x2C, MINECRAFT_26_1, false)); serverbound.register( PluginMessagePacket.class, PluginMessagePacket::new, diff --git a/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/ServerboundPlayerLoadedPacket.java b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/ServerboundPlayerLoadedPacket.java new file mode 100644 index 00000000..8d069bbe --- /dev/null +++ b/proxy/src/main/java/com/velocitypowered/proxy/protocol/packet/ServerboundPlayerLoadedPacket.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2025 Velocity Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.velocitypowered.proxy.protocol.packet; + +import com.velocitypowered.api.network.ProtocolVersion; +import com.velocitypowered.proxy.connection.MinecraftSessionHandler; +import com.velocitypowered.proxy.protocol.MinecraftPacket; +import com.velocitypowered.proxy.protocol.ProtocolUtils; +import io.netty.buffer.ByteBuf; + +public class ServerboundPlayerLoadedPacket implements MinecraftPacket { + + public static final ServerboundPlayerLoadedPacket INSTANCE = new ServerboundPlayerLoadedPacket(); + + private ServerboundPlayerLoadedPacket() { + } + + @Override + public void decode(ByteBuf buf, ProtocolUtils.Direction direction, ProtocolVersion version) { + } + + @Override + public void encode(ByteBuf buf, ProtocolUtils.Direction direction, ProtocolVersion version) { + } + + @Override + public int decodeExpectedMaxLength(ByteBuf buf, ProtocolUtils.Direction direction, ProtocolVersion version) { + return 0; + } + + @Override + public boolean handle(MinecraftSessionHandler handler) { + return handler.handle(this); + } +} diff --git a/proxy/src/main/resources/com/velocitypowered/proxy/l10n/messages.properties b/proxy/src/main/resources/com/velocitypowered/proxy/l10n/messages.properties index 31f8d1fc..f90a4de6 100644 --- a/proxy/src/main/resources/com/velocitypowered/proxy/l10n/messages.properties +++ b/proxy/src/main/resources/com/velocitypowered/proxy/l10n/messages.properties @@ -50,6 +50,7 @@ velocity.command.glist-view-all=To view all players on servers, use /glist all. velocity.command.reload-success=Velocity configuration successfully reloaded. velocity.command.reload-failure=Unable to reload your Velocity configuration. Check the console for more details. velocity.command.version-copyright=Copyright 2018- . is licensed under the terms of the GNU General Public License v3. +velocity.command.version-offer-copy-version=Click to copy version to clipboard velocity.command.no-plugins=There are no plugins currently installed. velocity.command.plugins-list=Plugins: velocity.command.plugin-tooltip-website=Website: diff --git a/proxy/src/test/java/com/velocitypowered/proxy/connection/client/ClientConfigSessionHandlerTest.java b/proxy/src/test/java/com/velocitypowered/proxy/connection/client/ClientConfigSessionHandlerTest.java new file mode 100644 index 00000000..96e27766 --- /dev/null +++ b/proxy/src/test/java/com/velocitypowered/proxy/connection/client/ClientConfigSessionHandlerTest.java @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2018-2026 Velocity Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.velocitypowered.proxy.connection.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.velocitypowered.proxy.VelocityServer; +import com.velocitypowered.proxy.connection.MinecraftConnection; +import com.velocitypowered.proxy.connection.backend.BackendConnectionPhase; +import com.velocitypowered.proxy.connection.backend.VelocityServerConnection; +import com.velocitypowered.proxy.protocol.packet.ServerboundCustomClickActionPacket; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.util.ReferenceCountUtil; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ClientConfigSessionHandlerTest { + + private VelocityServer server; + private ConnectedPlayer player; + private ClientConfigSessionHandler handler; + + @BeforeEach + void setUp() { + server = mock(VelocityServer.class); + player = mock(ConnectedPlayer.class); + handler = new ClientConfigSessionHandler(server, player); + } + + @AfterEach + void tearDown() { + // nothing to clean up; each test manages its own ByteBufs + } + + private ServerboundCustomClickActionPacket makePacket() { + ByteBuf frame = Unpooled.buffer().writeByte(0); + ServerboundCustomClickActionPacket pkt = new ServerboundCustomClickActionPacket(); + pkt.replace(frame.readRetainedSlice(frame.readableBytes())); + return pkt; + } + + @Test + void handleForwardsToInFlightServer() { + VelocityServerConnection inFlight = mock(VelocityServerConnection.class); + MinecraftConnection backend = mock(MinecraftConnection.class); + when(player.getConnectionInFlightOrConnectedServer()).thenReturn(inFlight); + when(inFlight.ensureConnected()).thenReturn(backend); + + ServerboundCustomClickActionPacket pkt = makePacket(); + assertTrue(handler.handle(pkt)); + verify(backend).write(pkt); + ReferenceCountUtil.release(pkt); + } + + @Test + void handleForwardsToConnectedServerWhenInFlightIsNull() { + VelocityServerConnection connected = mock(VelocityServerConnection.class); + MinecraftConnection backend = mock(MinecraftConnection.class); + when(player.getConnectionInFlightOrConnectedServer()).thenReturn(connected); + when(connected.ensureConnected()).thenReturn(backend); + + ServerboundCustomClickActionPacket pkt = makePacket(); + assertTrue(handler.handle(pkt)); + verify(backend).write(pkt); + ReferenceCountUtil.release(pkt); + } + + @Test + void handleReturnsFalseWhenNoServer() { + when(player.getConnectionInFlightOrConnectedServer()).thenReturn(null); + + ServerboundCustomClickActionPacket pkt = makePacket(); + assertFalse(handler.handle(pkt)); + ReferenceCountUtil.release(pkt); + } + + @Test + void handleGenericRetainsAndForwards() { + VelocityServerConnection connected = mock(VelocityServerConnection.class); + MinecraftConnection backend = mock(MinecraftConnection.class); + BackendConnectionPhase phase = mock(BackendConnectionPhase.class); + when(player.getConnectedServer()).thenReturn(connected); + when(connected.getConnection()).thenReturn(backend); + when(connected.getPhase()).thenReturn(phase); + when(phase.consideredComplete()).thenReturn(true); + + ServerboundCustomClickActionPacket pkt = makePacket(); + int refBefore = pkt.refCnt(); + + handler.handleGeneric(pkt); + + // retain() was called (+1) before write + assertEquals(refBefore + 1, pkt.refCnt()); + verify(backend).write(pkt); + ReferenceCountUtil.release(pkt); + } +} diff --git a/proxy/src/test/java/com/velocitypowered/proxy/plugin/util/PluginDependencyUtilsTest.java b/proxy/src/test/java/com/velocitypowered/proxy/plugin/util/PluginDependencyUtilsTest.java index 3716f85e..4abc3a2d 100644 --- a/proxy/src/test/java/com/velocitypowered/proxy/plugin/util/PluginDependencyUtilsTest.java +++ b/proxy/src/test/java/com/velocitypowered/proxy/plugin/util/PluginDependencyUtilsTest.java @@ -44,6 +44,15 @@ class PluginDependencyUtilsTest { private static final PluginDescription CIRCULAR_DEPENDENCY_2 = testDescription("oval", new PluginDependency("circle", "", false)); + // "provider" is loaded from a real ID but provides the virtual ID "some-api"; "consumer" and + // "zdependent" depend on it only through that provided ID / a chain that reaches it. + private static final PluginDescription PROVIDES_API = providingDescription("provider", + ImmutableList.of("some-api")); + private static final PluginDescription DEPENDS_ON_PROVIDED = providingDescription("consumer", + ImmutableList.of(), new PluginDependency("some-api", null, false)); + private static final PluginDescription DEPENDS_ON_CONSUMER = testDescription("zdependent", + new PluginDependency("consumer", null, false)); + @Test void sortCandidatesTrivial() throws Exception { List descriptionList = new ArrayList<>(); @@ -96,10 +105,31 @@ class PluginDependencyUtilsTest { assertThrows(IllegalStateException.class, () -> PluginDependencyUtils.sortCandidates(descs)); } + @Test + void sortCandidatesResolvesProvidedDependency() throws Exception { + List plugins = ImmutableList.of(DEPENDS_ON_PROVIDED, PROVIDES_API); + List expected = ImmutableList.of(PROVIDES_API, DEPENDS_ON_PROVIDED); + assertEquals(expected, PluginDependencyUtils.sortCandidates(plugins)); + } + + @Test + void sortCandidatesResolvesTransitiveProvidedDependency() throws Exception { + List plugins = ImmutableList.of(DEPENDS_ON_CONSUMER, DEPENDS_ON_PROVIDED, + PROVIDES_API); + List expected = ImmutableList.of(PROVIDES_API, DEPENDS_ON_PROVIDED, + DEPENDS_ON_CONSUMER); + assertEquals(expected, PluginDependencyUtils.sortCandidates(plugins)); + } + private static PluginDescription testDescription(String id, PluginDependency... dependencies) { + return providingDescription(id, ImmutableList.of(), dependencies); + } + + private static PluginDescription providingDescription(String id, List provides, + PluginDependency... dependencies) { return new VelocityPluginDescription( id, "tuxed", "0.1", null, null, ImmutableList.of(), - ImmutableList.copyOf(dependencies), null + ImmutableList.copyOf(dependencies), provides, null ); } }