Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .mex/events/decisions.jsonl
Original file line number Diff line number Diff line change
@@ -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"}
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 17 additions & 6 deletions VotingPlugin/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -159,10 +159,6 @@
<shadedPattern>
${project.groupId}.votingplugin.bstats</shadedPattern>
</relocation>
<relocation>
<pattern>org.bouncycastle</pattern>
<shadedPattern>${project.groupId}.votingplugin.bouncycastle</shadedPattern>
</relocation>
<relocation>
<pattern>xyz.upperlevel.spigot</pattern>
<shadedPattern>
Expand Down Expand Up @@ -201,6 +197,8 @@
<exclude>com.google.*:*</exclude>
<!-- Server platforms provide SLF4J; embedding its package conflicts with NeoForge's module layer. -->
<exclude>org.slf4j:*</exclude>
<!-- SimpleAPI's JDK-only TLS identity does not need an external provider. -->
<exclude>org.bouncycastle:*</exclude>
</excludes>
</artifactSet>
<minimizeJar>false</minimizeJar>
Expand Down Expand Up @@ -228,16 +226,29 @@
</excludes>
</filter>
<filter>
<artifact>org.bouncycastle:*</artifact>
<artifact>org.xerial:sqlite-jdbc</artifact>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<excludes>
<exclude>META-INF/versions/25/**</exclude>
<!-- Keep the common Linux x86_64 native for offline startup. Other
targets are fetched with a pinned digest only when SQLite is selected. -->
<exclude>org/sqlite/native/FreeBSD/**</exclude>
<exclude>org/sqlite/native/Linux-Musl/**</exclude>
<exclude>org/sqlite/native/Linux/aarch64/**</exclude>
<exclude>org/sqlite/native/Linux/arm/**</exclude>
<exclude>org/sqlite/native/Linux/armv6/**</exclude>
<exclude>org/sqlite/native/Linux/armv7/**</exclude>
<exclude>org/sqlite/native/Linux/ppc64/**</exclude>
<exclude>org/sqlite/native/Linux/riscv64/**</exclude>
<exclude>org/sqlite/native/Linux/x86/**</exclude>
<exclude>org/sqlite/native/Mac/**</exclude>
<exclude>org/sqlite/native/Windows/**</exclude>
</excludes>
</filter>
<filter>
<artifact>*:*</artifact>
<excludes>
<!-- AdvancedCore and SimpleAPI also embed these classes. -->
<exclude>org/slf4j/**</exclude>
<exclude>org/checkerframework/**</exclude>
<exclude>META-INF/services/org.slf4j.spi.SLF4JServiceProvider</exclude>
<exclude>META-INF/maven/org.slf4j/**</exclude>
<exclude>META-INF/*.SF</exclude>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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")); }
Comment thread
BenCodez marked this conversation as resolved.
catch (IOException failure) { throw new IllegalStateException("Could not prepare the SQLite native library", failure); }
}

loadVoteSites();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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;
}

}
Loading
Loading