diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..6d3e90ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +**/target/ +.compat/ diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/ConfigDocument.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/ConfigDocument.java new file mode 100644 index 00000000..f570d27e --- /dev/null +++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/ConfigDocument.java @@ -0,0 +1,23 @@ +package com.bencodez.simpleapi.file.config; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.function.Consumer; + +/** + * Synchronous local-file configuration operations. Call from an I/O worker, not + * a server/entity thread. Reads and failed operations never create empty files + * or replace a last-good snapshot. The caller owns exclusive writes to the file; + * content revisions detect observed external edits, not cross-process file CAS. + */ +public interface ConfigDocument { + Path path(); + ConfigSnapshot snapshot(); + ConfigSnapshot reload() throws IOException; + /** + * Edits a private copy, persists it, then publishes the new snapshot. Any + * validation, stale-revision or pre-publication I/O failure leaves the current + * in-memory snapshot unchanged. The callback must not reenter document writes. + */ + ConfigSnapshot update(String expectedRevision, Consumer edit) throws IOException; +} diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/ConfigEditor.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/ConfigEditor.java new file mode 100644 index 00000000..1ee333c1 --- /dev/null +++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/ConfigEditor.java @@ -0,0 +1,15 @@ +package com.bencodez.simpleapi.file.config; + +/** + * A short-lived editor supplied by ConfigDocument.update. Retaining an editor + * does not grant access to future document state. Implementations reject writes + * after the callback returns. Values are plain scalar/list/string-keyed map trees; + * native player/item objects must be converted by the owning platform first. + */ +public interface ConfigEditor extends ConfigView { + /** Sets a value using the document's separator. Null removes the value. */ + void set(String path, Object value); + /** Sets a value using literal key segments, including keys containing dots. */ + void setAt(Object value, String... keys); + default void remove(String path) { set(path, null); } +} diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/ConfigSnapshot.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/ConfigSnapshot.java new file mode 100644 index 00000000..d345a134 --- /dev/null +++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/ConfigSnapshot.java @@ -0,0 +1,11 @@ +package com.bencodez.simpleapi.file.config; + +import java.util.Objects; + +/** Detached read view and opaque content revision used to reject stale edits. */ +public record ConfigSnapshot(String revision, ConfigView view) { + public ConfigSnapshot { + Objects.requireNonNull(revision, "revision"); + Objects.requireNonNull(view, "view"); + } +} diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/configurate/ConfigurateConfigView.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/configurate/ConfigurateConfigView.java new file mode 100644 index 00000000..8c5f44b2 --- /dev/null +++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/configurate/ConfigurateConfigView.java @@ -0,0 +1,133 @@ +package com.bencodez.simpleapi.file.config.configurate; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; + +import org.spongepowered.configurate.ConfigurationNode; + +import com.bencodez.simpleapi.file.config.ConfigView; + +/** + * Read-only view of a Configurate section. Scalar numbers/booleans are read + * strictly, rather than using Configurate's more permissive string coercions. + * This matches the Bukkit scalar/list rules used by the existing binder. + * + *

Paths use '.' by default; at(String...) uses literal segments for identifiers + * containing dots. An externally supplied node is a live view and the caller must + * coordinate its mutation. YamlConfigDocument supplies detached snapshots.

+ * + *

Getter/annotation defaults are supported. Bukkit's mutable default-section + * overlay and native serialized Bukkit objects are deliberately not emulated.

+ */ +public class ConfigurateConfigView implements ConfigView { + protected final ConfigurationNode node; + private final char separator; + + public ConfigurateConfigView(ConfigurationNode node) { this(node, '.'); } + + public ConfigurateConfigView(ConfigurationNode node, char separator) { + this.node = Objects.requireNonNull(node, "node"); + this.separator = separator; + } + + protected final String[] segments(String path) { + Objects.requireNonNull(path, "path"); + return path.isEmpty() ? new String[0] : path.split(Pattern.quote(String.valueOf(separator)), -1); + } + + protected final ConfigurationNode resolve(String... keys) { + Objects.requireNonNull(keys, "keys"); + ConfigurationNode current = node; + for (String key : keys) { + Objects.requireNonNull(key, "key"); + ConfigurationNode match = null; + // YAML numeric keys remain addressable as strings, without replacing + // them with a second, differently typed key when an editor writes. + for (Map.Entry entry : current.childrenMap().entrySet()) { + if (String.valueOf(entry.getKey()).equals(key)) { + if (match != null) throw new IllegalArgumentException("Ambiguous configuration key"); + match = entry.getValue(); + } + } + current = match == null ? current.node(key) : match; + } + return current; + } + + /** Returns a section using literal key segments, or null for an absent/non-map node. */ + public ConfigurateConfigView at(String... keys) { + ConfigurationNode child = resolve(keys); + return child.isMap() || child == node && child.isNull() + ? new ConfigurateConfigView(child, separator) : null; + } + + @Override public boolean contains(String path) { + return path.isEmpty() || !resolve(segments(path)).isNull(); + } + @Override public String getString(String path, String fallback) { + ConfigurationNode child = resolve(segments(path)); + if (child.isMap()) return fallback; // no Bukkit section toString emulation + Object value = child.raw(); + return value == null ? fallback : value.toString(); + } + @Override public boolean getBoolean(String path, boolean fallback) { + Object value = resolve(segments(path)).rawScalar(); + return value instanceof Boolean bool ? bool : fallback; + } + @Override public int getInt(String path, int fallback) { + Object value = resolve(segments(path)).rawScalar(); + return value instanceof Number number ? number.intValue() : fallback; + } + @Override public long getLong(String path, long fallback) { + Object value = resolve(segments(path)).rawScalar(); + return value instanceof Number number ? number.longValue() : fallback; + } + @Override public double getDouble(String path, double fallback) { + Object value = resolve(segments(path)).rawScalar(); + return value instanceof Number number ? number.doubleValue() : fallback; + } + @Override public List getStringList(String path) { + List result = new ArrayList<>(); + for (ConfigurationNode child : resolve(segments(path)).childrenList()) { + Object value = child.rawScalar(); + if (value instanceof String || value instanceof Number || value instanceof Boolean || value instanceof Character) + result.add(value.toString()); + } + return result; + } + @Override public List getIntegerList(String path) { + List result = new ArrayList<>(); + for (ConfigurationNode child : resolve(segments(path)).childrenList()) { + Object value = child.rawScalar(); + if (value instanceof Number number) result.add(number.intValue()); + else if (value instanceof Character character) result.add((int) character); + else if (value instanceof String string) { + try { result.add(Integer.parseInt(string)); } catch (NumberFormatException ignored) { } + } + } + return result; + } + @Override public boolean isConfigurationSection(String path) { return at(segments(path)) != null; } + @Override public ConfigurateConfigView getConfigurationSection(String path) { return at(segments(path)); } + @Override public Set getKeys(boolean deep) { + LinkedHashSet result = new LinkedHashSet<>(); + collectKeys(node, "", deep, result, 0); + return result; + } + private void collectKeys(ConfigurationNode parent, String prefix, boolean deep, Set keys, int depth) { + if (depth > 64) throw new IllegalArgumentException("Configuration nesting is too deep"); + Set siblings = new LinkedHashSet<>(); + for (Map.Entry entry : parent.childrenMap().entrySet()) { + String key = String.valueOf(entry.getKey()); + if (!siblings.add(key)) throw new IllegalArgumentException("Ambiguous configuration key"); + String path = prefix + key; + keys.add(path); + if (deep && entry.getValue().isMap()) collectKeys(entry.getValue(), path + separator, true, keys, depth + 1); + } + } +} diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/configurate/YamlConfigDocument.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/configurate/YamlConfigDocument.java new file mode 100644 index 00000000..fb79ca14 --- /dev/null +++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/file/config/configurate/YamlConfigDocument.java @@ -0,0 +1,270 @@ +package com.bencodez.simpleapi.file.config.configurate; + +import java.io.BufferedWriter; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.PosixFilePermission; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; + +import org.spongepowered.configurate.ConfigurationNode; +import org.spongepowered.configurate.yaml.NodeStyle; +import org.spongepowered.configurate.yaml.YamlConfigurationLoader; + +import com.bencodez.simpleapi.file.config.ConfigDocument; +import com.bencodez.simpleapi.file.config.ConfigEditor; +import com.bencodez.simpleapi.file.config.ConfigSnapshot; + +/** + * YAML document for administrator-owned local configuration. No file is created + * by open/reload. Updates use private copies, content revisions, a bounded staged + * write and an atomic replacement; there is no truncate-in-place fallback. + * + *

The parent directory must exist and be trusted. Target symlinks/non-regular + * files are rejected. The caller owns exclusive cross-process writes: revision + * checks detect observed edits but cannot make an atomic filesystem compare/swap. + * File bytes are forced before rename; power-loss durability of directory entries + * and preservation of non-POSIX ACLs are not promised.

+ * + *

Parsing uses Configurate's YAML loader and its parser policies. This is not + * a hostile YAML upload endpoint. Formatting/inline comments and native Bukkit + * serialized objects are not a lossless migration contract. Existing Bukkit and + * Velocity file implementations are not changed by this class.

+ */ +public final class YamlConfigDocument implements ConfigDocument { + public static final int DEFAULT_MAX_BYTES = 1024 * 1024; + private static final int MAX_NODES = 100_000; + private static final int MAX_DEPTH = 64; + private final Path path; + private final int maxBytes; + private ConfigurationNode root; + private String revision; + private boolean editing; + + private YamlConfigDocument(Path path, int maxBytes) { + this.path = path; + this.maxBytes = maxBytes; + } + + public static YamlConfigDocument open(Path path) throws IOException { return open(path, DEFAULT_MAX_BYTES); } + + public static YamlConfigDocument open(Path path, int maxBytes) throws IOException { + Objects.requireNonNull(path, "path"); + if (maxBytes < 1 || maxBytes > 16 * DEFAULT_MAX_BYTES) + throw new IllegalArgumentException("maxBytes must be between 1 byte and 16 MiB"); + Path absolute = path.toAbsolutePath().normalize(); + if (absolute.getFileName() == null) throw new IOException("A configuration filename is required"); + // Resolve the trusted parent once. Never create parent directories as a side effect of reading. + Path canonical = absolute.getParent().toRealPath().resolve(absolute.getFileName()); + YamlConfigDocument document = new YamlConfigDocument(canonical, maxBytes); + document.reload(); + return document; + } + + @Override public Path path() { return path; } + + @Override public synchronized ConfigSnapshot snapshot() { + return new ConfigSnapshot(revision, new ConfigurateConfigView(root.copy())); + } + + @Override public synchronized ConfigSnapshot reload() throws IOException { + if (editing) throw new IllegalStateException("Cannot reload inside an edit callback"); + byte[] bytes = readCurrent(); + ConfigurationNode candidate = parse(bytes == null ? new byte[0] : bytes); + ConfigSnapshot result = new ConfigSnapshot(revisionOf(bytes), new ConfigurateConfigView(candidate.copy())); + root = candidate; + revision = result.revision(); + return result; + } + + @Override public synchronized ConfigSnapshot update(String expectedRevision, Consumer edit) throws IOException { + Objects.requireNonNull(expectedRevision, "expectedRevision"); + Objects.requireNonNull(edit, "edit"); + if (editing) throw new IllegalStateException("Cannot nest document updates"); + if (!revision.equals(expectedRevision)) throw new IOException("Stale in-memory configuration revision"); + requireUnchanged(); + editing = true; + Editor editor = new Editor(root.copy()); + try { + try { edit.accept(editor); } finally { editor.active = false; } + validate(editor.node, 0, new int[1]); + ConfigurationNode candidate = editor.node.copy(); + byte[] encoded = serialize(candidate); + String nextRevision = revisionOf(encoded); + ConfigSnapshot result = new ConfigSnapshot(nextRevision, new ConfigurateConfigView(candidate.copy())); + replace(encoded); + root = candidate; + revision = nextRevision; + return result; + } finally { + editing = false; + } + } + + private void requireUnchanged() throws IOException { + if (!revision.equals(revisionOf(readCurrent()))) + throw new IOException("Configuration changed on disk; reload before applying an edit"); + } + + private byte[] readCurrent() throws IOException { + BasicFileAttributes attributes; + try { attributes = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); } + catch (NoSuchFileException missing) { return null; } + if (!attributes.isRegularFile() || attributes.isSymbolicLink()) + throw new IOException("Configuration target must be a regular file, not a symbolic link"); + if (attributes.size() > maxBytes) throw new IOException("Configuration exceeds byte limit"); + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + ByteBuffer buffer = ByteBuffer.allocate(8192); + while (channel.read(buffer) != -1) { + buffer.flip(); + int count = buffer.remaining(); + if (count > maxBytes - bytes.size()) throw new IOException("Configuration exceeds byte limit"); + bytes.write(buffer.array(), 0, count); + buffer.clear(); + } + return bytes.toByteArray(); + } + } + + private ConfigurationNode parse(byte[] bytes) throws IOException { + String text = StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(bytes)).toString(); + ConfigurationNode candidate = YamlConfigurationLoader.builder().buildAndLoadString(text); + if (candidate.isNull()) candidate.raw(new LinkedHashMap()); + if (!candidate.isMap()) throw new IOException("Configuration root must be a mapping"); + try { validate(candidate, 0, new int[1]); } + catch (IllegalArgumentException invalid) { throw new IOException("Unsupported configuration structure", invalid); } + return candidate; + } + + private byte[] serialize(ConfigurationNode candidate) throws IOException { + LimitedOutput output = new LimitedOutput(maxBytes); + try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8))) { + YamlConfigurationLoader.builder().nodeStyle(NodeStyle.BLOCK).sink(() -> writer).build().save(candidate); + } + return output.bytes.toByteArray(); + } + + private void replace(byte[] encoded) throws IOException { + requireUnchanged(); + Set permissions = null; + if (Files.getFileAttributeView(path.getParent(), java.nio.file.attribute.PosixFileAttributeView.class) != null) { + permissions = revision.equals("missing") + ? Set.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE) + : Files.getPosixFilePermissions(path, LinkOption.NOFOLLOW_LINKS); + } + Path temporary = Files.createTempFile(path.getParent(), ".simpleapi-config-", ".tmp"); + try { + if (permissions != null) Files.setPosixFilePermissions(temporary, permissions); + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) { + ByteBuffer buffer = ByteBuffer.wrap(encoded); + while (buffer.hasRemaining()) channel.write(buffer); + channel.force(true); + } + requireUnchanged(); + Files.move(temporary, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } finally { + Files.deleteIfExists(temporary); + } + } + + private static String revisionOf(byte[] bytes) { + if (bytes == null) return "missing"; + try { return "sha256:" + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); } + catch (NoSuchAlgorithmException impossible) { throw new IllegalStateException("SHA-256 unavailable", impossible); } + } + + private static void validate(ConfigurationNode node, int depth, int[] count) { + if (depth > MAX_DEPTH || ++count[0] > MAX_NODES) throw new IllegalArgumentException("Configuration structure exceeds limits"); + if (node.isMap()) { + Set keys = new LinkedHashSet<>(); + for (Map.Entry entry : node.childrenMap().entrySet()) { + Object key = entry.getKey(); + if (!(key instanceof String || key instanceof Number || key instanceof Boolean || key instanceof Character) + || !keys.add(key.toString())) throw new IllegalArgumentException("Unsupported or ambiguous configuration key"); + validate(entry.getValue(), depth + 1, count); + } + } else if (node.isList()) { + for (ConfigurationNode child : node.childrenList()) validate(child, depth + 1, count); + } + } + + private static Object copyValue(Object value, int depth, int[] count, IdentityHashMap ancestors) { + if (depth > MAX_DEPTH || ++count[0] > MAX_NODES) throw new IllegalArgumentException("Configuration value exceeds structure limits"); + if (value == null || value instanceof String || value instanceof Boolean + || value instanceof Byte || value instanceof Short || value instanceof Integer || value instanceof Long + || value instanceof Float || value instanceof Double || value instanceof java.math.BigInteger + || value instanceof java.math.BigDecimal) return value; + if (value instanceof Character character) return character.toString(); + if (ancestors.put(value, Boolean.TRUE) != null) throw new IllegalArgumentException("Cyclic configuration values are not supported"); + try { + if (value instanceof Map map) { + Map copy = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + if (!(entry.getKey() instanceof String key)) throw new IllegalArgumentException("Edited map keys must be strings"); + copy.put(key, copyValue(entry.getValue(), depth + 1, count, ancestors)); + } + return copy; + } + if (value instanceof List list) { + List copy = new ArrayList<>(); + for (Object child : list) copy.add(copyValue(child, depth + 1, count, ancestors)); + return copy; + } + throw new IllegalArgumentException("Only scalar, list and string-keyed map values are supported"); + } finally { ancestors.remove(value); } + } + + private static final class Editor extends ConfigurateConfigView implements ConfigEditor { + private final Thread owner = Thread.currentThread(); + private boolean active = true; + private Editor(ConfigurationNode node) { super(node); } + @Override public void set(String path, Object value) { setAt(value, segments(path)); } + @Override public void setAt(Object value, String... keys) { + if (!active || Thread.currentThread() != owner) throw new IllegalStateException("Editor is no longer active on this thread"); + Objects.requireNonNull(keys, "keys"); + if (keys.length == 0 || keys.length > MAX_DEPTH) throw new IllegalArgumentException("A nonempty bounded key path is required"); + for (String key : keys) if (key == null || key.isEmpty()) throw new IllegalArgumentException("Empty/null key segment"); + Object copy = copyValue(value, 0, new int[1], new IdentityHashMap<>()); + resolve(keys).raw(copy); + } + } + + private static final class LimitedOutput extends OutputStream { + private final int limit; + private final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + private LimitedOutput(int limit) { this.limit = limit; } + @Override public void write(int value) throws IOException { + if (bytes.size() >= limit) throw new IOException("Serialized configuration exceeds byte limit"); + bytes.write(value); + } + @Override public void write(byte[] value, int offset, int length) throws IOException { + if (length > limit - bytes.size()) throw new IOException("Serialized configuration exceeds byte limit"); + bytes.write(value, offset, length); + } + } +} diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/sql/mysql/config/MysqlConfigView.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/sql/mysql/config/MysqlConfigView.java new file mode 100644 index 00000000..42e958f4 --- /dev/null +++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/sql/mysql/config/MysqlConfigView.java @@ -0,0 +1,38 @@ +package com.bencodez.simpleapi.sql.mysql.config; + +import java.util.Objects; +import com.bencodez.simpleapi.file.config.ConfigView; +import com.bencodez.simpleapi.sql.mysql.DbType; + +/** Platform-neutral snapshot of the existing MySQL section keys and defaults. */ +public final class MysqlConfigView extends MysqlConfig { + public MysqlConfigView(ConfigView section) { + Objects.requireNonNull(section, "section"); + setTablePrefix(section.getString("Prefix", null)); + String tableName = section.getString("Name", ""); + if (tableName != null && !tableName.isEmpty()) setTableName(tableName); + setHostName(section.getString("Host", null)); + setPort(section.getInt("Port", 0)); + setUser(section.getString("Username", null)); + setPass(section.getString("Password", null)); + setDatabase(section.getString("Database", null)); + setLifeTime(section.getLong("MaxLifeTime", -1)); + setMaxThreads(Math.max(1, section.getInt("MaxConnections", 1))); + setMinimumIdle(section.getInt("MinimumIdle", 2)); + setIdleTimeoutMs(section.getLong("IdleTimeoutMs", 10 * 60_000L)); + setKeepaliveMs(section.getLong("KeepaliveMs", 5 * 60_000L)); + setValidationMs(section.getLong("ValidationMs", 5_000L)); + setLeakDetectMs(section.getLong("LeakDetectMs", 20_000L)); + setConnectionTimeout(section.getInt("ConnectionTimeout", 50_000)); + String type = section.getString("DbType", ""); + setDbType(type != null && !type.isEmpty() ? DbType.fromString(type) + : section.getBoolean("UseMariaDB", false) ? DbType.MARIADB : DbType.MYSQL); + setDriver(section.getString("Driver", "")); + setUseSSL(section.getBoolean("UseSSL", false)); + setPublicKeyRetrieval(section.getBoolean("PublicKeyRetrieval", false)); + setUseMariaDB(section.getBoolean("UseMariaDB", false)); + setLine(section.getString("Line", "")); + setDebug(section.getBoolean("Debug", false)); + setPoolName(section.getString("PoolName", "")); + } +} diff --git a/docs/shared-libraries.md b/docs/shared-libraries.md new file mode 100644 index 00000000..8751adb6 --- /dev/null +++ b/docs/shared-libraries.md @@ -0,0 +1,190 @@ +# Shared SimpleAPI libraries + +This follows the configuration binding foundation in PR #75. It adds buildable, +separately publishable, Bukkit-free artifacts and production configuration I/O. +It does not port VotingPlugin or AdvancedCore, and does not change their releases. + +## Artifacts + +| Artifact | Contents | Runtime dependencies | +| --- | --- | --- | +| `com.bencodez:simpleapi-core` | Existing annotation binder and annotation types, `ConfigView`, duration support, debug enum, SQL data values/columns; new document/editor/snapshot contracts | JDK 21 only | +| `com.bencodez:simpleapi-configurate` | `ConfigurateConfigView` and `YamlConfigDocument` | Core and Configurate YAML 4.2.0 | +| `com.bencodez:simpleapi-sql` | Existing JDBC/Hikari connection management, MySQL wrapper, abstract tables, queries and configuration model; new `MysqlConfigView` | Core and HikariCP 7.0.2 | +| `com.bencodez:simpleapi` | Existing full Bukkit/proxy distribution, including the shared classes | Existing dependency set, unchanged | + +The new modules currently use version `1.0.2-SNAPSHOT`, aligned with SimpleAPI. +They are libraries, not independently installed server mods. SQL drivers are still +selected/provided by the consuming application; publishing the SQL module does not +silently add a JDBC driver or change any existing database behavior/schema. + +### One maintained implementation, two packaging targets + +All implementation source stays under `SimpleAPI/src/main/java`, including the +new APIs. The shared module POMs stage explicit allow-lists into their own +`target/generated-sources/shared` directories. This is generated build input, +not a maintained source fork. Their javac source paths never include the rest of +the Bukkit tree. Source JARs contain the staged implementation as well. + +This layout deliberately preserves the old standalone build, source packages, +shading, and artifact coordinates while making the same implementation available +to native loaders. No existing class body, public signature, or legacy POM is +changed. Full distribution users can use the new APIs without adding new Maven +coordinates. + +**Do not package both representations of the same classes.** A native application +uses the shared artifacts and never the full `simpleapi` artifact. A Bukkit +application retains the full distribution. Future AdvancedCore common-module +integration must choose one representation of each SimpleAPI class in the final +Bukkit JAR, keep versions aligned, and verify the final shaded artifact's linkage. +Existing AdvancedCore/VotingPlugin builds do not consume the new thin modules and +need no dependency changes in this PR. + +The thin SQL artifact intentionally uses unrelocated HikariCP, whereas the full +SimpleAPI distribution already relocates HikariCP. Some existing SQL public +signatures expose Hikari types. Therefore excluding the thin dependencies in favor +of the full JAR is not sufficient by itself for common code compiled against those +signatures: the final Bukkit packaging must relocate the common callers and the +provided SQL implementations consistently. Prefer JDBC/JDK types at new shared +boundaries and add a final packaged Bukkit linkage test when making that consumer +change. This PR does not claim that future mixed packaging is already validated. + +A native mod packager must include its actual runtime dependency graph using the +target loader's supported mechanism. No changes to existing full-JAR shading are +made here. + +## Native configuration example + +```java +Path directory = Path.of("config", "votingplugin"); +Files.createDirectories(directory); // explicit application-owned setup +ConfigDocument document = YamlConfigDocument.open(directory.resolve("VoteSites.yml")); +ConfigSnapshot before = document.snapshot(); +ConfigSnapshot after = document.update(before.revision(), edit -> { + edit.set("PointsOnVote", 1); + edit.set("Rewards.Commands", List.of("give player minecraft:diamond")); + edit.setAt(Map.of("ServiceSite", "example.site"), "VoteSites", "example.site"); +}); +new AnnotationBinder().load(after.view(), options); +``` + +Shared section-annotated fields use `ConfigView`. Existing Bukkit callers keep +using `AnnotationHandler` and receive native `ConfigurationSection` fields as in +PR #75. The old API has no new overloads or altered coercion rules. + +`ConfigurateConfigView` reads numeric and boolean scalars strictly, matching the +Bukkit getter rules rather than converting arbitrary strings to numbers/bools. +String/integer lists preserve the legacy element conversions and empty-list +annotation fallbacks. It supports a selectable path separator and literal key +segments through `at(String...)`. Numeric YAML keys remain addressable as strings; +ambiguous string representations are rejected by document validation. + +Getter and annotation defaults work. Bukkit's mutable configuration-default tree, +section `toString()` output, and Bukkit-serialized item/player objects are not +emulated. Parse-format-specific behavior is not claimed to be a lossless Bukkit +YAML migration. Port native object representations in the owning platform adapter. + +## Document safety and ownership + +All methods are synchronous: call file operations on an I/O worker, not a game or +entity thread. `open` and `reload` never create a file or parent directory. Missing +files produce an empty snapshot with revision `missing`; invalid/unreadable files +throw. A failed reload retains the last-good in-memory configuration. + +An update takes an expected revision and edits a private copy. Callback/validation +failure discards that copy. The editor is limited to its callback thread and cannot +write after the callback. Input collections and returned snapshots are detached +from future document state. Edits accept ordinary scalar/list/string-keyed-map +values, not native server objects or cyclic graphs. + +The writer checks the expected revision and current disk content, bounds serialized +bytes, writes a temporary sibling file, forces its contents, and atomically replaces +the target. There is no truncate-in-place or non-atomic replacement fallback. Target +symlinks and non-regular files are rejected. Existing POSIX permission bits are +retained; new POSIX files are owner-read/write only. Temporary files are cleaned up. + +Default size limit: 1 MiB (explicitly configurable from 1 byte to 16 MiB). Parsed/ +edited trees are limited to 64 levels and 100,000 nodes. UTF-8 decoding reports +malformed input instead of silently replacing bytes. + +This is for administrator-owned local files. Parent directories must be trusted. +The caller must own cross-process writes: revision checks detect observed external +edits, but an unrelated process can race the final check/rename. This is not a +filesystem compare-and-swap, database transaction, or hostile YAML upload validator. +Parsing uses Configurate's YAML parser policies. Inline comments/formatting, file +ownership/non-POSIX ACLs and directory-entry power-loss durability are not promised. +Do not wire this directly to an untrusted network editor without the application's +path, authentication, YAML-complexity, revision and secret-masking controls. + +## Building and publishing + +Existing command and distribution remain available: + +```sh +mvn -B -f SimpleAPI/pom.xml package +``` + +Build/test/install all artifacts, including the full legacy distribution: + +```sh +mvn -B clean install +``` + +Build only shared modules and the parent (no server APIs needed): + +```sh +mvn -B -pl simpleapi-core,simpleapi-configurate,simpleapi-sql -am clean install +``` + +A release operator can publish the new artifacts and their parent using existing +Nexus credentials. CI does not run this command: + +```sh +mvn -B -Pdeploy-shared -pl simpleapi-core,simpleapi-configurate,simpleapi-sql -am deploy +``` + +Existing Jenkins/legacy publication remains untouched; it will not automatically +publish the new coordinates until its operator adds the shared publication step. +Keep all four artifacts' versions aligned when releasing. + +## Verification + +No additional shared-library GitHub Actions workflow is included. The existing +`maven.yml` remains unchanged and runs `mvn -B -f SimpleAPI/pom.xml package`; +it does not build or test the sibling shared modules. Run their tests explicitly +using the root or shared-module build commands above. + +The packaged-artifact probes remain available for manual validation. From the +repository root with JDK 21, Maven and Python 3 available, run: + +```sh +mvn -B -ntp clean install +mvn -B -ntp -nsu -pl simpleapi-core,simpleapi-configurate,simpleapi-sql -am org.apache.maven.plugins:maven-dependency-plugin:3.8.1:copy-dependencies -DincludeScope=runtime -DoutputDirectory=target/runtime-deps +mvn -B -ntp -nsu -f SimpleAPI/pom.xml org.apache.maven.plugins:maven-dependency-plugin:3.8.1:build-classpath -Dmdep.outputFile=target/compatibility-classpath.txt +python3 tools/verify-shared-artifacts.py +``` + +These commands install artifacts only into the local Maven repository, not a +remote repository. The probes check packaged shared-class boundaries, compile +and run a native consumer against the JARs, compare Bukkit/shared configuration +behavior, and check source staging against the maintained implementation. + +Cross-repository builds remain an explicit check for significant API changes or +release preparation, not an automatic dependency of every SimpleAPI PR. The +initial downstream validation results are recorded in PR #76 as historical +verification. SQL tests here cover configuration/linkage, not a live database +matrix. Live Minecraft/Folia/Forge/Fabric smoke tests remain application-level work. + +## What remains for the platform port + +SimpleAPI's reusable configuration/duration/data/SQL layer is now packaged for +consumption without Bukkit. AdvancedCore still needs its own shared runtime and +platform-specific execution boundaries. The existing Bukkit/Folia scheduler, +player, messaging, item, inventory and mixed `ArrayUtils` APIs remain untouched; +their game operations belong behind adapters during that extraction. Do not replace +entity-aware scheduling with a generic global-thread executor. + +The separate HTTP transport work from PR #73 is not copied or reworked here. It +merged into main while this change was being validated; the initial PR integration +build included it. Packaging that transport as a thin native dependency is a +separate follow-up, not part of these configuration/data/SQL artifacts. diff --git a/pom.xml b/pom.xml new file mode 100644 index 00000000..272edff6 --- /dev/null +++ b/pom.xml @@ -0,0 +1,47 @@ + + + 4.0.0 + com.bencodez + simpleapi-parent + 1.0.2-SNAPSHOT + pom + SimpleAPI shared libraries + + + simpleapi-core + simpleapi-configurate + simpleapi-sql + SimpleAPI + + + UTF-8 + 21 + 1.18.44 + 6.0.3 + 4.2.0 + + + org.projectlomboklombok${lombok.version}provided + org.junit.jupiterjunit-jupiter${junit.version}test + + + org.apache.maven.pluginsmaven-compiler-plugin3.15.0 + 21none + org.projectlomboklombok${lombok.version} + + + org.apache.maven.pluginsmaven-resources-plugin3.4.0 + org.apache.maven.pluginsmaven-surefire-plugin3.5.4 + org.apache.maven.pluginsmaven-jar-plugin3.5.0 + org.apache.maven.pluginsmaven-source-plugin3.3.1 + org.apache.maven.pluginsmaven-install-plugin3.1.4 + org.apache.maven.pluginsmaven-deploy-plugin3.1.4 + org.codehaus.mojobuild-helper-maven-plugin3.6.0 + + + deploy-shared + nexushttps://nexus.bencodez.com/repository/maven-snapshots/ + nexushttps://nexus.bencodez.com/repository/maven-releases/ + + diff --git a/simpleapi-configurate/pom.xml b/simpleapi-configurate/pom.xml new file mode 100644 index 00000000..afd68651 --- /dev/null +++ b/simpleapi-configurate/pom.xml @@ -0,0 +1,31 @@ + + + 4.0.0 + com.bencodezsimpleapi-parent1.0.2-SNAPSHOT + simpleapi-configurate + SimpleAPI Configurate adapter + + com.bencodezsimpleapi-core${project.version} + org.spongepoweredconfigurate-yaml${configurate.version} + org.junit.jupiterjunit-jupiter + + + org.apache.maven.pluginsmaven-resources-plugin + stage-existing-shared-sourcesgenerate-sourcescopy-resources + ${project.build.directory}/generated-sources/shared + ${project.basedir}/../SimpleAPI/src/main/javafalse + com/bencodez/simpleapi/file/config/configurate/*.java + + + + org.codehaus.mojobuild-helper-maven-plugin + existing-shared-sourcesgenerate-sourcesadd-source + ${project.build.directory}/generated-sources/shared + + + org.apache.maven.pluginsmaven-source-plugin + sourcespackagejar-no-fork + + + diff --git a/simpleapi-configurate/src/test/java/com/bencodez/simpleapi/file/config/configurate/ConfigurateConfigViewTest.java b/simpleapi-configurate/src/test/java/com/bencodez/simpleapi/file/config/configurate/ConfigurateConfigViewTest.java new file mode 100644 index 00000000..c7d95063 --- /dev/null +++ b/simpleapi-configurate/src/test/java/com/bencodez/simpleapi/file/config/configurate/ConfigurateConfigViewTest.java @@ -0,0 +1,125 @@ +package com.bencodez.simpleapi.file.config.configurate; + +import static org.junit.jupiter.api.Assertions.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.spongepowered.configurate.BasicConfigurationNode; +import org.spongepowered.configurate.ConfigurationNode; +import org.spongepowered.configurate.yaml.YamlConfigurationLoader; +import com.bencodez.simpleapi.file.annotation.*; +import com.bencodez.simpleapi.file.config.ConfigView; +import com.bencodez.simpleapi.time.ParsedDuration; + +class ConfigurateConfigViewTest { + @Test void scalarReadsPreserveTypesAndExplicitDefaults() { + ConfigurationNode root = BasicConfigurationNode.root(); + root.node("text").raw(42); + root.node("trueString").raw("true"); + root.node("numberString").raw("12"); + root.node("flag").raw(false); + root.node("empty").raw(""); + root.node("zero").raw(0); + root.node("large").raw(4_000_000_000L); + root.node("decimal").raw(2.9); + ConfigView view = new ConfigurateConfigView(root); + assertEquals("42", view.getString("text", "fallback")); + assertFalse(view.getBoolean("trueString", false)); + assertEquals(99, view.getInt("numberString", 99)); + assertFalse(view.getBoolean("flag", true)); + assertEquals("", view.getString("empty", "fallback")); + assertEquals(0, view.getInt("zero", 99)); + assertEquals(4_000_000_000L, view.getLong("large", -1)); + assertEquals(2, view.getInt("decimal", -1)); + assertEquals(2.9, view.getDouble("decimal", -1)); + assertEquals(99L, view.getLong("missing", 99)); + } + + @Test void missingReadsDoNotCreateNodes() { + ConfigurationNode root = BasicConfigurationNode.root(); + ConfigView view = new ConfigurateConfigView(root); + assertFalse(view.contains("missing")); + assertNull(view.getConfigurationSection("missing.child")); + assertEquals(List.of(), view.getStringList("missing.list")); + assertEquals(List.of(), view.getIntegerList("missing.list")); + assertEquals(Set.of(), view.getKeys(true)); + assertTrue(root.childrenMap().isEmpty()); + assertTrue(view.contains("")); + assertNotNull(view.getConfigurationSection("")); + } + + @Test void listsUseBukkitCompatibleElementConversions() { + ConfigurationNode root = BasicConfigurationNode.root(); + root.node("list").raw(Arrays.asList("2", 3, 4.75, true, Map.of("nested", 1), List.of("nested"), "bad", " 5")); + ConfigView view = new ConfigurateConfigView(root); + assertEquals(List.of("2", "3", "4.75", "true", "bad", " 5"), view.getStringList("list")); + assertEquals(List.of(2, 3, 4), view.getIntegerList("list")); + List copy = view.getStringList("list"); + copy.clear(); + assertEquals(6, view.getStringList("list").size()); + } + + @Test void sectionsKeysAndLiteralSegmentsAreDistinct() { + ConfigurationNode root = BasicConfigurationNode.root(); + root.node("sites", "some.site", "reward").raw("say voted"); + root.node("sites", "other").raw("url"); + ConfigurateConfigView view = new ConfigurateConfigView(root); + assertNull(view.getConfigurationSection("sites.some.site")); + assertEquals("say voted", view.at("sites", "some.site").getString("reward", "")); + assertEquals(Set.of("sites"), view.getKeys(false)); + assertEquals(Set.of("sites", "sites.some.site", "sites.some.site.reward", "sites.other"), view.getKeys(true)); + assertNull(view.getConfigurationSection("sites.other")); + assertEquals("say voted", new ConfigurateConfigView(root, '/').getString("sites/some.site/reward", "")); + } + + @Test void numericYamlKeysCanBeReadUsingStringPaths() throws Exception { + ConfigurationNode root = YamlConfigurationLoader.builder().buildAndLoadString("rewards:\n 10:\n command: hello\n"); + ConfigurateConfigView view = new ConfigurateConfigView(root); + assertEquals("hello", view.getString("rewards.10.command", "")); + assertEquals(Set.of("10"), view.getConfigurationSection("rewards").getKeys(false)); + } + + static class Values { + @ConfigDataString(path="message", secondPath="old.message") String message="initial"; + @ConfigDataInt(path="votes") int votes=3; + @ConfigDataBoolean(path="enabled", secondPath="disabled", secondPathInvert=true) boolean enabled; + @ConfigDataListString(path="commands", secondPath="old.commands") ArrayList commands=new ArrayList<>(List.of("default")); + @ConfigDataConfigurationSection(path="rewards") ConfigView rewards; + @ConfigDataKeys(path="rewards") Set keys; + @ConfigDataParsedDuration(path="delay") ParsedDuration delay; + } + + @Test void realYamlFeedsTheSharedBinderWithoutBukkit() throws Exception { + ConfigurationNode root = YamlConfigurationLoader.builder().buildAndLoadString(""" + old: + message: thanks + commands: [say voted] + votes: 0 + disabled: false + commands: [] + rewards: + first: command + delay: 3s + """); + Values target = new Values(); + new AnnotationBinder().load(new ConfigurateConfigView(root), target); + assertEquals("thanks", target.message); + assertEquals(0, target.votes); + assertTrue(target.enabled); + assertEquals(List.of("say voted"), target.commands); + assertEquals("command", target.rewards.getString("first", "")); + assertEquals(Set.of("first"), target.keys); + assertEquals(3000L, target.delay.getMillis()); + assertThrows(ClassNotFoundException.class, () -> Class.forName("org.bukkit.configuration.ConfigurationSection")); + } + + @Test void adapterRejectsNullArguments() { + assertThrows(NullPointerException.class, () -> new ConfigurateConfigView(null)); + ConfigurateConfigView view=new ConfigurateConfigView(BasicConfigurationNode.root()); + assertThrows(NullPointerException.class, () -> view.getInt(null, 1)); + } +} diff --git a/simpleapi-configurate/src/test/java/com/bencodez/simpleapi/file/config/configurate/YamlConfigDocumentTest.java b/simpleapi-configurate/src/test/java/com/bencodez/simpleapi/file/config/configurate/YamlConfigDocumentTest.java new file mode 100644 index 00000000..5dd4a674 --- /dev/null +++ b/simpleapi-configurate/src/test/java/com/bencodez/simpleapi/file/config/configurate/YamlConfigDocumentTest.java @@ -0,0 +1,217 @@ +package com.bencodez.simpleapi.file.config.configurate; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeTrue; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFileAttributeView; +import java.nio.file.attribute.PosixFilePermission; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import com.bencodez.simpleapi.file.config.ConfigEditor; +import com.bencodez.simpleapi.file.config.ConfigSnapshot; + +class YamlConfigDocumentTest { + @TempDir Path directory; + private Path path() { return directory.resolve("votes.yml"); } + private YamlConfigDocument initialized() throws Exception { + Files.writeString(path(), "points: 7\nunknown: preserve\n"); + return YamlConfigDocument.open(path()); + } + + @Test void readingMissingFileDoesNotCreateIt() throws Exception { + YamlConfigDocument document=YamlConfigDocument.open(path()); + assertEquals("missing", document.snapshot().revision()); + document.reload(); + assertFalse(Files.exists(path())); + assertEquals(Set.of(), document.snapshot().view().getKeys(false)); + } + @Test void persistsEditsAndReloadsWithStableRevisions() throws Exception { + YamlConfigDocument document=initialized(); + ConfigSnapshot before=document.snapshot(); + ConfigSnapshot after=document.update(before.revision(), e -> e.set("points", 8)); + assertEquals(7, before.view().getInt("points", -1)); + assertEquals(8, after.view().getInt("points", -1)); + assertEquals("preserve", after.view().getString("unknown", "")); + assertNotEquals(before.revision(), after.revision()); + assertEquals(after.revision(), YamlConfigDocument.open(path()).snapshot().revision()); + assertEquals(8, document.reload().view().getInt("points", -1)); + try (var children=Files.list(directory)) { assertEquals(List.of(path()), children.toList()); } + } + @Test void createsMissingFileOnlyWhenExplicitlyUpdated() throws Exception { + YamlConfigDocument document=YamlConfigDocument.open(path()); + document.update("missing", e -> e.set("rewards.commands", List.of("say voted"))); + assertTrue(Files.exists(path())); + assertEquals(List.of("say voted"), document.reload().view().getStringList("rewards.commands")); + } + @Test void malformedReloadPreservesLastGoodAndCannotBeOverwrittenByOldRevision() throws Exception { + YamlConfigDocument document=initialized(); + ConfigSnapshot before=document.snapshot(); + String corrupt="points: [unterminated"; + Files.writeString(path(), corrupt); + assertThrows(IOException.class, document::reload); + assertEquals(before.revision(), document.snapshot().revision()); + assertEquals(7, document.snapshot().view().getInt("points", -1)); + assertThrows(IOException.class, () -> document.update(before.revision(), e -> e.set("points", 9))); + assertEquals(corrupt, Files.readString(path())); + } + @Test void rejectsStaleInMemoryRevision() throws Exception { + YamlConfigDocument document=initialized(); + String before=document.snapshot().revision(); + document.update(before, e -> e.set("points", 8)); + assertThrows(IOException.class, () -> document.update(before, e -> e.set("points", 9))); + assertEquals(8, document.snapshot().view().getInt("points", -1)); + } + @Test void rejectsExternalEditAndRequiresExplicitReload() throws Exception { + YamlConfigDocument document=initialized(); + Files.writeString(path(), "points: 50\n"); + assertThrows(IOException.class, () -> document.update(document.snapshot().revision(), e -> e.set("points", 9))); + assertEquals("points: 50\n", Files.readString(path())); + assertEquals(50, document.reload().view().getInt("points", -1)); + } + @Test void detectsFileCreatedSinceMissingSnapshot() throws Exception { + YamlConfigDocument document=YamlConfigDocument.open(path()); + Files.writeString(path(), ""); + assertThrows(IOException.class, () -> document.update("missing", e -> e.set("points", 1))); + assertEquals("", Files.readString(path())); + } + @Test void editExceptionDiscardsPrivateCopy() throws Exception { + YamlConfigDocument document=initialized(); + String bytes=Files.readString(path()); + String revision=document.snapshot().revision(); + assertThrows(IllegalArgumentException.class, () -> document.update(revision, e -> { + e.set("points", 99); + throw new IllegalArgumentException("cancel"); + })); + assertEquals(bytes, Files.readString(path())); + assertEquals(revision, document.snapshot().revision()); + assertEquals(7, document.snapshot().view().getInt("points", -1)); + } + @Test void externalEditDuringCallbackIsNotOverwritten() throws Exception { + YamlConfigDocument document=initialized(); + assertThrows(IOException.class, () -> document.update(document.snapshot().revision(), e -> { + e.set("points", 99); + try { Files.writeString(path(), "points: 42\n"); } catch (IOException failure) { throw new RuntimeException(failure); } + })); + assertEquals("points: 42\n", Files.readString(path())); + assertEquals(7, document.snapshot().view().getInt("points", -1)); + } + @Test void editorsAndCallerCollectionsCannotMutatePublishedState() throws Exception { + YamlConfigDocument document=initialized(); + AtomicReference retained=new AtomicReference<>(); + List list=new ArrayList<>(List.of("say voted")); + document.update(document.snapshot().revision(), e -> { retained.set(e); e.set("commands", list); }); + list.add("unwanted"); + assertThrows(IllegalStateException.class, () -> retained.get().set("points", 123)); + assertEquals(List.of("say voted"), document.snapshot().view().getStringList("commands")); + } + @Test void rejectsNestedWritesAndReloads() throws Exception { + YamlConfigDocument document=initialized(); + assertThrows(IllegalStateException.class, () -> document.update(document.snapshot().revision(), e -> { + try { document.reload(); } catch (IOException failure) { throw new RuntimeException(failure); } + })); + assertThrows(IllegalStateException.class, () -> document.update(document.snapshot().revision(), e -> { + try { document.update(document.snapshot().revision(), next -> next.set("points", 10)); } + catch (IOException failure) { throw new RuntimeException(failure); } + })); + assertEquals(7, document.snapshot().view().getInt("points", -1)); + } + @Test void rejectsEditorWritesOnOtherThreads() throws Exception { + YamlConfigDocument document=initialized(); + AtomicReference observed=new AtomicReference<>(); + document.update(document.snapshot().revision(), e -> { + Thread thread=new Thread(() -> { try { e.set("points", 99); } catch (Throwable failure) { observed.set(failure); } }); + thread.start(); + try { thread.join(5000); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new RuntimeException(interrupted); } + assertFalse(thread.isAlive()); + }); + assertInstanceOf(IllegalStateException.class, observed.get()); + assertEquals(7, document.snapshot().view().getInt("points", -1)); + } + @Test void boundsInputAndOutputWithoutTruncatingOriginal() throws Exception { + Files.writeString(path(), "points: 7\n"); + YamlConfigDocument document=YamlConfigDocument.open(path(), 128); + String before=Files.readString(path()); + assertThrows(IOException.class, () -> document.update(document.snapshot().revision(), e -> e.set("large", "x".repeat(512)))); + assertEquals(before, Files.readString(path())); + Files.writeString(path(), "x".repeat(129)); + assertThrows(IOException.class, document::reload); + assertEquals(7, document.snapshot().view().getInt("points", -1)); + } + @Test void malformedUtf8DoesNotReplaceLastGood() throws Exception { + YamlConfigDocument document=initialized(); + Files.write(path(), new byte[] {(byte)0xc3, 0x28}); + assertThrows(IOException.class, document::reload); + assertEquals(7, document.snapshot().view().getInt("points", -1)); + } + @Test void refusesSymlinkAndDirectoryTargets() throws Exception { + Path actual=directory.resolve("actual.yml"); + Files.writeString(actual, "points: 4\n"); + Files.createSymbolicLink(path(), actual); + assertThrows(IOException.class, () -> YamlConfigDocument.open(path())); + assertEquals("points: 4\n", Files.readString(actual)); + assertThrows(IOException.class, () -> YamlConfigDocument.open(directory)); + } + @Test void refusesSymlinkIntroducedAfterOpen() throws Exception { + YamlConfigDocument document=initialized(); + Path other=directory.resolve("other.yml"); + Files.writeString(other, "do not overwrite\n"); + Files.delete(path()); + Files.createSymbolicLink(path(), other); + assertThrows(IOException.class, () -> document.update(document.snapshot().revision(), e -> e.set("points", 99))); + assertEquals("do not overwrite\n", Files.readString(other)); + } + @Test void preservesPosixModeAndDefaultsNewFilesToOwnerOnly() throws Exception { + assumeTrue(Files.getFileAttributeView(directory, PosixFileAttributeView.class) != null); + YamlConfigDocument document=initialized(); + Set mode=Set.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE, PosixFilePermission.GROUP_READ); + Files.setPosixFilePermissions(path(), mode); + document.update(document.snapshot().revision(), e -> e.set("points", 8)); + assertEquals(mode, Files.getPosixFilePermissions(path())); + Path fresh=directory.resolve("new.yml"); + YamlConfigDocument.open(fresh).update("missing", e -> e.set("points", 1)); + assertEquals(Set.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE), Files.getPosixFilePermissions(fresh)); + } + @Test void supportsLiteralAndNumericKeysWithoutCreatingDuplicateSections() throws Exception { + Files.writeString(path(), "rewards:\n 10:\n command: before\n"); + YamlConfigDocument document=YamlConfigDocument.open(path()); + document.update(document.snapshot().revision(), e -> { e.set("rewards.10.command", "after"); e.setAt(Map.of("command", "literal"), "some.site"); }); + assertEquals(Set.of("10"), document.reload().view().getConfigurationSection("rewards").getKeys(false)); + ConfigurateConfigView view=(ConfigurateConfigView)document.snapshot().view(); + assertEquals("after", view.getString("rewards.10.command", "")); + assertEquals("literal", view.at("some.site").getString("command", "")); + } + @Test void rejectsCyclesDeepTreesNativeObjectsAndOversizedNodeTrees() throws Exception { + YamlConfigDocument document=initialized(); + Map cycle=new LinkedHashMap<>();cycle.put("cycle", cycle); + assertThrows(IllegalArgumentException.class, () -> document.update(document.snapshot().revision(), e -> e.set("cycle", cycle))); + Object tree="leaf"; + for (int i=0;i<70;i++) tree=Map.of("child",tree); + Object tooDeep=tree; + assertThrows(IllegalArgumentException.class, () -> document.update(document.snapshot().revision(), e -> e.set("deep", tooDeep))); + assertThrows(IllegalArgumentException.class, () -> document.update(document.snapshot().revision(), e -> e.set("native", new Object()))); + assertThrows(IllegalArgumentException.class, () -> document.update(document.snapshot().revision(), e -> e.set("many", Collections.nCopies(100_001, 1)))); + assertEquals(7, document.snapshot().view().getInt("points", -1)); + } + @Test void nullRemovesOnlyTheSelectedValue() throws Exception { + YamlConfigDocument document=initialized(); + document.update(document.snapshot().revision(), e -> e.remove("points")); + assertFalse(document.reload().view().contains("points")); + assertEquals("preserve", document.snapshot().view().getString("unknown", "")); + } + @Test void rejectsMissingParentInvalidLimitAndScalarRoot() throws Exception { + assertThrows(IOException.class, () -> YamlConfigDocument.open(directory.resolve("missing/file.yml"))); + assertFalse(Files.exists(directory.resolve("missing"))); + assertThrows(IllegalArgumentException.class, () -> YamlConfigDocument.open(path(), 0)); + Files.writeString(path(), "[1, 2, 3]\n"); + assertThrows(IOException.class, () -> YamlConfigDocument.open(path())); + } +} diff --git a/simpleapi-core/pom.xml b/simpleapi-core/pom.xml new file mode 100644 index 00000000..d0f6e73b --- /dev/null +++ b/simpleapi-core/pom.xml @@ -0,0 +1,50 @@ + + + 4.0.0 + com.bencodezsimpleapi-parent1.0.2-SNAPSHOT + simpleapi-core + simpleapi-core + + org.projectlomboklomboktrue + org.junit.jupiterjunit-jupiter + + + + + org.apache.maven.pluginsmaven-resources-plugin + stage-existing-shared-sourcesgenerate-sourcescopy-resources + ${project.build.directory}/generated-sources/shared + ${project.basedir}/../SimpleAPI/src/main/javafalse + com/bencodez/simpleapi/file/config/*.java + com/bencodez/simpleapi/file/annotation/AnnotationBinder.java + com/bencodez/simpleapi/file/annotation/ConfigData*.java + com/bencodez/simpleapi/time/ParsedDuration.java + com/bencodez/simpleapi/debug/DebugLevel.java + com/bencodez/simpleapi/sql/Column.java + com/bencodez/simpleapi/sql/DataType.java + com/bencodez/simpleapi/sql/data/*.java + + + stage-existing-headless-testsgenerate-test-sourcescopy-resources + ${project.build.directory}/generated-test-sources/shared + ${project.basedir}/../SimpleAPI/src/test/javafalse + com/bencodez/simpleapi/tests/ParsedDurationTest.java + com/bencodez/simpleapi/tests/file/config/AnnotationBinderHeadlessTest.java + com/bencodez/simpleapi/tests/file/config/HeadlessBindingFixture.java + + + + org.codehaus.mojobuild-helper-maven-plugin + existing-shared-sourcesgenerate-sourcesadd-source + ${project.build.directory}/generated-sources/shared + + existing-headless-testsgenerate-test-sourcesadd-test-source + ${project.build.directory}/generated-test-sources/shared + + + org.apache.maven.pluginsmaven-source-plugin + sourcespackagejar-no-fork + + + diff --git a/simpleapi-core/src/test/java/com/bencodez/simpleapi/core/CoreClasspathTest.java b/simpleapi-core/src/test/java/com/bencodez/simpleapi/core/CoreClasspathTest.java new file mode 100644 index 00000000..b2526597 --- /dev/null +++ b/simpleapi-core/src/test/java/com/bencodez/simpleapi/core/CoreClasspathTest.java @@ -0,0 +1,17 @@ +package com.bencodez.simpleapi.core; + +import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Test; +import com.bencodez.simpleapi.sql.data.DataValueInt; +import com.bencodez.simpleapi.time.ParsedDuration; +import java.util.concurrent.TimeUnit; + +class CoreClasspathTest { + @Test void existingValueAndDurationClassesRemainUsableWithoutPlatforms() { + assertEquals(7, new DataValueInt(7).getInt()); + assertEquals(5000L, ParsedDuration.parse("5s", TimeUnit.SECONDS).getMillis()); + assertThrows(ClassNotFoundException.class, () -> Class.forName("org.bukkit.Bukkit")); + assertThrows(ClassNotFoundException.class, () -> Class.forName("net.md_5.bungee.api.ProxyServer")); + assertThrows(ClassNotFoundException.class, () -> Class.forName("com.velocitypowered.api.proxy.ProxyServer")); + } +} diff --git a/simpleapi-sql/pom.xml b/simpleapi-sql/pom.xml new file mode 100644 index 00000000..ce91646c --- /dev/null +++ b/simpleapi-sql/pom.xml @@ -0,0 +1,42 @@ + + + 4.0.0 + com.bencodezsimpleapi-parent1.0.2-SNAPSHOT + simpleapi-sql + simpleapi-sql + + org.projectlomboklomboktrue + org.junit.jupiterjunit-jupiter + com.bencodezsimpleapi-core${project.version} + com.zaxxerHikariCP7.0.2 + com.bencodezsimpleapi-configurate${project.version}test + + + + org.apache.maven.pluginsmaven-resources-plugin + stage-existing-shared-sourcesgenerate-sourcescopy-resources + ${project.build.directory}/generated-sources/shared + ${project.basedir}/../SimpleAPI/src/main/javafalse + com/bencodez/simpleapi/sql/mysql/AbstractSqlTable.java + com/bencodez/simpleapi/sql/mysql/ConnectionManager.java + com/bencodez/simpleapi/sql/mysql/DbType.java + com/bencodez/simpleapi/sql/mysql/MySQL.java + com/bencodez/simpleapi/sql/mysql/config/MysqlConfig.java + com/bencodez/simpleapi/sql/mysql/config/MysqlConfigView.java + com/bencodez/simpleapi/sql/mysql/queries/*.java + + + + + org.codehaus.mojobuild-helper-maven-plugin + existing-shared-sourcesgenerate-sourcesadd-source + ${project.build.directory}/generated-sources/shared + + + + org.apache.maven.pluginsmaven-source-plugin + sourcespackagejar-no-fork + + + diff --git a/simpleapi-sql/src/test/java/com/bencodez/simpleapi/sql/mysql/SharedSqlTest.java b/simpleapi-sql/src/test/java/com/bencodez/simpleapi/sql/mysql/SharedSqlTest.java new file mode 100644 index 00000000..4bba72a2 --- /dev/null +++ b/simpleapi-sql/src/test/java/com/bencodez/simpleapi/sql/mysql/SharedSqlTest.java @@ -0,0 +1,39 @@ +package com.bencodez.simpleapi.sql.mysql; + +import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Test; +import org.spongepowered.configurate.BasicConfigurationNode; +import com.bencodez.simpleapi.file.config.configurate.ConfigurateConfigView; +import com.bencodez.simpleapi.sql.mysql.config.MysqlConfigView; +import com.bencodez.simpleapi.sql.mysql.queries.Query; + +class SharedSqlTest { + @Test void readsExistingDefaultsWithoutAPlatformOrOpeningConnections() { + MysqlConfigView config=new MysqlConfigView(new ConfigurateConfigView(BasicConfigurationNode.root())); + assertEquals(1, config.getMaxThreads()); + assertEquals(-1, config.getLifeTime()); + assertEquals(2, config.getMinimumIdle()); + assertEquals(50_000, config.getConnectionTimeout()); + assertEquals(DbType.MYSQL, config.getDbType()); + assertFalse(config.isUseSSL()); + assertFalse(config.hasTableNameSet()); + assertThrows(ClassNotFoundException.class, () -> Class.forName("org.bukkit.Bukkit")); + } + @Test void preservesMariaDbFallbackAndExplicitDbSelection() { + var node=BasicConfigurationNode.root(); + node.node("UseMariaDB").raw(true); + node.node("MaxConnections").raw(0); + node.node("Name").raw("votes"); + MysqlConfigView maria=new MysqlConfigView(new ConfigurateConfigView(node)); + assertEquals(DbType.MARIADB, maria.getDbType()); + assertEquals(1, maria.getMaxThreads()); + assertEquals("votes", maria.getTableName()); + node.node("DbType").raw("POSTGRESQL"); + assertEquals(DbType.fromString("POSTGRESQL"), new MysqlConfigView(new ConfigurateConfigView(node)).getDbType()); + } + @Test void loadsTheExistingQueryAndConnectionApiWithoutBukkit() { + assertDoesNotThrow(() -> ConnectionManager.class.getDeclaredMethods()); + assertDoesNotThrow(() -> AbstractSqlTable.class.getDeclaredMethods()); + assertDoesNotThrow(() -> Query.class.getDeclaredMethods()); + } +} diff --git a/tools/ConfigParityProbe.java b/tools/ConfigParityProbe.java new file mode 100644 index 00000000..4f5d70b4 --- /dev/null +++ b/tools/ConfigParityProbe.java @@ -0,0 +1,54 @@ +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Objects; +import org.bukkit.configuration.MemoryConfiguration; +import org.spongepowered.configurate.BasicConfigurationNode; +import com.bencodez.simpleapi.file.config.configurate.ConfigurateConfigView; +import com.bencodez.simpleapi.sql.mysql.config.MysqlConfig; +import com.bencodez.simpleapi.sql.mysql.config.MysqlConfigSpigot; +import com.bencodez.simpleapi.sql.mysql.config.MysqlConfigView; + +/** Deliberately has both platforms' test inputs; never a native runtime dependency. */ +public final class ConfigParityProbe { + private static int checks; + private static void equal(Object expected,Object actual) { + checks++; + if (!Objects.equals(expected,actual)) throw new AssertionError("Configuration parity failed: expected="+expected+", actual="+actual); + } + public static void main(String[] arguments) throws Exception { + Object[] values={null,"", "text", "true", "12", false, true, 0, -2, 5L, 4_000_000_000L, 1.25, List.of(), List.of("1", 2, 3.75, true, "bad")}; + for (Object value:values) { + var bukkit=new MemoryConfiguration(); + var node=BasicConfigurationNode.root(); + bukkit.set("value",value); node.node("value").raw(value); + var view=new ConfigurateConfigView(node); + equal(bukkit.contains("value"),view.contains("value")); + equal(bukkit.getString("value","fallback"),view.getString("value","fallback")); + equal(bukkit.getBoolean("value",false),view.getBoolean("value",false)); + equal(bukkit.getBoolean("value",true),view.getBoolean("value",true)); + equal(bukkit.getInt("value",7),view.getInt("value",7)); + equal(bukkit.getLong("value",9L),view.getLong("value",9L)); + equal(bukkit.getDouble("value",2.5),view.getDouble("value",2.5)); + equal(bukkit.getStringList("value"),view.getStringList("value")); + equal(bukkit.getIntegerList("value"),view.getIntegerList("value")); + } + for (boolean populated:new boolean[]{false,true}) { + var bukkit=new MemoryConfiguration();var node=BasicConfigurationNode.root(); + if (populated) { + var data=new LinkedHashMap(); + data.put("Host","localhost");data.put("Port",3306);data.put("Username","fixture");data.put("Password","synthetic-fixture"); + data.put("Database","votes");data.put("Prefix","test_");data.put("Name","users");data.put("MaxConnections",0); + data.put("UseMariaDB",true);data.put("UseSSL",true);data.put("PoolName","fixture");data.put("ConnectionTimeout",1000); + for (var entry:data.entrySet()) { bukkit.set(entry.getKey(),entry.getValue()); node.node(entry.getKey()).raw(entry.getValue()); } + } + var oldConfig=new MysqlConfigSpigot(bukkit); + var sharedConfig=new MysqlConfigView(new ConfigurateConfigView(node)); + for (Method method:MysqlConfig.class.getDeclaredMethods()) + if (method.getParameterCount()==0 && (method.getName().startsWith("get") || method.getName().startsWith("is") || method.getName().startsWith("has"))) + equal(method.invoke(oldConfig),method.invoke(sharedConfig)); + } + System.out.println("Bukkit/shared configuration parity: "+checks+" comparisons passed"); + } +} diff --git a/tools/NativeConfigSmoke.java b/tools/NativeConfigSmoke.java new file mode 100644 index 00000000..6c035fb4 --- /dev/null +++ b/tools/NativeConfigSmoke.java @@ -0,0 +1,36 @@ +import java.nio.file.Files; +import java.util.List; +import com.bencodez.simpleapi.file.annotation.AnnotationBinder; +import com.bencodez.simpleapi.file.annotation.ConfigDataInt; +import com.bencodez.simpleapi.file.annotation.ConfigDataListString; +import com.bencodez.simpleapi.file.config.configurate.YamlConfigDocument; +import com.bencodez.simpleapi.sql.mysql.config.MysqlConfigView; + +/** Consumer compiled against packaged artifacts, not reactor target/classes. */ +public final class NativeConfigSmoke { + static final class Options { + @ConfigDataInt(path="PointsOnVote") int points; + @ConfigDataListString(path="Rewards.Commands") java.util.ArrayList commands; + } + public static void main(String[] arguments) throws Exception { + var directory=Files.createTempDirectory("simpleapi-native-smoke-"); + var file=directory.resolve("VoteSites.yml"); + try { + var document=YamlConfigDocument.open(file); + document.update("missing", edit -> { + edit.set("PointsOnVote", 3); + edit.set("Rewards.Commands", List.of("give player minecraft:diamond")); + edit.set("MySQL.Host", "localhost"); + }); + var snapshot=YamlConfigDocument.open(file).snapshot(); + var options=new Options(); + new AnnotationBinder().load(snapshot.view(), options); + if (options.points != 3 || options.commands.size() != 1) throw new AssertionError("Native binding failed"); + var sql=new MysqlConfigView(snapshot.view().getConfigurationSection("MySQL")); + if (!"localhost".equals(sql.getHostName()) || sql.getMaxThreads() != 1) throw new AssertionError("Native SQL configuration failed"); + try { Class.forName("org.bukkit.Bukkit"); throw new AssertionError("Bukkit was available"); } + catch (ClassNotFoundException expected) { } + System.out.println("Packaged native consumer: YAML, binder, persistence and SQL configuration passed"); + } finally { Files.deleteIfExists(file); Files.deleteIfExists(directory); } + } +} diff --git a/tools/SharedArtifactProbe.java b/tools/SharedArtifactProbe.java new file mode 100644 index 00000000..3b99cec6 --- /dev/null +++ b/tools/SharedArtifactProbe.java @@ -0,0 +1,41 @@ +import java.net.URLClassLoader; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.jar.JarFile; + +/** Link every published SimpleAPI class using only the selected runtime JARs. */ +public final class SharedArtifactProbe { + public static void main(String[] arguments) throws Exception { + var urls=new ArrayList(); + for (String argument:arguments) urls.add(Path.of(argument).toUri().toURL()); + int checked=0; + try (var loader=new URLClassLoader(urls.toArray(java.net.URL[]::new), ClassLoader.getPlatformClassLoader())) { + for (String forbidden:new String[]{"org.bukkit.Bukkit", "net.minecraft.server.MinecraftServer", "net.md_5.bungee.api.ProxyServer", "com.velocitypowered.api.proxy.ProxyServer"}) { + try { loader.loadClass(forbidden); throw new AssertionError("Platform leaked into native runtime: " + forbidden); } + catch (ClassNotFoundException expected) { } + } + var names=new LinkedHashSet(); + for (String argument:arguments) { + try (var jar=new JarFile(argument)) { + for (var entry:jar.stream().toList()) { + String name=entry.getName(); + if (name.startsWith("com/bencodez/simpleapi/") && name.endsWith(".class")) + names.add(name.substring(0,name.length()-6).replace('/', '.')); + } + } + } + if (names.isEmpty()) throw new AssertionError("No shared classes found"); + for (String name:names) { + Class type=Class.forName(name, false, loader); + type.getDeclaredConstructors(); type.getDeclaredFields(); type.getDeclaredMethods(); + type.getGenericSuperclass(); type.getGenericInterfaces(); + for (var method:type.getDeclaredMethods()) { + method.getGenericReturnType(); method.getGenericParameterTypes(); method.getGenericExceptionTypes(); + } + checked++; + } + } + System.out.println("Packaged headless linkage: " + checked + " classes passed"); + } +} diff --git a/tools/verify-shared-artifacts.py b/tools/verify-shared-artifacts.py new file mode 100644 index 00000000..0773d904 --- /dev/null +++ b/tools/verify-shared-artifacts.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Check packaged class boundaries and compile/run a downstream native consumer.""" +from pathlib import Path +import os +import subprocess +import tempfile +import zipfile + +ROOT=Path(__file__).resolve().parents[1] +MODULES=("simpleapi-core", "simpleapi-configurate", "simpleapi-sql") +FORBIDDEN=(b"org/bukkit/", b"org/spigotmc/", b"io/papermc/paper/", b"net/minecraft/", + b"net/fabricmc/", b"net/minecraftforge/", b"net/neoforged/", b"net/md_5/bungee/", + b"com/velocitypowered/api/") + +def main(): + jars=[] + for module in MODULES: + target=ROOT/module/"target" + candidates=[p for p in target.glob(f"{module}-*.jar") if not p.name.endswith(("-sources.jar","-javadoc.jar"))] + if len(candidates)!=1: raise RuntimeError(f"Expected one freshly built artifact for {module}") + jar=candidates[0] + with zipfile.ZipFile(jar) as archive: + classes=[n for n in archive.namelist() if n.endswith(".class")] + if not classes: raise RuntimeError(f"Empty artifact: {jar}") + for name in classes: + data=archive.read(name) + if any(prefix in data or prefix.decode() in name for prefix in FORBIDDEN): + raise RuntimeError(f"Platform dependency in {module}: {name}") + sources=jar.with_name(jar.stem+"-sources.jar") + if not sources.is_file(): raise RuntimeError(f"Missing source artifact: {sources}") + # Copied at build time from the existing implementation, never maintained twice. + for source in (target/"generated-sources/shared").rglob("*.java"): + relative=source.relative_to(target/"generated-sources/shared") + if source.read_bytes()!=(ROOT/"SimpleAPI/src/main/java"/relative).read_bytes(): + raise RuntimeError(f"Shared source drift: {relative}") + jars.append(jar) + jars.extend(sorted((target/"runtime-deps").glob("*.jar"))) + jars=list(dict.fromkeys(p.resolve() for p in jars)) + published_classes={} + for jar in jars: + with zipfile.ZipFile(jar) as archive: + for name in archive.namelist(): + if name.startswith("com/bencodez/simpleapi/") and name.endswith(".class"): + data=archive.read(name) + if name in published_classes and published_classes[name]!=data: + raise RuntimeError(f"Conflicting shared class bytes: {name} in {jar}") + published_classes[name]=data + if any(name.startswith(prefix.decode()) for prefix in FORBIDDEN): + raise RuntimeError(f"Platform classes in native runtime dependency: {jar.name}: {name}") + subprocess.run(["java",str(ROOT/"tools/SharedArtifactProbe.java"),*[str(p) for p in jars]],check=True) + classpath=os.pathsep.join(str(p) for p in jars) + with tempfile.TemporaryDirectory(prefix="simpleapi-consumer-") as output: + subprocess.run(["javac","--release","21","-proc:none","-cp",classpath,"-d",output,str(ROOT/"tools/NativeConfigSmoke.java")],check=True) + subprocess.run(["java","-cp",output+os.pathsep+classpath,"NativeConfigSmoke"],check=True) + legacy_cp_file=ROOT/"SimpleAPI/target/compatibility-classpath.txt" + legacy_cp=legacy_cp_file.read_text().strip() + parity_cp=classpath+os.pathsep+str(ROOT/"SimpleAPI/target/SimpleAPI.jar")+os.pathsep+legacy_cp + subprocess.run(["javac","--release","21","-proc:none","-cp",parity_cp,"-d",output,str(ROOT/"tools/ConfigParityProbe.java")],check=True) + subprocess.run(["java","-cp",output+os.pathsep+parity_cp,"ConfigParityProbe"],check=True) + print("Shared binary/source artifacts and runtime boundaries passed") + +if __name__=="__main__": main()