diff --git a/README.md b/README.md index 7046996..91695ae 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # Purpur Event Producer -A Purpur 26.2 plugin that records Minecraft activity as CloudEvents 1.0, persists it in a local SQLite outbox, and sends newline-delimited batches to `game-ingest-server`. +A Purpur 26.3 plugin that records Minecraft activity as CloudEvents 1.0, persists it in a local SQLite outbox, and sends newline-delimited batches to `game-ingest-server`. ## Requirements -- Purpur 26.2 build 2618 +- Purpur 26.3 build 2651 - Java 25 - Network access to the ingest endpoint @@ -19,6 +19,8 @@ cp build/libs/purpur-event-producer-0.1.0-SNAPSHOT.jar /path/to/server/plugins/ Start the Minecraft server once to create `plugins/SpigotEventProducer/config.yml`. Set a meaningful, stable `server-name`. When `server-id` is empty, the plugin generates and persists a UUID. Do not reuse that generated UUID on another server. +The full verification command compiles against Purpur API `26.3.build.2651-stable`, downloads and SHA-256 verifies the exact Purpur 26.3 build-2651 runtime, and loads, enables, and disables the packaged entrypoint on that runtime. Its isolated lifecycle check uses temporary configuration, a loopback-only unreachable ingest URL and a real SQLite outbox; it verifies generated server-UUID ownership plus durable startup/shutdown CloudEvents without contacting the configured production service. The suite also covers event/privacy contracts, outbox delivery, registry/statistic behavior, Java 25 bytecode, plugin metadata, shaded JDBC services, and API/server/ProtocolLib/test exclusions. This automated native boundary is not a live-server or real-ingest test. + The default ingest endpoint is: ```text diff --git a/build.gradle.kts b/build.gradle.kts index ca6a80c..7382c31 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,6 @@ +import java.net.URI +import java.security.MessageDigest + plugins { java } @@ -23,10 +26,10 @@ tasks.withType().configureEach { } dependencies { - compileOnly("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable") + compileOnly("org.purpurmc.purpur:purpur-api:26.3.build.2651-stable") implementation("org.xerial:sqlite-jdbc:3.50.3.0") - testImplementation("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable") + testImplementation("org.purpurmc.purpur:purpur-api:26.3.build.2651-stable") testImplementation(platform("org.junit:junit-bom:5.13.4")) testImplementation("org.junit.jupiter:junit-jupiter") testRuntimeOnly("org.junit.platform:junit-platform-launcher") @@ -39,6 +42,61 @@ tasks.test { systemProperty("distribution.version", project.version.toString()) } +val nativeRuntime = layout.buildDirectory.dir("native-runtime") +val downloadNativeRuntime = tasks.register("downloadNativeRuntime") { + val launcher = nativeRuntime.map { it.file("purpur-26.3-2651.jar") } + outputs.file(launcher) + doLast { + val file = launcher.get().asFile + file.parentFile.mkdirs() + val connection = URI("https://api.purpurmc.org/v2/purpur/26.3/2651/download") + .toURL().openConnection() + connection.connectTimeout = 30_000 + connection.readTimeout = 120_000 + val bytes = connection.getInputStream().use { it.readBytes() } + val digest = MessageDigest.getInstance("SHA-256") + .digest(bytes).joinToString("") { "%02x".format(it) } + check(digest == "0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598") { + "Purpur 2651 checksum mismatch" + } + file.writeBytes(bytes) + } +} +val prepareNativeRuntime = tasks.register("prepareNativeRuntime") { + dependsOn(downloadNativeRuntime) + javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) } + classpath = files(nativeRuntime.map { it.file("purpur-26.3-2651.jar") }) + mainClass = "io.papermc.paperclip.Main" + jvmArgs("-Dpaperclip.patchonly=true") + workingDir(nativeRuntime) + outputs.dir(nativeRuntime.map { it.dir("versions") }) + outputs.dir(nativeRuntime.map { it.dir("libraries") }) +} +val nativeTest = sourceSets.create("nativeTest") +dependencies { + add(nativeTest.implementationConfigurationName, platform("org.junit:junit-bom:5.13.4")) + add(nativeTest.implementationConfigurationName, "org.junit.jupiter:junit-jupiter") + add(nativeTest.implementationConfigurationName, "org.mockito:mockito-core:5.18.0") + add(nativeTest.runtimeOnlyConfigurationName, "org.junit.platform:junit-platform-launcher") +} +val nativeRuntimeJars = files(fileTree(nativeRuntime) { + include("versions/**/*.jar", "libraries/**/*.jar") +}).builtBy(prepareNativeRuntime) +nativeTest.compileClasspath += sourceSets.main.get().output + nativeRuntimeJars +nativeTest.runtimeClasspath += sourceSets.main.get().runtimeClasspath + nativeRuntimeJars +val nativeLifecycleTest = tasks.register("nativeLifecycleTest") { + description = "Loads, enables, and disables the packaged plugin on the pinned Purpur runtime" + dependsOn(tasks.jar) + testClassesDirs = nativeTest.output.classesDirs + classpath = nativeTest.runtimeClasspath + useJUnitPlatform() + maxHeapSize = "1G" + workingDir(nativeRuntime) + systemProperty("distribution.jar", tasks.jar.get().archiveFile.get().asFile.absolutePath) + systemProperty("distribution.version", project.version.toString()) +} +tasks.check { dependsOn(nativeLifecycleTest) } + val pluginVersion = version tasks.processResources { diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 6d2e56e..3a9177f 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -1,6 +1,6 @@ name: SpigotEventProducer version: '${version}' main: games.dmg.spigotevents.SpigotEventProducerPlugin -api-version: '26.2' +api-version: '26.3' author: dmg.games description: Writes Minecraft CloudEvents to a durable outbox and delivers them to game-ingest-server. diff --git a/src/nativeTest/java/games/dmg/spigotevents/EventProducerLifecycleNativeTest.java b/src/nativeTest/java/games/dmg/spigotevents/EventProducerLifecycleNativeTest.java new file mode 100644 index 0000000..ccfb922 --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotevents/EventProducerLifecycleNativeTest.java @@ -0,0 +1,256 @@ +package games.dmg.spigotevents; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.google.gson.JsonParser; +import io.papermc.paper.plugin.configuration.PluginMeta; +import io.papermc.paper.plugin.provider.classloader.ConfiguredPluginClassLoader; +import io.papermc.paper.plugin.provider.classloader.PluginClassLoaderGroup; +import java.lang.reflect.Proxy; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.DriverManager; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.jar.JarFile; +import java.util.logging.Logger; +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.bukkit.event.Listener; +import org.bukkit.plugin.PluginDescriptionFile; +import org.bukkit.plugin.PluginManager; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitScheduler; +import org.bukkit.scheduler.BukkitTask; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; + +/** Exercises the packaged entrypoint through Purpur's native plugin lifecycle. */ +class EventProducerLifecycleNativeTest { + @TempDir Path temporaryDirectory; + + @BeforeAll + static void bootstrap() throws Exception { + NativeRuntime.bootstrap(); + } + + @Test + void packagedPluginOwnsOneServerIdentityAndDurableLifecycleEvents() throws Exception { + Path dataDirectory = temporaryDirectory.resolve("SpigotEventProducer"); + Files.createDirectories(dataDirectory); + Files.writeString(dataDirectory.resolve("config.yml"), """ + server-name: "native-lifecycle" + server-id: "" + ingest-url: "http://127.0.0.1:1/events" + send-interval-seconds: 86400 + snapshot-interval-seconds: 86400 + http-timeout-seconds: 1 + batch: + max-events: 1000 + max-bytes: 900000 + dispatch-threshold: + events: 1000 + bytes: 900000 + statistics: + chunk-json-bytes: 200000 + operations-per-tick: 1000 + """); + + List> listenerTypes = new ArrayList<>(); + List scheduledTasks = new ArrayList<>(); + PluginManager manager = port(PluginManager.class, (name, arguments) -> switch (name) { + case "registerEvents" -> { + listenerTypes.add(((Listener) arguments[0]).getClass()); + yield null; + } + case "disablePlugin" -> throw new AssertionError("Native lifecycle initialization failed"); + default -> throw new AssertionError("Unexpected plugin-manager call " + name); + }); + BukkitScheduler scheduler = port(BukkitScheduler.class, (name, arguments) -> switch (name) { + case "runTaskTimer" -> { + BukkitTask task = mock(BukkitTask.class); + scheduledTasks.add(task); + yield task; + } + default -> throw new AssertionError("Unexpected scheduler call " + name); + }); + Server server = port(Server.class, (name, arguments) -> switch (name) { + case "getPluginManager" -> manager; + case "getScheduler" -> scheduler; + case "getOnlinePlayers" -> List.of(); + case "getLogger" -> Logger.getLogger("EventProducer lifecycle fixture"); + case "getWarningState" -> org.bukkit.Warning.WarningState.DEFAULT; + case "getName" -> "Purpur"; + case "getVersion" -> "Purpur 2651 (MC: 26.3)"; + case "getMinecraftVersion" -> "26.3"; + case "getBukkitVersion" -> "26.3-R0.1-SNAPSHOT"; + case "isPrimaryThread" -> true; + default -> throw new AssertionError("Unexpected server call " + name); + }); + + try (MockedStatic platform = mockStatic(Bukkit.class, call -> switch (call.getMethod().getName()) { + case "getServer" -> server; + case "getScheduler" -> scheduler; + case "getOnlinePlayers" -> List.of(); + case "getPlayer" -> null; + case "getLogger" -> Logger.getLogger("EventProducer lifecycle fixture"); + case "getVersion" -> "Purpur 2651 (MC: 26.3)"; + case "getMinecraftVersion" -> "26.3"; + case "getBukkitVersion" -> "26.3-R0.1-SNAPSHOT"; + case "isPrimaryThread" -> true; + default -> call.callRealMethod(); + }); + var loader = new Loader(server, dataDirectory)) { + JavaPlugin plugin = (JavaPlugin) loader.loadClass( + "games.dmg.spigotevents.SpigotEventProducerPlugin").getConstructor().newInstance(); + + assertEquals("SpigotEventProducer", plugin.getName()); + assertEquals("26.3", loader.getConfiguration().getAPIVersion()); + assertFalse(plugin.isEnabled()); + plugin.setEnabled(true); + try { + assertTrue(plugin.isEnabled()); + assertEquals(List.of("GameEventListener"), listenerTypes.stream() + .map(Class::getSimpleName).toList()); + assertEquals(2, scheduledTasks.size()); + assertTrue(Files.isRegularFile(dataDirectory.resolve("event-outbox.sqlite3"))); + } finally { + plugin.setEnabled(false); + } + assertFalse(plugin.isEnabled()); + for (BukkitTask task : scheduledTasks) { + verify(task).cancel(); + } + } + + var saved = org.bukkit.configuration.file.YamlConfiguration.loadConfiguration( + dataDirectory.resolve("config.yml").toFile()); + UUID serverId = UUID.fromString(saved.getString("server-id")); + assertEquals("native-lifecycle", saved.getString("server-name")); + assertEquals("http://127.0.0.1:1/events", saved.getString("ingest-url")); + + List payloads = new ArrayList<>(); + try (var connection = DriverManager.getConnection( + "jdbc:sqlite:" + dataDirectory.resolve("event-outbox.sqlite3").toAbsolutePath())) { + try (var result = connection.createStatement().executeQuery( + "SELECT payload FROM pending_events ORDER BY sequence")) { + while (result.next()) { + payloads.add(result.getString(1)); + } + } + var columns = new java.util.LinkedHashSet(); + try (var result = connection.createStatement().executeQuery( + "PRAGMA table_info(pending_events)")) { + while (result.next()) { + columns.add(result.getString("name")); + } + } + assertEquals(java.util.Set.of( + "sequence", "event_id", "payload", "byte_size", "created_at"), columns); + } + assertEquals(2, payloads.size()); + assertEquals(List.of( + "games.dmg.minecraft.server.started", + "games.dmg.minecraft.server.stopped"), payloads.stream() + .map(JsonParser::parseString) + .map(value -> value.getAsJsonObject().get("type").getAsString()) + .toList()); + for (String payload : payloads) { + var event = JsonParser.parseString(payload).getAsJsonObject(); + assertEquals("1.0", event.get("specversion").getAsString()); + assertEquals("urn:minecraft-server:" + serverId, event.get("source").getAsString()); + assertEquals(serverId.toString(), event.get("serverid").getAsString()); + assertEquals("native-lifecycle", event.get("servername").getAsString()); + assertEquals("26.3", event.get("gameversion").getAsString()); + assertFalse(event.has("playerid")); + } + assertTrue(noLiveEventWorker()); + } + + private static boolean noLiveEventWorker() { + return Thread.getAllStackTraces().keySet().stream().noneMatch(thread -> thread.isAlive() + && (thread.getName().equals("spigot-event-persistence") + || thread.getName().equals("spigot-event-delivery"))); + } + + static final class Loader extends URLClassLoader implements ConfiguredPluginClassLoader { + private static final String PACKAGE = "games.dmg.spigotevents."; + private final Server server; + private final Path dataDirectory; + private final Path jar = Path.of(System.getProperty("distribution.jar")); + private final PluginDescriptionFile description; + private JavaPlugin plugin; + + Loader(Server server, Path dataDirectory) throws Exception { + super(new URL[] {Path.of(System.getProperty("distribution.jar")).toUri().toURL()}, + EventProducerLifecycleNativeTest.class.getClassLoader()); + this.server = server; + this.dataDirectory = dataDirectory; + try (var archive = new JarFile(jar.toFile()); + var input = archive.getInputStream(archive.getJarEntry("plugin.yml"))) { + description = new PluginDescriptionFile(input); + } + } + + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + synchronized (getClassLoadingLock(name)) { + if (!name.startsWith(PACKAGE) || name.equals(getClass().getName())) { + return super.loadClass(name, resolve); + } + Class loaded = findLoadedClass(name); + if (loaded == null) { + loaded = findClass(name); + } + if (resolve) { + resolveClass(loaded); + } + return loaded; + } + } + + @Override + public Class loadClass(String name, boolean resolve, boolean global, boolean libraries) + throws ClassNotFoundException { + return loadClass(name, resolve); + } + + @Override public PluginMeta getConfiguration() { return description; } + @Override public JavaPlugin getPlugin() { return plugin; } + @Override public PluginClassLoaderGroup getGroup() { return null; } + + @Override + public void init(JavaPlugin created) { + plugin = created; + created.init(server, description, dataDirectory.toFile(), jar.toFile(), this, description, + Logger.getLogger("EventProducer lifecycle fixture")); + } + } + + @FunctionalInterface + interface Call { + Object invoke(String name, Object[] arguments); + } + + static T port(Class type, Call call) { + return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[] {type}, + (object, method, arguments) -> { + Object[] values = arguments == null ? new Object[0] : arguments; + if (method.getDeclaringClass() == Object.class) { + return switch (method.getName()) { + case "equals" -> object == values[0]; + case "hashCode" -> System.identityHashCode(object); + case "toString" -> "Lifecycle port " + type.getSimpleName(); + default -> throw new AssertionError(method.getName()); + }; + } + return call.invoke(method.getName(), values); + })); + } +} diff --git a/src/nativeTest/java/games/dmg/spigotevents/NativeRuntime.java b/src/nativeTest/java/games/dmg/spigotevents/NativeRuntime.java new file mode 100644 index 0000000..ec8365b --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigotevents/NativeRuntime.java @@ -0,0 +1,76 @@ +package games.dmg.spigotevents; + +import java.util.List; +import java.util.stream.Stream; +import net.minecraft.SharedConstants; +import net.minecraft.commands.Commands; +import net.minecraft.core.HolderLookup; +import net.minecraft.core.LayeredRegistryAccess; +import net.minecraft.core.Registry; +import net.minecraft.core.RegistryAccess; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.RegistryDataLoader; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.RegistryLayer; +import net.minecraft.server.ReloadableServerResources; +import net.minecraft.server.packs.PackType; +import net.minecraft.server.packs.repository.ServerPacksSource; +import net.minecraft.server.packs.resources.MultiPackResourceManager; +import net.minecraft.server.permissions.LevelBasedPermissionSet; +import net.minecraft.tags.TagLoader; +import net.minecraft.util.Util; +import net.minecraft.world.flag.FeatureFlags; +import net.minecraft.world.level.DataPackConfig; +import net.minecraft.world.level.WorldDataConfiguration; +import org.bukkit.craftbukkit.CraftRegistry; + +/** Loads vanilla registries, tags, and components for native statistic discovery. */ +final class NativeRuntime { + private static boolean initialized; + + private NativeRuntime() {} + + static synchronized void bootstrap() throws Exception { + if (initialized) { + return; + } + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + var flags = FeatureFlags.VANILLA_SET; + var packs = ServerPacksSource.createVanillaTrustedRepository(); + MinecraftServer.configurePackRepository(packs, new WorldDataConfiguration(new DataPackConfig( + FeatureFlags.REGISTRY.toNames(flags).stream().map(Identifier::getPath).toList(), List.of()), + flags), true, false); + try (var resources = new MultiPackResourceManager(PackType.SERVER_DATA, packs.openAllSelected())) { + LayeredRegistryAccess layers = RegistryLayer.createRegistryAccess(); + List> tags = TagLoader.loadTagsForExistingRegistries( + resources, layers.getLayer(RegistryLayer.STATIC)); + List> lookups = TagLoader.buildUpdatedLookups( + layers.getAccessForLoading(RegistryLayer.WORLD), tags); + RegistryAccess.Frozen world = RegistryDataLoader.load( + resources, lookups, RegistryDataLoader.WORLD_REGISTRIES, + Util.backgroundExecutor()).join(); + layers = layers.replaceFrom(RegistryLayer.WORLD, world); + RegistryAccess.Frozen dimensions = RegistryDataLoader.load( + resources, + Stream.concat(lookups.stream(), world.listRegistries()).toList(), + RegistryDataLoader.DIMENSION_REGISTRIES, + Util.backgroundExecutor()).join(); + layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions); + Class.forName(org.bukkit.Registry.class.getName()); + var datapack = ReloadableServerResources.loadResources( + resources, + layers, + tags, + flags, + Commands.CommandSelection.DEDICATED, + LevelBasedPermissionSet.ALL_PERMISSIONS, + Util.backgroundExecutor(), + Runnable::run).join(); + datapack.updateComponentsAndStaticRegistryTags(); + CraftRegistry.setMinecraftRegistry(layers.compositeAccess().freeze()); + } + initialized = true; + } +} diff --git a/src/test/java/games/dmg/spigotevents/PurpurDistributionTest.java b/src/test/java/games/dmg/spigotevents/PurpurDistributionTest.java index 3229529..af92297 100644 --- a/src/test/java/games/dmg/spigotevents/PurpurDistributionTest.java +++ b/src/test/java/games/dmg/spigotevents/PurpurDistributionTest.java @@ -21,31 +21,48 @@ class PurpurDistributionTest { assertEquals(version, metadata.get("version")); assertEquals("SpigotEventProducer", metadata.get("name"), "Retain the data directory and persistent namespace"); assertEquals("games.dmg.spigotevents.SpigotEventProducerPlugin", metadata.get("main")); + assertEquals("26.3", metadata.get("api-version")); assertFalse(metadata.containsKey("permissions")); assertFalse(metadata.containsKey("commands")); + assertFalse(metadata.containsKey("depend")); + assertFalse(metadata.containsKey("softdepend")); assertNotNull(jar.getJarEntry("org/sqlite/JDBC.class"), "The SQLite driver must remain bundled"); assertNotNull(jar.getJarEntry("META-INF/services/java.sql.Driver")); assertEquals("true", jar.getManifest().getMainAttributes().getValue("Multi-Release")); assertFalse(jar.stream().anyMatch(entry -> entry.getName().startsWith("org/bukkit/") - || entry.getName().startsWith("org/purpurmc/") || entry.getName().endsWith("Test.class"))); + || entry.getName().startsWith("org/purpurmc/") + || entry.getName().startsWith("io/papermc/") + || entry.getName().startsWith("net/minecraft/") + || entry.getName().startsWith("com/destroystokyo/") + || entry.getName().startsWith("com/comphenix/protocol/") + || entry.getName().endsWith("Test.class"))); } } @Test void pluginBytecodeTargetsJava25() throws Exception { - try (var jar = new JarFile(System.getProperty("distribution.jar")); - var bytecode = new DataInputStream(jar.getInputStream( - jar.getJarEntry("games/dmg/spigotevents/SpigotEventProducerPlugin.class")))) { - assertEquals(0xCAFEBABE, bytecode.readInt()); - bytecode.readUnsignedShort(); - assertEquals(69, bytecode.readUnsignedShort()); + try (var jar = new JarFile(System.getProperty("distribution.jar"))) { + var pluginClasses = jar.stream() + .filter(entry -> entry.getName().startsWith("games/dmg/spigotevents/") + && entry.getName().endsWith(".class")) + .toList(); + assertFalse(pluginClasses.isEmpty()); + for (var entry : pluginClasses) { + try (var bytecode = new DataInputStream(jar.getInputStream(entry))) { + assertEquals(0xCAFEBABE, bytecode.readInt(), entry.getName()); + bytecode.readUnsignedShort(); + assertEquals(69, bytecode.readUnsignedShort(), entry.getName()); + } + } } } @Test void releaseAndCiAgreeWithThePurpurDistributionNameAndToolchain() throws Exception { String build = Files.readString(Path.of("build.gradle.kts")); - assertTrue(build.contains("purpur-api:26.2.build.2618-stable")); + assertTrue(build.contains("purpur-api:26.3.build.2651-stable")); + assertTrue(build.contains("https://api.purpurmc.org/v2/purpur/26.3/2651/download")); + assertTrue(build.contains("0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598")); assertFalse(build.contains("org.spigotmc:spigot-api")); for (String workflow : new String[] {"ci.yml", "release.yml"}) { String content = Files.readString(Path.of(".gitea/workflows", workflow));