feat(plugins): allow to set minimum required jadx version in plugin info (#2314)

This commit is contained in:
Skylot
2024-11-06 16:29:43 +00:00
parent 5d064d3e50
commit be6cb573b1
19 changed files with 453 additions and 96 deletions
@@ -1,24 +1,23 @@
package jadx.plugins.tools;
import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.ServiceLoader;
import java.util.Set;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import jadx.api.plugins.JadxPlugin;
import jadx.api.plugins.loader.JadxPluginLoader;
import jadx.core.utils.Utils;
import jadx.core.utils.exceptions.JadxRuntimeException;
public class JadxExternalPluginsLoader implements JadxPluginLoader {
@@ -31,9 +30,8 @@ public class JadxExternalPluginsLoader implements JadxPluginLoader {
close();
long start = System.currentTimeMillis();
Map<Class<? extends JadxPlugin>, JadxPlugin> map = new HashMap<>();
ClassLoader classLoader = JadxPluginsTools.class.getClassLoader();
loadFromClsLoader(map, classLoader);
loadInstalledPlugins(map, classLoader);
loadFromClsLoader(map, thisClassLoader());
loadInstalledPlugins(map);
List<JadxPlugin> list = new ArrayList<>(map.size());
list.addAll(map.values());
@@ -44,52 +42,53 @@ public class JadxExternalPluginsLoader implements JadxPluginLoader {
return list;
}
/**
* TODO: find a better way to load only plugin from single jar without plugins from parent
* classloader
*/
public JadxPlugin loadFromJar(Path jar) {
Map<Class<? extends JadxPlugin>, JadxPlugin> map = new HashMap<>();
ClassLoader classLoader = JadxPluginsTools.class.getClassLoader();
loadFromClsLoader(map, classLoader);
Set<Class<? extends JadxPlugin>> clspPlugins = new HashSet<>(map.keySet());
try (URLClassLoader pluginClassLoader = loadFromJar(map, classLoader, jar)) {
return map.entrySet().stream()
.filter(entry -> !clspPlugins.contains(entry.getKey()))
.findFirst()
.map(Map.Entry::getValue)
.orElseThrow(() -> new RuntimeException("No plugin found in jar: " + jar));
} catch (IOException e) {
throw new RuntimeException("Failed to load plugin jar: " + jar, e);
loadFromJar(map, jar);
int loaded = map.size();
if (loaded == 0) {
throw new JadxRuntimeException("No plugin found in jar: " + jar);
}
if (loaded > 1) {
String plugins = map.values().stream().map(p -> p.getPluginInfo().getPluginId()).collect(Collectors.joining(", "));
throw new JadxRuntimeException("Expect only one plugin per jar: " + jar + ", but found: " + loaded + " - " + plugins);
}
return Utils.first(map.values());
}
private void loadFromClsLoader(Map<Class<? extends JadxPlugin>, JadxPlugin> map, ClassLoader classLoader) {
ServiceLoader.load(JadxPlugin.class, classLoader)
.stream()
.filter(p -> p.type().getClassLoader() == classLoader)
.filter(p -> !map.containsKey(p.type()))
.forEach(p -> map.put(p.type(), p.get()));
}
private void loadInstalledPlugins(Map<Class<? extends JadxPlugin>, JadxPlugin> map, ClassLoader classLoader) {
private void loadInstalledPlugins(Map<Class<? extends JadxPlugin>, JadxPlugin> map) {
List<Path> jars = JadxPluginsTools.getInstance().getEnabledPluginJars();
for (Path jar : jars) {
classLoaders.add(loadFromJar(map, classLoader, jar));
loadFromJar(map, jar);
}
}
private URLClassLoader loadFromJar(Map<Class<? extends JadxPlugin>, JadxPlugin> map, ClassLoader classLoader, Path jar) {
private void loadFromJar(Map<Class<? extends JadxPlugin>, JadxPlugin> map, Path jar) {
try {
File jarFile = jar.toFile();
String clsLoaderName = "jadx-plugin:" + jarFile.getName();
URL[] urls = new URL[] { jarFile.toURI().toURL() };
URLClassLoader pluginClsLoader = new URLClassLoader("jadx-plugin:" + jarFile.getName(), urls, classLoader);
URLClassLoader pluginClsLoader = new URLClassLoader(clsLoaderName, urls, thisClassLoader());
classLoaders.add(pluginClsLoader);
loadFromClsLoader(map, pluginClsLoader);
return pluginClsLoader;
} catch (Exception e) {
throw new JadxRuntimeException("Failed to load plugins, jar: " + jar, e);
throw new JadxRuntimeException("Failed to load plugins from jar: " + jar, e);
}
}
private static ClassLoader thisClassLoader() {
return JadxExternalPluginsLoader.class.getClassLoader();
}
@Override
public void close() {
try {
@@ -16,12 +16,18 @@ import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import jadx.api.plugins.JadxPlugin;
import jadx.api.plugins.JadxPluginInfo;
import jadx.core.Jadx;
import jadx.core.plugins.versions.VerifyRequiredVersion;
import jadx.core.utils.StringUtils;
import jadx.core.utils.exceptions.JadxRuntimeException;
import jadx.core.utils.files.FileUtils;
import jadx.plugins.tools.data.JadxInstalledPlugins;
import jadx.plugins.tools.data.JadxPluginMetadata;
@@ -35,6 +41,8 @@ import static jadx.plugins.tools.utils.PluginFiles.INSTALLED_DIR;
import static jadx.plugins.tools.utils.PluginFiles.PLUGINS_JSON;
public class JadxPluginsTools {
private static final Logger LOG = LoggerFactory.getLogger(JadxPluginsTools.class);
private static final JadxPluginsTools INSTANCE = new JadxPluginsTools();
public static JadxPluginsTools getInstance() {
@@ -45,25 +53,62 @@ public class JadxPluginsTools {
}
public JadxPluginMetadata install(String locationId) {
JadxPluginMetadata pluginMetadata = resolveMetadata(locationId);
install(pluginMetadata);
return pluginMetadata;
IJadxPluginResolver resolver = ResolversRegistry.getResolver(locationId);
boolean hasVersion = resolver.hasVersion(locationId);
if (hasVersion) {
JadxPluginMetadata pluginMetadata = resolver.resolve(locationId)
.orElseThrow(() -> new JadxRuntimeException("Failed to resolve plugin location: " + locationId));
fillMetadata(pluginMetadata);
install(pluginMetadata);
return pluginMetadata;
}
// try other versions in case latest is not compatible with current jadx
VerifyRequiredVersion verifyRequiredVersion = new VerifyRequiredVersion();
for (int i = 1; i <= 5; i++) {
try {
for (JadxPluginMetadata pluginMetadata : resolver.resolveVersions(locationId, i, 1)) {
fillMetadata(pluginMetadata);
if (verifyRequiredVersion.isCompatible(pluginMetadata.getRequiredJadxVersion())) {
install(pluginMetadata);
return pluginMetadata;
}
}
} catch (Exception e) {
LOG.warn("Failed to fetch plugin ({} version before latest)", i, e);
}
}
throw new JadxRuntimeException("Can't find compatible version to install");
}
public JadxPluginMetadata resolveMetadata(String locationId) {
JadxPluginMetadata pluginMetadata = ResolversRegistry.resolve(locationId)
IJadxPluginResolver resolver = ResolversRegistry.getResolver(locationId);
JadxPluginMetadata pluginMetadata = resolver.resolve(locationId)
.orElseThrow(() -> new RuntimeException("Failed to resolve locationId: " + locationId));
fillMetadata(pluginMetadata);
return pluginMetadata;
}
public List<JadxPluginMetadata> getVersionsByLocation(String locationId, int page, int perPage) {
IJadxPluginResolver resolver = ResolversRegistry.getResolver(locationId);
List<JadxPluginMetadata> list = resolver.resolveVersions(locationId, page, perPage);
for (JadxPluginMetadata pluginMetadata : list) {
fillMetadata(pluginMetadata);
}
return list;
}
public List<JadxPluginUpdate> updateAll() {
JadxInstalledPlugins plugins = loadPluginsJson();
int size = plugins.getInstalled().size();
List<JadxPluginUpdate> updates = new ArrayList<>(size);
List<JadxPluginMetadata> newList = new ArrayList<>(size);
for (JadxPluginMetadata plugin : plugins.getInstalled()) {
JadxPluginMetadata newVersion = update(plugin);
JadxPluginMetadata newVersion = null;
try {
newVersion = update(plugin);
} catch (Exception e) {
LOG.warn("Failed to update plugin: {}", plugin.getPluginId(), e);
}
if (newVersion != null) {
updates.add(new JadxPluginUpdate(plugin, newVersion));
newList.add(newVersion);
@@ -135,7 +180,7 @@ public class JadxPluginsTools {
for (JadxPluginMetadata pluginMetadata : loadPluginsJson().getInstalled()) {
list.add(INSTALLED_DIR.resolve(pluginMetadata.getJar()));
}
collectFromDir(list, DROPINS_DIR);
collectJarsFromDir(list, DROPINS_DIR);
return list;
}
@@ -147,7 +192,7 @@ public class JadxPluginsTools {
}
list.add(INSTALLED_DIR.resolve(pluginMetadata.getJar()));
}
collectFromDir(list, DROPINS_DIR);
collectJarsFromDir(list, DROPINS_DIR);
return list;
}
@@ -172,7 +217,7 @@ public class JadxPluginsTools {
}
private @Nullable JadxPluginMetadata update(JadxPluginMetadata plugin) {
IJadxPluginResolver resolver = ResolversRegistry.getById(plugin.getResolverId());
IJadxPluginResolver resolver = ResolversRegistry.getResolver(plugin.getLocationId());
if (!resolver.isUpdateSupported()) {
return null;
}
@@ -189,19 +234,28 @@ public class JadxPluginsTools {
return update;
}
public void install(JadxPluginMetadata metadata) {
private void install(JadxPluginMetadata metadata) {
String reqVersionStr = metadata.getRequiredJadxVersion();
if (!VerifyRequiredVersion.isJadxCompatible(reqVersionStr)) {
throw new JadxRuntimeException("Can't install plugin, required version: \"" + reqVersionStr + '\"'
+ " is not compatible with current jadx version: " + Jadx.getVersion());
}
String version = metadata.getVersion();
String fileName = metadata.getPluginId() + (version != null ? '-' + version : "") + ".jar";
String fileName = metadata.getPluginId() + (StringUtils.notBlank(version) ? '-' + version : "") + ".jar";
Path pluginJar = INSTALLED_DIR.resolve(fileName);
copyJar(Paths.get(metadata.getJar()), pluginJar);
metadata.setJar(INSTALLED_DIR.relativize(pluginJar).toString());
JadxInstalledPlugins plugins = loadPluginsJson();
// remove previous version jar
plugins.getInstalled().stream()
.filter(p -> p.getPluginId().equals(metadata.getPluginId()))
.forEach(this::deletePluginJar);
plugins.getInstalled().remove(metadata);
plugins.getInstalled().removeIf(p -> {
if (p.getPluginId().equals(metadata.getPluginId())) {
deletePluginJar(p);
return true;
}
return false;
});
plugins.getInstalled().add(metadata);
plugins.setUpdated(System.currentTimeMillis());
savePluginsJson(plugins);
@@ -227,6 +281,9 @@ public class JadxPluginsTools {
metadata.setName(pluginInfo.getName());
metadata.setDescription(pluginInfo.getDescription());
metadata.setHomepage(pluginInfo.getHomepage());
metadata.setRequiredJadxVersion(pluginInfo.getRequiredJadxVersion());
} catch (NoSuchMethodError e) {
throw new RuntimeException("Looks like plugin uses unknown API, try to update jadx version", e);
}
}
@@ -258,10 +315,14 @@ public class JadxPluginsTools {
private JadxInstalledPlugins loadPluginsJson() {
if (!Files.isRegularFile(PLUGINS_JSON)) {
return new JadxInstalledPlugins();
JadxInstalledPlugins plugins = new JadxInstalledPlugins();
plugins.setVersion(1);
return plugins;
}
try (Reader reader = Files.newBufferedReader(PLUGINS_JSON, StandardCharsets.UTF_8)) {
return buildGson().fromJson(reader, JadxInstalledPlugins.class);
JadxInstalledPlugins data = buildGson().fromJson(reader, JadxInstalledPlugins.class);
upgradePluginsData(data);
return data;
} catch (Exception e) {
throw new RuntimeException("Failed to read file: " + PLUGINS_JSON);
}
@@ -284,7 +345,13 @@ public class JadxPluginsTools {
}
}
private static void collectFromDir(List<Path> list, Path dir) {
private void upgradePluginsData(JadxInstalledPlugins data) {
if (data.getVersion() == 0) {
data.setVersion(1);
}
}
private static void collectJarsFromDir(List<Path> list, Path dir) {
try (Stream<Path> files = Files.list(dir)) {
files.filter(p -> p.getFileName().toString().endsWith(".jar")).forEach(list::add);
} catch (IOException e) {
@@ -4,11 +4,18 @@ import java.util.ArrayList;
import java.util.List;
public class JadxInstalledPlugins {
private int version;
private long updated;
private List<JadxPluginMetadata> installed = new ArrayList<>();
public int getVersion() {
return version;
}
public void setVersion(int version) {
this.version = version;
}
public long getUpdated() {
return updated;
}
@@ -8,9 +8,10 @@ public class JadxPluginMetadata implements Comparable<JadxPluginMetadata> {
private String name;
private String description;
private String homepage;
private @Nullable String requiredJadxVersion;
private @Nullable String version;
private String locationId;
private String resolverId;
private String jar;
private boolean disabled;
@@ -34,7 +35,7 @@ public class JadxPluginMetadata implements Comparable<JadxPluginMetadata> {
return version;
}
public void setVersion(String version) {
public void setVersion(@Nullable String version) {
this.version = version;
}
@@ -54,6 +55,14 @@ public class JadxPluginMetadata implements Comparable<JadxPluginMetadata> {
this.homepage = homepage;
}
public @Nullable String getRequiredJadxVersion() {
return requiredJadxVersion;
}
public void setRequiredJadxVersion(@Nullable String requiredJadxVersion) {
this.requiredJadxVersion = requiredJadxVersion;
}
public String getLocationId() {
return locationId;
}
@@ -62,14 +71,6 @@ public class JadxPluginMetadata implements Comparable<JadxPluginMetadata> {
this.locationId = locationId;
}
public String getResolverId() {
return resolverId;
}
public void setResolverId(String resolverId) {
this.resolverId = resolverId;
}
public String getJar() {
return jar;
}
@@ -1,14 +1,37 @@
package jadx.plugins.tools.resolvers;
import java.util.List;
import java.util.Optional;
import jadx.plugins.tools.data.JadxPluginMetadata;
public interface IJadxPluginResolver {
/**
* Unique resolver identifier, should be same as locationId prefix
*/
String id();
/**
* This resolver support updates and can fetch the latest version.
*/
boolean isUpdateSupported();
/**
* Fetch the latest version plugin metadata by location
*/
Optional<JadxPluginMetadata> resolve(String locationId);
/**
* Fetch several latest versions (pageable) of plugin by locationId.
*
* @param page page number, starts with 1
* @param perPage result's count limit
*/
List<JadxPluginMetadata> resolveVersions(String locationId, int page, int perPage);
/**
* Check if locationId has a specified version number
*/
boolean hasVersion(String locationId);
}
@@ -1,16 +1,15 @@
package jadx.plugins.tools.resolvers;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
import java.util.Objects;
import jadx.plugins.tools.data.JadxPluginMetadata;
import jadx.plugins.tools.resolvers.file.LocalFileResolver;
import jadx.plugins.tools.resolvers.github.GithubReleaseResolver;
public class ResolversRegistry {
private static final Map<String, IJadxPluginResolver> RESOLVERS_MAP = new TreeMap<>();
private static final Map<String, IJadxPluginResolver> RESOLVERS_MAP = new HashMap<>();
static {
register(new LocalFileResolver());
@@ -21,14 +20,13 @@ public class ResolversRegistry {
RESOLVERS_MAP.put(resolver.id(), resolver);
}
public static Optional<JadxPluginMetadata> resolve(String locationId) {
for (IJadxPluginResolver resolver : RESOLVERS_MAP.values()) {
Optional<JadxPluginMetadata> result = resolver.resolve(locationId);
if (result.isPresent()) {
return result;
}
public static IJadxPluginResolver getResolver(String locationId) {
Objects.requireNonNull(locationId);
int sep = locationId.indexOf(':');
if (sep <= 0) {
throw new IllegalArgumentException("Malformed locationId: " + locationId);
}
return Optional.empty();
return getById(locationId.substring(0, sep));
}
public static IJadxPluginResolver getById(String resolverId) {
@@ -1,6 +1,7 @@
package jadx.plugins.tools.resolvers.file;
import java.io.File;
import java.util.List;
import java.util.Optional;
import jadx.plugins.tools.data.JadxPluginMetadata;
@@ -30,8 +31,23 @@ public class LocalFileResolver implements IJadxPluginResolver {
}
JadxPluginMetadata metadata = new JadxPluginMetadata();
metadata.setLocationId(locationId);
metadata.setResolverId(id());
metadata.setJar(jarFile.getAbsolutePath());
return Optional.of(metadata);
}
@Override
public List<JadxPluginMetadata> resolveVersions(String locationId, int page, int perPage) {
if (page > 1) {
// no other versions
return List.of();
}
// return only the current file
return resolve(locationId).map(List::of).orElseGet(List::of);
}
@Override
public boolean hasVersion(String locationId) {
// no supported
return false;
}
}
@@ -3,6 +3,7 @@ package jadx.plugins.tools.resolvers.github;
import java.util.List;
import java.util.Optional;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import jadx.core.utils.ListUtils;
import jadx.plugins.tools.data.JadxPluginMetadata;
@@ -22,16 +23,38 @@ public class GithubReleaseResolver implements IJadxPluginResolver {
return Optional.empty();
}
Release release = GithubTools.fetchRelease(info);
JadxPluginMetadata metadata = buildMetadata(release, info);
return Optional.of(metadata);
}
@Override
public List<JadxPluginMetadata> resolveVersions(String locationId, int page, int perPage) {
LocationInfo info = parseLocation(locationId);
if (info == null) {
return List.of();
}
return GithubTools.fetchReleases(info, page, perPage)
.stream()
.map(r -> buildMetadata(r, info))
.collect(Collectors.toList());
}
@Override
public boolean hasVersion(String locationId) {
LocationInfo locationInfo = parseLocation(locationId);
return locationInfo != null && locationInfo.getVersion() != null;
}
private JadxPluginMetadata buildMetadata(Release release, LocationInfo info) {
List<Asset> assets = release.getAssets();
String releaseVersion = removePrefix(release.getName(), "v");
Asset asset = searchPluginAsset(assets, info.getArtifactPrefix(), releaseVersion);
JadxPluginMetadata metadata = new JadxPluginMetadata();
metadata.setResolverId(id());
metadata.setVersion(releaseVersion);
metadata.setLocationId(buildLocationIdWithoutVersion(info)); // exclude version for later updates
metadata.setJar(asset.getDownloadUrl());
return Optional.of(metadata);
return metadata;
}
private static LocationInfo parseLocation(String locationId) {
@@ -87,7 +110,7 @@ public class GithubReleaseResolver implements IJadxPluginResolver {
@Override
public String id() {
return "github-release";
return "github";
}
@Override
@@ -30,8 +30,8 @@ public class GithubTools {
// get latest version
return get(projectUrl + "/releases/latest", RELEASE_TYPE);
}
// search version among all releases (by name)
List<Release> releases = get(projectUrl + "/releases", RELEASE_LIST_TYPE);
// search version in other releases (by name)
List<Release> releases = fetchReleases(info, 1, 50);
return releases.stream()
.filter(r -> r.getName().equals(version))
.findFirst()
@@ -39,6 +39,12 @@ public class GithubTools {
+ " Available versions: " + releases.stream().map(Release::getName).collect(Collectors.joining(", "))));
}
public static List<Release> fetchReleases(LocationInfo info, int page, int perPage) {
String projectUrl = GITHUB_API_URL + "repos/" + info.getOwner() + "/" + info.getProject();
String requestUrl = projectUrl + "/releases?page=" + page + "&per_page=" + perPage;
return get(requestUrl, RELEASE_LIST_TYPE);
}
private static <T> T get(String url, Type type) {
HttpURLConnection con;
try {