diff --git a/pom.xml b/pom.xml index b592756..1cb66ab 100644 --- a/pom.xml +++ b/pom.xml @@ -113,28 +113,33 @@ org.apache.commons commons-compress 1.26.2 + provided com.github.luben zstd-jni 1.5.6-3 + provided com.zaxxer HikariCP 4.0.3 + provided org.xerial sqlite-jdbc 3.46.0.0 + provided redis.clients jedis 5.1.3 + provided diff --git a/src/main/java/com/io/yutian/elementoriginlib/ElementOriginLib.java b/src/main/java/com/io/yutian/elementoriginlib/ElementOriginLib.java index bd2e2e3..836b4b1 100644 --- a/src/main/java/com/io/yutian/elementoriginlib/ElementOriginLib.java +++ b/src/main/java/com/io/yutian/elementoriginlib/ElementOriginLib.java @@ -3,21 +3,47 @@ package com.io.yutian.elementoriginlib; import com.io.yutian.elementoriginlib.lang.Lang; import com.io.yutian.elementoriginlib.listener.GuiHandlerListener; import com.io.yutian.elementoriginlib.listener.PlayerChatInputListener; +import com.io.yutian.elementoriginlib.logger.Logger; +import com.io.yutian.elementoriginlib.redis.RedisIO; +import net.byteflux.libby.*; +import net.byteflux.libby.logging.LogLevel; import org.bukkit.plugin.java.JavaPlugin; +import org.slf4j.LoggerFactory; +import redis.clients.jedis.Jedis; public final class ElementOriginLib extends JavaPlugin { + private static final org.slf4j.Logger log = LoggerFactory.getLogger(ElementOriginLib.class); private static ElementOriginLib instance; + private static Logger logger = Logger.getLogger(ElementOriginLib.class); + @Override public void onEnable() { instance = this; + loadLibraries(); + Lang.registerLangFile(this); Lang.reload(); new GuiHandlerListener(this); new PlayerChatInputListener(this); + + logger.info("Successfully load ElementOriginLib v"+getPluginMeta().getVersion()); + } + + private void loadLibraries() { + logger.info("Loading libraries..."); + LibraryManager libraryManager = new BukkitLibraryManager(this); + libraryManager.addMavenCentral(); + libraryManager.setLogLevel(LogLevel.WARN); + libraryManager.loadLibrary(Library.builder().groupId("org{}apache{}commons").artifactId("commons-compress").version("1.26.2").build()); + libraryManager.loadLibrary(Library.builder().groupId("com{}github{}luben").artifactId("zstd-jni").version("1.5.6-3").build()); + libraryManager.loadLibrary(Library.builder().groupId("com{}zaxxer").artifactId("HikariCP").version("4.0.3").build()); + libraryManager.loadLibrary(Library.builder().groupId("org{}xerial").artifactId("sqlite-jdbc").version("3.46.0.0").build()); + libraryManager.loadLibrary(Library.builder().groupId("redis{}clients").id("jedis").artifactId("jedis").version("5.1.3").build()); + logger.info("Successfully loaded libraries."); } @Override diff --git a/src/main/java/com/io/yutian/elementoriginlib/logger/Logger.java b/src/main/java/com/io/yutian/elementoriginlib/logger/Logger.java new file mode 100644 index 0000000..a6c4ac9 --- /dev/null +++ b/src/main/java/com/io/yutian/elementoriginlib/logger/Logger.java @@ -0,0 +1,64 @@ +package com.io.yutian.elementoriginlib.logger; + +import org.bukkit.Bukkit; + +import java.util.logging.Level; + +public class Logger { + + private Class clazz; + protected String formatClassName; + + private Logger(Class clazz) { + this.clazz = clazz; + this.formatClassName = getFormatClassName(clazz, 2); + } + + public String getFormatClassName() { + return formatClassName; + } + + protected String getFormatClassName(Class clazz, int keepChars) { + String className = clazz.getName(); + String[] parts = className.split("\\."); + StringBuilder abbreviated = new StringBuilder(); + for (int i = 0; i < parts.length; i++) { + if (i < parts.length - 1) { + abbreviated.append(parts[i].substring(0, Math.min(keepChars, parts[i].length()))).append("."); + } else { + abbreviated.append(parts[i]); + } + } + return abbreviated.toString(); + } + + private String getPrefix(String type) { + return "[" + clazz.getSimpleName() +"] "; + } + + public void info(String msg) { + Bukkit.getLogger().info(getPrefix("INFO")+ msg); + } + + public void warn(String msg) { + Bukkit.getLogger().warning(getPrefix("WARN") + msg); + } + + public void error(String msg) { + Bukkit.getLogger().log(Level.SEVERE, getPrefix("ERROR") + msg); + } + + public void fatal(String msg) { + Bukkit.getLogger().log(Level.SEVERE, "[!!!] "+getPrefix("FATAL") + msg); + } + + public void debug(String msg) { + Bukkit.getLogger().finer(getPrefix("DEBUG") + msg); + } + + + public static Logger getLogger(Class clazz) { + return new Logger(clazz); + } + +} diff --git a/src/main/java/com/io/yutian/elementoriginlib/nbt/NBTItem.java b/src/main/java/com/io/yutian/elementoriginlib/nbt/NBTItem.java index bd76d73..5af509c 100644 --- a/src/main/java/com/io/yutian/elementoriginlib/nbt/NBTItem.java +++ b/src/main/java/com/io/yutian/elementoriginlib/nbt/NBTItem.java @@ -1,7 +1,7 @@ package com.io.yutian.elementoriginlib.nbt; import net.minecraft.nbt.NBTTagCompound; -import org.bukkit.craftbukkit.v1_20_R1.inventory.CraftItemStack; +import org.bukkit.craftbukkit.v1_20_R3.inventory.CraftItemStack; import org.bukkit.inventory.ItemStack; import org.jetbrains.annotations.NotNull; diff --git a/src/main/java/com/io/yutian/elementoriginlib/serialize/SerializeHelper.java b/src/main/java/com/io/yutian/elementoriginlib/serialize/SerializeHelper.java index c8577da..8502524 100644 --- a/src/main/java/com/io/yutian/elementoriginlib/serialize/SerializeHelper.java +++ b/src/main/java/com/io/yutian/elementoriginlib/serialize/SerializeHelper.java @@ -4,7 +4,7 @@ import com.io.yutian.elementoriginlib.exception.SerializeException; import com.io.yutian.elementoriginlib.serialize.serializers.ItemStackSerializer; import com.io.yutian.elementoriginlib.serialize.serializers.UUIDSerializer; import com.io.yutian.elementoriginlib.util.ReflectionUtil; -import org.bukkit.craftbukkit.v1_20_R1.inventory.CraftItemStack; +import org.bukkit.craftbukkit.v1_20_R3.inventory.CraftItemStack; import org.bukkit.inventory.ItemStack; import org.json.JSONArray; import org.json.JSONObject; diff --git a/src/main/java/com/io/yutian/elementoriginlib/serialize/serializers/ItemStackSerializer.java b/src/main/java/com/io/yutian/elementoriginlib/serialize/serializers/ItemStackSerializer.java index c79c0ac..1dd7930 100644 --- a/src/main/java/com/io/yutian/elementoriginlib/serialize/serializers/ItemStackSerializer.java +++ b/src/main/java/com/io/yutian/elementoriginlib/serialize/serializers/ItemStackSerializer.java @@ -3,7 +3,7 @@ package com.io.yutian.elementoriginlib.serialize.serializers; import com.io.yutian.elementoriginlib.serialize.Serializer; import com.mojang.brigadier.exceptions.CommandSyntaxException; import net.minecraft.nbt.*; -import org.bukkit.craftbukkit.v1_20_R1.inventory.CraftItemStack; +import org.bukkit.craftbukkit.v1_20_R3.inventory.CraftItemStack; import org.bukkit.inventory.ItemStack; import org.json.JSONArray; import org.json.JSONObject; @@ -52,7 +52,7 @@ public class ItemStackSerializer implements Serializer { return tagByteArray.e(); case 8: NBTTagString nbtTagString = (NBTTagString) nbtBase; - return nbtTagString.m_(); + return nbtTagString.t_(); case 9: NBTTagList nbtTagList = (NBTTagList) nbtBase; JSONArray jsonArray = new JSONArray(); diff --git a/src/main/java/net/byteflux/libby/BukkitLibraryManager.java b/src/main/java/net/byteflux/libby/BukkitLibraryManager.java new file mode 100644 index 0000000..203e50e --- /dev/null +++ b/src/main/java/net/byteflux/libby/BukkitLibraryManager.java @@ -0,0 +1,50 @@ +package net.byteflux.libby; + +import net.byteflux.libby.classloader.URLClassLoaderHelper; +import net.byteflux.libby.logging.adapters.JDKLogAdapter; +import org.bukkit.plugin.Plugin; + +import java.net.URLClassLoader; +import java.nio.file.Path; + +import static java.util.Objects.requireNonNull; + +/** + * A runtime dependency manager for Bukkit plugins. + */ +public class BukkitLibraryManager extends LibraryManager { + /** + * Plugin classpath helper + */ + private final URLClassLoaderHelper classLoader; + + /** + * Creates a new Bukkit library manager. + * + * @param plugin the plugin to manage + */ + public BukkitLibraryManager(Plugin plugin) { + this(plugin, "lib"); + } + + /** + * Creates a new Bukkit library manager. + * + * @param plugin the plugin to manage + * @param directoryName download directory name + */ + public BukkitLibraryManager(Plugin plugin, String directoryName) { + super(new JDKLogAdapter(requireNonNull(plugin, "plugin").getLogger()), plugin.getDataFolder().toPath(), directoryName); + classLoader = new URLClassLoaderHelper((URLClassLoader) plugin.getClass().getClassLoader(), this); + } + + /** + * Adds a file to the Bukkit plugin's classpath. + * + * @param file the file to add + */ + @Override + protected void addToClasspath(Path file) { + classLoader.addToClasspath(file); + } +} diff --git a/src/main/java/net/byteflux/libby/LibbyProperties.java b/src/main/java/net/byteflux/libby/LibbyProperties.java new file mode 100644 index 0000000..8d5dcc4 --- /dev/null +++ b/src/main/java/net/byteflux/libby/LibbyProperties.java @@ -0,0 +1,16 @@ +package net.byteflux.libby; + +/** + * Filtered Maven properties and other related constants. + */ +public class LibbyProperties { + /** + * Project version + */ + public static final String VERSION = "${project.version}"; + + /** + * User agent string to use when downloading libraries + */ + public static final String HTTP_USER_AGENT = "libby/" + VERSION; +} diff --git a/src/main/java/net/byteflux/libby/Library.java b/src/main/java/net/byteflux/libby/Library.java new file mode 100644 index 0000000..8486f51 --- /dev/null +++ b/src/main/java/net/byteflux/libby/Library.java @@ -0,0 +1,533 @@ +package net.byteflux.libby; + +import net.byteflux.libby.relocation.Relocation; + +import java.util.Base64; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedList; +import java.util.UUID; + +import static java.util.Objects.requireNonNull; + +/** + * An immutable representation of a Maven artifact that can be downloaded, + * relocated and then loaded into a plugin's classpath at runtime. + * + * @see #builder() + */ +public class Library { + /** + * Direct download URLs for this library + */ + private final Collection urls; + + /** + * Repository URLs for this library + */ + private final Collection repositories; + + /** + * Library id (used by Isolated Class Loaders) + */ + private final String id; + + /** + * Maven group ID + */ + private final String groupId; + + /** + * Maven artifact ID + */ + private final String artifactId; + + /** + * Artifact version + */ + private final String version; + + /** + * Artifact classifier + */ + private final String classifier; + + /** + * Binary SHA-256 checksum for this library's jar file + */ + private final byte[] checksum; + + /** + * Jar relocations to apply + */ + private final Collection relocations; + + /** + * Relative Maven path to this library's artifact + */ + private final String path; + + /** + * Relative partial Maven path to this library + */ + private final String partialPath; + + /** + * Relative path to this library's relocated jar + */ + private final String relocatedPath; + + /** + * Should this library be loaded in an isolated class loader? + */ + private final boolean isolatedLoad; + + /** + * Creates a new library. + * + * @param urls direct download URLs + * @param id library ID + * @param groupId Maven group ID + * @param artifactId Maven artifact ID + * @param version artifact version + * @param classifier artifact classifier or null + * @param checksum binary SHA-256 checksum or null + * @param relocations jar relocations or null + * @param isolatedLoad isolated load for this library + */ + private Library(Collection urls, + String id, + String groupId, + String artifactId, + String version, + String classifier, + byte[] checksum, + Collection relocations, + boolean isolatedLoad) { + + this(urls, null, id, groupId, artifactId, version, classifier, checksum, relocations, isolatedLoad); + } + + /** + * Creates a new library. + * + * @param urls direct download URLs + * @param repositories repository URLs + * @param id library ID + * @param groupId Maven group ID + * @param artifactId Maven artifact ID + * @param version artifact version + * @param classifier artifact classifier or null + * @param checksum binary SHA-256 checksum or null + * @param relocations jar relocations or null + * @param isolatedLoad isolated load for this library + */ + private Library(Collection urls, + Collection repositories, + String id, + String groupId, + String artifactId, + String version, + String classifier, + byte[] checksum, + Collection relocations, + boolean isolatedLoad) { + + this.urls = urls != null ? Collections.unmodifiableList(new LinkedList<>(urls)) : Collections.emptyList(); + this.id = id != null ? id : UUID.randomUUID().toString(); + this.groupId = requireNonNull(groupId, "groupId").replace("{}", "."); + this.artifactId = requireNonNull(artifactId, "artifactId"); + this.version = requireNonNull(version, "version"); + this.classifier = classifier; + this.checksum = checksum; + this.relocations = relocations != null ? Collections.unmodifiableList(new LinkedList<>(relocations)) : Collections.emptyList(); + + this.partialPath = this.groupId.replace('.', '/') + '/' + artifactId + '/' + version + '/'; + String path = this.partialPath + artifactId + '-' + version; + if (hasClassifier()) { + path += '-' + classifier; + } + + this.path = path + ".jar"; + + this.repositories = repositories != null ? Collections.unmodifiableList(new LinkedList<>(repositories)) : Collections.emptyList(); + relocatedPath = hasRelocations() ? path + "-relocated.jar" : null; + this.isolatedLoad = isolatedLoad; + } + + /** + * Gets the direct download URLs for this library. + * + * @return direct download URLs + */ + public Collection getUrls() { + return urls; + } + + /** + * Gets the repositories URLs for this library. + * + * @return repositories URLs + */ + public Collection getRepositories() { + return repositories; + } + + /** + * Gets the library ID + * + * @return the library id + */ + public String getId() { + return id; + } + + /** + * Gets the Maven group ID for this library. + * + * @return Maven group ID + */ + public String getGroupId() { + return groupId; + } + + /** + * Gets the Maven artifact ID for this library. + * + * @return Maven artifact ID + */ + public String getArtifactId() { + return artifactId; + } + + /** + * Gets the artifact version for this library. + * + * @return artifact version + */ + public String getVersion() { + return version; + } + + /** + * Gets the artifact classifier for this library. + * + * @return artifact classifier or null + */ + public String getClassifier() { + return classifier; + } + + /** + * Gets whether this library has an artifact classifier. + * + * @return true if library has classifier, false otherwise + */ + public boolean hasClassifier() { + return classifier != null; + } + + /** + * Gets the binary SHA-256 checksum of this library's jar file. + * + * @return checksum or null + */ + public byte[] getChecksum() { + return checksum; + } + + /** + * Gets whether this library has a checksum. + * + * @return true if library has checksum, false otherwise + */ + public boolean hasChecksum() { + return checksum != null; + } + + /** + * Gets the jar relocations to apply to this library. + * + * @return jar relocations to apply + */ + public Collection getRelocations() { + return relocations; + } + + /** + * Gets whether this library has any jar relocations. + * + * @return true if library has relocations, false otherwise + */ + public boolean hasRelocations() { + return !relocations.isEmpty(); + } + + /** + * Gets the relative Maven path to this library's artifact. + * + * @return relative Maven path for this library + */ + public String getPath() { + return path; + } + + /** + * Gets the relative partial Maven path to this library. + * + * @return relative partial Maven path for this library + */ + public String getPartialPath() { + return partialPath; + } + + /** + * Gets the relative path to this library's relocated jar. + * + * @return path to relocated artifact or null if has no relocations + */ + public String getRelocatedPath() { + return relocatedPath; + } + + /** + * Is the library loaded isolated? + * + * @return true if the library is loaded isolated + */ + public boolean isIsolatedLoad() { + return isolatedLoad; + } + + /** + * Whether the library is a snapshot. + * + * @return whether the library is a snapshot. + */ + public boolean isSnapshot() { + return version.endsWith("-SNAPSHOT"); + } + + /** + * Gets a concise, human-readable string representation of this library. + * + * @return string representation + */ + @Override + public String toString() { + String name = groupId + ':' + artifactId + ':' + version; + if (hasClassifier()) { + name += ':' + classifier; + } + + return name; + } + + /** + * Creates a new library builder. + * + * @return new library builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Due to the constructor complexity of an immutable {@link Library}, + * instead this fluent builder is used to configure and then construct + * a new library. + */ + public static class Builder { + /** + * Direct download URLs for this library + */ + private final Collection urls = new LinkedList<>(); + + /** + * Repository URLs for this library + */ + private final Collection repositories = new LinkedList<>(); + + /** + * The library ID + */ + private String id; + + /** + * Maven group ID + */ + private String groupId; + + /** + * Maven artifact ID + */ + private String artifactId; + + /** + * Artifact version + */ + private String version; + + /** + * Artifact classifier + */ + private String classifier; + + /** + * Binary SHA-256 checksum for this library's jar file + */ + private byte[] checksum; + + /** + * Isolated load + */ + private boolean isolatedLoad; + + /** + * Jar relocations to apply + */ + private final Collection relocations = new LinkedList<>(); + + /** + * Adds a direct download URL for this library. + * + * @param url direct download URL + * @return this builder + */ + public Builder url(String url) { + urls.add(requireNonNull(url, "url")); + return this; + } + + /** + * Adds a repository URL for this library. + *

Most common repositories can be found in {@link Repositories} class as constants. + *

Note that repositories should be preferably added to the {@link LibraryManager} via {@link LibraryManager#addRepository(String)}. + * + * @param url repository URL + * @return this builder + */ + public Builder repository(String url) { + repositories.add(requireNonNull(url, "repository").endsWith("/") ? url : url + '/'); + return this; + } + + /** + * Sets the id for this library. + * + * @param id the ID + * @return this builder + */ + public Builder id(String id) { + this.id = id != null ? id : UUID.randomUUID().toString(); + return this; + } + + /** + * Sets the Maven group ID for this library. + * + * @param groupId Maven group ID + * @return this builder + */ + public Builder groupId(String groupId) { + this.groupId = requireNonNull(groupId, "groupId"); + return this; + } + + /** + * Sets the Maven artifact ID for this library. + * + * @param artifactId Maven artifact ID + * @return this builder + */ + public Builder artifactId(String artifactId) { + this.artifactId = requireNonNull(artifactId, "artifactId"); + return this; + } + + /** + * Sets the artifact version for this library. + * + * @param version artifact version + * @return this builder + */ + public Builder version(String version) { + this.version = requireNonNull(version, "version"); + return this; + } + + /** + * Sets the artifact classifier for this library. + * + * @param classifier artifact classifier + * @return this builder + */ + public Builder classifier(String classifier) { + this.classifier = requireNonNull(classifier, "classifier"); + return this; + } + + /** + * Sets the binary SHA-256 checksum for this library. + * + * @param checksum binary SHA-256 checksum + * @return this builder + */ + public Builder checksum(byte[] checksum) { + this.checksum = requireNonNull(checksum, "checksum"); + return this; + } + + /** + * Sets the Base64-encoded SHA-256 checksum for this library. + * + * @param checksum Base64-encoded SHA-256 checksum + * @return this builder + */ + public Builder checksum(String checksum) { + return checksum(Base64.getDecoder().decode(requireNonNull(checksum, "checksum"))); + } + + /** + * Sets the isolated load for this library. + * + * @param isolatedLoad the isolated load boolean + * @return this builder + */ + public Builder isolatedLoad(boolean isolatedLoad) { + this.isolatedLoad = isolatedLoad; + return this; + } + + /** + * Adds a jar relocation to apply to this library. + * + * @param relocation jar relocation to apply + * @return this builder + */ + public Builder relocate(Relocation relocation) { + relocations.add(requireNonNull(relocation, "relocation")); + return this; + } + + /** + * Adds a jar relocation to apply to this library. + * + * @param pattern search pattern + * @param relocatedPattern replacement pattern + * @return this builder + */ + public Builder relocate(String pattern, String relocatedPattern) { + return relocate(new Relocation(pattern, relocatedPattern)); + } + + /** + * Creates a new library using this builder's configuration. + * + * @return new library + */ + public Library build() { + return new Library(urls, repositories, id, groupId, artifactId, version, classifier, checksum, relocations, isolatedLoad); + } + } +} diff --git a/src/main/java/net/byteflux/libby/LibraryManager.java b/src/main/java/net/byteflux/libby/LibraryManager.java new file mode 100644 index 0000000..9892996 --- /dev/null +++ b/src/main/java/net/byteflux/libby/LibraryManager.java @@ -0,0 +1,596 @@ +package net.byteflux.libby; + +import net.byteflux.libby.classloader.IsolatedClassLoader; +import net.byteflux.libby.logging.LogLevel; +import net.byteflux.libby.logging.Logger; +import net.byteflux.libby.logging.adapters.LogAdapter; +import net.byteflux.libby.relocation.Relocation; +import net.byteflux.libby.relocation.RelocationHelper; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import java.io.ByteArrayOutputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.net.MalformedURLException; +import java.net.SocketTimeoutException; +import java.net.URL; +import java.net.URLConnection; +import java.net.UnknownHostException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static java.util.Objects.requireNonNull; + +/** + * A runtime dependency manager for plugins. + *

+ * The library manager can resolve a dependency jar through the configured + * Maven repositories, download it into a local cache, relocate it and then + * load it into the plugin's classpath. + *

+ * Transitive dependencies for a library aren't downloaded automatically and + * must be explicitly loaded like every other library. + *

+ * It's recommended that libraries are relocated to prevent any namespace + * conflicts with different versions of the same library bundled with other + * plugins or maybe even bundled with the server itself. + * + * @see Library + */ +public abstract class LibraryManager { + /** + * Wrapped plugin logger + */ + protected final Logger logger; + + /** + * Directory where downloaded library jars are saved to + */ + protected final Path saveDirectory; + + /** + * Maven repositories used to resolve artifacts + */ + private final Set repositories = new LinkedHashSet<>(); + + /** + * Lazily-initialized relocation helper that uses reflection to call into + * Luck's Jar Relocator + */ + private RelocationHelper relocator; + + /** + * Map of isolated class loaders and theirs id + */ + private final Map isolatedLibraries = new HashMap<>(); + + /** + * Creates a new library manager. + * + * @param logAdapter plugin logging adapter + * @param dataDirectory plugin's data directory + * + * @deprecated Use {@link LibraryManager#LibraryManager(LogAdapter, Path, String)} + */ + @Deprecated + protected LibraryManager(LogAdapter logAdapter, Path dataDirectory) { + logger = new Logger(requireNonNull(logAdapter, "logAdapter")); + saveDirectory = requireNonNull(dataDirectory, "dataDirectory").toAbsolutePath().resolve("lib"); + } + + /** + * Creates a new library manager. + * + * @param logAdapter plugin logging adapter + * @param dataDirectory plugin's data directory + * @param directoryName download directory name + */ + protected LibraryManager(LogAdapter logAdapter, Path dataDirectory, String directoryName) { + logger = new Logger(requireNonNull(logAdapter, "logAdapter")); + saveDirectory = requireNonNull(dataDirectory, "dataDirectory").toAbsolutePath().resolve(requireNonNull(directoryName, "directoryName")); + } + + /** + * Adds a file to the plugin's classpath. + * + * @param file the file to add + */ + protected abstract void addToClasspath(Path file); + + /** + * Adds a file to the isolated class loader + * + * @param library the library to add + * @param file the file to add + */ + protected void addToIsolatedClasspath(Library library, Path file) { + IsolatedClassLoader classLoader; + String id = library.getId(); + if (id != null) { + classLoader = isolatedLibraries.computeIfAbsent(id, s -> new IsolatedClassLoader()); + } else { + classLoader = new IsolatedClassLoader(); + } + classLoader.addPath(file); + } + + /** + * Get the isolated class loader of the library + * + * @param libraryId the id of the library + */ + public IsolatedClassLoader getIsolatedClassLoaderOf(String libraryId) { + return isolatedLibraries.get(libraryId); + } + + /** + * Gets the logging level for this library manager. + * + * @return log level + */ + public LogLevel getLogLevel() { + return logger.getLevel(); + } + + /** + * Sets the logging level for this library manager. + *

+ * By setting this value, the library manager's logger will not log any + * messages with a level less severe than the configured level. This can be + * useful for silencing the download and relocation logging. + *

+ * Setting this value to {@link LogLevel#WARN} would silence informational + * logging but still print important things like invalid checksum warnings. + * + * @param level the log level to set + */ + public void setLogLevel(LogLevel level) { + logger.setLevel(level); + } + + /** + * Gets the currently added repositories used to resolve artifacts. + *

+ * For each library this list is traversed to download artifacts after the + * direct download URLs have been attempted. + * + * @return current repositories + */ + public Collection getRepositories() { + List urls; + synchronized (repositories) { + urls = new LinkedList<>(repositories); + } + + return Collections.unmodifiableList(urls); + } + + /** + * Adds a repository URL to this library manager. + *

+ * Artifacts will be resolved using this repository when attempts to locate + * the artifact through previously added repositories are all unsuccessful. + * + * @param url repository URL to add + */ + public void addRepository(String url) { + String repo = requireNonNull(url, "url").endsWith("/") ? url : url + '/'; + synchronized (repositories) { + repositories.add(repo); + } + } + + /** + * Adds the current user's local Maven repository. + */ + public void addMavenLocal() { + addRepository(Paths.get(System.getProperty("user.home")).resolve(".m2/repository").toUri().toString()); + } + + /** + * Adds the Maven Central repository. + */ + public void addMavenCentral() { + addRepository(Repositories.MAVEN_CENTRAL); + } + + /** + * Adds the Sonatype OSS repository. + */ + public void addSonatype() { + addRepository(Repositories.SONATYPE); + } + + /** + * Adds the Bintray JCenter repository. + */ + public void addJCenter() { + addRepository(Repositories.JCENTER); + } + + /** + * Adds the JitPack repository. + */ + public void addJitPack() { + addRepository(Repositories.JITPACK); + } + + /** + * Gets all of the possible download URLs for this library. Entries are + * ordered by direct download URLs first and then repository download URLs. + *
This method also resolves SNAPSHOT artifacts URLs. + * + * @param library the library to resolve + * @return download URLs + */ + public Collection resolveLibrary(Library library) { + Set urls = new LinkedHashSet<>(requireNonNull(library, "library").getUrls()); + boolean snapshot = library.isSnapshot(); + + // Try from library-declared repos first + for (String repository : library.getRepositories()) { + if (snapshot) { + String url = resolveSnapshot(repository, library); + if (url != null) + urls.add(repository + url); + } else { + urls.add(repository + library.getPath()); + } + } + + for (String repository : getRepositories()) { + if (snapshot) { + String url = resolveSnapshot(repository, library); + if (url != null) + urls.add(repository + url); + } else { + urls.add(repository + library.getPath()); + } + } + + return Collections.unmodifiableSet(urls); + } + + /** + * Resolves the URL of the artifact of a snapshot library. + * + * @param repository The repository to query for snapshot information + * @param library The library + * @return The URl of the artifact of a snapshot library or null if no information could be gathered from the + * provided repository + */ + private String resolveSnapshot(String repository, Library library) { + String url = requireNonNull(repository, "repository") + requireNonNull(library, "library").getPartialPath() + "maven-metadata.xml"; + try { + URLConnection connection = new URL(requireNonNull(url, "url")).openConnection(); + + connection.setConnectTimeout(5000); + connection.setReadTimeout(5000); + connection.setRequestProperty("User-Agent", LibbyProperties.HTTP_USER_AGENT); + + try (InputStream in = connection.getInputStream()) { + return getURLFromMetadata(in, library); + } + } catch (MalformedURLException e) { + throw new IllegalArgumentException(e); + } catch (IOException e) { + if (e instanceof FileNotFoundException) { + logger.debug("File not found: " + url); + } else if (e instanceof SocketTimeoutException) { + logger.debug("Connect timed out: " + url); + } else if (e instanceof UnknownHostException) { + logger.debug("Unknown host: " + url); + } else { + logger.debug("Unexpected IOException", e); + } + + return null; + } + } + + /** + * Gets the URL of the artifact of a snapshot library from the provided InputStream, which should be opened to the + * library's maven-metadata.xml. + * + * @param inputStream The InputStream opened to the library's maven-metadata.xml + * @param library The library + * @return The URl of the artifact of a snapshot library or null if no information could be gathered from the + * provided inputStream + * @throws IOException If any IO errors occur + */ + private String getURLFromMetadata(InputStream inputStream, Library library) throws IOException { + requireNonNull(inputStream, "inputStream"); + requireNonNull(library, "library"); + + String timestamp, buildNumber; + try { + // This reads the maven-metadata.xml file and gets the snapshot info from the tag. + // Example tag: + // + // 20220617.013635 + // 12 + // + + DocumentBuilderFactory dbFactory = DocumentBuilderFactory.newInstance(); + DocumentBuilder dBuilder = dbFactory.newDocumentBuilder(); + Document doc = dBuilder.parse(inputStream); + doc.getDocumentElement().normalize(); + + NodeList nodes = doc.getElementsByTagName("snapshot"); + if (nodes.getLength() == 0) { + return null; + } + Node snapshot = nodes.item(0); + if (snapshot.getNodeType() != Node.ELEMENT_NODE) { + return null; + } + Node timestampNode = ((Element) snapshot).getElementsByTagName("timestamp").item(0); + if (timestampNode == null || timestampNode.getNodeType() != Node.ELEMENT_NODE) { + return null; + } + Node buildNumberNode = ((Element) snapshot).getElementsByTagName("buildNumber").item(0); + if (buildNumberNode == null || buildNumberNode.getNodeType() != Node.ELEMENT_NODE) { + return null; + } + Node timestampChild = timestampNode.getFirstChild(); + if (timestampChild == null || timestampChild.getNodeType() != Node.TEXT_NODE) { + return null; + } + Node buildNumberChild = buildNumberNode.getFirstChild(); + if (buildNumberChild == null || buildNumberChild.getNodeType() != Node.TEXT_NODE) { + return null; + } + timestamp = timestampChild.getNodeValue(); + buildNumber = buildNumberChild.getNodeValue(); + } catch (ParserConfigurationException | SAXException e) { + logger.debug("Invalid maven-metadata.xml", e); + return null; + } + + String version = library.getVersion(); + // Call .substring(...) only on versions ending in "-SNAPSHOT". + // It should never happen that a snapshot version doesn't end in "-SNAPSHOT", but better be sure + if (version.endsWith("-SNAPSHOT")) { + version = version.substring(0, version.length() - "-SNAPSHOT".length()); + } + + String url = library.getPartialPath() + library.getArtifactId() + '-' + version + '-' + timestamp + '-' + buildNumber; + if (library.hasClassifier()) { + url += '-' + library.getClassifier(); + } + return url + ".jar"; + } + + /** + * Downloads a library jar and returns the contents as a byte array. + * + * @param url the URL to the library jar + * @return downloaded jar as byte array or null if nothing was downloaded + */ + private byte[] downloadLibrary(String url) { + try { + URLConnection connection = new URL(requireNonNull(url, "url")).openConnection(); + + connection.setConnectTimeout(5000); + connection.setReadTimeout(5000); + connection.setRequestProperty("User-Agent", LibbyProperties.HTTP_USER_AGENT); + + try (InputStream in = connection.getInputStream()) { + int len; + byte[] buf = new byte[8192]; + ByteArrayOutputStream out = new ByteArrayOutputStream(); + + try { + while ((len = in.read(buf)) != -1) { + out.write(buf, 0, len); + } + } catch (SocketTimeoutException e) { + logger.warn("Download timed out: " + connection.getURL()); + return null; + } + + logger.info("Downloaded library " + connection.getURL()); + return out.toByteArray(); + } + } catch (MalformedURLException e) { + throw new IllegalArgumentException(e); + } catch (IOException e) { + if (e instanceof FileNotFoundException) { + logger.debug("File not found: " + url); + } else if (e instanceof SocketTimeoutException) { + logger.debug("Connect timed out: " + url); + } else if (e instanceof UnknownHostException) { + logger.debug("Unknown host: " + url); + } else { + logger.debug("Unexpected IOException", e); + } + + return null; + } + } + + /** + * Downloads a library jar to the save directory if it doesn't already + * exist (snapshot libraries are always re-downloaded) and returns + * the local file path. + *

+ * If the library has a checksum, it will be compared against the + * downloaded jar's checksum to verify the integrity of the download. If + * the checksums don't match, a warning is generated and the next download + * URL is attempted. + *

+ * Checksum comparison is ignored if the library doesn't have a checksum + * or if the library jar already exists in the save directory. + *

+ * Most of the time it is advised to use {@link #loadLibrary(Library)} + * instead of this method because this one is only concerned with + * downloading the jar and returning the local path. It's usually more + * desirable to download the jar and add it to the plugin's classpath in + * one operation. + * + * @param library the library to download + * @return local file path to library + * @see #loadLibrary(Library) + */ + public Path downloadLibrary(Library library) { + Path file = saveDirectory.resolve(requireNonNull(library, "library").getPath()); + if (Files.exists(file)) { + // Early return only if library isn't a snapshot, since snapshot libraries are always re-downloaded + if (!library.isSnapshot()) { + return file; + } + + // Delete the file since the Files.move call down below will fail if it exists + try { + Files.delete(file); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + Collection urls = resolveLibrary(library); + if (urls.isEmpty()) { + throw new RuntimeException("Library '" + library + "' couldn't be resolved, add a repository"); + } + + MessageDigest md = null; + if (library.hasChecksum()) { + try { + md = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + Path out = file.resolveSibling(file.getFileName() + ".tmp"); + out.toFile().deleteOnExit(); + + try { + Files.createDirectories(file.getParent()); + + for (String url : urls) { + byte[] bytes = downloadLibrary(url); + if (bytes == null) { + continue; + } + + if (md != null) { + byte[] checksum = md.digest(bytes); + if (!Arrays.equals(checksum, library.getChecksum())) { + logger.warn("*** INVALID CHECKSUM ***"); + logger.warn(" Library : " + library); + logger.warn(" URL : " + url); + logger.warn(" Expected : " + Base64.getEncoder().encodeToString(library.getChecksum())); + logger.warn(" Actual : " + Base64.getEncoder().encodeToString(checksum)); + continue; + } + } + + Files.write(out, bytes); + Files.move(out, file); + + return file; + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } finally { + try { + Files.deleteIfExists(out); + } catch (IOException ignored) { + } + } + + throw new RuntimeException("Failed to download library '" + library + "'"); + } + + /** + * Processes the input jar and generates an output jar with the provided + * relocation rules applied, then returns the path to the relocated jar. + * + * @param in input jar + * @param out output jar + * @param relocations relocations to apply + * @return the relocated file + * @see RelocationHelper#relocate(Path, Path, Collection) + */ + private Path relocate(Path in, String out, Collection relocations) { + requireNonNull(in, "in"); + requireNonNull(out, "out"); + requireNonNull(relocations, "relocations"); + + Path file = saveDirectory.resolve(out); + if (Files.exists(file)) { + return file; + } + + Path tmpOut = file.resolveSibling(file.getFileName() + ".tmp"); + tmpOut.toFile().deleteOnExit(); + + synchronized (this) { + if (relocator == null) { + relocator = new RelocationHelper(this); + } + } + + try { + relocator.relocate(in, tmpOut, relocations); + Files.move(tmpOut, file); + + logger.info("Relocations applied to " + saveDirectory.getParent().relativize(in)); + + return file; + } catch (IOException e) { + throw new UncheckedIOException(e); + } finally { + try { + Files.deleteIfExists(tmpOut); + } catch (IOException ignored) { + } + } + } + + /** + * Loads a library jar into the plugin's classpath. If the library jar + * doesn't exist locally, it will be downloaded. + *

+ * If the provided library has any relocations, they will be applied to + * create a relocated jar and the relocated jar will be loaded instead. + * + * @param library the library to load + * @see #downloadLibrary(Library) + */ + public void loadLibrary(Library library) { + Path file = downloadLibrary(requireNonNull(library, "library")); + if (library.hasRelocations()) { + file = relocate(file, library.getRelocatedPath(), library.getRelocations()); + } + + if (library.isIsolatedLoad()) { + addToIsolatedClasspath(library, file); + } else { + addToClasspath(file); + } + } +} diff --git a/src/main/java/net/byteflux/libby/PaperLibraryManager.java b/src/main/java/net/byteflux/libby/PaperLibraryManager.java new file mode 100644 index 0000000..af44cac --- /dev/null +++ b/src/main/java/net/byteflux/libby/PaperLibraryManager.java @@ -0,0 +1,85 @@ +package net.byteflux.libby; + +import net.byteflux.libby.classloader.URLClassLoaderHelper; +import net.byteflux.libby.logging.adapters.JDKLogAdapter; +import org.bukkit.plugin.Plugin; + +import java.lang.reflect.Field; +import java.net.URLClassLoader; +import java.nio.file.Path; + +import static java.util.Objects.requireNonNull; + +/** + * A runtime dependency manager for Paper Plugins. (Not to be confused with bukkit plugins loaded on paper) + * See: Paper docs + */ +public class PaperLibraryManager extends LibraryManager { + /** + * Plugin classpath helper + */ + private final URLClassLoaderHelper classLoader; + + /** + * Creates a new Paper library manager. + * + * @param plugin the plugin to manage + */ + public PaperLibraryManager(Plugin plugin) { + this(plugin, "lib"); + } + + /** + * Creates a new Paper library manager. + * + * @param plugin the plugin to manage + * @param directoryName download directory name + */ + public PaperLibraryManager(Plugin plugin, String directoryName) { + super(new JDKLogAdapter(requireNonNull(plugin, "plugin").getLogger()), plugin.getDataFolder().toPath(), directoryName); + + ClassLoader cl = plugin.getClass().getClassLoader(); + Class paperClClazz; + + try { + paperClClazz = Class.forName("io.papermc.paper.plugin.entrypoint.classloader.PaperPluginClassLoader"); + } catch (ClassNotFoundException e) { + System.err.println("PaperPluginClassLoader not found, are you using Paper 1.19.3+?"); + throw new RuntimeException(e); + } + + if (!paperClClazz.isAssignableFrom(cl.getClass())) { + throw new RuntimeException("Plugin classloader is not a PaperPluginClassLoader, are you using paper-plugin.yml?"); + } + + Field libraryLoaderField; + + try { + libraryLoaderField = paperClClazz.getDeclaredField("libraryLoader"); + } catch (NoSuchFieldException e) { + System.err.println("Cannot find libraryLoader field in PaperPluginClassLoader, please open a bug report."); + throw new RuntimeException(e); + } + + libraryLoaderField.setAccessible(true); + + URLClassLoader libraryLoader; + try { + libraryLoader = (URLClassLoader) libraryLoaderField.get(cl); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); // Should never happen + } + + classLoader = new URLClassLoaderHelper(libraryLoader, this); + } + + /** + * Adds a file to the Paper plugin's classpath. + * + * @param file the file to add + */ + @Override + protected void addToClasspath(Path file) { + classLoader.addToClasspath(file); + } +} diff --git a/src/main/java/net/byteflux/libby/Repositories.java b/src/main/java/net/byteflux/libby/Repositories.java new file mode 100644 index 0000000..2fae350 --- /dev/null +++ b/src/main/java/net/byteflux/libby/Repositories.java @@ -0,0 +1,31 @@ +package net.byteflux.libby; + +/** + * Class containing URLs of public repositories. + */ +public class Repositories { + + /** + * Maven Central repository URL. + */ + public static final String MAVEN_CENTRAL = "https://repo1.maven.org/maven2/"; + + /** + * Sonatype OSS repository URL. + */ + public static final String SONATYPE = "https://oss.sonatype.org/content/groups/public/"; + + /** + * Bintray JCenter repository URL. + */ + public static final String JCENTER = "https://jcenter.bintray.com/"; + + /** + * JitPack repository URL. + */ + public static final String JITPACK = "https://jitpack.io/"; + + private Repositories() { + throw new UnsupportedOperationException("Private constructor"); + } +} diff --git a/src/main/java/net/byteflux/libby/classloader/IsolatedClassLoader.java b/src/main/java/net/byteflux/libby/classloader/IsolatedClassLoader.java new file mode 100644 index 0000000..965d778 --- /dev/null +++ b/src/main/java/net/byteflux/libby/classloader/IsolatedClassLoader.java @@ -0,0 +1,51 @@ +package net.byteflux.libby.classloader; + +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Path; + +import static java.util.Objects.requireNonNull; + +/** + * This class loader is a simple child of {@code URLClassLoader} that uses + * the JVM's Extensions Class Loader as the parent instead of the system class + * loader to provide an unpolluted classpath. + */ +public class IsolatedClassLoader extends URLClassLoader { + static { + ClassLoader.registerAsParallelCapable(); + } + + /** + * Creates a new isolated class loader for the given URLs. + * + * @param urls the URLs to add to the classpath + */ + public IsolatedClassLoader(URL... urls) { + super(requireNonNull(urls, "urls"), ClassLoader.getSystemClassLoader().getParent()); + } + + /** + * Adds a URL to the classpath. + * + * @param url the URL to add + */ + @Override + public void addURL(URL url) { + super.addURL(url); + } + + /** + * Adds a path to the classpath. + * + * @param path the path to add + */ + public void addPath(Path path) { + try { + addURL(requireNonNull(path, "path").toUri().toURL()); + } catch (MalformedURLException e) { + throw new IllegalArgumentException(e); + } + } +} diff --git a/src/main/java/net/byteflux/libby/classloader/URLClassLoaderHelper.java b/src/main/java/net/byteflux/libby/classloader/URLClassLoaderHelper.java new file mode 100644 index 0000000..403c2a6 --- /dev/null +++ b/src/main/java/net/byteflux/libby/classloader/URLClassLoaderHelper.java @@ -0,0 +1,233 @@ +package net.byteflux.libby.classloader; + +import net.byteflux.libby.Library; +import net.byteflux.libby.LibraryManager; +import net.byteflux.libby.Repositories; +import sun.misc.Unsafe; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Path; +import java.util.Collections; +import java.util.Map; +import java.util.Set; + +import static java.util.Objects.requireNonNull; + +/** + * A reflection-based wrapper around {@link URLClassLoader} for adding URLs to + * the classpath. + */ +public class URLClassLoaderHelper { + + /** + * Unsafe class instance. Used in {@link #getPrivilegedMethodHandle(Method)}. + */ + private static final Unsafe theUnsafe; + + static { + Unsafe unsafe = null; // Used to make theUnsafe field final + + // getDeclaredField("theUnsafe") is not used to avoid breakage on JVMs with changed field name + for (Field f : Unsafe.class.getDeclaredFields()) { + try { + if (f.getType() == Unsafe.class && Modifier.isStatic(f.getModifiers())) { + f.setAccessible(true); + unsafe = (Unsafe) f.get(null); + } + } catch (Exception ignored) { + } + } + theUnsafe = unsafe; + } + + /** + * The class loader being managed by this helper. + */ + private final URLClassLoader classLoader; + + /** + * A reflected method in {@link URLClassLoader}, when invoked adds a URL to the classpath. + */ + private MethodHandle addURLMethodHandle = null; + + /** + * Creates a new URL class loader helper. + * + * @param classLoader the class loader to manage + * @param libraryManager the library manager used to download dependencies + */ + public URLClassLoaderHelper(URLClassLoader classLoader, LibraryManager libraryManager) { + requireNonNull(libraryManager, "libraryManager"); + this.classLoader = requireNonNull(classLoader, "classLoader"); + + try { + Method addURLMethod = URLClassLoader.class.getDeclaredMethod("addURL", URL.class); + + try { + openUrlClassLoaderModule(); + } catch (Exception ignored) { + } + + try { + addURLMethod.setAccessible(true); + } catch (Exception exception) { + // InaccessibleObjectException has been added in Java 9 + if (exception.getClass().getName().equals("java.lang.reflect.InaccessibleObjectException")) { + // It is Java 9+, try to open java.net package + if (theUnsafe != null) + try { + addURLMethodHandle = getPrivilegedMethodHandle(addURLMethod).bindTo(classLoader); + return; // We're done + } catch (Exception ignored) { + addURLMethodHandle = null; // Just to be sure the field is set to null + } + // Cannot use privileged MethodHandles.Lookup, trying with java agent + try { + addOpensWithAgent(libraryManager); + addURLMethod.setAccessible(true); + } catch (Exception e) { + // Cannot access at all + System.err.println("Cannot access URLClassLoader#addURL(URL), if you are using Java 9+ try to add the following option to your java command: --add-opens java.base/java.net=ALL-UNNAMED"); + throw new RuntimeException("Cannot access URLClassLoader#addURL(URL)", e); + } + } else { + throw new RuntimeException("Cannot set accessible URLClassLoader#addURL(URL)", exception); + } + } + this.addURLMethodHandle = MethodHandles.lookup().unreflect(addURLMethod).bindTo(classLoader); + } catch (NoSuchMethodException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } + + /** + * Adds a URL to the class loader's classpath. + * + * @param url the URL to add + */ + public void addToClasspath(URL url) { + try { + addURLMethodHandle.invokeWithArguments(requireNonNull(url, "url")); + } catch (Throwable e) { + throw new RuntimeException(e); + } + } + + /** + * Adds a path to the class loader's classpath. + * + * @param path the path to add + */ + public void addToClasspath(Path path) { + try { + addToClasspath(requireNonNull(path, "path").toUri().toURL()); + } catch (MalformedURLException e) { + throw new IllegalArgumentException(e); + } + } + + private static void openUrlClassLoaderModule() throws Exception { + // + // Thanks to lucko (Luck) for this snippet used in his own class loader + // + // This is a workaround used to maintain Java 9+ support with reflections + // Thanks to this you will be able to run this class loader with Java 8+ + + // This is effectively calling: + // + // URLClassLoader.class.getModule().addOpens( + // URLClassLoader.class.getPackageName(), + // URLClassLoaderHelper.class.getModule() + // ); + // + // We use reflection since we build against Java 8. + + Class moduleClass = Class.forName("java.lang.Module"); + Method getModuleMethod = Class.class.getMethod("getModule"); + Method addOpensMethod = moduleClass.getMethod("addOpens", String.class, moduleClass); + + Object urlClassLoaderModule = getModuleMethod.invoke(URLClassLoader.class); + Object thisModule = getModuleMethod.invoke(URLClassLoaderHelper.class); + + addOpensMethod.invoke(urlClassLoaderModule, URLClassLoader.class.getPackage().getName(), thisModule); + } + + private MethodHandle getPrivilegedMethodHandle(Method method) throws Exception { + // Try to get a MethodHandle to URLClassLoader#addURL. + // The Unsafe class is used to get a privileged MethodHandles.Lookup instance. + + // Looking for MethodHandles.Lookup#IMPL_LOOKUP private static field + // getDeclaredField("IMPL_LOOKUP") is not used to avoid breakage on JVMs with changed field name + for (Field trustedLookup : MethodHandles.Lookup.class.getDeclaredFields()) { + if (trustedLookup.getType() != MethodHandles.Lookup.class || !Modifier.isStatic(trustedLookup.getModifiers()) || trustedLookup.isSynthetic()) + continue; + + try { + MethodHandles.Lookup lookup = (MethodHandles.Lookup) theUnsafe.getObject(theUnsafe.staticFieldBase(trustedLookup), theUnsafe.staticFieldOffset(trustedLookup)); + return lookup.unreflect(method); + } catch (Exception ignored) { + // Unreflect went wrong, trying the next field + } + } + + // Every field has been tried + throw new RuntimeException("Cannot get privileged method handle."); + } + + private void addOpensWithAgent(LibraryManager libraryManager) throws Exception { + // To open URLClassLoader's module we need permissions. + // Try to add a java agent at runtime (specifically, ByteBuddy's agent) and use it to open the module, + // since java agents should have such permission. + + // Download ByteBuddy's agent and load it through an IsolatedClassLoader + IsolatedClassLoader isolatedClassLoader = new IsolatedClassLoader(); + try { + isolatedClassLoader.addPath(libraryManager.downloadLibrary( + Library.builder() + .groupId("net.bytebuddy") + .artifactId("byte-buddy-agent") + .version("1.12.1") + .checksum("mcCtBT9cljUEniB5ESpPDYZMfVxEs1JRPllOiWTP+bM=") + .repository(Repositories.MAVEN_CENTRAL) + .build() + )); + + Class byteBuddyAgent = isolatedClassLoader.loadClass("net.bytebuddy.agent.ByteBuddyAgent"); + + // This is effectively calling: + // + // Instrumentation instrumentation = ByteBuddyAgent.install(); + // instrumentation.redefineModule( + // URLClassLoader.class.getModule(), + // Collections.emptySet(), + // Collections.emptyMap(), + // Collections.singletonMap("java.net", Collections.singleton(getClass().getModule())), + // Collections.emptySet(), + // Collections.emptyMap() + // ); + // + // For more information see https://docs.oracle.com/en/java/javase/16/docs/api/java.instrument/java/lang/instrument/Instrumentation.html + // + // We use reflection since we build against Java 8. + + Object instrumentation = byteBuddyAgent.getDeclaredMethod("install").invoke(null); + Class instrumentationClass = Class.forName("java.lang.instrument.Instrumentation"); + Method redefineModule = instrumentationClass.getDeclaredMethod("redefineModule", Class.forName("java.lang.Module"), Set.class, Map.class, Map.class, Set.class, Map.class); + Method getModule = Class.class.getDeclaredMethod("getModule"); + Map> toOpen = Collections.singletonMap("java.net", Collections.singleton(getModule.invoke(getClass()))); + redefineModule.invoke(instrumentation, getModule.invoke(URLClassLoader.class), Collections.emptySet(), Collections.emptyMap(), toOpen, Collections.emptySet(), Collections.emptyMap()); + } finally { + try { + isolatedClassLoader.close(); + } catch (Exception ignored) { + } + } + } +} diff --git a/src/main/java/net/byteflux/libby/logging/LogLevel.java b/src/main/java/net/byteflux/libby/logging/LogLevel.java new file mode 100644 index 0000000..3ea4e45 --- /dev/null +++ b/src/main/java/net/byteflux/libby/logging/LogLevel.java @@ -0,0 +1,26 @@ +package net.byteflux.libby.logging; + +/** + * Represents the severity of a log message in {@link Logger}. + */ +public enum LogLevel { + /** + * Stuff that isn't useful to end-users + */ + DEBUG, + + /** + * Stuff that might be useful to know + */ + INFO, + + /** + * Non-fatal, often recoverable errors or notices + */ + WARN, + + /** + * Probably an unrecoverable error + */ + ERROR +} diff --git a/src/main/java/net/byteflux/libby/logging/Logger.java b/src/main/java/net/byteflux/libby/logging/Logger.java new file mode 100644 index 0000000..9eaea85 --- /dev/null +++ b/src/main/java/net/byteflux/libby/logging/Logger.java @@ -0,0 +1,195 @@ +package net.byteflux.libby.logging; + +import net.byteflux.libby.logging.adapters.LogAdapter; + +import static java.util.Objects.requireNonNull; + +/** + * A logging wrapper that logs to a log adapter and can be configured to filter + * log messages by severity. + */ +public class Logger { + /** + * Log adapter for the current platform + */ + private final LogAdapter adapter; + + /** + * Log level controlling which messages are logged + */ + private LogLevel level = LogLevel.INFO; + + /** + * Creates a new logger with the provided adapter. + * + * @param adapter the adapter to wrap + */ + public Logger(LogAdapter adapter) { + this.adapter = requireNonNull(adapter, "adapter"); + } + + /** + * Gets the current log level. + * + * @return current log level + */ + public LogLevel getLevel() { + return level; + } + + /** + * Sets a new log level. + * + * @param level new log level + */ + public void setLevel(LogLevel level) { + this.level = requireNonNull(level, "level"); + } + + /** + * Gets whether messages matching the provided level can be logged under + * the current log level setting. + *

+ * Returns true if provided log level is equal to or more severe than the + * logger's configured log level. + * + * @param level the level to check + * @return true if message can be logged, or false + */ + private boolean canLog(LogLevel level) { + return requireNonNull(level, "level").compareTo(this.level) >= 0; + } + + /** + * Logs a message with the provided level. + *

+ * If the provided log level is less severe than the logger's + * configured log level, this message won't be logged. + * + * @param level message severity level + * @param message the message to log + * @see #debug(String) + * @see #info(String) + * @see #warn(String) + * @see #error(String) + */ + public void log(LogLevel level, String message) { + if (canLog(level)) { + adapter.log(level, message); + } + } + + /** + * Logs a message and stack trace with the provided level. + *

+ * If the provided log level is less severe than the logger's + * configured log level, this message won't be logged. + * + * @param level message severity level + * @param message the message to log + * @param throwable the throwable to print + * @see #debug(String, Throwable) + * @see #info(String, Throwable) + * @see #warn(String, Throwable) + * @see #error(String, Throwable) + */ + public void log(LogLevel level, String message, Throwable throwable) { + if (canLog(level)) { + adapter.log(level, message, throwable); + } + } + + /** + * Logs a debug message. + *

+ * If the logger's configured log level is more severe than + * {@link LogLevel#DEBUG}, this message won't be logged. + * + * @param message the message to log + */ + public void debug(String message) { + log(LogLevel.DEBUG, message); + } + + /** + * Logs a debug message with a stack trace. + *

+ * If the logger's configured log level is more severe than + * {@link LogLevel#DEBUG}, this message won't be logged. + * + * @param message the message to log + * @param throwable the throwable to print + */ + public void debug(String message, Throwable throwable) { + log(LogLevel.DEBUG, message, throwable); + } + + /** + * Logs an informational message. + *

+ * If the logger's configured log level is more severe than + * {@link LogLevel#INFO}, this message won't be logged. + * + * @param message the message to log + */ + public void info(String message) { + log(LogLevel.INFO, message); + } + + /** + * Logs an informational message with a stack trace. + *

+ * If the logger's configured log level is more severe than + * {@link LogLevel#INFO}, this message won't be logged. + * + * @param message the message to log + * @param throwable the throwable to print + */ + public void info(String message, Throwable throwable) { + log(LogLevel.INFO, message, throwable); + } + + /** + * Logs a warning message. + *

+ * If the logger's configured log level is more severe than + * {@link LogLevel#WARN}, this message won't be logged. + * + * @param message the message to log + */ + public void warn(String message) { + log(LogLevel.WARN, message); + } + + /** + * Logs a warning message with a stack trace. + *

+ * If the logger's configured log level is more severe than + * {@link LogLevel#WARN}, this message won't be logged. + * + * @param message the message to log + * @param throwable the throwable to print + */ + public void warn(String message, Throwable throwable) { + log(LogLevel.WARN, message, throwable); + } + + /** + * Logs an error message. + * + * @param message the message to log + */ + public void error(String message) { + log(LogLevel.ERROR, message); + } + + /** + * Logs an error message with a stack trace. + * + * @param message message to log + * @param throwable the throwable to print + */ + public void error(String message, Throwable throwable) { + log(LogLevel.ERROR, message, throwable); + } +} diff --git a/src/main/java/net/byteflux/libby/logging/adapters/JDKLogAdapter.java b/src/main/java/net/byteflux/libby/logging/adapters/JDKLogAdapter.java new file mode 100644 index 0000000..b3d0a55 --- /dev/null +++ b/src/main/java/net/byteflux/libby/logging/adapters/JDKLogAdapter.java @@ -0,0 +1,77 @@ +package net.byteflux.libby.logging.adapters; + +import net.byteflux.libby.logging.LogLevel; + +import java.util.logging.Level; +import java.util.logging.Logger; + +import static java.util.Objects.requireNonNull; + +/** + * Logging adapter that logs to a JDK logger. + */ +public class JDKLogAdapter implements LogAdapter { + /** + * JDK logger + */ + private final Logger logger; + + /** + * Creates a new JDK log adapter that logs to a {@link Logger}. + * + * @param logger the JDK logger to wrap + */ + public JDKLogAdapter(Logger logger) { + this.logger = requireNonNull(logger, "logger"); + } + + /** + * Logs a message with the provided level to the JDK logger. + * + * @param level message severity level + * @param message the message to log + */ + @Override + public void log(LogLevel level, String message) { + switch (requireNonNull(level, "level")) { + case DEBUG: + logger.log(Level.FINE, message); + break; + case INFO: + logger.log(Level.INFO, message); + break; + case WARN: + logger.log(Level.WARNING, message); + break; + case ERROR: + logger.log(Level.SEVERE, message); + break; + } + } + + /** + * Logs a message and stack trace with the provided level to the JDK + * logger. + * + * @param level message severity level + * @param message the message to log + * @param throwable the throwable to print + */ + @Override + public void log(LogLevel level, String message, Throwable throwable) { + switch (requireNonNull(level, "level")) { + case DEBUG: + logger.log(Level.FINE, message, throwable); + break; + case INFO: + logger.log(Level.INFO, message, throwable); + break; + case WARN: + logger.log(Level.WARNING, message, throwable); + break; + case ERROR: + logger.log(Level.SEVERE, message, throwable); + break; + } + } +} diff --git a/src/main/java/net/byteflux/libby/logging/adapters/LogAdapter.java b/src/main/java/net/byteflux/libby/logging/adapters/LogAdapter.java new file mode 100644 index 0000000..93bfa14 --- /dev/null +++ b/src/main/java/net/byteflux/libby/logging/adapters/LogAdapter.java @@ -0,0 +1,25 @@ +package net.byteflux.libby.logging.adapters; + +import net.byteflux.libby.logging.LogLevel; + +/** + * Logging interface for adapting platform-specific loggers to our logging API. + */ +public interface LogAdapter { + /** + * Logs a message with the provided level. + * + * @param level message severity level + * @param message the message to log + */ + void log(LogLevel level, String message); + + /** + * Logs a message and stack trace with the provided level. + * + * @param level message severity level + * @param message the message to log + * @param throwable the throwable to print + */ + void log(LogLevel level, String message, Throwable throwable); +} diff --git a/src/main/java/net/byteflux/libby/relocation/Relocation.java b/src/main/java/net/byteflux/libby/relocation/Relocation.java new file mode 100644 index 0000000..5c3f8df --- /dev/null +++ b/src/main/java/net/byteflux/libby/relocation/Relocation.java @@ -0,0 +1,184 @@ +package net.byteflux.libby.relocation; + +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedList; + +import static java.util.Objects.requireNonNull; + +/** + * Relocations are used to describe a search and replace pattern for renaming + * packages in a library jar for the purpose of preventing namespace conflicts + * with other plugins that bundle their own version of the same library. + */ +public class Relocation { + /** + * Search pattern + */ + private final String pattern; + + /** + * Replacement pattern + */ + private final String relocatedPattern; + + /** + * Classes and resources to include + */ + private final Collection includes; + + /** + * Classes and resources to exclude + */ + private final Collection excludes; + + /** + * Creates a new relocation. + * + * @param pattern search pattern + * @param relocatedPattern replacement pattern + * @param includes classes and resources to include + * @param excludes classes and resources to exclude + */ + public Relocation(String pattern, String relocatedPattern, Collection includes, Collection excludes) { + this.pattern = requireNonNull(pattern, "pattern").replace("{}", "."); + this.relocatedPattern = requireNonNull(relocatedPattern, "relocatedPattern").replace("{}", "."); + this.includes = includes != null ? Collections.unmodifiableList(new LinkedList<>(includes)) : Collections.emptyList(); + this.excludes = excludes != null ? Collections.unmodifiableList(new LinkedList<>(excludes)) : Collections.emptyList(); + } + + /** + * Creates a new relocation with empty includes and excludes. + * + * @param pattern search pattern + * @param relocatedPattern replacement pattern + */ + public Relocation(String pattern, String relocatedPattern) { + this(pattern, relocatedPattern, null, null); + } + + /** + * Gets the search pattern. + * + * @return pattern to search + */ + public String getPattern() { + return pattern; + } + + /** + * Gets the replacement pattern. + * + * @return pattern to replace with + */ + public String getRelocatedPattern() { + return relocatedPattern; + } + + /** + * Gets included classes and resources. + * + * @return classes and resources to include + */ + public Collection getIncludes() { + return includes; + } + + /** + * Gets excluded classes and resources. + * + * @return classes and resources to exclude + */ + public Collection getExcludes() { + return excludes; + } + + /** + * Creates a new relocation builder. + * + * @return new relocation builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Provides an alternative method of creating a {@link Relocation}. This + * builder may be more intuitive for configuring relocations that also have + * any includes or excludes. + */ + public static class Builder { + /** + * Search pattern + */ + private String pattern; + + /** + * Replacement pattern + */ + private String relocatedPattern; + + /** + * Classes and resources to include + */ + private final Collection includes = new LinkedList<>(); + + /** + * Classes and resources to exclude + */ + private final Collection excludes = new LinkedList<>(); + + /** + * Sets the search pattern. + * + * @param pattern pattern to search + * @return this builder + */ + public Builder pattern(String pattern) { + this.pattern = requireNonNull(pattern, "pattern"); + return this; + } + + /** + * Sets the replacement pattern. + * + * @param relocatedPattern pattern to replace with + * @return this builder + */ + public Builder relocatedPattern(String relocatedPattern) { + this.relocatedPattern = requireNonNull(relocatedPattern, "relocatedPattern"); + return this; + } + + /** + * Adds a class or resource to be included. + * + * @param include class or resource to include + * @return this builder + */ + public Builder include(String include) { + includes.add(requireNonNull(include, "include")); + return this; + } + + /** + * Adds a class or resource to be excluded. + * + * @param exclude class or resource to exclude + * @return this builder + */ + public Builder exclude(String exclude) { + excludes.add(requireNonNull(exclude, "exclude")); + return this; + } + + /** + * Creates a new relocation using this builder's configuration. + * + * @return new relocation + */ + public Relocation build() { + return new Relocation(pattern, relocatedPattern, includes, excludes); + } + } +} diff --git a/src/main/java/net/byteflux/libby/relocation/RelocationHelper.java b/src/main/java/net/byteflux/libby/relocation/RelocationHelper.java new file mode 100644 index 0000000..a36f178 --- /dev/null +++ b/src/main/java/net/byteflux/libby/relocation/RelocationHelper.java @@ -0,0 +1,130 @@ +package net.byteflux.libby.relocation; + +import net.byteflux.libby.Library; +import net.byteflux.libby.LibraryManager; +import net.byteflux.libby.Repositories; +import net.byteflux.libby.classloader.IsolatedClassLoader; + +import java.io.File; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.nio.file.Path; +import java.util.Collection; +import java.util.LinkedList; +import java.util.List; + +import static java.util.Objects.requireNonNull; + +/** + * A reflection-based helper for relocating library jars. It automatically + * downloads and invokes Luck's Jar Relocator to perform jar relocations. + * + * @see Luck's Jar Relocator + */ +public class RelocationHelper { + /** + * Reflected constructor for creating new jar relocator instances + */ + private final Constructor jarRelocatorConstructor; + + /** + * Reflected method for running a jar relocator + */ + private final Method jarRelocatorRunMethod; + + /** + * Reflected constructor for creating relocation instances + */ + private final Constructor relocationConstructor; + + /** + * Creates a new relocation helper using the provided library manager to + * download the dependencies required for runtime relocation. + * + * @param libraryManager the library manager used to download dependencies + */ + public RelocationHelper(LibraryManager libraryManager) { + requireNonNull(libraryManager, "libraryManager"); + + IsolatedClassLoader classLoader = new IsolatedClassLoader(); + + // ObjectWeb ASM Commons + classLoader.addPath(libraryManager.downloadLibrary( + Library.builder() + .groupId("org.ow2.asm") + .artifactId("asm-commons") + .version("9.7") + .checksum("OJvCR5WOBJ/JoECNOYySxtNwwYA1EgOV1Muh2dkwS3o=") + .repository(Repositories.MAVEN_CENTRAL) + .build() + )); + + // ObjectWeb ASM + classLoader.addPath(libraryManager.downloadLibrary( + Library.builder() + .groupId("org.ow2.asm") + .artifactId("asm") + .version("9.7") + .checksum("rfRtXjSUC98Ujs3Sap7o7qlElqcgNP9xQQZrPupcTp0=") + .repository(Repositories.MAVEN_CENTRAL) + .build() + )); + + // Luck's Jar Relocator + classLoader.addPath(libraryManager.downloadLibrary( + Library.builder() + .groupId("me.lucko") + .artifactId("jar-relocator") + .version("1.7") + .checksum("b30RhOF6kHiHl+O5suNLh/+eAr1iOFEFLXhwkHHDu4I=") + .repository(Repositories.MAVEN_CENTRAL) + .build() + )); + + try { + Class jarRelocatorClass = classLoader.loadClass("me.lucko.jarrelocator.JarRelocator"); + Class relocationClass = classLoader.loadClass("me.lucko.jarrelocator.Relocation"); + + // me.lucko.jarrelocator.JarRelocator(File, File, Collection) + jarRelocatorConstructor = jarRelocatorClass.getConstructor(File.class, File.class, Collection.class); + + // me.lucko.jarrelocator.JarRelocator#run() + jarRelocatorRunMethod = jarRelocatorClass.getMethod("run"); + + // me.lucko.jarrelocator.Relocation(String, String, Collection, Collection) + relocationConstructor = relocationClass.getConstructor(String.class, String.class, Collection.class, Collection.class); + } catch (ReflectiveOperationException e) { + throw new RuntimeException(e); + } + } + + /** + * Invokes the jar relocator to process the input jar and generate an + * output jar with the provided relocation rules applied. + * + * @param in input jar + * @param out output jar + * @param relocations relocations to apply + */ + public void relocate(Path in, Path out, Collection relocations) { + requireNonNull(in, "in"); + requireNonNull(out, "out"); + requireNonNull(relocations, "relocations"); + + try { + List rules = new LinkedList<>(); + for (Relocation relocation : relocations) { + rules.add(relocationConstructor.newInstance( + relocation.getPattern(), + relocation.getRelocatedPattern(), + relocation.getIncludes(), + relocation.getExcludes() + )); + } + + jarRelocatorRunMethod.invoke(jarRelocatorConstructor.newInstance(in.toFile(), out.toFile(), rules)); + } catch (ReflectiveOperationException e) { + throw new RuntimeException(e); + } + } +}