diff --git a/.mex/events/decisions.jsonl b/.mex/events/decisions.jsonl
new file mode 100644
index 000000000..16851696d
--- /dev/null
+++ b/.mex/events/decisions.jsonl
@@ -0,0 +1 @@
+{"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"}
diff --git a/AGENTS.md b/AGENTS.md
index 82c4f79f7..ddf7a3b93 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..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,9 +226,21 @@
- org.bouncycastle:*
+ org.xerial:sqlite-jdbc
- META-INF/versions/25/**
+
+ 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/**
@@ -238,6 +248,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/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..fc3513ff5
--- /dev/null
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/SqliteNativeLibrary.java
@@ -0,0 +1,216 @@
+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.LinkOption;
+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.UUID;
+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 static final long STALE_NATIVE_MILLIS = 24L * 60L * 60L * 1000L;
+
+ 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)) {
+ 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");
+ 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 982d96ab9..71d6c59bf 100644
--- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/packaging/PackagedArtifactTest.java
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/packaging/PackagedArtifactTest.java
@@ -18,6 +18,7 @@
/** Package-phase checks for the actual downloadable plugin artifact. */
public class PackagedArtifactTest {
+ private static final long MAX_DOWNLOAD_BYTES = 10L * 1024L * 1024L;
@Test
void containsOneRelocatedRuntimeWithoutUnusedHttpCrypto() throws Exception {
@@ -33,7 +34,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"));
@@ -53,10 +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/25/")));
+ 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");
}
- System.out.printf("VotingPlugin downloadable artifact: %,d bytes; duplicate Rhino and unused HTTP crypto absent%n",
+ 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, external crypto and uncommon SQLite natives absent%n",
Files.size(artifactPath));
}
@@ -73,6 +83,40 @@ 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 packagedJdkTlsIdentityWorksWithoutExternalCrypto(@TempDir Path directory) throws Exception {
+ URL jar = packagedJar().toUri().toURL();
+ try (URLClassLoader loader = new URLClassLoader(new URL[] { jar }, ClassLoader.getPlatformClassLoader())) {
+ 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));
+ 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));
+ }
+ }
+
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/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..38af95933
--- /dev/null
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/SqliteNativeLibraryTest.java
@@ -0,0 +1,70 @@
+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;
+
+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));
+ }
+ }
+ }
+
+ @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 2bf0136c5..3a9536b9f 100644
--- a/docs/jar-packaging.md
+++ b/docs/jar-packaging.md
@@ -8,13 +8,39 @@ 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. 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 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, 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.
+
+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.
+
+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.