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.