From 9bd6da2178752f154dd65c48748677e25971d0ca Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 21 Sep 2026 04:59:53 -0600 Subject: [PATCH 1/9] Reduce shaded VotingPlugin jar size --- .mex/events/decisions.jsonl | 1 + AGENTS.md | 5 ++++ VotingPlugin/pom.xml | 14 +++++++++- .../packaging/PackagedArtifactTest.java | 26 ++++++++++++++++++- docs/jar-packaging.md | 15 ++++++++--- 5 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 .mex/events/decisions.jsonl diff --git a/.mex/events/decisions.jsonl b/.mex/events/decisions.jsonl new file mode 100644 index 000000000..e9c06247e --- /dev/null +++ b/.mex/events/decisions.jsonl @@ -0,0 +1 @@ +{"timestamp":"2026-09-21T10:57:35.704Z","kind":"decision","message":"Preserve all SQLite native targets and the complete base Bouncy Castle provider. Reduce the JAR through unused Bouncy Castle multi-release payloads and optional Jedis module clients; enforce a 31 MiB budget and packaged TLS identity startup.","files":["VotingPlugin/pom.xml","docs/jar-packaging.md"],"cwd":".","source":"agent","status":"implemented"} diff --git a/AGENTS.md b/AGENTS.md index 74472b23b..411da86e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,11 @@ mvn -B -f VotingPlugin/pom.xml -Dtest=BackendControlConnectorProtocolTest,Contro CI runs `mvn -B -f VotingPlugin/pom.xml package`; see `.github/workflows/maven.yml`. Do not use the `dev` Maven profile in automation because it copies a JAR into a developer-specific server directory. +Keep the downloadable VotingPlugin JAR as small as practical. Inspect the shaded +artifact when dependencies change, avoid duplicate embedded packages, and update +the package-phase size and runtime checks when a necessary dependency increases +the artifact budget. + ## Architecture and file map - `VotingPluginMain` is the Bukkit entry point and lifecycle owner. diff --git a/VotingPlugin/pom.xml b/VotingPlugin/pom.xml index 74a5ee86a..4711b18a1 100644 --- a/VotingPlugin/pom.xml +++ b/VotingPlugin/pom.xml @@ -230,7 +230,19 @@ org.bouncycastle:* - META-INF/versions/25/** + + META-INF/versions/** + + + + redis.clients:jedis + + + redis/clients/jedis/bloom/** + redis/clients/jedis/json/** + redis/clients/jedis/search/** + redis/clients/jedis/timeseries/** diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/packaging/PackagedArtifactTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/packaging/PackagedArtifactTest.java index 982d96ab9..07b42a17c 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/packaging/PackagedArtifactTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/packaging/PackagedArtifactTest.java @@ -11,6 +11,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.security.Provider; import java.util.jar.JarFile; import org.junit.jupiter.api.Test; @@ -18,6 +19,7 @@ /** Package-phase checks for the actual downloadable plugin artifact. */ public class PackagedArtifactTest { + private static final long MAX_DOWNLOAD_BYTES = 31L * 1024L * 1024L; @Test void containsOneRelocatedRuntimeWithoutUnusedHttpCrypto() throws Exception { @@ -54,8 +56,14 @@ void containsOneRelocatedRuntimeWithoutUnusedHttpCrypto() throws Exception { assertNull(artifact.getEntry("com/zaxxer/hikari/HikariDataSource.class")); assertNull(artifact.getEntry("com/tcoded/folialib/FoliaLib.class")); assertFalse(artifact.stream().anyMatch(entry -> entry.getName().startsWith("org/bouncycastle/"))); - assertFalse(artifact.stream().anyMatch(entry -> entry.getName().startsWith("META-INF/versions/25/"))); + assertFalse(artifact.stream().anyMatch(entry -> entry.getName().startsWith("META-INF/versions/") + && entry.getName().contains("/bouncycastle/"))); + assertFalse(artifact.stream().anyMatch(entry -> entry.getName() + .startsWith("redis/clients/jedis/search/"))); } + long artifactBytes = Files.size(artifactPath); + assertTrue(artifactBytes <= MAX_DOWNLOAD_BYTES, + () -> "VotingPlugin downloadable artifact exceeded 31 MiB: " + artifactBytes); System.out.printf("VotingPlugin downloadable artifact: %,d bytes; duplicate Rhino and unused HTTP crypto absent%n", Files.size(artifactPath)); } @@ -73,6 +81,22 @@ void packagedNeoForgeRuntimeStartsAndClosesWithoutTestDependencies(@TempDir Path } } + @Test + void packagedBaseCryptoProviderLoadsWithoutMultiReleasePayload(@TempDir Path directory) throws Exception { + URL jar = packagedJar().toUri().toURL(); + try (URLClassLoader loader = new URLClassLoader(new URL[] { jar }, ClassLoader.getPlatformClassLoader())) { + Class providerType = Class.forName( + "com.bencodez.votingplugin.bouncycastle.jce.provider.BouncyCastleProvider", true, loader); + Provider provider = (Provider) providerType.getConstructor().newInstance(); + assertNotNull(provider.getService("Signature", "SHA256WITHRSA")); + Class identityType = Class.forName( + "com.bencodez.votingplugin.simpleapi.servercomm.http.HttpTlsIdentity", true, loader); + Object identity = identityType.getMethod("loadOrCreate", Path.class, String.class) + .invoke(null, directory, "localhost"); + assertNotNull(identityType.getMethod("serverContext").invoke(identity)); + } + } + private static Path packagedJar() { String configured = System.getProperty("votingplugin.packagedJar"); assumeTrue(configured != null, "Packaged artifact is available only in the package lifecycle"); diff --git a/docs/jar-packaging.md b/docs/jar-packaging.md index 2bf0136c5..432eefee0 100644 --- a/docs/jar-packaging.md +++ b/docs/jar-packaging.md @@ -15,6 +15,15 @@ AdvancedCore artifact to provide them. The package phase runs `PackagedArtifactTest` after shading. It opens the actual downloadable JAR, checks plugin resources and required relocated classes, and -rejects duplicate Rhino, raw Hikari/Folia, unused Bouncy Castle, and unsupported -Java 25 versioned payload. Release/deployment profiles reuse this Shade setup; -the artifact check follows their configured JAR name. +rejects duplicate Rhino, raw Hikari/Folia, unused multi-release crypto payloads, +and optional Jedis module clients. SQLite keeps its complete +native platform set so packaging changes do not narrow existing installations. +The test also caps the downloadable artifact at 31 MiB so dependency growth must +be reviewed explicitly. Release/deployment profiles +reuse this Shade setup; the artifact check follows their configured JAR name. + +Keep the downloadable VotingPlugin JAR as small as practical. Before adding a +runtime dependency, inspect the shaded artifact and assign one owner for each +embedded package. Prefer platform-provided APIs where every supported loader +supplies them, and filter unused native targets or duplicate transitive classes +only when the retained runtime paths are covered by packaging and startup tests. From 4c4f20c58c644f0135f1eeaa36cecb8de636b062 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 21 Sep 2026 05:12:58 -0600 Subject: [PATCH 2/9] Correct JAR packaging documentation --- docs/jar-packaging.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/jar-packaging.md b/docs/jar-packaging.md index 432eefee0..e28181fab 100644 --- a/docs/jar-packaging.md +++ b/docs/jar-packaging.md @@ -8,10 +8,11 @@ transports. Gson is platform-supplied and is therefore `provided`. AdvancedCore already contains the relocated Rhino implementation needed by its JavaScript support, so VotingPlugin excludes the second unrelocated Rhino -dependency. The default branch has no HTTP transport and therefore does not -bundle Bouncy Castle. Adding HTTP transport support must explicitly own its TLS -implementation and crypto dependencies; it must not rely on the non-HTTP -AdvancedCore artifact to provide them. +dependency. VotingPlugin bundles the relocated Bouncy Castle base provider used +by `BouncyCastleProvider` and `HttpTlsIdentity`, while excluding its unused +multi-release payloads. HTTP transport support explicitly owns its TLS +implementation and crypto dependencies; it does not rely on AdvancedCore to +provide them. The package phase runs `PackagedArtifactTest` after shading. It opens the actual downloadable JAR, checks plugin resources and required relocated classes, and From afaf703288ca2a46a74cbe75b289e7e69c02c87c Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:35:20 -0600 Subject: [PATCH 3/9] Trim unused crypto payloads --- VotingPlugin/pom.xml | 14 ++++++++++ .../packaging/PackagedArtifactTest.java | 27 ++++++++++++++++--- docs/jar-packaging.md | 15 ++++++----- 3 files changed, 47 insertions(+), 9 deletions(-) diff --git a/VotingPlugin/pom.xml b/VotingPlugin/pom.xml index 4711b18a1..f614cc476 100644 --- a/VotingPlugin/pom.xml +++ b/VotingPlugin/pom.xml @@ -233,6 +233,19 @@ META-INF/versions/** + + org/bouncycastle/dvcs/** + org/bouncycastle/eac/** + org/bouncycastle/est/** + org/bouncycastle/its/** + org/bouncycastle/mime/** + org/bouncycastle/mozilla/** + org/bouncycastle/oer/** + org/bouncycastle/openssl/** + org/bouncycastle/pkcs/** + org/bouncycastle/tsp/** + org/bouncycastle/voms/** @@ -250,6 +263,7 @@ org/slf4j/** + org/checkerframework/** META-INF/services/org.slf4j.spi.SLF4JServiceProvider META-INF/maven/org.slf4j/** META-INF/*.SF diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/packaging/PackagedArtifactTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/packaging/PackagedArtifactTest.java index 07b42a17c..6d79c513a 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/packaging/PackagedArtifactTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/packaging/PackagedArtifactTest.java @@ -19,7 +19,12 @@ /** Package-phase checks for the actual downloadable plugin artifact. */ public class PackagedArtifactTest { - private static final long MAX_DOWNLOAD_BYTES = 31L * 1024L * 1024L; + private static final long MAX_DOWNLOAD_BYTES = 30L * 1024L * 1024L; + private static final String RELOCATED_BOUNCY_CASTLE = "com/bencodez/votingplugin/bouncycastle/"; + private static final String[] UNUSED_BOUNCY_CASTLE_PACKAGES = { + "dvcs/", "eac/", "est/", "its/", "mime/", "mozilla/", + "oer/", "openssl/", "pkcs/", "tsp/", "voms/" + }; @Test void containsOneRelocatedRuntimeWithoutUnusedHttpCrypto() throws Exception { @@ -35,7 +40,7 @@ void containsOneRelocatedRuntimeWithoutUnusedHttpCrypto() throws Exception { assertNotNull(artifact.getEntry("com/bencodez/votingplugin/neoforge/NeoForgeVotingPlugin.class")); assertNotNull(artifact.getEntry("org/sqlite/JDBC.class")); assertNull(artifact.getEntry("net/neoforged/neoforge/common/NeoForge.class")); - assertNull(artifact.getEntry("org/checkerframework/checker/nullness/qual/Nullable.class")); + assertFalse(artifact.stream().anyMatch(entry -> entry.getName().startsWith("org/checkerframework/"))); assertNull(artifact.getEntry("org/slf4j/Logger.class")); assertNull(artifact.getEntry("com/bencodez/votingplugin/slf4j/Logger.class")); assertNull(artifact.getEntry("META-INF/services/org.slf4j.spi.SLF4JServiceProvider")); @@ -58,12 +63,18 @@ void containsOneRelocatedRuntimeWithoutUnusedHttpCrypto() throws Exception { assertFalse(artifact.stream().anyMatch(entry -> entry.getName().startsWith("org/bouncycastle/"))); assertFalse(artifact.stream().anyMatch(entry -> entry.getName().startsWith("META-INF/versions/") && entry.getName().contains("/bouncycastle/"))); + for (String packageName : UNUSED_BOUNCY_CASTLE_PACKAGES) { + String prefix = RELOCATED_BOUNCY_CASTLE + packageName; + assertFalse(artifact.stream().anyMatch(entry -> entry.getName().startsWith(prefix)), + () -> "Unused Bouncy Castle package was bundled: " + prefix); + } assertFalse(artifact.stream().anyMatch(entry -> entry.getName() .startsWith("redis/clients/jedis/search/"))); } long artifactBytes = Files.size(artifactPath); assertTrue(artifactBytes <= MAX_DOWNLOAD_BYTES, - () -> "VotingPlugin downloadable artifact exceeded 31 MiB: " + artifactBytes); + () -> "VotingPlugin downloadable artifact exceeded " + + (MAX_DOWNLOAD_BYTES / (1024L * 1024L)) + " MiB: " + artifactBytes); System.out.printf("VotingPlugin downloadable artifact: %,d bytes; duplicate Rhino and unused HTTP crypto absent%n", Files.size(artifactPath)); } @@ -94,6 +105,16 @@ void packagedBaseCryptoProviderLoadsWithoutMultiReleasePayload(@TempDir Path dir Object identity = identityType.getMethod("loadOrCreate", Path.class, String.class) .invoke(null, directory, "localhost"); assertNotNull(identityType.getMethod("serverContext").invoke(identity)); + Object issued = identityType.getMethod("issueClientCertificate", String.class) + .invoke(identity, "packaged-artifact-test"); + assertNotNull(issued.getClass().getMethod("certificate").invoke(issued)); + assertNotNull(issued.getClass().getMethod("pkcs12").invoke(issued)); + Class credentialStoreType = Class.forName( + "com.bencodez.votingplugin.simpleapi.servercomm.http.HttpClientCredentialStore", true, loader); + Path clientDirectory = directory.resolve("client"); + credentialStoreType.getMethod("save", Path.class, issued.getClass()) + .invoke(null, clientDirectory, issued); + assertNotNull(credentialStoreType.getMethod("load", Path.class).invoke(null, clientDirectory)); } } diff --git a/docs/jar-packaging.md b/docs/jar-packaging.md index e28181fab..9a6ceec92 100644 --- a/docs/jar-packaging.md +++ b/docs/jar-packaging.md @@ -10,16 +10,19 @@ AdvancedCore already contains the relocated Rhino implementation needed by its JavaScript support, so VotingPlugin excludes the second unrelocated Rhino dependency. VotingPlugin bundles the relocated Bouncy Castle base provider used by `BouncyCastleProvider` and `HttpTlsIdentity`, while excluding its unused -multi-release payloads. HTTP transport support explicitly owns its TLS -implementation and crypto dependencies; it does not rely on AdvancedCore to -provide them. +multi-release payloads and unrelated timestamping and other +protocol stacks. HTTP transport support explicitly owns its TLS implementation +and the remaining crypto classes; it does not rely on AdvancedCore to provide +them. The package phase runs `PackagedArtifactTest` after shading. It opens the actual downloadable JAR, checks plugin resources and required relocated classes, and rejects duplicate Rhino, raw Hikari/Folia, unused multi-release crypto payloads, -and optional Jedis module clients. SQLite keeps its complete -native platform set so packaging changes do not narrow existing installations. -The test also caps the downloadable artifact at 31 MiB so dependency growth must +unused Bouncy Castle protocol packages, Checker Framework annotations, and +optional Jedis module clients. It creates both server and client TLS identities +from the packaged crypto classes. SQLite keeps its complete native platform set +so packaging changes do not narrow existing installations. +The test also caps the downloadable artifact at 30 MiB so dependency growth must be reviewed explicitly. Release/deployment profiles reuse this Shade setup; the artifact check follows their configured JAR name. From 1a31b88e01e0ab528c1a58a242e0e214c7ad7706 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:58:47 -0600 Subject: [PATCH 4/9] Load large runtime libraries on demand --- .mex/events/decisions.jsonl | 1 + VotingPlugin/pom.xml | 36 ++-- .../votingplugin/VotingPluginMain.java | 8 + .../neoforge/NeoForgeRuntime.java | 2 + .../velocity/VelocityRuntimeLibraries.java | 181 ++++++++++++++++++ .../proxy/velocity/VotingPluginVelocity.java | 6 + .../util/SqliteNativeLibrary.java | 147 ++++++++++++++ VotingPlugin/src/main/resources/bungee.yml | 10 +- VotingPlugin/src/main/resources/plugin.yml | 4 + .../packaging/PackagedArtifactTest.java | 71 +++++-- .../VelocityRuntimeLibrariesTest.java | 80 ++++++++ docs/jar-packaging.md | 28 +-- 12 files changed, 515 insertions(+), 59 deletions(-) create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityRuntimeLibraries.java create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/util/SqliteNativeLibrary.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/VelocityRuntimeLibrariesTest.java diff --git a/.mex/events/decisions.jsonl b/.mex/events/decisions.jsonl index e9c06247e..e5f63de82 100644 --- a/.mex/events/decisions.jsonl +++ b/.mex/events/decisions.jsonl @@ -1 +1,2 @@ {"timestamp":"2026-09-21T10:57:35.704Z","kind":"decision","message":"Preserve all SQLite native targets and the complete base Bouncy Castle provider. Reduce the JAR through unused Bouncy Castle multi-release payloads and optional Jedis module clients; enforce a 31 MiB budget and packaged TLS identity startup.","files":["VotingPlugin/pom.xml","docs/jar-packaging.md"],"cwd":".","source":"agent","status":"implemented"} +{"timestamp":"2026-09-24T04:55:12.940Z","kind":"decision","message":"Supersede the bundled-all-platform-libraries decision: keep the downloadable JAR near its historical size by resolving Bouncy Castle through platform or verified Velocity library loading and caching one verified Xerial SQLite artifact from which only the current native is extracted. Enforce a 10 MiB artifact budget with packaged TLS and SQLite startup coverage.","files":["VotingPlugin/pom.xml","docs/jar-packaging.md"],"cwd":".","source":"agent","status":"implemented"} diff --git a/VotingPlugin/pom.xml b/VotingPlugin/pom.xml index f614cc476..59453f2d8 100644 --- a/VotingPlugin/pom.xml +++ b/VotingPlugin/pom.xml @@ -159,10 +159,6 @@ ${project.groupId}.votingplugin.bstats - - org.bouncycastle - ${project.groupId}.votingplugin.bouncycastle - xyz.upperlevel.spigot @@ -201,6 +197,9 @@ com.google.*:* org.slf4j:* + + org.bouncycastle:* false @@ -227,27 +226,6 @@ META-INF/versions/** - - org.bouncycastle:* - - - META-INF/versions/** - - org/bouncycastle/dvcs/** - org/bouncycastle/eac/** - org/bouncycastle/est/** - org/bouncycastle/its/** - org/bouncycastle/mime/** - org/bouncycastle/mozilla/** - org/bouncycastle/oer/** - org/bouncycastle/openssl/** - org/bouncycastle/pkcs/** - org/bouncycastle/tsp/** - org/bouncycastle/voms/** - - redis.clients:jedis @@ -258,6 +236,14 @@ redis/clients/jedis/timeseries/** + + org.xerial:sqlite-jdbc + + + org/sqlite/native/** + + *:* diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 49401c28c..9fd132575 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -105,6 +105,7 @@ import com.bencodez.votingplugin.util.BoundedScheduledExecutor; import com.bencodez.votingplugin.util.BukkitCompletionScheduler; import com.bencodez.votingplugin.util.ControlCredentialFile.PendingAutoEnrollment; +import com.bencodez.votingplugin.util.SqliteNativeLibrary; import com.bencodez.votingplugin.rewards.VotingPluginRewardRegistrar; import com.bencodez.votingplugin.servicesites.ServiceSiteHandler; import com.bencodez.votingplugin.signs.Signs; @@ -1859,6 +1860,13 @@ public void onPreLoad() { plugin = this; setupFiles(); + if ("SQLITE".equalsIgnoreCase(configFile.getData().getString("DataStorage", "SQLITE"))) { + try { + SqliteNativeLibrary.ensureAvailable(getDataFolder().toPath().resolve("libraries")); + } catch (IOException failure) { + throw new IllegalStateException("Could not prepare the SQLite native library", failure); + } + } loadVoteSites(); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeRuntime.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeRuntime.java index 5e1ddddad..5955ef03a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeRuntime.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeRuntime.java @@ -14,6 +14,7 @@ import com.bencodez.advancedcore.core.user.storage.sql.SqlBackendLogger; import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackendFactory; +import com.bencodez.votingplugin.util.SqliteNativeLibrary; /** Owns NeoForge bootstrap resources; vote and reward services are not started here. */ public final class NeoForgeRuntime implements AutoCloseable { @@ -43,6 +44,7 @@ public static NeoForgeRuntime start(Path directory) throws IOException { } SqlUserBackend storage; try { + SqliteNativeLibrary.ensureAvailable(directory.resolve("libraries")); // Use AdvancedCore's existing SQL backend. No vote/user mutations are enabled yet. storage = SqlUserBackendFactory.sqlite(directory, "VotingPlugin", "VotingPlugin_NeoForgeUsers", List.of(), SqlBackendLogger.NO_OP); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityRuntimeLibraries.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityRuntimeLibraries.java new file mode 100644 index 000000000..9be8afeb3 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityRuntimeLibraries.java @@ -0,0 +1,181 @@ +package com.bencodez.votingplugin.proxy.velocity; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.net.HttpURLConnection; +import java.net.URI; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.PosixFilePermission; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.List; +import java.util.Set; + +/** Loads the HTTP TLS libraries that Velocity does not resolve from plugin metadata. */ +final class VelocityRuntimeLibraries { + private static final String CENTRAL_MIRROR = "https://maven-central.storage-download.googleapis.com/maven2/"; + private static final int CONNECT_TIMEOUT_MILLIS = 10_000; + private static final int READ_TIMEOUT_MILLIS = 30_000; + private static final long MAX_ARTIFACT_BYTES = 16L * 1024L * 1024L; + private static final List LIBRARIES = List.of( + library("org/bouncycastle/bcprov-jdk18on/1.85/bcprov-jdk18on-1.85.jar", + "20af26bf6060bb8005cc2389916812c1e0e998dc48d2ced7131b89461b54cff7"), + library("org/bouncycastle/bcutil-jdk18on/1.85/bcutil-jdk18on-1.85.jar", + "590f55ed5d68529239898a4a5c4f730b6e37f45d1cfa3fbe51f8485abe32c42d"), + library("org/bouncycastle/bcpkix-jdk18on/1.85/bcpkix-jdk18on-1.85.jar", + "c9f82b2d4e99c4bbdfccf684e52cc06ea06a0b567bfd0d08f9c5a3f417055996")); + + private VelocityRuntimeLibraries() { + } + + static void ensureAvailable(Path directory, ClassLoader pluginLoader) throws IOException { + ensureAvailable(directory, pluginLoader, VelocityRuntimeLibraries::download); + } + + static void ensureAvailable(Path directory, ClassLoader pluginLoader, ArtifactFetcher fetcher) throws IOException { + if (areLibrariesAvailable(pluginLoader)) { + return; + } + Files.createDirectories(directory); + for (Library library : LIBRARIES) { + Path artifact = directory.resolve(library.fileName()); + if (!hasExpectedDigest(artifact, library.sha256())) { + downloadVerified(library, artifact, fetcher); + } + addPath(pluginLoader, artifact); + } + if (!areLibrariesAvailable(pluginLoader)) { + throw new IOException("Bouncy Castle runtime remained incomplete after loading its libraries"); + } + } + + private static Library library(String path, String sha256) { + return new Library(path.substring(path.lastIndexOf('/') + 1), URI.create(CENTRAL_MIRROR + path), sha256); + } + + private static void downloadVerified(Library library, Path artifact, ArtifactFetcher fetcher) throws IOException { + Path temporary = Files.createTempFile(artifact.getParent(), artifact.getFileName().toString() + ".", ".download"); + setPrivatePermissions(temporary); + try { + fetcher.fetch(library.uri(), temporary); + if (!hasExpectedDigest(temporary, library.sha256())) { + throw new IOException("Downloaded runtime library failed SHA-256 verification: " + library.fileName()); + } + try { + Files.move(temporary, artifact, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException ignored) { + Files.move(temporary, artifact, StandardCopyOption.REPLACE_EXISTING); + } + } finally { + Files.deleteIfExists(temporary); + } + } + + private static void download(URI source, Path target) throws IOException { + if (!"https".equalsIgnoreCase(source.getScheme())) { + throw new IOException("Runtime library source must use HTTPS"); + } + HttpURLConnection connection = (HttpURLConnection) source.toURL().openConnection(); + connection.setConnectTimeout(CONNECT_TIMEOUT_MILLIS); + connection.setReadTimeout(READ_TIMEOUT_MILLIS); + connection.setInstanceFollowRedirects(false); + connection.setRequestProperty("User-Agent", "VotingPlugin-runtime-library-loader"); + try { + if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) { + throw new IOException("Runtime library download returned HTTP " + connection.getResponseCode()); + } + long declaredLength = connection.getContentLengthLong(); + if (declaredLength > MAX_ARTIFACT_BYTES) { + throw new IOException("Runtime library exceeds download limit"); + } + try (InputStream input = connection.getInputStream(); var output = Files.newOutputStream(target)) { + byte[] buffer = new byte[8192]; + long total = 0; + int read; + while ((read = input.read(buffer)) >= 0) { + total += read; + if (total > MAX_ARTIFACT_BYTES) { + throw new IOException("Runtime library exceeds download limit"); + } + output.write(buffer, 0, read); + } + } + } finally { + connection.disconnect(); + } + } + + private static void addPath(ClassLoader loader, Path artifact) throws IOException { + Method addPath = null; + for (Class type = loader.getClass(); type != null && addPath == null; type = type.getSuperclass()) { + try { + addPath = type.getDeclaredMethod("addPath", Path.class); + } catch (NoSuchMethodException ignored) { + // Continue through the class-loader hierarchy. + } + } + if (addPath == null) { + throw new IOException("Velocity plugin class loader does not expose addPath(Path)"); + } + try { + addPath.setAccessible(true); + addPath.invoke(loader, artifact); + } catch (IllegalAccessException | InvocationTargetException | RuntimeException failure) { + throw new IOException("Could not attach Velocity runtime library " + artifact.getFileName(), failure); + } + } + + private static boolean areLibrariesAvailable(ClassLoader loader) { + for (String requiredClass : new String[] { + "org.bouncycastle.jce.provider.BouncyCastleProvider", + "org.bouncycastle.asn1.cms.ContentInfo", + "org.bouncycastle.cert.X509CertificateHolder" }) { + try { + Class.forName(requiredClass, false, loader); + } catch (ClassNotFoundException unavailable) { + return false; + } + } + return true; + } + + private static boolean hasExpectedDigest(Path file, String expected) throws IOException { + if (!Files.isRegularFile(file)) { + return false; + } + try (InputStream input = Files.newInputStream(file)) { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) >= 0) { + digest.update(buffer, 0, read); + } + return expected.equals(HexFormat.of().formatHex(digest.digest())); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 is unavailable", impossible); + } + } + + private static void setPrivatePermissions(Path file) { + try { + Files.setPosixFilePermissions(file, Set.of(PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE)); + } catch (IOException | UnsupportedOperationException ignored) { + // Non-POSIX systems retain their default file permissions. + } + } + + @FunctionalInterface + interface ArtifactFetcher { + void fetch(URI source, Path target) throws IOException; + } + + private record Library(String fileName, URI uri, String sha256) { + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java index 1dcc4e8c0..b5cc94a51 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java @@ -322,6 +322,12 @@ public void onProxyDisable(ProxyShutdownEvent event) { @Subscribe public void onProxyInitialization(ProxyInitializeEvent event) { + try { + VelocityRuntimeLibraries.ensureAvailable(dataDirectory.resolve("libraries"), getClass().getClassLoader()); + } catch (IOException failure) { + throw new IllegalStateException("Could not load VotingPlugin runtime libraries", failure); + } + File configFile = new File(dataDirectory.toFile(), "bungeeconfig.yml"); configFile.getParentFile().mkdirs(); if (!configFile.exists()) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/SqliteNativeLibrary.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/SqliteNativeLibrary.java new file mode 100644 index 000000000..25e9c6708 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/SqliteNativeLibrary.java @@ -0,0 +1,147 @@ +package com.bencodez.votingplugin.util; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.PosixFilePermission; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Set; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +import org.sqlite.util.OSInfo; + +/** Prepares the current Xerial SQLite native without embedding every target in the plugin JAR. */ +public final class SqliteNativeLibrary { + static final String DRIVER_FILE = "sqlite-jdbc-3.53.4.0.jar"; + static final String DRIVER_SHA256 = "bcb1f51e36f940867e83342f9efbf5968ac44a6bef4d397bb4af7b17b45cd2fb"; + private static final URI DRIVER_URI = URI.create("https://maven-central.storage-download.googleapis.com/maven2/" + + "org/xerial/sqlite-jdbc/3.53.4.0/" + DRIVER_FILE); + private static final long MAX_DRIVER_BYTES = 16L * 1024L * 1024L; + + private SqliteNativeLibrary() { + } + + /** Ensures Xerial can load the native for this operating system and architecture. */ + public static synchronized void ensureAvailable(Path directory) throws IOException { + if (System.getProperty("org.sqlite.lib.path") != null) { + return; + } + String folder = OSInfo.getNativeLibFolderPathForCurrentOS(); + String libraryName = nativeLibraryName(folder); + String resource = "org/sqlite/native/" + folder + "/" + libraryName; + if (SqliteNativeLibrary.class.getClassLoader().getResource(resource) != null) { + return; + } + + Files.createDirectories(directory); + Path driver = directory.resolve(DRIVER_FILE); + if (!hasExpectedDigest(driver, DRIVER_SHA256)) { + downloadVerified(driver); + } + Path nativeDirectory = directory.resolve("sqlite-native").resolve(folder); + Files.createDirectories(nativeDirectory); + Path nativeLibrary = nativeDirectory.resolve(libraryName); + extractVerifiedEntry(driver, resource, nativeLibrary); + System.setProperty("org.sqlite.lib.path", nativeDirectory.toAbsolutePath().normalize().toString()); + System.setProperty("org.sqlite.lib.name", libraryName); + } + + private static String nativeLibraryName(String folder) throws IOException { + if (folder.startsWith("Windows/")) return "sqlitejdbc.dll"; + if (folder.startsWith("Mac/")) return "libsqlitejdbc.dylib"; + if (folder.startsWith("Linux/") || folder.startsWith("Linux-Musl/") + || folder.startsWith("FreeBSD/")) return "libsqlitejdbc.so"; + throw new IOException("SQLite does not publish a native library for " + folder); + } + + private static void downloadVerified(Path target) throws IOException { + Path temporary = Files.createTempFile(target.getParent(), DRIVER_FILE + ".", ".download"); + setPrivatePermissions(temporary); + HttpURLConnection connection = (HttpURLConnection) DRIVER_URI.toURL().openConnection(); + connection.setConnectTimeout(10_000); + connection.setReadTimeout(30_000); + connection.setInstanceFollowRedirects(false); + connection.setRequestProperty("User-Agent", "VotingPlugin-sqlite-native-loader"); + try { + if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) { + throw new IOException("SQLite driver download returned HTTP " + connection.getResponseCode()); + } + long declaredLength = connection.getContentLengthLong(); + if (declaredLength > MAX_DRIVER_BYTES) throw new IOException("SQLite driver exceeds download limit"); + try (InputStream input = connection.getInputStream(); var output = Files.newOutputStream(temporary)) { + byte[] buffer = new byte[8192]; + long total = 0; + int read; + while ((read = input.read(buffer)) >= 0) { + total += read; + if (total > MAX_DRIVER_BYTES) throw new IOException("SQLite driver exceeds download limit"); + output.write(buffer, 0, read); + } + } + if (!hasExpectedDigest(temporary, DRIVER_SHA256)) { + throw new IOException("Downloaded SQLite driver failed SHA-256 verification"); + } + moveReplacing(temporary, target); + } finally { + connection.disconnect(); + Files.deleteIfExists(temporary); + } + } + + private static void extractVerifiedEntry(Path driver, String resource, Path target) throws IOException { + try (JarFile jar = new JarFile(driver.toFile())) { + JarEntry entry = jar.getJarEntry(resource); + if (entry == null || entry.isDirectory() || entry.getSize() <= 0 || entry.getSize() > 2L * 1024L * 1024L) { + throw new IOException("SQLite driver does not contain the expected native: " + resource); + } + Path temporary = Files.createTempFile(target.getParent(), target.getFileName().toString() + ".", ".extract"); + setPrivatePermissions(temporary); + try { + try (InputStream input = jar.getInputStream(entry)) { + Files.copy(input, temporary, StandardCopyOption.REPLACE_EXISTING); + } + moveReplacing(temporary, target); + } finally { + Files.deleteIfExists(temporary); + } + } + } + + private static void moveReplacing(Path source, Path target) throws IOException { + try { + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException ignored) { + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); + } + } + + private static boolean hasExpectedDigest(Path file, String expected) throws IOException { + if (!Files.isRegularFile(file)) return false; + try (InputStream input = Files.newInputStream(file)) { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) >= 0) digest.update(buffer, 0, read); + return expected.equals(HexFormat.of().formatHex(digest.digest())); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 is unavailable", impossible); + } + } + + private static void setPrivatePermissions(Path file) { + try { + Files.setPosixFilePermissions(file, Set.of(PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE)); + } catch (IOException | UnsupportedOperationException ignored) { + // Non-POSIX systems retain their default file permissions. + } + } +} diff --git a/VotingPlugin/src/main/resources/bungee.yml b/VotingPlugin/src/main/resources/bungee.yml index 4427ee0bc..4ea63c25a 100644 --- a/VotingPlugin/src/main/resources/bungee.yml +++ b/VotingPlugin/src/main/resources/bungee.yml @@ -1,6 +1,10 @@ name: ${project.name} version: ${project.version} main: com.bencodez.votingplugin.proxy.bungee.VotingPluginBungee -author: BenCodez -softDepends: -- RedisBungee \ No newline at end of file +author: BenCodez +libraries: +- org.bouncycastle:bcprov-jdk18on:1.85 +- org.bouncycastle:bcutil-jdk18on:1.85 +- org.bouncycastle:bcpkix-jdk18on:1.85 +softDepends: +- RedisBungee diff --git a/VotingPlugin/src/main/resources/plugin.yml b/VotingPlugin/src/main/resources/plugin.yml index 633dc7841..05fa4aff8 100644 --- a/VotingPlugin/src/main/resources/plugin.yml +++ b/VotingPlugin/src/main/resources/plugin.yml @@ -23,6 +23,10 @@ loadbefore: - MCPerks api-version: 1.13 folia-supported: true +libraries: +- org.bouncycastle:bcprov-jdk18on:1.85 +- org.bouncycastle:bcutil-jdk18on:1.85 +- org.bouncycastle:bcpkix-jdk18on:1.85 commands: vote: description: Vote command diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/packaging/PackagedArtifactTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/packaging/PackagedArtifactTest.java index 6d79c513a..717162538 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/packaging/PackagedArtifactTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/packaging/PackagedArtifactTest.java @@ -19,15 +19,10 @@ /** Package-phase checks for the actual downloadable plugin artifact. */ public class PackagedArtifactTest { - private static final long MAX_DOWNLOAD_BYTES = 30L * 1024L * 1024L; - private static final String RELOCATED_BOUNCY_CASTLE = "com/bencodez/votingplugin/bouncycastle/"; - private static final String[] UNUSED_BOUNCY_CASTLE_PACKAGES = { - "dvcs/", "eac/", "est/", "its/", "mime/", "mozilla/", - "oer/", "openssl/", "pkcs/", "tsp/", "voms/" - }; + private static final long MAX_DOWNLOAD_BYTES = 10L * 1024L * 1024L; @Test - void containsOneRelocatedRuntimeWithoutUnusedHttpCrypto() throws Exception { + void containsRuntimeWithoutDownloadedHttpCrypto() throws Exception { Path artifactPath = packagedJar(); try (JarFile artifact = new JarFile(artifactPath.toFile())) { assertNotNull(artifact.getEntry("com/bencodez/votingplugin/VotingPluginMain.class")); @@ -61,43 +56,66 @@ void containsOneRelocatedRuntimeWithoutUnusedHttpCrypto() throws Exception { assertNull(artifact.getEntry("com/zaxxer/hikari/HikariDataSource.class")); assertNull(artifact.getEntry("com/tcoded/folialib/FoliaLib.class")); assertFalse(artifact.stream().anyMatch(entry -> entry.getName().startsWith("org/bouncycastle/"))); - assertFalse(artifact.stream().anyMatch(entry -> entry.getName().startsWith("META-INF/versions/") - && entry.getName().contains("/bouncycastle/"))); - for (String packageName : UNUSED_BOUNCY_CASTLE_PACKAGES) { - String prefix = RELOCATED_BOUNCY_CASTLE + packageName; - assertFalse(artifact.stream().anyMatch(entry -> entry.getName().startsWith(prefix)), - () -> "Unused Bouncy Castle package was bundled: " + prefix); - } + assertFalse(artifact.stream().anyMatch(entry -> entry.getName() + .startsWith("com/bencodez/votingplugin/bouncycastle/"))); + assertFalse(artifact.stream().anyMatch(entry -> entry.getName().startsWith("org/sqlite/native/"))); assertFalse(artifact.stream().anyMatch(entry -> entry.getName() .startsWith("redis/clients/jedis/search/"))); + + String pluginDescriptor = new String(artifact.getInputStream(artifact.getEntry("plugin.yml")).readAllBytes(), + StandardCharsets.UTF_8); + String bungeeDescriptor = new String(artifact.getInputStream(artifact.getEntry("bungee.yml")).readAllBytes(), + StandardCharsets.UTF_8); + for (String coordinate : new String[] { "bcprov-jdk18on:1.85", "bcutil-jdk18on:1.85", + "bcpkix-jdk18on:1.85" }) { + assertTrue(pluginDescriptor.contains(coordinate)); + assertTrue(bungeeDescriptor.contains(coordinate)); + } } long artifactBytes = Files.size(artifactPath); assertTrue(artifactBytes <= MAX_DOWNLOAD_BYTES, () -> "VotingPlugin downloadable artifact exceeded " + (MAX_DOWNLOAD_BYTES / (1024L * 1024L)) + " MiB: " + artifactBytes); - System.out.printf("VotingPlugin downloadable artifact: %,d bytes; duplicate Rhino and unused HTTP crypto absent%n", + System.out.printf("VotingPlugin downloadable artifact: %,d bytes; duplicate Rhino and downloaded HTTP crypto absent%n", Files.size(artifactPath)); } @Test - void packagedNeoForgeRuntimeStartsAndClosesWithoutTestDependencies(@TempDir Path directory) throws Exception { + void packagedNeoForgeRuntimeStartsWithCachedSqliteDriver(@TempDir Path directory) throws Exception { URL jar = packagedJar().toUri().toURL(); URL platformSlf4j = org.slf4j.Logger.class.getProtectionDomain().getCodeSource().getLocation(); + Path libraries = directory.resolve("libraries"); + Files.createDirectories(libraries); + Files.copy(dependency("sqlite-jdbc-3.53.4.0.jar"), + libraries.resolve("sqlite-jdbc-3.53.4.0.jar")); + String previousPath = System.clearProperty("org.sqlite.lib.path"); + String previousName = System.clearProperty("org.sqlite.lib.name"); try (URLClassLoader loader = new URLClassLoader(new URL[] { jar, platformSlf4j }, ClassLoader.getPlatformClassLoader())) { Class runtime = Class.forName("com.bencodez.votingplugin.neoforge.NeoForgeRuntime", true, loader); try (AutoCloseable instance = (AutoCloseable) runtime.getMethod("start", Path.class).invoke(null, directory)) { assertTrue(Files.isRegularFile(directory.resolve("VotingPlugin.db"))); + try (var files = Files.walk(libraries.resolve("sqlite-native"))) { + assertTrue(files.anyMatch(path -> Files.isRegularFile(path) + && path.getFileName().toString().contains("sqlite"))); + } } + } finally { + restoreProperty("org.sqlite.lib.path", previousPath); + restoreProperty("org.sqlite.lib.name", previousName); } } @Test - void packagedBaseCryptoProviderLoadsWithoutMultiReleasePayload(@TempDir Path directory) throws Exception { + void packagedHttpTlsLoadsWithExternalCryptoLibraries(@TempDir Path directory) throws Exception { URL jar = packagedJar().toUri().toURL(); - try (URLClassLoader loader = new URLClassLoader(new URL[] { jar }, ClassLoader.getPlatformClassLoader())) { + URL bcProvider = dependency("bcprov-jdk18on-1.85.jar").toUri().toURL(); + URL bcPkix = dependency("bcpkix-jdk18on-1.85.jar").toUri().toURL(); + URL bcUtil = dependency("bcutil-jdk18on-1.85.jar").toUri().toURL(); + try (URLClassLoader loader = new URLClassLoader(new URL[] { jar, bcProvider, bcPkix, bcUtil }, + ClassLoader.getPlatformClassLoader())) { Class providerType = Class.forName( - "com.bencodez.votingplugin.bouncycastle.jce.provider.BouncyCastleProvider", true, loader); + "org.bouncycastle.jce.provider.BouncyCastleProvider", true, loader); Provider provider = (Provider) providerType.getConstructor().newInstance(); assertNotNull(provider.getService("Signature", "SHA256WITHRSA")); Class identityType = Class.forName( @@ -125,4 +143,19 @@ private static Path packagedJar() { assertTrue(Files.isRegularFile(artifact), "Missing packaged artifact: " + artifact); return artifact; } + + private static Path dependency(String fileName) { + for (String entry : System.getProperty("java.class.path").split(java.io.File.pathSeparator)) { + Path candidate = Path.of(entry); + if (candidate.getFileName() != null && fileName.equals(candidate.getFileName().toString())) { + return candidate; + } + } + throw new IllegalStateException("Dependency was not present on the test classpath: " + fileName); + } + + private static void restoreProperty(String key, String value) { + if (value == null) System.clearProperty(key); + else System.setProperty(key, value); + } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/VelocityRuntimeLibrariesTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/VelocityRuntimeLibrariesTest.java new file mode 100644 index 000000000..007129afd --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/VelocityRuntimeLibrariesTest.java @@ -0,0 +1,80 @@ +package com.bencodez.votingplugin.proxy.velocity; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class VelocityRuntimeLibrariesTest { + @Test + void rejectsAnUnverifiedDownload(@TempDir Path directory) throws Exception { + try (TestPluginClassLoader loader = new TestPluginClassLoader()) { + assertThrows(IOException.class, () -> VelocityRuntimeLibraries.ensureAvailable(directory, loader, + (source, target) -> Files.writeString(target, "unverified"))); + } + assertFalse(Files.exists(directory.resolve("bcprov-jdk18on-1.85.jar"))); + } + + @Test + void installsVerifiedLibrariesAndReplacesCorruptCache(@TempDir Path directory) throws Exception { + Map dependencies = Map.of( + "bcprov-jdk18on-1.85.jar", dependency("bcprov-jdk18on-1.85.jar"), + "bcutil-jdk18on-1.85.jar", dependency("bcutil-jdk18on-1.85.jar"), + "bcpkix-jdk18on-1.85.jar", dependency("bcpkix-jdk18on-1.85.jar")); + Path libraries = directory.resolve("libraries"); + Files.createDirectories(libraries); + Files.writeString(libraries.resolve("bcprov-jdk18on-1.85.jar"), "corrupt"); + + // A parent or another plugin may expose only bcprov. That partial state must + // not suppress loading bcutil and bcpkix. + try (TestPluginClassLoader loader = new TestPluginClassLoader( + dependencies.get("bcprov-jdk18on-1.85.jar").toUri().toURL())) { + VelocityRuntimeLibraries.ensureAvailable(libraries, loader, (source, target) -> { + Path dependency = dependencies.get(Path.of(source.getPath()).getFileName().toString()); + if (dependency == null) { + throw new IOException("Unexpected dependency " + source); + } + Files.copy(dependency, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + }); + + assertTrue(Files.size(libraries.resolve("bcprov-jdk18on-1.85.jar")) > 1_000_000L); + assertTrue(Class.forName("org.bouncycastle.jce.provider.BouncyCastleProvider", true, loader) + .getConstructor().newInstance() instanceof java.security.Provider); + assertTrue(Class.forName("org.bouncycastle.asn1.cms.ContentInfo", false, loader) != null); + assertTrue(Class.forName("org.bouncycastle.cert.X509CertificateHolder", false, loader) != null); + } + try (var files = Files.list(libraries)) { + assertFalse(files.anyMatch(path -> path.getFileName().toString().endsWith(".download"))); + } + } + + private static Path dependency(String fileName) throws IOException { + for (String entry : System.getProperty("java.class.path").split(java.io.File.pathSeparator)) { + Path candidate = Path.of(entry); + if (candidate.getFileName() != null && fileName.equals(candidate.getFileName().toString())) { + return candidate; + } + } + throw new IOException("Dependency was not present on the test classpath: " + fileName); + } + + private static final class TestPluginClassLoader extends URLClassLoader { + private TestPluginClassLoader(URL... initial) { + super(initial, ClassLoader.getPlatformClassLoader()); + } + + @SuppressWarnings("unused") + void addPath(Path path) throws IOException { + addURL(path.toUri().toURL()); + } + } +} diff --git a/docs/jar-packaging.md b/docs/jar-packaging.md index 9a6ceec92..42ca197b3 100644 --- a/docs/jar-packaging.md +++ b/docs/jar-packaging.md @@ -8,21 +8,25 @@ transports. Gson is platform-supplied and is therefore `provided`. AdvancedCore already contains the relocated Rhino implementation needed by its JavaScript support, so VotingPlugin excludes the second unrelocated Rhino -dependency. VotingPlugin bundles the relocated Bouncy Castle base provider used -by `BouncyCastleProvider` and `HttpTlsIdentity`, while excluding its unused -multi-release payloads and unrelated timestamping and other -protocol stacks. HTTP transport support explicitly owns its TLS implementation -and the remaining crypto classes; it does not rely on AdvancedCore to provide -them. +dependency. The Bukkit and Bungee descriptors ask their platform library loaders +for the three Bouncy Castle 1.85 artifacts used by `HttpTlsIdentity`. Velocity, +which has no descriptor-level Maven library support, downloads those same exact +artifacts from Paper's Maven Central mirror, verifies pinned SHA-256 digests, +caches them under the plugin data directory, and attaches them before the proxy +runtime starts. The downloadable plugin JAR therefore does not duplicate the +crypto payload. The package phase runs `PackagedArtifactTest` after shading. It opens the actual downloadable JAR, checks plugin resources and required relocated classes, and -rejects duplicate Rhino, raw Hikari/Folia, unused multi-release crypto payloads, -unused Bouncy Castle protocol packages, Checker Framework annotations, and -optional Jedis module clients. It creates both server and client TLS identities -from the packaged crypto classes. SQLite keeps its complete native platform set -so packaging changes do not narrow existing installations. -The test also caps the downloadable artifact at 30 MiB so dependency growth must +rejects duplicate Rhino, raw Hikari/Folia, embedded Bouncy Castle payloads, +Checker Framework annotations, and optional Jedis module clients. It creates +both server and client TLS identities with the declared external crypto +libraries. VotingPlugin keeps the versioned SQLite driver classes in the JAR, +then downloads the exact driver artifact once, verifies its pinned SHA-256, and +extracts only the current operating system and architecture's native library. +This preserves Xerial's supported targets without shipping every native in every +plugin download. +The test also caps the downloadable artifact at 10 MiB so dependency growth must be reviewed explicitly. Release/deployment profiles reuse this Shade setup; the artifact check follows their configured JAR name. From 5626aa7a55f3bcff836c2372af9398a8be5fb595 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:13:22 -0600 Subject: [PATCH 5/9] Restore offline runtime dependencies --- .mex/events/decisions.jsonl | 3 +- VotingPlugin/pom.xml | 36 ++-- .../votingplugin/VotingPluginMain.java | 8 - .../neoforge/NeoForgeRuntime.java | 2 - .../velocity/VelocityRuntimeLibraries.java | 181 ------------------ .../proxy/velocity/VotingPluginVelocity.java | 6 - .../util/SqliteNativeLibrary.java | 147 -------------- VotingPlugin/src/main/resources/bungee.yml | 10 +- VotingPlugin/src/main/resources/plugin.yml | 4 - .../packaging/PackagedArtifactTest.java | 76 +++----- .../VelocityRuntimeLibrariesTest.java | 80 -------- docs/jar-packaging.md | 28 ++- 12 files changed, 64 insertions(+), 517 deletions(-) delete mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityRuntimeLibraries.java delete mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/util/SqliteNativeLibrary.java delete mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/VelocityRuntimeLibrariesTest.java diff --git a/.mex/events/decisions.jsonl b/.mex/events/decisions.jsonl index e5f63de82..55e437b0f 100644 --- a/.mex/events/decisions.jsonl +++ b/.mex/events/decisions.jsonl @@ -1,2 +1 @@ -{"timestamp":"2026-09-21T10:57:35.704Z","kind":"decision","message":"Preserve all SQLite native targets and the complete base Bouncy Castle provider. Reduce the JAR through unused Bouncy Castle multi-release payloads and optional Jedis module clients; enforce a 31 MiB budget and packaged TLS identity startup.","files":["VotingPlugin/pom.xml","docs/jar-packaging.md"],"cwd":".","source":"agent","status":"implemented"} -{"timestamp":"2026-09-24T04:55:12.940Z","kind":"decision","message":"Supersede the bundled-all-platform-libraries decision: keep the downloadable JAR near its historical size by resolving Bouncy Castle through platform or verified Velocity library loading and caching one verified Xerial SQLite artifact from which only the current native is extracted. Enforce a 10 MiB artifact budget with packaged TLS and SQLite startup coverage.","files":["VotingPlugin/pom.xml","docs/jar-packaging.md"],"cwd":".","source":"agent","status":"implemented"} +{"timestamp":"2026-09-21T10:57:35.704Z","kind":"decision","message":"Preserve all SQLite native targets and the complete base Bouncy Castle provider. Reduce the JAR through unused Bouncy Castle multi-release payloads and optional Jedis module clients; enforce a 30 MiB budget and packaged TLS identity startup.","files":["VotingPlugin/pom.xml","docs/jar-packaging.md"],"cwd":".","source":"agent","status":"implemented"} diff --git a/VotingPlugin/pom.xml b/VotingPlugin/pom.xml index 59453f2d8..f614cc476 100644 --- a/VotingPlugin/pom.xml +++ b/VotingPlugin/pom.xml @@ -159,6 +159,10 @@ ${project.groupId}.votingplugin.bstats + + org.bouncycastle + ${project.groupId}.votingplugin.bouncycastle + xyz.upperlevel.spigot @@ -197,9 +201,6 @@ com.google.*:* org.slf4j:* - - org.bouncycastle:* false @@ -226,6 +227,27 @@ META-INF/versions/** + + org.bouncycastle:* + + + META-INF/versions/** + + org/bouncycastle/dvcs/** + org/bouncycastle/eac/** + org/bouncycastle/est/** + org/bouncycastle/its/** + org/bouncycastle/mime/** + org/bouncycastle/mozilla/** + org/bouncycastle/oer/** + org/bouncycastle/openssl/** + org/bouncycastle/pkcs/** + org/bouncycastle/tsp/** + org/bouncycastle/voms/** + + redis.clients:jedis @@ -236,14 +258,6 @@ redis/clients/jedis/timeseries/** - - org.xerial:sqlite-jdbc - - - org/sqlite/native/** - - *:* diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 9fd132575..49401c28c 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -105,7 +105,6 @@ import com.bencodez.votingplugin.util.BoundedScheduledExecutor; import com.bencodez.votingplugin.util.BukkitCompletionScheduler; import com.bencodez.votingplugin.util.ControlCredentialFile.PendingAutoEnrollment; -import com.bencodez.votingplugin.util.SqliteNativeLibrary; import com.bencodez.votingplugin.rewards.VotingPluginRewardRegistrar; import com.bencodez.votingplugin.servicesites.ServiceSiteHandler; import com.bencodez.votingplugin.signs.Signs; @@ -1860,13 +1859,6 @@ public void onPreLoad() { plugin = this; setupFiles(); - if ("SQLITE".equalsIgnoreCase(configFile.getData().getString("DataStorage", "SQLITE"))) { - try { - SqliteNativeLibrary.ensureAvailable(getDataFolder().toPath().resolve("libraries")); - } catch (IOException failure) { - throw new IllegalStateException("Could not prepare the SQLite native library", failure); - } - } loadVoteSites(); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeRuntime.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeRuntime.java index 5955ef03a..5e1ddddad 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeRuntime.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeRuntime.java @@ -14,7 +14,6 @@ import com.bencodez.advancedcore.core.user.storage.sql.SqlBackendLogger; import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackendFactory; -import com.bencodez.votingplugin.util.SqliteNativeLibrary; /** Owns NeoForge bootstrap resources; vote and reward services are not started here. */ public final class NeoForgeRuntime implements AutoCloseable { @@ -44,7 +43,6 @@ public static NeoForgeRuntime start(Path directory) throws IOException { } SqlUserBackend storage; try { - SqliteNativeLibrary.ensureAvailable(directory.resolve("libraries")); // Use AdvancedCore's existing SQL backend. No vote/user mutations are enabled yet. storage = SqlUserBackendFactory.sqlite(directory, "VotingPlugin", "VotingPlugin_NeoForgeUsers", List.of(), SqlBackendLogger.NO_OP); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityRuntimeLibraries.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityRuntimeLibraries.java deleted file mode 100644 index 9be8afeb3..000000000 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityRuntimeLibraries.java +++ /dev/null @@ -1,181 +0,0 @@ -package com.bencodez.votingplugin.proxy.velocity; - -import java.io.IOException; -import java.io.InputStream; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.net.HttpURLConnection; -import java.net.URI; -import java.nio.file.AtomicMoveNotSupportedException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; -import java.nio.file.attribute.PosixFilePermission; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.HexFormat; -import java.util.List; -import java.util.Set; - -/** Loads the HTTP TLS libraries that Velocity does not resolve from plugin metadata. */ -final class VelocityRuntimeLibraries { - private static final String CENTRAL_MIRROR = "https://maven-central.storage-download.googleapis.com/maven2/"; - private static final int CONNECT_TIMEOUT_MILLIS = 10_000; - private static final int READ_TIMEOUT_MILLIS = 30_000; - private static final long MAX_ARTIFACT_BYTES = 16L * 1024L * 1024L; - private static final List LIBRARIES = List.of( - library("org/bouncycastle/bcprov-jdk18on/1.85/bcprov-jdk18on-1.85.jar", - "20af26bf6060bb8005cc2389916812c1e0e998dc48d2ced7131b89461b54cff7"), - library("org/bouncycastle/bcutil-jdk18on/1.85/bcutil-jdk18on-1.85.jar", - "590f55ed5d68529239898a4a5c4f730b6e37f45d1cfa3fbe51f8485abe32c42d"), - library("org/bouncycastle/bcpkix-jdk18on/1.85/bcpkix-jdk18on-1.85.jar", - "c9f82b2d4e99c4bbdfccf684e52cc06ea06a0b567bfd0d08f9c5a3f417055996")); - - private VelocityRuntimeLibraries() { - } - - static void ensureAvailable(Path directory, ClassLoader pluginLoader) throws IOException { - ensureAvailable(directory, pluginLoader, VelocityRuntimeLibraries::download); - } - - static void ensureAvailable(Path directory, ClassLoader pluginLoader, ArtifactFetcher fetcher) throws IOException { - if (areLibrariesAvailable(pluginLoader)) { - return; - } - Files.createDirectories(directory); - for (Library library : LIBRARIES) { - Path artifact = directory.resolve(library.fileName()); - if (!hasExpectedDigest(artifact, library.sha256())) { - downloadVerified(library, artifact, fetcher); - } - addPath(pluginLoader, artifact); - } - if (!areLibrariesAvailable(pluginLoader)) { - throw new IOException("Bouncy Castle runtime remained incomplete after loading its libraries"); - } - } - - private static Library library(String path, String sha256) { - return new Library(path.substring(path.lastIndexOf('/') + 1), URI.create(CENTRAL_MIRROR + path), sha256); - } - - private static void downloadVerified(Library library, Path artifact, ArtifactFetcher fetcher) throws IOException { - Path temporary = Files.createTempFile(artifact.getParent(), artifact.getFileName().toString() + ".", ".download"); - setPrivatePermissions(temporary); - try { - fetcher.fetch(library.uri(), temporary); - if (!hasExpectedDigest(temporary, library.sha256())) { - throw new IOException("Downloaded runtime library failed SHA-256 verification: " + library.fileName()); - } - try { - Files.move(temporary, artifact, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); - } catch (AtomicMoveNotSupportedException ignored) { - Files.move(temporary, artifact, StandardCopyOption.REPLACE_EXISTING); - } - } finally { - Files.deleteIfExists(temporary); - } - } - - private static void download(URI source, Path target) throws IOException { - if (!"https".equalsIgnoreCase(source.getScheme())) { - throw new IOException("Runtime library source must use HTTPS"); - } - HttpURLConnection connection = (HttpURLConnection) source.toURL().openConnection(); - connection.setConnectTimeout(CONNECT_TIMEOUT_MILLIS); - connection.setReadTimeout(READ_TIMEOUT_MILLIS); - connection.setInstanceFollowRedirects(false); - connection.setRequestProperty("User-Agent", "VotingPlugin-runtime-library-loader"); - try { - if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) { - throw new IOException("Runtime library download returned HTTP " + connection.getResponseCode()); - } - long declaredLength = connection.getContentLengthLong(); - if (declaredLength > MAX_ARTIFACT_BYTES) { - throw new IOException("Runtime library exceeds download limit"); - } - try (InputStream input = connection.getInputStream(); var output = Files.newOutputStream(target)) { - byte[] buffer = new byte[8192]; - long total = 0; - int read; - while ((read = input.read(buffer)) >= 0) { - total += read; - if (total > MAX_ARTIFACT_BYTES) { - throw new IOException("Runtime library exceeds download limit"); - } - output.write(buffer, 0, read); - } - } - } finally { - connection.disconnect(); - } - } - - private static void addPath(ClassLoader loader, Path artifact) throws IOException { - Method addPath = null; - for (Class type = loader.getClass(); type != null && addPath == null; type = type.getSuperclass()) { - try { - addPath = type.getDeclaredMethod("addPath", Path.class); - } catch (NoSuchMethodException ignored) { - // Continue through the class-loader hierarchy. - } - } - if (addPath == null) { - throw new IOException("Velocity plugin class loader does not expose addPath(Path)"); - } - try { - addPath.setAccessible(true); - addPath.invoke(loader, artifact); - } catch (IllegalAccessException | InvocationTargetException | RuntimeException failure) { - throw new IOException("Could not attach Velocity runtime library " + artifact.getFileName(), failure); - } - } - - private static boolean areLibrariesAvailable(ClassLoader loader) { - for (String requiredClass : new String[] { - "org.bouncycastle.jce.provider.BouncyCastleProvider", - "org.bouncycastle.asn1.cms.ContentInfo", - "org.bouncycastle.cert.X509CertificateHolder" }) { - try { - Class.forName(requiredClass, false, loader); - } catch (ClassNotFoundException unavailable) { - return false; - } - } - return true; - } - - private static boolean hasExpectedDigest(Path file, String expected) throws IOException { - if (!Files.isRegularFile(file)) { - return false; - } - try (InputStream input = Files.newInputStream(file)) { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] buffer = new byte[8192]; - int read; - while ((read = input.read(buffer)) >= 0) { - digest.update(buffer, 0, read); - } - return expected.equals(HexFormat.of().formatHex(digest.digest())); - } catch (NoSuchAlgorithmException impossible) { - throw new IllegalStateException("SHA-256 is unavailable", impossible); - } - } - - private static void setPrivatePermissions(Path file) { - try { - Files.setPosixFilePermissions(file, Set.of(PosixFilePermission.OWNER_READ, - PosixFilePermission.OWNER_WRITE)); - } catch (IOException | UnsupportedOperationException ignored) { - // Non-POSIX systems retain their default file permissions. - } - } - - @FunctionalInterface - interface ArtifactFetcher { - void fetch(URI source, Path target) throws IOException; - } - - private record Library(String fileName, URI uri, String sha256) { - } -} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java index b5cc94a51..1dcc4e8c0 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java @@ -322,12 +322,6 @@ public void onProxyDisable(ProxyShutdownEvent event) { @Subscribe public void onProxyInitialization(ProxyInitializeEvent event) { - try { - VelocityRuntimeLibraries.ensureAvailable(dataDirectory.resolve("libraries"), getClass().getClassLoader()); - } catch (IOException failure) { - throw new IllegalStateException("Could not load VotingPlugin runtime libraries", failure); - } - File configFile = new File(dataDirectory.toFile(), "bungeeconfig.yml"); configFile.getParentFile().mkdirs(); if (!configFile.exists()) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/SqliteNativeLibrary.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/SqliteNativeLibrary.java deleted file mode 100644 index 25e9c6708..000000000 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/SqliteNativeLibrary.java +++ /dev/null @@ -1,147 +0,0 @@ -package com.bencodez.votingplugin.util; - -import java.io.IOException; -import java.io.InputStream; -import java.net.HttpURLConnection; -import java.net.URI; -import java.nio.file.AtomicMoveNotSupportedException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; -import java.nio.file.attribute.PosixFilePermission; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.HexFormat; -import java.util.Set; -import java.util.jar.JarEntry; -import java.util.jar.JarFile; - -import org.sqlite.util.OSInfo; - -/** Prepares the current Xerial SQLite native without embedding every target in the plugin JAR. */ -public final class SqliteNativeLibrary { - static final String DRIVER_FILE = "sqlite-jdbc-3.53.4.0.jar"; - static final String DRIVER_SHA256 = "bcb1f51e36f940867e83342f9efbf5968ac44a6bef4d397bb4af7b17b45cd2fb"; - private static final URI DRIVER_URI = URI.create("https://maven-central.storage-download.googleapis.com/maven2/" - + "org/xerial/sqlite-jdbc/3.53.4.0/" + DRIVER_FILE); - private static final long MAX_DRIVER_BYTES = 16L * 1024L * 1024L; - - private SqliteNativeLibrary() { - } - - /** Ensures Xerial can load the native for this operating system and architecture. */ - public static synchronized void ensureAvailable(Path directory) throws IOException { - if (System.getProperty("org.sqlite.lib.path") != null) { - return; - } - String folder = OSInfo.getNativeLibFolderPathForCurrentOS(); - String libraryName = nativeLibraryName(folder); - String resource = "org/sqlite/native/" + folder + "/" + libraryName; - if (SqliteNativeLibrary.class.getClassLoader().getResource(resource) != null) { - return; - } - - Files.createDirectories(directory); - Path driver = directory.resolve(DRIVER_FILE); - if (!hasExpectedDigest(driver, DRIVER_SHA256)) { - downloadVerified(driver); - } - Path nativeDirectory = directory.resolve("sqlite-native").resolve(folder); - Files.createDirectories(nativeDirectory); - Path nativeLibrary = nativeDirectory.resolve(libraryName); - extractVerifiedEntry(driver, resource, nativeLibrary); - System.setProperty("org.sqlite.lib.path", nativeDirectory.toAbsolutePath().normalize().toString()); - System.setProperty("org.sqlite.lib.name", libraryName); - } - - private static String nativeLibraryName(String folder) throws IOException { - if (folder.startsWith("Windows/")) return "sqlitejdbc.dll"; - if (folder.startsWith("Mac/")) return "libsqlitejdbc.dylib"; - if (folder.startsWith("Linux/") || folder.startsWith("Linux-Musl/") - || folder.startsWith("FreeBSD/")) return "libsqlitejdbc.so"; - throw new IOException("SQLite does not publish a native library for " + folder); - } - - private static void downloadVerified(Path target) throws IOException { - Path temporary = Files.createTempFile(target.getParent(), DRIVER_FILE + ".", ".download"); - setPrivatePermissions(temporary); - HttpURLConnection connection = (HttpURLConnection) DRIVER_URI.toURL().openConnection(); - connection.setConnectTimeout(10_000); - connection.setReadTimeout(30_000); - connection.setInstanceFollowRedirects(false); - connection.setRequestProperty("User-Agent", "VotingPlugin-sqlite-native-loader"); - try { - if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) { - throw new IOException("SQLite driver download returned HTTP " + connection.getResponseCode()); - } - long declaredLength = connection.getContentLengthLong(); - if (declaredLength > MAX_DRIVER_BYTES) throw new IOException("SQLite driver exceeds download limit"); - try (InputStream input = connection.getInputStream(); var output = Files.newOutputStream(temporary)) { - byte[] buffer = new byte[8192]; - long total = 0; - int read; - while ((read = input.read(buffer)) >= 0) { - total += read; - if (total > MAX_DRIVER_BYTES) throw new IOException("SQLite driver exceeds download limit"); - output.write(buffer, 0, read); - } - } - if (!hasExpectedDigest(temporary, DRIVER_SHA256)) { - throw new IOException("Downloaded SQLite driver failed SHA-256 verification"); - } - moveReplacing(temporary, target); - } finally { - connection.disconnect(); - Files.deleteIfExists(temporary); - } - } - - private static void extractVerifiedEntry(Path driver, String resource, Path target) throws IOException { - try (JarFile jar = new JarFile(driver.toFile())) { - JarEntry entry = jar.getJarEntry(resource); - if (entry == null || entry.isDirectory() || entry.getSize() <= 0 || entry.getSize() > 2L * 1024L * 1024L) { - throw new IOException("SQLite driver does not contain the expected native: " + resource); - } - Path temporary = Files.createTempFile(target.getParent(), target.getFileName().toString() + ".", ".extract"); - setPrivatePermissions(temporary); - try { - try (InputStream input = jar.getInputStream(entry)) { - Files.copy(input, temporary, StandardCopyOption.REPLACE_EXISTING); - } - moveReplacing(temporary, target); - } finally { - Files.deleteIfExists(temporary); - } - } - } - - private static void moveReplacing(Path source, Path target) throws IOException { - try { - Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); - } catch (AtomicMoveNotSupportedException ignored) { - Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); - } - } - - private static boolean hasExpectedDigest(Path file, String expected) throws IOException { - if (!Files.isRegularFile(file)) return false; - try (InputStream input = Files.newInputStream(file)) { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] buffer = new byte[8192]; - int read; - while ((read = input.read(buffer)) >= 0) digest.update(buffer, 0, read); - return expected.equals(HexFormat.of().formatHex(digest.digest())); - } catch (NoSuchAlgorithmException impossible) { - throw new IllegalStateException("SHA-256 is unavailable", impossible); - } - } - - private static void setPrivatePermissions(Path file) { - try { - Files.setPosixFilePermissions(file, Set.of(PosixFilePermission.OWNER_READ, - PosixFilePermission.OWNER_WRITE)); - } catch (IOException | UnsupportedOperationException ignored) { - // Non-POSIX systems retain their default file permissions. - } - } -} diff --git a/VotingPlugin/src/main/resources/bungee.yml b/VotingPlugin/src/main/resources/bungee.yml index 4ea63c25a..4427ee0bc 100644 --- a/VotingPlugin/src/main/resources/bungee.yml +++ b/VotingPlugin/src/main/resources/bungee.yml @@ -1,10 +1,6 @@ name: ${project.name} version: ${project.version} main: com.bencodez.votingplugin.proxy.bungee.VotingPluginBungee -author: BenCodez -libraries: -- org.bouncycastle:bcprov-jdk18on:1.85 -- org.bouncycastle:bcutil-jdk18on:1.85 -- org.bouncycastle:bcpkix-jdk18on:1.85 -softDepends: -- RedisBungee +author: BenCodez +softDepends: +- RedisBungee \ No newline at end of file diff --git a/VotingPlugin/src/main/resources/plugin.yml b/VotingPlugin/src/main/resources/plugin.yml index 05fa4aff8..633dc7841 100644 --- a/VotingPlugin/src/main/resources/plugin.yml +++ b/VotingPlugin/src/main/resources/plugin.yml @@ -23,10 +23,6 @@ loadbefore: - MCPerks api-version: 1.13 folia-supported: true -libraries: -- org.bouncycastle:bcprov-jdk18on:1.85 -- org.bouncycastle:bcutil-jdk18on:1.85 -- org.bouncycastle:bcpkix-jdk18on:1.85 commands: vote: description: Vote command diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/packaging/PackagedArtifactTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/packaging/PackagedArtifactTest.java index 717162538..18626a26b 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/packaging/PackagedArtifactTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/packaging/PackagedArtifactTest.java @@ -19,10 +19,15 @@ /** Package-phase checks for the actual downloadable plugin artifact. */ public class PackagedArtifactTest { - private static final long MAX_DOWNLOAD_BYTES = 10L * 1024L * 1024L; + private static final long MAX_DOWNLOAD_BYTES = 30L * 1024L * 1024L; + private static final String RELOCATED_BOUNCY_CASTLE = "com/bencodez/votingplugin/bouncycastle/"; + private static final String[] UNUSED_BOUNCY_CASTLE_PACKAGES = { + "dvcs/", "eac/", "est/", "its/", "mime/", "mozilla/", + "oer/", "openssl/", "pkcs/", "tsp/", "voms/" + }; @Test - void containsRuntimeWithoutDownloadedHttpCrypto() throws Exception { + void containsOneRelocatedRuntimeWithoutUnusedHttpCrypto() throws Exception { Path artifactPath = packagedJar(); try (JarFile artifact = new JarFile(artifactPath.toFile())) { assertNotNull(artifact.getEntry("com/bencodez/votingplugin/VotingPluginMain.class")); @@ -56,66 +61,46 @@ void containsRuntimeWithoutDownloadedHttpCrypto() throws Exception { assertNull(artifact.getEntry("com/zaxxer/hikari/HikariDataSource.class")); assertNull(artifact.getEntry("com/tcoded/folialib/FoliaLib.class")); assertFalse(artifact.stream().anyMatch(entry -> entry.getName().startsWith("org/bouncycastle/"))); - assertFalse(artifact.stream().anyMatch(entry -> entry.getName() - .startsWith("com/bencodez/votingplugin/bouncycastle/"))); - assertFalse(artifact.stream().anyMatch(entry -> entry.getName().startsWith("org/sqlite/native/"))); - assertFalse(artifact.stream().anyMatch(entry -> entry.getName() - .startsWith("redis/clients/jedis/search/"))); - - String pluginDescriptor = new String(artifact.getInputStream(artifact.getEntry("plugin.yml")).readAllBytes(), - StandardCharsets.UTF_8); - String bungeeDescriptor = new String(artifact.getInputStream(artifact.getEntry("bungee.yml")).readAllBytes(), - StandardCharsets.UTF_8); - for (String coordinate : new String[] { "bcprov-jdk18on:1.85", "bcutil-jdk18on:1.85", - "bcpkix-jdk18on:1.85" }) { - assertTrue(pluginDescriptor.contains(coordinate)); - assertTrue(bungeeDescriptor.contains(coordinate)); + assertFalse(artifact.stream().anyMatch(entry -> entry.getName().startsWith("META-INF/versions/") + && entry.getName().contains("/bouncycastle/"))); + for (String packageName : UNUSED_BOUNCY_CASTLE_PACKAGES) { + String prefix = RELOCATED_BOUNCY_CASTLE + packageName; + assertFalse(artifact.stream().anyMatch(entry -> entry.getName().startsWith(prefix)), + () -> "Unused Bouncy Castle package was bundled: " + prefix); + } + for (String module : new String[] { "bloom/", "json/", "search/", "timeseries/" }) { + String prefix = "redis/clients/jedis/" + module; + assertFalse(artifact.stream().anyMatch(entry -> entry.getName().startsWith(prefix)), + () -> "Unused Jedis module was bundled: " + prefix); } } long artifactBytes = Files.size(artifactPath); assertTrue(artifactBytes <= MAX_DOWNLOAD_BYTES, () -> "VotingPlugin downloadable artifact exceeded " + (MAX_DOWNLOAD_BYTES / (1024L * 1024L)) + " MiB: " + artifactBytes); - System.out.printf("VotingPlugin downloadable artifact: %,d bytes; duplicate Rhino and downloaded HTTP crypto absent%n", + System.out.printf("VotingPlugin downloadable artifact: %,d bytes; duplicate Rhino and unused HTTP crypto absent%n", Files.size(artifactPath)); } @Test - void packagedNeoForgeRuntimeStartsWithCachedSqliteDriver(@TempDir Path directory) throws Exception { + void packagedNeoForgeRuntimeStartsAndClosesWithoutTestDependencies(@TempDir Path directory) throws Exception { URL jar = packagedJar().toUri().toURL(); URL platformSlf4j = org.slf4j.Logger.class.getProtectionDomain().getCodeSource().getLocation(); - Path libraries = directory.resolve("libraries"); - Files.createDirectories(libraries); - Files.copy(dependency("sqlite-jdbc-3.53.4.0.jar"), - libraries.resolve("sqlite-jdbc-3.53.4.0.jar")); - String previousPath = System.clearProperty("org.sqlite.lib.path"); - String previousName = System.clearProperty("org.sqlite.lib.name"); try (URLClassLoader loader = new URLClassLoader(new URL[] { jar, platformSlf4j }, ClassLoader.getPlatformClassLoader())) { Class runtime = Class.forName("com.bencodez.votingplugin.neoforge.NeoForgeRuntime", true, loader); try (AutoCloseable instance = (AutoCloseable) runtime.getMethod("start", Path.class).invoke(null, directory)) { assertTrue(Files.isRegularFile(directory.resolve("VotingPlugin.db"))); - try (var files = Files.walk(libraries.resolve("sqlite-native"))) { - assertTrue(files.anyMatch(path -> Files.isRegularFile(path) - && path.getFileName().toString().contains("sqlite"))); - } } - } finally { - restoreProperty("org.sqlite.lib.path", previousPath); - restoreProperty("org.sqlite.lib.name", previousName); } } @Test - void packagedHttpTlsLoadsWithExternalCryptoLibraries(@TempDir Path directory) throws Exception { + void packagedBaseCryptoProviderLoadsWithoutMultiReleasePayload(@TempDir Path directory) throws Exception { URL jar = packagedJar().toUri().toURL(); - URL bcProvider = dependency("bcprov-jdk18on-1.85.jar").toUri().toURL(); - URL bcPkix = dependency("bcpkix-jdk18on-1.85.jar").toUri().toURL(); - URL bcUtil = dependency("bcutil-jdk18on-1.85.jar").toUri().toURL(); - try (URLClassLoader loader = new URLClassLoader(new URL[] { jar, bcProvider, bcPkix, bcUtil }, - ClassLoader.getPlatformClassLoader())) { + try (URLClassLoader loader = new URLClassLoader(new URL[] { jar }, ClassLoader.getPlatformClassLoader())) { Class providerType = Class.forName( - "org.bouncycastle.jce.provider.BouncyCastleProvider", true, loader); + "com.bencodez.votingplugin.bouncycastle.jce.provider.BouncyCastleProvider", true, loader); Provider provider = (Provider) providerType.getConstructor().newInstance(); assertNotNull(provider.getService("Signature", "SHA256WITHRSA")); Class identityType = Class.forName( @@ -143,19 +128,4 @@ private static Path packagedJar() { assertTrue(Files.isRegularFile(artifact), "Missing packaged artifact: " + artifact); return artifact; } - - private static Path dependency(String fileName) { - for (String entry : System.getProperty("java.class.path").split(java.io.File.pathSeparator)) { - Path candidate = Path.of(entry); - if (candidate.getFileName() != null && fileName.equals(candidate.getFileName().toString())) { - return candidate; - } - } - throw new IllegalStateException("Dependency was not present on the test classpath: " + fileName); - } - - private static void restoreProperty(String key, String value) { - if (value == null) System.clearProperty(key); - else System.setProperty(key, value); - } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/VelocityRuntimeLibrariesTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/VelocityRuntimeLibrariesTest.java deleted file mode 100644 index 007129afd..000000000 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/velocity/VelocityRuntimeLibrariesTest.java +++ /dev/null @@ -1,80 +0,0 @@ -package com.bencodez.votingplugin.proxy.velocity; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import java.io.IOException; -import java.net.URL; -import java.net.URLClassLoader; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Map; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -class VelocityRuntimeLibrariesTest { - @Test - void rejectsAnUnverifiedDownload(@TempDir Path directory) throws Exception { - try (TestPluginClassLoader loader = new TestPluginClassLoader()) { - assertThrows(IOException.class, () -> VelocityRuntimeLibraries.ensureAvailable(directory, loader, - (source, target) -> Files.writeString(target, "unverified"))); - } - assertFalse(Files.exists(directory.resolve("bcprov-jdk18on-1.85.jar"))); - } - - @Test - void installsVerifiedLibrariesAndReplacesCorruptCache(@TempDir Path directory) throws Exception { - Map dependencies = Map.of( - "bcprov-jdk18on-1.85.jar", dependency("bcprov-jdk18on-1.85.jar"), - "bcutil-jdk18on-1.85.jar", dependency("bcutil-jdk18on-1.85.jar"), - "bcpkix-jdk18on-1.85.jar", dependency("bcpkix-jdk18on-1.85.jar")); - Path libraries = directory.resolve("libraries"); - Files.createDirectories(libraries); - Files.writeString(libraries.resolve("bcprov-jdk18on-1.85.jar"), "corrupt"); - - // A parent or another plugin may expose only bcprov. That partial state must - // not suppress loading bcutil and bcpkix. - try (TestPluginClassLoader loader = new TestPluginClassLoader( - dependencies.get("bcprov-jdk18on-1.85.jar").toUri().toURL())) { - VelocityRuntimeLibraries.ensureAvailable(libraries, loader, (source, target) -> { - Path dependency = dependencies.get(Path.of(source.getPath()).getFileName().toString()); - if (dependency == null) { - throw new IOException("Unexpected dependency " + source); - } - Files.copy(dependency, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING); - }); - - assertTrue(Files.size(libraries.resolve("bcprov-jdk18on-1.85.jar")) > 1_000_000L); - assertTrue(Class.forName("org.bouncycastle.jce.provider.BouncyCastleProvider", true, loader) - .getConstructor().newInstance() instanceof java.security.Provider); - assertTrue(Class.forName("org.bouncycastle.asn1.cms.ContentInfo", false, loader) != null); - assertTrue(Class.forName("org.bouncycastle.cert.X509CertificateHolder", false, loader) != null); - } - try (var files = Files.list(libraries)) { - assertFalse(files.anyMatch(path -> path.getFileName().toString().endsWith(".download"))); - } - } - - private static Path dependency(String fileName) throws IOException { - for (String entry : System.getProperty("java.class.path").split(java.io.File.pathSeparator)) { - Path candidate = Path.of(entry); - if (candidate.getFileName() != null && fileName.equals(candidate.getFileName().toString())) { - return candidate; - } - } - throw new IOException("Dependency was not present on the test classpath: " + fileName); - } - - private static final class TestPluginClassLoader extends URLClassLoader { - private TestPluginClassLoader(URL... initial) { - super(initial, ClassLoader.getPlatformClassLoader()); - } - - @SuppressWarnings("unused") - void addPath(Path path) throws IOException { - addURL(path.toUri().toURL()); - } - } -} diff --git a/docs/jar-packaging.md b/docs/jar-packaging.md index 42ca197b3..9a6ceec92 100644 --- a/docs/jar-packaging.md +++ b/docs/jar-packaging.md @@ -8,25 +8,21 @@ transports. Gson is platform-supplied and is therefore `provided`. AdvancedCore already contains the relocated Rhino implementation needed by its JavaScript support, so VotingPlugin excludes the second unrelocated Rhino -dependency. The Bukkit and Bungee descriptors ask their platform library loaders -for the three Bouncy Castle 1.85 artifacts used by `HttpTlsIdentity`. Velocity, -which has no descriptor-level Maven library support, downloads those same exact -artifacts from Paper's Maven Central mirror, verifies pinned SHA-256 digests, -caches them under the plugin data directory, and attaches them before the proxy -runtime starts. The downloadable plugin JAR therefore does not duplicate the -crypto payload. +dependency. VotingPlugin bundles the relocated Bouncy Castle base provider used +by `BouncyCastleProvider` and `HttpTlsIdentity`, while excluding its unused +multi-release payloads and unrelated timestamping and other +protocol stacks. HTTP transport support explicitly owns its TLS implementation +and the remaining crypto classes; it does not rely on AdvancedCore to provide +them. The package phase runs `PackagedArtifactTest` after shading. It opens the actual downloadable JAR, checks plugin resources and required relocated classes, and -rejects duplicate Rhino, raw Hikari/Folia, embedded Bouncy Castle payloads, -Checker Framework annotations, and optional Jedis module clients. It creates -both server and client TLS identities with the declared external crypto -libraries. VotingPlugin keeps the versioned SQLite driver classes in the JAR, -then downloads the exact driver artifact once, verifies its pinned SHA-256, and -extracts only the current operating system and architecture's native library. -This preserves Xerial's supported targets without shipping every native in every -plugin download. -The test also caps the downloadable artifact at 10 MiB so dependency growth must +rejects duplicate Rhino, raw Hikari/Folia, unused multi-release crypto payloads, +unused Bouncy Castle protocol packages, Checker Framework annotations, and +optional Jedis module clients. It creates both server and client TLS identities +from the packaged crypto classes. SQLite keeps its complete native platform set +so packaging changes do not narrow existing installations. +The test also caps the downloadable artifact at 30 MiB so dependency growth must be reviewed explicitly. Release/deployment profiles reuse this Shade setup; the artifact check follows their configured JAR name. From 98637a29263f39f41088a21e8cc8892c32300784 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:23:40 -0600 Subject: [PATCH 6/9] Preserve Jedis module linkage --- .mex/events/decisions.jsonl | 2 +- VotingPlugin/pom.xml | 10 ---------- .../packaging/PackagedArtifactTest.java | 17 ++++++++++++----- docs/jar-packaging.md | 4 ++-- 4 files changed, 15 insertions(+), 18 deletions(-) diff --git a/.mex/events/decisions.jsonl b/.mex/events/decisions.jsonl index 55e437b0f..a2f404760 100644 --- a/.mex/events/decisions.jsonl +++ b/.mex/events/decisions.jsonl @@ -1 +1 @@ -{"timestamp":"2026-09-21T10:57:35.704Z","kind":"decision","message":"Preserve all SQLite native targets and the complete base Bouncy Castle provider. Reduce the JAR through unused Bouncy Castle multi-release payloads and optional Jedis module clients; enforce a 30 MiB budget and packaged TLS identity startup.","files":["VotingPlugin/pom.xml","docs/jar-packaging.md"],"cwd":".","source":"agent","status":"implemented"} +{"timestamp":"2026-09-21T10:57:35.704Z","kind":"decision","message":"Preserve all SQLite native targets and the complete base Bouncy Castle provider. Reduce the JAR through unused Bouncy Castle multi-release payloads; enforce a 30 MiB budget and packaged TLS identity startup.","files":["VotingPlugin/pom.xml","docs/jar-packaging.md"],"cwd":".","source":"agent","status":"implemented"} diff --git a/VotingPlugin/pom.xml b/VotingPlugin/pom.xml index f614cc476..5c5f96a67 100644 --- a/VotingPlugin/pom.xml +++ b/VotingPlugin/pom.xml @@ -248,16 +248,6 @@ org/bouncycastle/voms/** - - redis.clients:jedis - - - redis/clients/jedis/bloom/** - redis/clients/jedis/json/** - redis/clients/jedis/search/** - redis/clients/jedis/timeseries/** - - *:* diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/packaging/PackagedArtifactTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/packaging/PackagedArtifactTest.java index 18626a26b..e6d1c6a4c 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/packaging/PackagedArtifactTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/packaging/PackagedArtifactTest.java @@ -68,11 +68,6 @@ void containsOneRelocatedRuntimeWithoutUnusedHttpCrypto() throws Exception { assertFalse(artifact.stream().anyMatch(entry -> entry.getName().startsWith(prefix)), () -> "Unused Bouncy Castle package was bundled: " + prefix); } - for (String module : new String[] { "bloom/", "json/", "search/", "timeseries/" }) { - String prefix = "redis/clients/jedis/" + module; - assertFalse(artifact.stream().anyMatch(entry -> entry.getName().startsWith(prefix)), - () -> "Unused Jedis module was bundled: " + prefix); - } } long artifactBytes = Files.size(artifactPath); assertTrue(artifactBytes <= MAX_DOWNLOAD_BYTES, @@ -95,6 +90,18 @@ void packagedNeoForgeRuntimeStartsAndClosesWithoutTestDependencies(@TempDir Path } } + @Test + void packagedRedisClientLinksWithModuleApis() throws Exception { + URL jar = packagedJar().toUri().toURL(); + URL platformSlf4j = org.slf4j.Logger.class.getProtectionDomain().getCodeSource().getLocation(); + try (URLClassLoader loader = new URLClassLoader(new URL[] { jar, platformSlf4j }, + ClassLoader.getPlatformClassLoader())) { + Class unifiedJedis = Class.forName("redis.clients.jedis.UnifiedJedis", true, loader); + Object client = unifiedJedis.getConstructor().newInstance(); + unifiedJedis.getMethod("close").invoke(client); + } + } + @Test void packagedBaseCryptoProviderLoadsWithoutMultiReleasePayload(@TempDir Path directory) throws Exception { URL jar = packagedJar().toUri().toURL(); diff --git a/docs/jar-packaging.md b/docs/jar-packaging.md index 9a6ceec92..e61ab1119 100644 --- a/docs/jar-packaging.md +++ b/docs/jar-packaging.md @@ -18,8 +18,8 @@ them. The package phase runs `PackagedArtifactTest` after shading. It opens the actual downloadable JAR, checks plugin resources and required relocated classes, and rejects duplicate Rhino, raw Hikari/Folia, unused multi-release crypto payloads, -unused Bouncy Castle protocol packages, Checker Framework annotations, and -optional Jedis module clients. It creates both server and client TLS identities +unused Bouncy Castle protocol packages, and Checker Framework annotations. It +creates both server and client TLS identities from the packaged crypto classes. SQLite keeps its complete native platform set so packaging changes do not narrow existing installations. The test also caps the downloadable artifact at 30 MiB so dependency growth must From 4c1821f935db1a17651a89f95fca0eee7fcc5bb7 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:26:57 -0600 Subject: [PATCH 7/9] Reduce packaged runtime to under 10 MiB --- VotingPlugin/pom.xml | 37 ++-- .../votingplugin/VotingPluginMain.java | 5 + .../neoforge/NeoForgeRuntime.java | 2 + .../util/SqliteNativeLibrary.java | 183 ++++++++++++++++++ .../packaging/PackagedArtifactTest.java | 31 +-- .../util/SqliteNativeLibraryTest.java | 44 +++++ docs/jar-packaging.md | 28 +-- 7 files changed, 275 insertions(+), 55 deletions(-) create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/util/SqliteNativeLibrary.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/util/SqliteNativeLibraryTest.java diff --git a/VotingPlugin/pom.xml b/VotingPlugin/pom.xml index 5c5f96a67..1105852df 100644 --- a/VotingPlugin/pom.xml +++ b/VotingPlugin/pom.xml @@ -159,10 +159,6 @@ ${project.groupId}.votingplugin.bstats - - org.bouncycastle - ${project.groupId}.votingplugin.bouncycastle - xyz.upperlevel.spigot @@ -201,6 +197,8 @@ com.google.*:* org.slf4j:* + + org.bouncycastle:* false @@ -228,24 +226,21 @@ - org.bouncycastle:* + org.xerial:sqlite-jdbc - - META-INF/versions/** - - org/bouncycastle/dvcs/** - org/bouncycastle/eac/** - org/bouncycastle/est/** - org/bouncycastle/its/** - org/bouncycastle/mime/** - org/bouncycastle/mozilla/** - org/bouncycastle/oer/** - org/bouncycastle/openssl/** - org/bouncycastle/pkcs/** - org/bouncycastle/tsp/** - org/bouncycastle/voms/** + + org/sqlite/native/FreeBSD/** + org/sqlite/native/Linux-Musl/** + org/sqlite/native/Linux/aarch64/** + org/sqlite/native/Linux/arm/** + org/sqlite/native/Linux/armv6/** + org/sqlite/native/Linux/armv7/** + org/sqlite/native/Linux/ppc64/** + org/sqlite/native/Linux/riscv64/** + org/sqlite/native/Linux/x86/** + org/sqlite/native/Mac/** + org/sqlite/native/Windows/** diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 49401c28c..da451cb49 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -105,6 +105,7 @@ import com.bencodez.votingplugin.util.BoundedScheduledExecutor; import com.bencodez.votingplugin.util.BukkitCompletionScheduler; import com.bencodez.votingplugin.util.ControlCredentialFile.PendingAutoEnrollment; +import com.bencodez.votingplugin.util.SqliteNativeLibrary; import com.bencodez.votingplugin.rewards.VotingPluginRewardRegistrar; import com.bencodez.votingplugin.servicesites.ServiceSiteHandler; import com.bencodez.votingplugin.signs.Signs; @@ -1859,6 +1860,10 @@ public void onPreLoad() { plugin = this; setupFiles(); + if ("SQLITE".equalsIgnoreCase(configFile.getData().getString("DataStorage", "SQLITE"))) { + try { SqliteNativeLibrary.ensureAvailable(getDataFolder().toPath().resolve("libraries")); } + catch (IOException failure) { throw new IllegalStateException("Could not prepare the SQLite native library", failure); } + } loadVoteSites(); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeRuntime.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeRuntime.java index 5e1ddddad..5955ef03a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeRuntime.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/neoforge/NeoForgeRuntime.java @@ -14,6 +14,7 @@ import com.bencodez.advancedcore.core.user.storage.sql.SqlBackendLogger; import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackendFactory; +import com.bencodez.votingplugin.util.SqliteNativeLibrary; /** Owns NeoForge bootstrap resources; vote and reward services are not started here. */ public final class NeoForgeRuntime implements AutoCloseable { @@ -43,6 +44,7 @@ public static NeoForgeRuntime start(Path directory) throws IOException { } SqlUserBackend storage; try { + SqliteNativeLibrary.ensureAvailable(directory.resolve("libraries")); // Use AdvancedCore's existing SQL backend. No vote/user mutations are enabled yet. storage = SqlUserBackendFactory.sqlite(directory, "VotingPlugin", "VotingPlugin_NeoForgeUsers", List.of(), SqlBackendLogger.NO_OP); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/SqliteNativeLibrary.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/SqliteNativeLibrary.java new file mode 100644 index 000000000..70802f3fa --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/SqliteNativeLibrary.java @@ -0,0 +1,183 @@ +package com.bencodez.votingplugin.util; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.PosixFilePermission; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Set; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +import org.sqlite.util.OSInfo; + +/** Prepares the current Xerial SQLite native without embedding every target in the plugin JAR. */ +public final class SqliteNativeLibrary { + static final String DRIVER_FILE = "sqlite-jdbc-3.53.4.0.jar"; + static final String DRIVER_SHA256 = "bcb1f51e36f940867e83342f9efbf5968ac44a6bef4d397bb4af7b17b45cd2fb"; + private static final URI DRIVER_URI = URI.create("https://maven-central.storage-download.googleapis.com/maven2/" + + "org/xerial/sqlite-jdbc/3.53.4.0/" + DRIVER_FILE); + private static final long MAX_DRIVER_BYTES = 16L * 1024L * 1024L; + + private SqliteNativeLibrary() { + } + + /** Ensures Xerial can load the native for this operating system and architecture. */ + public static synchronized void ensureAvailable(Path directory) throws IOException { + if (System.getProperty("org.sqlite.lib.path") != null) { + try { + if (!org.sqlite.core.NativeDB.load()) throw new IOException("Configured SQLite native library did not load"); + return; + } catch (Exception failure) { + throw failure instanceof IOException io ? io + : new IOException("Could not load the configured SQLite native library", failure); + } + } + String folder = OSInfo.getNativeLibFolderPathForCurrentOS(); + Path nativeLibrary = prepareNative(directory, folder, SqliteNativeLibrary.class.getClassLoader(), + SqliteNativeLibrary::download); + if (nativeLibrary != null) loadPreparedNative(nativeLibrary.getParent(), nativeLibrary.getFileName().toString()); + } + + static Path prepareNative(Path directory, String folder, ClassLoader resourceLoader, ArtifactFetcher fetcher) + throws IOException { + String libraryName = nativeLibraryName(folder); + String resource = "org/sqlite/native/" + folder + "/" + libraryName; + if (resourceLoader.getResource(resource) != null) return null; + Files.createDirectories(directory); + Path driver = directory.resolve(DRIVER_FILE); + if (!hasExpectedDigest(driver, DRIVER_SHA256)) downloadVerified(driver, fetcher); + Path nativeDirectory = directory.resolve("sqlite-native").resolve(folder); + Files.createDirectories(nativeDirectory); + Path nativeLibrary = nativeDirectory.resolve(libraryName); + extractVerifiedEntry(driver, resource, nativeLibrary); + return nativeLibrary; + } + + private static void loadPreparedNative(Path nativeDirectory, String libraryName) throws IOException { + synchronized (System.getProperties()) { + String oldPath = System.getProperty("org.sqlite.lib.path"); + String oldName = System.getProperty("org.sqlite.lib.name"); + try { + System.setProperty("org.sqlite.lib.path", nativeDirectory.toAbsolutePath().normalize().toString()); + System.setProperty("org.sqlite.lib.name", libraryName); + if (!org.sqlite.core.NativeDB.load()) throw new IOException("SQLite native library did not load"); + } catch (Exception failure) { + throw failure instanceof IOException io ? io : new IOException("Could not load SQLite native library", failure); + } finally { + restoreProperty("org.sqlite.lib.path", oldPath); + restoreProperty("org.sqlite.lib.name", oldName); + } + } + } + + private static void restoreProperty(String name, String value) { + if (value == null) System.clearProperty(name); + else System.setProperty(name, value); + } + + private static String nativeLibraryName(String folder) throws IOException { + if (folder.startsWith("Windows/")) return "sqlitejdbc.dll"; + if (folder.startsWith("Mac/")) return "libsqlitejdbc.dylib"; + if (folder.startsWith("Linux/") || folder.startsWith("Linux-Musl/") + || folder.startsWith("FreeBSD/")) return "libsqlitejdbc.so"; + throw new IOException("SQLite does not publish a native library for " + folder); + } + + private static void downloadVerified(Path target, ArtifactFetcher fetcher) throws IOException { + Path temporary = Files.createTempFile(target.getParent(), DRIVER_FILE + ".", ".download"); + setPrivatePermissions(temporary); + try { + fetcher.fetch(DRIVER_URI, temporary); + if (!hasExpectedDigest(temporary, DRIVER_SHA256)) + throw new IOException("Downloaded SQLite driver failed SHA-256 verification"); + moveReplacing(temporary, target); + } finally { Files.deleteIfExists(temporary); } + } + + private static void download(URI source, Path target) throws IOException { + if (!"https".equalsIgnoreCase(source.getScheme())) throw new IOException("SQLite driver source must use HTTPS"); + HttpURLConnection connection = (HttpURLConnection) source.toURL().openConnection(); + connection.setConnectTimeout(10_000); + connection.setReadTimeout(30_000); + connection.setInstanceFollowRedirects(false); + connection.setRequestProperty("User-Agent", "VotingPlugin-sqlite-native-loader"); + try { + if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) + throw new IOException("SQLite driver download returned HTTP " + connection.getResponseCode()); + long declaredLength = connection.getContentLengthLong(); + if (declaredLength > MAX_DRIVER_BYTES) throw new IOException("SQLite driver exceeds download limit"); + try (InputStream input = connection.getInputStream(); var output = Files.newOutputStream(target)) { + byte[] buffer = new byte[8192]; + long total = 0; + int read; + while ((read = input.read(buffer)) >= 0) { + total += read; + if (total > MAX_DRIVER_BYTES) throw new IOException("SQLite driver exceeds download limit"); + output.write(buffer, 0, read); + } + } + } finally { connection.disconnect(); } + } + + private static void extractVerifiedEntry(Path driver, String resource, Path target) throws IOException { + try (JarFile jar = new JarFile(driver.toFile())) { + JarEntry entry = jar.getJarEntry(resource); + if (entry == null || entry.isDirectory() || entry.getSize() <= 0 || entry.getSize() > 2L * 1024L * 1024L) { + throw new IOException("SQLite driver does not contain the expected native: " + resource); + } + Path temporary = Files.createTempFile(target.getParent(), target.getFileName().toString() + ".", ".extract"); + setPrivatePermissions(temporary); + try { + try (InputStream input = jar.getInputStream(entry)) { + Files.copy(input, temporary, StandardCopyOption.REPLACE_EXISTING); + } + moveReplacing(temporary, target); + } finally { + Files.deleteIfExists(temporary); + } + } + } + + private static void moveReplacing(Path source, Path target) throws IOException { + try { + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException ignored) { + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); + } + } + + private static boolean hasExpectedDigest(Path file, String expected) throws IOException { + if (!Files.isRegularFile(file)) return false; + try (InputStream input = Files.newInputStream(file)) { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) >= 0) digest.update(buffer, 0, read); + return expected.equals(HexFormat.of().formatHex(digest.digest())); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 is unavailable", impossible); + } + } + + private static void setPrivatePermissions(Path file) { + try { + Files.setPosixFilePermissions(file, Set.of(PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE)); + } catch (IOException | UnsupportedOperationException ignored) { + // Non-POSIX systems retain their default file permissions. + } + } + @FunctionalInterface + interface ArtifactFetcher { + void fetch(URI source, Path target) throws IOException; + } + +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/packaging/PackagedArtifactTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/packaging/PackagedArtifactTest.java index e6d1c6a4c..71d6c59bf 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/packaging/PackagedArtifactTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/packaging/PackagedArtifactTest.java @@ -11,7 +11,6 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.security.Provider; import java.util.jar.JarFile; import org.junit.jupiter.api.Test; @@ -19,12 +18,7 @@ /** Package-phase checks for the actual downloadable plugin artifact. */ public class PackagedArtifactTest { - private static final long MAX_DOWNLOAD_BYTES = 30L * 1024L * 1024L; - private static final String RELOCATED_BOUNCY_CASTLE = "com/bencodez/votingplugin/bouncycastle/"; - private static final String[] UNUSED_BOUNCY_CASTLE_PACKAGES = { - "dvcs/", "eac/", "est/", "its/", "mime/", "mozilla/", - "oer/", "openssl/", "pkcs/", "tsp/", "voms/" - }; + private static final long MAX_DOWNLOAD_BYTES = 10L * 1024L * 1024L; @Test void containsOneRelocatedRuntimeWithoutUnusedHttpCrypto() throws Exception { @@ -60,20 +54,19 @@ void containsOneRelocatedRuntimeWithoutUnusedHttpCrypto() throws Exception { assertNull(artifact.getEntry("org/mozilla/javascript/Context.class")); assertNull(artifact.getEntry("com/zaxxer/hikari/HikariDataSource.class")); assertNull(artifact.getEntry("com/tcoded/folialib/FoliaLib.class")); - assertFalse(artifact.stream().anyMatch(entry -> entry.getName().startsWith("org/bouncycastle/"))); - assertFalse(artifact.stream().anyMatch(entry -> entry.getName().startsWith("META-INF/versions/") - && entry.getName().contains("/bouncycastle/"))); - for (String packageName : UNUSED_BOUNCY_CASTLE_PACKAGES) { - String prefix = RELOCATED_BOUNCY_CASTLE + packageName; - assertFalse(artifact.stream().anyMatch(entry -> entry.getName().startsWith(prefix)), - () -> "Unused Bouncy Castle package was bundled: " + prefix); - } + assertFalse(artifact.stream().anyMatch(entry -> entry.getName().contains("/bouncycastle/"))); + assertNotNull(artifact.getEntry("org/sqlite/native/Linux/x86_64/libsqlitejdbc.so"), + "The common Linux x86_64 SQLite runtime must remain available offline"); + assertFalse(artifact.stream().anyMatch(entry -> !entry.isDirectory() + && entry.getName().startsWith("org/sqlite/native/") + && !entry.getName().startsWith("org/sqlite/native/Linux/x86_64/")), + "Only the common offline SQLite native may be embedded"); } long artifactBytes = Files.size(artifactPath); assertTrue(artifactBytes <= MAX_DOWNLOAD_BYTES, () -> "VotingPlugin downloadable artifact exceeded " + (MAX_DOWNLOAD_BYTES / (1024L * 1024L)) + " MiB: " + artifactBytes); - System.out.printf("VotingPlugin downloadable artifact: %,d bytes; duplicate Rhino and unused HTTP crypto absent%n", + System.out.printf("VotingPlugin downloadable artifact: %,d bytes; duplicate Rhino, external crypto and uncommon SQLite natives absent%n", Files.size(artifactPath)); } @@ -103,13 +96,9 @@ void packagedRedisClientLinksWithModuleApis() throws Exception { } @Test - void packagedBaseCryptoProviderLoadsWithoutMultiReleasePayload(@TempDir Path directory) throws Exception { + void packagedJdkTlsIdentityWorksWithoutExternalCrypto(@TempDir Path directory) throws Exception { URL jar = packagedJar().toUri().toURL(); try (URLClassLoader loader = new URLClassLoader(new URL[] { jar }, ClassLoader.getPlatformClassLoader())) { - Class providerType = Class.forName( - "com.bencodez.votingplugin.bouncycastle.jce.provider.BouncyCastleProvider", true, loader); - Provider provider = (Provider) providerType.getConstructor().newInstance(); - assertNotNull(provider.getService("Signature", "SHA256WITHRSA")); Class identityType = Class.forName( "com.bencodez.votingplugin.simpleapi.servercomm.http.HttpTlsIdentity", true, loader); Object identity = identityType.getMethod("loadOrCreate", Path.class, String.class) diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/SqliteNativeLibraryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/SqliteNativeLibraryTest.java new file mode 100644 index 000000000..c54bb9ad1 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/SqliteNativeLibraryTest.java @@ -0,0 +1,44 @@ +package com.bencodez.votingplugin.util; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.jar.JarFile; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class SqliteNativeLibraryTest { + @TempDir Path directory; + + @Test + void usesEmbeddedCommonNativeWithoutFetching() throws Exception { + Path prepared = SqliteNativeLibrary.prepareNative(directory, "Linux/x86_64", + SqliteNativeLibrary.class.getClassLoader(), (source, target) -> { + throw new AssertionError("embedded native must not download the driver"); + }); + assertNull(prepared); + } + + @Test + void verifiesDriverAndExtractsUncommonNative() throws Exception { + Path driver = Path.of(org.sqlite.JDBC.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + assertTrue(Files.isRegularFile(driver)); + try (URLClassLoader empty = new URLClassLoader(new URL[0], ClassLoader.getPlatformClassLoader())) { + Path prepared = SqliteNativeLibrary.prepareNative(directory, "Mac/x86_64", empty, + (source, target) -> Files.copy(driver, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING)); + assertNotNull(prepared); + assertTrue(Files.isRegularFile(prepared)); + try (JarFile jar = new JarFile(driver.toFile())) { + byte[] expected = jar.getInputStream(jar.getJarEntry( + "org/sqlite/native/Mac/x86_64/libsqlitejdbc.dylib")).readAllBytes(); + assertArrayEquals(expected, Files.readAllBytes(prepared)); + } + } + } +} diff --git a/docs/jar-packaging.md b/docs/jar-packaging.md index e61ab1119..68704e1f1 100644 --- a/docs/jar-packaging.md +++ b/docs/jar-packaging.md @@ -8,22 +8,24 @@ transports. Gson is platform-supplied and is therefore `provided`. AdvancedCore already contains the relocated Rhino implementation needed by its JavaScript support, so VotingPlugin excludes the second unrelocated Rhino -dependency. VotingPlugin bundles the relocated Bouncy Castle base provider used -by `BouncyCastleProvider` and `HttpTlsIdentity`, while excluding its unused -multi-release payloads and unrelated timestamping and other -protocol stacks. HTTP transport support explicitly owns its TLS implementation -and the remaining crypto classes; it does not rely on AdvancedCore to provide -them. +dependency. SimpleAPI's HTTP identity uses the JDK cryptography APIs, so the +downloadable plugin does not bundle Bouncy Castle. The package phase runs `PackagedArtifactTest` after shading. It opens the actual downloadable JAR, checks plugin resources and required relocated classes, and -rejects duplicate Rhino, raw Hikari/Folia, unused multi-release crypto payloads, -unused Bouncy Castle protocol packages, and Checker Framework annotations. It -creates both server and client TLS identities -from the packaged crypto classes. SQLite keeps its complete native platform set -so packaging changes do not narrow existing installations. -The test also caps the downloadable artifact at 30 MiB so dependency growth must -be reviewed explicitly. Release/deployment profiles +rejects duplicate Rhino, raw Hikari/Folia, external crypto providers, uncommon +SQLite native targets, and Checker Framework annotations. It creates both +server and client TLS identities from the packaged JDK-only implementation. + +SQLite keeps the Linux x86_64 native in the plugin for offline startup on the +common server platform. When SQLite is selected on another supported target, +VotingPlugin downloads the pinned `sqlite-jdbc` 3.53.4.0 artifact, verifies its +SHA-256 digest, extracts only that target's native into the plugin data folder, +loads it, and restores the JVM-wide Xerial loader properties. MySQL installations +and Linux x86_64 SQLite installations do not make this request. + +The test caps the downloadable artifact at 10 MiB so dependency growth must be +reviewed explicitly. Release/deployment profiles reuse this Shade setup; the artifact check follows their configured JAR name. Keep the downloadable VotingPlugin JAR as small as practical. Before adding a From 84cb2c3e475980a2433b2e01bb4b2688a9dc83b1 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:15:58 -0600 Subject: [PATCH 8/9] Make SQLite native provisioning reload safe --- .../util/SqliteNativeLibrary.java | 37 ++++++++++++++++++- .../util/SqliteNativeLibraryTest.java | 26 +++++++++++++ docs/jar-packaging.md | 11 ++++++ 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/SqliteNativeLibrary.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/SqliteNativeLibrary.java index 70802f3fa..fc3513ff5 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/SqliteNativeLibrary.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/SqliteNativeLibrary.java @@ -6,6 +6,7 @@ import java.net.URI; import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; +import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.nio.file.attribute.PosixFilePermission; @@ -13,6 +14,7 @@ import java.security.NoSuchAlgorithmException; import java.util.HexFormat; import java.util.Set; +import java.util.UUID; import java.util.jar.JarEntry; import java.util.jar.JarFile; @@ -25,6 +27,7 @@ public final class SqliteNativeLibrary { private static final URI DRIVER_URI = URI.create("https://maven-central.storage-download.googleapis.com/maven2/" + "org/xerial/sqlite-jdbc/3.53.4.0/" + DRIVER_FILE); private static final long MAX_DRIVER_BYTES = 16L * 1024L * 1024L; + private static final long STALE_NATIVE_MILLIS = 24L * 60L * 60L * 1000L; private SqliteNativeLibrary() { } @@ -53,14 +56,44 @@ static Path prepareNative(Path directory, String folder, ClassLoader resourceLoa if (resourceLoader.getResource(resource) != null) return null; Files.createDirectories(directory); Path driver = directory.resolve(DRIVER_FILE); - if (!hasExpectedDigest(driver, DRIVER_SHA256)) downloadVerified(driver, fetcher); - Path nativeDirectory = directory.resolve("sqlite-native").resolve(folder); + if (!hasExpectedDigest(driver, DRIVER_SHA256)) { + try { + downloadVerified(driver, fetcher); + } catch (IOException failure) { + throw new IOException("Unable to obtain the verified SQLite driver; pre-provision " + DRIVER_FILE + + " in the VotingPlugin libraries directory or configure org.sqlite.lib.path", failure); + } + } + Path platformDirectory = directory.resolve("sqlite-native").resolve(folder); + Files.createDirectories(platformDirectory); + cleanupStaleNativeCopies(platformDirectory); + Path nativeDirectory = platformDirectory.resolve("load-" + UUID.randomUUID()); Files.createDirectories(nativeDirectory); Path nativeLibrary = nativeDirectory.resolve(libraryName); extractVerifiedEntry(driver, resource, nativeLibrary); + nativeLibrary.toFile().deleteOnExit(); + nativeDirectory.toFile().deleteOnExit(); return nativeLibrary; } + private static void cleanupStaleNativeCopies(Path platformDirectory) { + try (var loads = Files.newDirectoryStream(platformDirectory, "load-*")) { + for (Path load : loads) { + if (!Files.isDirectory(load, LinkOption.NOFOLLOW_LINKS)) continue; + if (Files.getLastModifiedTime(load, LinkOption.NOFOLLOW_LINKS).toMillis() + > System.currentTimeMillis() - STALE_NATIVE_MILLIS) continue; + try (var files = Files.newDirectoryStream(load)) { + for (Path file : files) { + if (Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) Files.deleteIfExists(file); + } + } + Files.deleteIfExists(load); + } + } catch (IOException | SecurityException ignored) { + // A prior classloader may still own the native, especially on Windows. + } + } + private static void loadPreparedNative(Path nativeDirectory, String libraryName) throws IOException { synchronized (System.getProperties()) { String oldPath = System.getProperty("org.sqlite.lib.path"); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/SqliteNativeLibraryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/SqliteNativeLibraryTest.java index c54bb9ad1..38af95933 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/SqliteNativeLibraryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/SqliteNativeLibraryTest.java @@ -1,6 +1,7 @@ package com.bencodez.votingplugin.util; import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -41,4 +42,29 @@ void verifiesDriverAndExtractsUncommonNative() throws Exception { } } } + + @Test + void usesPreprovisionedVerifiedDriverWithoutFetching() throws Exception { + Path driver = Path.of(org.sqlite.JDBC.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + Files.createDirectories(directory); + Files.copy(driver, directory.resolve(SqliteNativeLibrary.DRIVER_FILE)); + try (URLClassLoader empty = new URLClassLoader(new URL[0], ClassLoader.getPlatformClassLoader())) { + Path prepared = SqliteNativeLibrary.prepareNative(directory, "Mac/x86_64", empty, + (source, target) -> { throw new AssertionError("verified pre-provisioned driver must be reused"); }); + assertTrue(Files.isRegularFile(prepared)); + } + } + + @Test + void extractsEachReloadToAUniqueNativePath() throws Exception { + Path driver = Path.of(org.sqlite.JDBC.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + try (URLClassLoader empty = new URLClassLoader(new URL[0], ClassLoader.getPlatformClassLoader())) { + SqliteNativeLibrary.ArtifactFetcher fetcher = (source, target) -> Files.copy(driver, target, + java.nio.file.StandardCopyOption.REPLACE_EXISTING); + Path first = SqliteNativeLibrary.prepareNative(directory, "Mac/x86_64", empty, fetcher); + Path second = SqliteNativeLibrary.prepareNative(directory, "Mac/x86_64", empty, fetcher); + assertNotEquals(first, second); + assertTrue(Files.isRegularFile(second)); + } + } } diff --git a/docs/jar-packaging.md b/docs/jar-packaging.md index 68704e1f1..3a9536b9f 100644 --- a/docs/jar-packaging.md +++ b/docs/jar-packaging.md @@ -24,6 +24,17 @@ SHA-256 digest, extracts only that target's native into the plugin data folder, loads it, and restores the JVM-wide Xerial loader properties. MySQL installations and Linux x86_64 SQLite installations do not make this request. +For an offline Windows, macOS, ARM, musl, or FreeBSD installation, pre-provision +the official `sqlite-jdbc-3.53.4.0.jar` as +`/libraries/sqlite-jdbc-3.53.4.0.jar`. Its SHA-256 +must be `bcb1f51e36f940867e83342f9efbf5968ac44a6bef4d397bb4af7b17b45cd2fb`; +VotingPlugin rejects any other content and then extracts only the current +platform's native without network access. Operators that already provision a +native may instead set both `org.sqlite.lib.path` and `org.sqlite.lib.name` as +JVM properties. Extracted natives use a unique load directory so a replacement +plugin classloader never reuses the prior classloader's JNI path; stale copies +are removed on a best-effort basis. + The test caps the downloadable artifact at 10 MiB so dependency growth must be reviewed explicitly. Release/deployment profiles reuse this Shade setup; the artifact check follows their configured JAR name. From 0474c8b7ef4f5872d25d0a4dba71fe5e7f28fd4c Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:28:43 -0600 Subject: [PATCH 9/9] Correct the packaging decision record --- .mex/events/decisions.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.mex/events/decisions.jsonl b/.mex/events/decisions.jsonl index a2f404760..16851696d 100644 --- a/.mex/events/decisions.jsonl +++ b/.mex/events/decisions.jsonl @@ -1 +1 @@ -{"timestamp":"2026-09-21T10:57:35.704Z","kind":"decision","message":"Preserve all SQLite native targets and the complete base Bouncy Castle provider. Reduce the JAR through unused Bouncy Castle multi-release payloads; enforce a 30 MiB budget and packaged TLS identity startup.","files":["VotingPlugin/pom.xml","docs/jar-packaging.md"],"cwd":".","source":"agent","status":"implemented"} +{"timestamp":"2026-09-21T10:57:35.704Z","kind":"decision","message":"Keep the common Linux x86_64 SQLite native in the downloadable JAR; provision other SQLite targets from the pinned, SHA-256-verified sqlite-jdbc artifact with a documented offline pre-provisioning path. Use SimpleAPI JDK-only TLS identity without Bouncy Castle, enforce a 10 MiB package gate, and alert on meaningful size growth.","files":["VotingPlugin/pom.xml","VotingPlugin/src/main/java/com/bencodez/votingplugin/util/SqliteNativeLibrary.java","docs/jar-packaging.md"],"cwd":".","source":"agent","status":"implemented"}