diff --git a/build.gradle.kts b/build.gradle.kts index b8e823d..a4c1e4d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,6 @@ import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar import org.gradle.api.file.DuplicatesStrategy +import java.util.zip.ZipFile plugins { alias(libs.plugins.kotlin.jvm) @@ -9,7 +10,7 @@ plugins { } group = "pl.syntaxdevteam" -version = "1.0.0-profile.1" +version = "1.0.0-profile.2" description = "Lightweight and versatile player plot protection plugin" repositories { @@ -78,6 +79,7 @@ tasks { } val runtimeLibraryVersions = mapOf( + "kotlinVersion" to libs.versions.kotlin.get(), "syntaxcoreVersion" to libs.versions.syntaxcore.get(), "syntaxdbVersion" to libs.versions.syntaxdb.get(), "messagehandlerVersion" to libs.versions.messagehandler.get(), @@ -102,6 +104,8 @@ tasks.named("shadowJar") { archiveClassifier.set("") archiveVersion.set(project.version.toString()) duplicatesStrategy = DuplicatesStrategy.EXCLUDE + // Kotlin belongs to Paper's library classloader together with SyntaxDB, not the plugin JAR. + dependencies { exclude(dependency("org.jetbrains.kotlin:.*:.*")) } mergeServiceFiles() filesMatching("META-INF/services/**") { duplicatesStrategy = DuplicatesStrategy.INCLUDE } filesMatching("META-INF/*.kotlin_module") { duplicatesStrategy = DuplicatesStrategy.INCLUDE } @@ -150,3 +154,27 @@ tasks.register("foliaLiveTestJar") { from(compileLiveTest.flatMap { it.destinationDirectory }) from("src/foliaLiveTest/resources") } + +val verifyRuntimePackaging = tasks.register("verifyRuntimePackaging") { + group = "verification" + description = "Prevents duplicate Kotlin runtimes across the plugin and Paper library classloaders." + val artifact = tasks.named("shadowJar").flatMap { it.archiveFile } + inputs.file(artifact) + dependsOn(tasks.named("shadowJar")) + doLast { + ZipFile(artifact.get().asFile).use { jar -> + check(jar.stream().noneMatch { it.name.startsWith("kotlin/") && it.name.endsWith(".class") }) { + "Kotlin runtime must be loaded by Paper, not bundled in the plugin JAR" + } + val manifest = jar.getInputStream(jar.getEntry("paper-libraries.yml")).bufferedReader().use { it.readText() } + check(manifest.contains("org.jetbrains.kotlin:kotlin-stdlib:${libs.versions.kotlin.get()}")) { + "Runtime manifest must explicitly load Kotlin matching the compiler" + } + val loader = jar.getInputStream(jar.getEntry("pl/syntaxdevteam/plotsx/loader/SyntaxLoader.class")).use { it.readBytes() } + check(!loader.toString(Charsets.ISO_8859_1).contains("kotlin/")) { + "Bootstrap loader must not depend on Kotlin before libraries are resolved" + } + } + } +} +tasks.named("check") { dependsOn(verifyRuntimePackaging) } diff --git a/docs/profile-details.md b/docs/profile-details.md index d103fbf..b6edaa9 100644 --- a/docs/profile-details.md +++ b/docs/profile-details.md @@ -3,3 +3,11 @@ Read-only persisted snapshots for MiniPortalPlayerProfile 0.3.0. Existing snapshot constructors and existing API methods remain unchanged. New optional owner-list method provides composed detailed snapshots. No entity/chunk loading, teleport, database writes or simulation updates. getOwnedPlotProfiles(UUID) uses the existing plot cache and owner filter. PlotProfileSnapshot wraps the original plot and nullable configured teleport block. TP X/Z are integer block coordinates (in-game destination uses +0.5). It is not a safe destination guarantee: the normal teleport flow revalidates blocks and can select a fallback. A missing configured point must not be replaced with a fabricated TP. + +## Kotlin runtime packaging (1.0.0-profile.2) + +The bootstrap loader is Java and uses only Paper-provided Java dependencies. `paper-libraries.yml` explicitly resolves `kotlin-stdlib` matching the Kotlin compiler. Kotlin runtime classes are excluded from the shaded plugin JAR, so PlotsX and SyntaxDB share the Kotlin classes in Paper's library classloader. Bundling Kotlin in the plugin while resolving SyntaxDB externally creates incompatible `kotlin.jvm.functions.Function1` identities and fails at `Database.connect` during enable. + +`build` runs `verifyRuntimePackaging`: it rejects bundled Kotlin classes, a missing or unexpanded Kotlin runtime coordinate, and Kotlin references in the bootstrap loader. Unit tests cover repository routing and runtime manifest parsing. The disposable Paper acceptance plugin checks the actual `Function1` identity used by SyntaxDB and PlotsX before exercising SQLite and plot protection. Its seed data uses scoped SyntaxDB connections. + +Update only the PlotsX JAR while the server is stopped, remove the previous duplicate, and restart fully. Keep the existing PlotsX configuration and database. No schema or player-data changes are part of this packaging fix. diff --git a/src/liveTest/java/pl/syntaxdevteam/plotsx/testing/PaperAcceptance.java b/src/liveTest/java/pl/syntaxdevteam/plotsx/testing/PaperAcceptance.java index 5ce11e3..3058729 100644 --- a/src/liveTest/java/pl/syntaxdevteam/plotsx/testing/PaperAcceptance.java +++ b/src/liveTest/java/pl/syntaxdevteam/plotsx/testing/PaperAcceptance.java @@ -62,20 +62,36 @@ private Player player(UUID uuid, World world) { }; }); } + @FunctionalInterface private interface SqlWork { void accept(Connection connection) throws Exception; } + /** Test-only access, preserving SyntaxDB's scoped connection lifecycle. */ + private void withJdbc(DatabaseHandler handler, SqlWork work) throws Exception { + var method = DatabaseHandler.class.getDeclaredMethod("database"); + method.setAccessible(true); + var database = (pl.syntaxdevteam.syntaxdb.Database) method.invoke(handler); + database.withConnection(connection -> { + try { work.accept(connection); } + catch (Exception failure) { throw new IllegalStateException("Acceptance fixture failed", failure); } + return Unit.INSTANCE; + }); + } private void runChecks() throws Exception { PlotsX plugin = (PlotsX) getServer().getPluginManager().getPlugin("PlotsX"); check(plugin != null && plugin.isEnabled(), "plugin startup and migration"); PlotsXApi api = getServer().getServicesManager().load(PlotsXApi.class); check(api != null && api.getApiVersion() == 2, "API v2 service"); + var connect = pl.syntaxdevteam.syntaxdb.Database.Companion.getClass().getMethod("connect", + pl.syntaxdevteam.syntaxdb.config.DatabaseType.class, kotlin.jvm.functions.Function1.class); + check(connect.getParameterTypes()[1] == kotlin.jvm.functions.Function1.class, + "SyntaxDB connect and PlotsX share the same Kotlin Function1 class"); + check(!kotlin.jvm.functions.Function1.class.getProtectionDomain().getCodeSource().getLocation().toString().contains("PlotsX-Paper"), + "Kotlin runtime is supplied by the library classloader"); boolean reuse = Boolean.getBoolean("plotsx.acceptance.reuse"); check(reuse ? api.getPlots().size() == 2 : api.getPlots().isEmpty(), reuse ? "persisted mixed plots survive mode restart" : "disposable empty database"); UUID owner = reuse ? api.getPlot(1).getOwner() : UUID.randomUUID(); World world = getServer().getWorlds().getFirst(); DatabaseHandler db = plugin.getDatabaseHandler(); // Seed both geometries using the migrated schema, before any actor events. - var connectionMethod = DatabaseHandler.class.getDeclaredMethod("getConnection"); - connectionMethod.setAccessible(true); - if (!reuse) try (Connection c = (Connection) connectionMethod.invoke(db)) { + if (!reuse) withJdbc(db, c -> { c.setAutoCommit(false); try (PreparedStatement s = c.prepareStatement("INSERT INTO plots (plot_id,owner_uuid,x,z,y,radius,world,name,creation_time,geometry_type,geometry_revision) VALUES (?,?,?,?,?,?,?,?,?,?,0)")) { for (int id = 1; id <= 2; id++) { @@ -90,7 +106,7 @@ private void runChecks() throws Exception { } } c.commit(); - } + }); plugin.getProtectionCoordinator().recover(() -> { plugin.getCacheManager().reloadAllCachesSync(); return Unit.INSTANCE; }); check(api.getPlotAt(world.getName(),-16,-16).getRadius() == null, "chunk runtime has no artificial radius"); check(api.getPlotAt(world.getName(),-32,-16).getArea() == 768, "negative boundary and exact area"); diff --git a/src/main/java/pl/syntaxdevteam/plotsx/loader/SyntaxLoader.java b/src/main/java/pl/syntaxdevteam/plotsx/loader/SyntaxLoader.java new file mode 100644 index 0000000..4d15fef --- /dev/null +++ b/src/main/java/pl/syntaxdevteam/plotsx/loader/SyntaxLoader.java @@ -0,0 +1,92 @@ +package pl.syntaxdevteam.plotsx.loader; + +import io.papermc.paper.plugin.loader.PluginClasspathBuilder; +import io.papermc.paper.plugin.loader.PluginLoader; +import io.papermc.paper.plugin.loader.library.impl.MavenLibraryResolver; +import org.eclipse.aether.artifact.DefaultArtifact; +import org.eclipse.aether.graph.Dependency; +import org.eclipse.aether.repository.RemoteRepository; +import org.yaml.snakeyaml.Yaml; + +import java.io.InputStream; +import java.util.*; + +/** Java bootstrap: Kotlin is resolved before any Kotlin plugin code is loaded. */ +@SuppressWarnings({"UnstableApiUsage", "unused"}) +public final class SyntaxLoader implements PluginLoader { + @Override + public void classloader(PluginClasspathBuilder classpath) { + final LibraryConfig config; + try (InputStream input = SyntaxLoader.class.getClassLoader().getResourceAsStream("paper-libraries.yml")) { + if (input == null) throw new IllegalStateException("paper-libraries.yml not found in plugin JAR"); + config = readLibraryConfig(input); + } catch (Exception failure) { + throw new IllegalStateException("Cannot configure PlotsX runtime libraries from paper-libraries.yml", failure); + } + Map resolvers = new LinkedHashMap<>(); + for (LibrarySpec library : config.libraries()) { + String repositoryId = selectRepository(library, config); + MavenLibraryResolver resolver = resolvers.computeIfAbsent(repositoryId, key -> { + MavenLibraryResolver created = new MavenLibraryResolver(); + repositoriesFor(key, config).forEach(created::addRepository); + return created; + }); + resolver.addDependency(new Dependency(new DefaultArtifact(library.coordinate()), null)); + } + resolvers.values().forEach(classpath::addLibrary); + } + + static LibraryConfig readLibraryConfig(InputStream input) { + Object raw = new Yaml().load(input); + if (!(raw instanceof Map document)) throw new IllegalArgumentException("paper-libraries.yml must contain a mapping"); + if (!(document.get("repositories") instanceof Map repositoryData)) throw new IllegalArgumentException("paper-libraries.yml must define repositories"); + Map repositories = new LinkedHashMap<>(); + repositoryData.forEach((key, value) -> { + if (!(key instanceof String id) || id.isBlank() || !(value instanceof String configuredUrl) || configuredUrl.isBlank()) + throw new IllegalArgumentException("Invalid repository entry: " + key); + String url = configuredUrl.trim().equals("MAVEN_CENTRAL_DEFAULT_MIRROR") + ? MavenLibraryResolver.MAVEN_CENTRAL_DEFAULT_MIRROR : configuredUrl.trim(); + if (!url.startsWith("https://") && !url.startsWith("http://")) throw new IllegalArgumentException("Invalid repository URL for " + id); + repositories.put(id.trim(), new RemoteRepository.Builder(id.trim(), "default", url).build()); + }); + if (!repositories.containsKey("central")) throw new IllegalArgumentException("Missing central repository in paper-libraries.yml"); + if (!(document.get("libraries") instanceof List libraryData) || libraryData.isEmpty()) throw new IllegalArgumentException("No runtime libraries defined in paper-libraries.yml"); + List libraries = new ArrayList<>(); + for (Object entry : libraryData) { + final LibrarySpec library; + if (entry instanceof String coordinate) library = new LibrarySpec(coordinate.trim(), null); + else if (entry instanceof Map map && map.get("coordinate") instanceof String coordinate) { + Object repository = map.get("repository"); + if (repository != null && !(repository instanceof String)) throw new IllegalArgumentException("Invalid repository in library entry"); + library = new LibrarySpec(coordinate.trim(), repository == null ? null : ((String) repository).trim()); + } else throw new IllegalArgumentException("Invalid library entry"); + if (library.coordinate().isEmpty() || library.coordinate().contains("${")) throw new IllegalArgumentException("Invalid or unexpanded library coordinate: " + library.coordinate()); + new DefaultArtifact(library.coordinate()); + libraries.add(library); + } + LibraryConfig config = new LibraryConfig(Collections.unmodifiableMap(repositories), List.copyOf(libraries)); + libraries.forEach(library -> selectRepository(library, config)); + return config; + } + + static String selectRepository(LibrarySpec library, LibraryConfig config) { + String repositoryId = library.repository(); + if (repositoryId == null) { + String group = library.coordinate().substring(0, library.coordinate().indexOf(':')); + repositoryId = group.equals("pl.syntaxdevteam") || group.startsWith("pl.syntaxdevteam.") + ? library.coordinate().endsWith("-SNAPSHOT") ? "syntaxdevteam-snapshots" : "syntaxdevteam-releases" + : "central"; + } + if (!config.repositories().containsKey(repositoryId)) throw new IllegalArgumentException("Unknown repository '" + repositoryId + "' for " + library.coordinate()); + return repositoryId; + } + + static List repositoriesFor(String repositoryId, LibraryConfig config) { + RemoteRepository selected = config.repositories().get(repositoryId); + if (selected == null) throw new IllegalArgumentException("Unknown repository: " + repositoryId); + return repositoryId.equals("central") ? List.of(selected) : List.of(selected, config.repositories().get("central")); + } + + record LibraryConfig(Map repositories, List libraries) {} + record LibrarySpec(String coordinate, String repository) {} +} diff --git a/src/main/kotlin/pl/syntaxdevteam/plotsx/loader/SyntaxLoader.kt b/src/main/kotlin/pl/syntaxdevteam/plotsx/loader/SyntaxLoader.kt deleted file mode 100644 index 30e2030..0000000 --- a/src/main/kotlin/pl/syntaxdevteam/plotsx/loader/SyntaxLoader.kt +++ /dev/null @@ -1,107 +0,0 @@ -package pl.syntaxdevteam.plotsx.loader - -import io.papermc.paper.plugin.loader.PluginClasspathBuilder -import io.papermc.paper.plugin.loader.PluginLoader -import io.papermc.paper.plugin.loader.library.impl.MavenLibraryResolver -import org.eclipse.aether.artifact.DefaultArtifact -import org.eclipse.aether.graph.Dependency -import org.eclipse.aether.repository.RemoteRepository -import org.yaml.snakeyaml.Yaml -import java.io.InputStream - -/** Reads repository assignments from the same runtime manifest format as PunisherX. */ -@Suppress("UnstableApiUsage", "unused") -class SyntaxLoader : PluginLoader { - - override fun classloader(pluginClasspath: PluginClasspathBuilder) { - val config = try { - val input = SyntaxLoader::class.java.classLoader.getResourceAsStream("paper-libraries.yml") - ?: error("paper-libraries.yml not found in plugin JAR") - input.use(::readLibraryConfig) - } catch (failure: Exception) { - throw IllegalStateException("Cannot configure PlotsX runtime libraries from paper-libraries.yml", failure) - } - val resolvers = linkedMapOf() - for (library in config.libraries) { - val repositoryId = selectRepository(library, config) - val resolver = resolvers.getOrPut(repositoryId) { - MavenLibraryResolver().apply { - repositoriesFor(repositoryId, config).forEach(::addRepository) - } - } - resolver.addDependency(Dependency(DefaultArtifact(library.coordinate), null)) - } - resolvers.values.forEach(pluginClasspath::addLibrary) - } - - internal fun readLibraryConfig(input: InputStream): LibraryConfig { - val document = Yaml().load(input) as? Map<*, *> - ?: error("paper-libraries.yml must contain a mapping") - val repositoryData = document["repositories"] as? Map<*, *> - ?: error("paper-libraries.yml must define repositories") - val repositories = linkedMapOf() - for ((key, value) in repositoryData) { - val id = (key as? String)?.trim().orEmpty() - val configuredUrl = (value as? String)?.trim().orEmpty() - require(id.isNotEmpty() && configuredUrl.isNotEmpty()) { "Invalid repository entry: $key" } - val url = if (configuredUrl == "MAVEN_CENTRAL_DEFAULT_MIRROR") { - MavenLibraryResolver.MAVEN_CENTRAL_DEFAULT_MIRROR - } else { - configuredUrl - } - require(url.startsWith("https://") || url.startsWith("http://")) { "Invalid repository URL for $id" } - repositories[id] = RemoteRepository.Builder(id, "default", url).build() - } - require(repositories.containsKey("central")) { "Missing central repository in paper-libraries.yml" } - val libraryData = document["libraries"] as? List<*> - ?: error("paper-libraries.yml must define libraries") - require(libraryData.isNotEmpty()) { "No runtime libraries defined in paper-libraries.yml" } - val libraries = libraryData.mapIndexed { index, entry -> - val library = when (entry) { - is String -> LibrarySpec(entry.trim()) - is Map<*, *> -> { - val coordinate = entry["coordinate"] as? String - ?: error("Missing coordinate in library entry ${index + 1}") - val repository = entry["repository"] - require(repository == null || repository is String) { "Invalid repository in library entry ${index + 1}" } - LibrarySpec(coordinate.trim(), repository?.trim()) - } - else -> error("Invalid library entry ${index + 1}") - } - require(library.coordinate.isNotEmpty() && !library.coordinate.contains("\${")) { - "Invalid or unexpanded library coordinate: ${library.coordinate}" - } - DefaultArtifact(library.coordinate) - library - } - return LibraryConfig(repositories, libraries).also { config -> - config.libraries.forEach { selectRepository(it, config) } - } - } - - internal fun selectRepository(library: LibrarySpec, config: LibraryConfig): String { - val repositoryId = library.repository ?: when { - library.coordinate.substringBefore(':').let { it == "pl.syntaxdevteam" || it.startsWith("pl.syntaxdevteam.") } -> - if (library.coordinate.endsWith("-SNAPSHOT")) "syntaxdevteam-snapshots" else "syntaxdevteam-releases" - else -> "central" - } - require(config.repositories.containsKey(repositoryId)) { - "Unknown repository '$repositoryId' for ${library.coordinate}" - } - return repositoryId - } - - internal fun repositoriesFor(repositoryId: String, config: LibraryConfig): List { - val selected = config.repositories.getValue(repositoryId) - // The selected repository also needs Central for transitive dependencies. - return if (repositoryId == "central") listOf(selected) - else listOf(selected, config.repositories.getValue("central")) - } - - internal data class LibraryConfig( - val repositories: Map, - val libraries: List - ) - - internal data class LibrarySpec(val coordinate: String, val repository: String? = null) -} diff --git a/src/main/resources/paper-libraries.yml b/src/main/resources/paper-libraries.yml index f56a55f..f7c8456 100644 --- a/src/main/resources/paper-libraries.yml +++ b/src/main/resources/paper-libraries.yml @@ -7,6 +7,7 @@ repositories: codemc-snapshots: "https://repo.codemc.io/repository/maven-snapshots/" libraries: + - org.jetbrains.kotlin:kotlin-stdlib:${kotlinVersion} - pl.syntaxdevteam:syntaxcore:${syntaxcoreVersion} - pl.syntaxdevteam:syntaxdb:${syntaxdbVersion} - pl.syntaxdevteam:messageHandler-paper:${messagehandlerVersion} diff --git a/src/test/java/pl/syntaxdevteam/plotsx/loader/SyntaxLoaderTest.java b/src/test/java/pl/syntaxdevteam/plotsx/loader/SyntaxLoaderTest.java new file mode 100644 index 0000000..3ca3633 --- /dev/null +++ b/src/test/java/pl/syntaxdevteam/plotsx/loader/SyntaxLoaderTest.java @@ -0,0 +1,35 @@ +package pl.syntaxdevteam.plotsx.loader; + +import org.junit.Test; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import static org.junit.Assert.*; + +public class SyntaxLoaderTest { + private SyntaxLoader.LibraryConfig parse(String libraries) { + String yaml = "repositories:\n central: https://repo.maven.apache.org/maven2/\n syntaxdevteam-snapshots: https://nexus.example/snapshots/\n syntaxdevteam-releases: https://nexus.example/releases/\nlibraries:\n" + libraries; + return SyntaxLoader.readLibraryConfig(new ByteArrayInputStream(yaml.getBytes(StandardCharsets.UTF_8))); + } + @Test public void selectsRepositoriesAndCentralTransitivesWithoutKotlinBootstrap() { + var config = parse(" - pl.syntaxdevteam:syntaxdb:0.1.0-SNAPSHOT\n - org.jetbrains.kotlin:kotlin-stdlib:2.4.20\n"); + assertEquals("syntaxdevteam-snapshots", SyntaxLoader.selectRepository(config.libraries().getFirst(), config)); + assertEquals("central", SyntaxLoader.selectRepository(config.libraries().get(1), config)); + assertEquals(2, SyntaxLoader.repositoriesFor("syntaxdevteam-snapshots", config).size()); + assertEquals(1, SyntaxLoader.repositoriesFor("central", config).size()); + } + @Test public void preservesExplicitRepositorySelection() { + var config = parse(" - coordinate: pl.syntaxdevteam:syntaxdb:0.1.0-SNAPSHOT\n repository: central\n"); + assertEquals("central", SyntaxLoader.selectRepository(config.libraries().getFirst(), config)); + } + @Test public void rejectsUnknownRepositoryAndUnexpandedCoordinates() { + assertThrows(IllegalArgumentException.class, () -> parse(" - coordinate: g:a:1\n repository: missing\n")); + assertThrows(IllegalArgumentException.class, () -> parse(" - org.jetbrains.kotlin:kotlin-stdlib:${kotlinVersion}\n")); + } + @Test public void runtimeManifestExplicitlyLoadsOneKotlinRuntime() { + try (var input = SyntaxLoader.class.getClassLoader().getResourceAsStream("paper-libraries.yml")) { + assertNotNull(input); + var config = SyntaxLoader.readLibraryConfig(input); + assertEquals(1L, config.libraries().stream().filter(l -> l.coordinate().startsWith("org.jetbrains.kotlin:kotlin-stdlib:")).count()); + } catch (java.io.IOException error) { throw new AssertionError(error); } + } +}