From 02ea3be8f278334bd6cf3f8407b099c57d4da9f2 Mon Sep 17 00:00:00 2001 From: Skylot <118523+skylot@users.noreply.github.com> Date: Mon, 22 Dec 2025 21:26:43 +0000 Subject: [PATCH] fix(plugins): improve errors handling and reporting (#2597) --- .../java/jadx/core/utils/files/FileUtils.java | 9 +- .../settings/ui/plugins/PluginSettings.java | 2 +- jadx-plugins-tools/build.gradle.kts | 3 + .../jadx/plugins/tools/JadxPluginsList.java | 4 +- .../jadx/plugins/tools/JadxPluginsTools.java | 55 +++++++----- .../tools/resolvers/github/GithubTools.java | 90 +++++++++++++++---- .../tools/resolvers/github/LocationInfo.java | 4 + .../resolvers/github/GithubToolsTest.java | 78 ++++++++++++++++ .../resources/github/plugins-list-good.json | 38 ++++++++ 9 files changed, 240 insertions(+), 43 deletions(-) create mode 100644 jadx-plugins-tools/src/test/java/jadx/plugins/tools/resolvers/github/GithubToolsTest.java create mode 100644 jadx-plugins-tools/src/test/resources/github/plugins-list-good.json diff --git a/jadx-core/src/main/java/jadx/core/utils/files/FileUtils.java b/jadx-core/src/main/java/jadx/core/utils/files/FileUtils.java index c8eef306e..51a0f1541 100644 --- a/jadx-core/src/main/java/jadx/core/utils/files/FileUtils.java +++ b/jadx-core/src/main/java/jadx/core/utils/files/FileUtils.java @@ -290,10 +290,11 @@ public class FileUtils { } public static byte[] streamToByteArray(InputStream input) throws IOException { - try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { - copyStream(input, out); - return out.toByteArray(); - } + return input.readAllBytes(); + } + + public static String streamToString(InputStream input) throws IOException { + return new String(streamToByteArray(input), StandardCharsets.UTF_8); } public static void close(Closeable c) { diff --git a/jadx-gui/src/main/java/jadx/gui/settings/ui/plugins/PluginSettings.java b/jadx-gui/src/main/java/jadx/gui/settings/ui/plugins/PluginSettings.java index 4e5cef0c7..48b94add6 100644 --- a/jadx-gui/src/main/java/jadx/gui/settings/ui/plugins/PluginSettings.java +++ b/jadx-gui/src/main/java/jadx/gui/settings/ui/plugins/PluginSettings.java @@ -82,7 +82,7 @@ public class PluginSettings { LOG.info("Plugin installed: {}", metadata); requestReload(); } catch (Exception e) { - LOG.error("Install failed", e); + LOG.error("Plugin install failed", e); mainWindow.showLogViewer(LogOptions.forLevel(Level.ERROR)); } }); diff --git a/jadx-plugins-tools/build.gradle.kts b/jadx-plugins-tools/build.gradle.kts index 1edbd7c2b..b10779b86 100644 --- a/jadx-plugins-tools/build.gradle.kts +++ b/jadx-plugins-tools/build.gradle.kts @@ -1,4 +1,5 @@ plugins { + id("jadx-java") id("jadx-library") } @@ -8,4 +9,6 @@ dependencies { implementation(project(":jadx-commons:jadx-app-commons")) implementation("com.google.code.gson:gson:2.13.2") + + testImplementation("com.squareup.okhttp3:mockwebserver3:5.3.0") } diff --git a/jadx-plugins-tools/src/main/java/jadx/plugins/tools/JadxPluginsList.java b/jadx-plugins-tools/src/main/java/jadx/plugins/tools/JadxPluginsList.java index 5128cc2ba..62c845b32 100644 --- a/jadx-plugins-tools/src/main/java/jadx/plugins/tools/JadxPluginsList.java +++ b/jadx-plugins-tools/src/main/java/jadx/plugins/tools/JadxPluginsList.java @@ -102,8 +102,8 @@ public class JadxPluginsList { } private Release fetchLatestRelease() { - LocationInfo latest = new LocationInfo("jadx-decompiler", "jadx-plugins-list", "list", null); - Release release = GithubTools.fetchRelease(latest); + LocationInfo pluginsList = new LocationInfo("jadx-decompiler", "jadx-plugins-list", "list"); + Release release = GithubTools.fetchRelease(pluginsList); List assets = release.getAssets(); if (assets.isEmpty()) { throw new RuntimeException("Release don't have assets"); diff --git a/jadx-plugins-tools/src/main/java/jadx/plugins/tools/JadxPluginsTools.java b/jadx-plugins-tools/src/main/java/jadx/plugins/tools/JadxPluginsTools.java index e1698988f..6e360707e 100644 --- a/jadx-plugins-tools/src/main/java/jadx/plugins/tools/JadxPluginsTools.java +++ b/jadx-plugins-tools/src/main/java/jadx/plugins/tools/JadxPluginsTools.java @@ -9,9 +9,11 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.function.Supplier; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -51,30 +53,43 @@ public class JadxPluginsTools { public JadxPluginMetadata install(String locationId) { 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; + Supplier> fetchVersions; + if (resolver.hasVersion(locationId)) { + fetchVersions = () -> { + JadxPluginMetadata version = resolver.resolve(locationId) + .orElseThrow(() -> new JadxRuntimeException("Failed to resolve plugin location: " + locationId)); + return Collections.singletonList(version); + }; + } else { + // load latest 10 version to search for compatible one + fetchVersions = () -> resolver.resolveVersions(locationId, 1, 10); + } + List versionsMetadata; + try { + versionsMetadata = fetchVersions.get(); + } catch (Exception e) { + throw new JadxRuntimeException("Plugin info fetch failed, locationId: " + locationId, e); + } + if (versionsMetadata.isEmpty()) { + throw new JadxRuntimeException("Plugin release not found, locationId: " + locationId); } - // 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); + List rejectedVersions = new ArrayList<>(); + for (JadxPluginMetadata pluginMetadata : versionsMetadata) { + // download plugin jar and fill metadata + // any download or plugin instantiation errors will stop versions check + fillMetadata(pluginMetadata); + if (verifyRequiredVersion.isCompatible(pluginMetadata.getRequiredJadxVersion())) { + install(pluginMetadata); + return pluginMetadata; } + rejectedVersions.add(" version " + pluginMetadata.getVersion() + + " not compatible, require: " + pluginMetadata.getRequiredJadxVersion()); } - throw new JadxRuntimeException("Can't find compatible version to install"); + throw new JadxRuntimeException("Can't find compatible version to install" + + ", current jadx version: " + verifyRequiredVersion.getJadxVersion() + + "\nrejected versions:\n" + + String.join("\n", rejectedVersions)); } public JadxPluginMetadata resolveMetadata(String locationId) { diff --git a/jadx-plugins-tools/src/main/java/jadx/plugins/tools/resolvers/github/GithubTools.java b/jadx-plugins-tools/src/main/java/jadx/plugins/tools/resolvers/github/GithubTools.java index 7c03abb45..c3a694fe9 100644 --- a/jadx-plugins-tools/src/main/java/jadx/plugins/tools/resolvers/github/GithubTools.java +++ b/jadx-plugins-tools/src/main/java/jadx/plugins/tools/resolvers/github/GithubTools.java @@ -1,23 +1,26 @@ package jadx.plugins.tools.resolvers.github; import java.io.IOException; +import java.io.InputStream; import java.io.InputStreamReader; import java.io.Reader; import java.lang.reflect.Type; import java.net.HttpURLConnection; import java.net.URI; import java.nio.charset.StandardCharsets; +import java.time.Instant; import java.util.List; import java.util.stream.Collectors; import com.google.gson.reflect.TypeToken; +import jadx.core.utils.files.FileUtils; import jadx.plugins.tools.resolvers.github.data.Release; import static jadx.core.utils.GsonUtils.buildGson; public class GithubTools { - private static final String GITHUB_API_URL = "https://api.github.com/"; + private static final GithubTools GITHUB_INSTANCE = new GithubTools("https://api.github.com"); private static final Type RELEASE_TYPE = new TypeToken() { }.getType(); @@ -25,7 +28,21 @@ public class GithubTools { }.getType(); public static Release fetchRelease(LocationInfo info) { - String projectUrl = GITHUB_API_URL + "repos/" + info.getOwner() + "/" + info.getProject(); + return GITHUB_INSTANCE.getRelease(info); + } + + public static List fetchReleases(LocationInfo info, int page, int perPage) { + return GITHUB_INSTANCE.getReleases(info, page, perPage); + } + + private final String baseUrl; + + GithubTools(String baseUrl) { + this.baseUrl = baseUrl; + } + + Release getRelease(LocationInfo info) { + String projectUrl = baseUrl + "/repos/" + info.getOwner() + "/" + info.getProject(); String version = info.getVersion(); if (version == null) { // get latest version @@ -40,29 +57,70 @@ public class GithubTools { + " Available versions: " + releases.stream().map(Release::getName).collect(Collectors.joining(", ")))); } - public static List fetchReleases(LocationInfo info, int page, int perPage) { - String projectUrl = GITHUB_API_URL + "repos/" + info.getOwner() + "/" + info.getProject(); + List getReleases(LocationInfo info, int page, int perPage) { + String projectUrl = baseUrl + "/repos/" + info.getOwner() + "/" + info.getProject(); String requestUrl = projectUrl + "/releases?page=" + page + "&per_page=" + perPage; return get(requestUrl, RELEASE_LIST_TYPE); } private static T get(String url, Type type) { - HttpURLConnection con; + HttpURLConnection con = null; try { - con = (HttpURLConnection) URI.create(url).toURL().openConnection(); - con.setRequestMethod("GET"); - int code = con.getResponseCode(); - if (code != 200) { - // TODO: support redirects? - throw new RuntimeException("Request failed, response: " + code + ", url: " + url); + try { + con = (HttpURLConnection) URI.create(url).toURL().openConnection(); + con.setRequestMethod("GET"); + con.setInstanceFollowRedirects(true); + int code = con.getResponseCode(); + if (code != 200) { + throw new RuntimeException(buildErrorDetails(con, url)); + } + } catch (IOException e) { + throw new RuntimeException("Request failed, url: " + url, e); + } + try (Reader reader = new InputStreamReader(con.getInputStream(), StandardCharsets.UTF_8)) { + return buildGson().fromJson(reader, type); + } catch (Exception e) { + throw new RuntimeException("Failed to parse response, url: " + url, e); + } + } finally { + if (con != null) { + con.disconnect(); } - } catch (IOException e) { - throw new RuntimeException("Request failed, url: " + url, e); } - try (Reader reader = new InputStreamReader(con.getInputStream(), StandardCharsets.UTF_8)) { - return buildGson().fromJson(reader, type); + } + + private static String buildErrorDetails(HttpURLConnection con, String url) throws IOException { + String shortMsg = con.getResponseMessage(); + String remainRateLimit = con.getHeaderField("X-RateLimit-Remaining"); + if ("0".equals(remainRateLimit)) { + String resetTimeMs = con.getHeaderField("X-RateLimit-Reset"); + String timeStr = resetTimeMs != null ? "after " + Instant.ofEpochSecond(Long.parseLong(resetTimeMs)) : "in one hour"; + shortMsg += " (rate limit reached, try again " + timeStr + ')'; + } + StringBuilder headers = new StringBuilder(); + for (int i = 0;; i++) { + String value = con.getHeaderField(i); + if (value == null) { + break; + } + String key = con.getHeaderFieldKey(i); + if (key != null) { + headers.append('\n').append(key).append(": ").append(value); + } + } + String responseStr = getResponseString(con); + return "Request failed: " + con.getResponseCode() + ' ' + shortMsg + + "\nURL: " + url + + "\nHeaders:" + headers + + (responseStr.isEmpty() ? "" : "\nresponse:\n" + responseStr); + } + + private static String getResponseString(HttpURLConnection con) { + try (InputStream in = con.getInputStream()) { + return new String(FileUtils.streamToByteArray(in), StandardCharsets.UTF_8); } catch (Exception e) { - throw new RuntimeException("Failed to parse response, url: " + url, e); + // ignore + return ""; } } } diff --git a/jadx-plugins-tools/src/main/java/jadx/plugins/tools/resolvers/github/LocationInfo.java b/jadx-plugins-tools/src/main/java/jadx/plugins/tools/resolvers/github/LocationInfo.java index f771865af..06f70aaf6 100644 --- a/jadx-plugins-tools/src/main/java/jadx/plugins/tools/resolvers/github/LocationInfo.java +++ b/jadx-plugins-tools/src/main/java/jadx/plugins/tools/resolvers/github/LocationInfo.java @@ -8,6 +8,10 @@ public class LocationInfo { private final String artifactPrefix; private final @Nullable String version; + public LocationInfo(String owner, String project, String artifactPrefix) { + this(owner, project, artifactPrefix, null); + } + public LocationInfo(String owner, String project, String artifactPrefix, @Nullable String version) { this.owner = owner; this.project = project; diff --git a/jadx-plugins-tools/src/test/java/jadx/plugins/tools/resolvers/github/GithubToolsTest.java b/jadx-plugins-tools/src/test/java/jadx/plugins/tools/resolvers/github/GithubToolsTest.java new file mode 100644 index 000000000..3c7c1c866 --- /dev/null +++ b/jadx-plugins-tools/src/test/java/jadx/plugins/tools/resolvers/github/GithubToolsTest.java @@ -0,0 +1,78 @@ +package jadx.plugins.tools.resolvers.github; + +import java.io.IOException; +import java.io.InputStream; +import java.time.Instant; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import mockwebserver3.MockResponse; +import mockwebserver3.MockWebServer; + +import jadx.core.utils.files.FileUtils; +import jadx.plugins.tools.resolvers.github.data.Release; + +import static org.assertj.core.api.Assertions.assertThat; + +class GithubToolsTest { + private static final Logger LOG = LoggerFactory.getLogger(GithubToolsTest.class); + + private MockWebServer server; + private GithubTools githubTools; + + @BeforeEach + public void setup() throws IOException { + server = new MockWebServer(); + server.start(); + String baseUrl = server.url("/").toString(); + githubTools = new GithubTools(baseUrl); + } + + @AfterEach + public void close() { + server.close(); + } + + @Test + public void getReleaseGood() { + server.enqueue(new MockResponse.Builder() + .body(loadFromResource("plugins-list-good.json")) + .build()); + + LocationInfo pluginsList = new LocationInfo("jadx-decompiler", "jadx-plugins-list", "list"); + Release release = githubTools.getRelease(pluginsList); + + LOG.info("Result release: {}", release); + assertThat(release.getName()).isEqualTo("v15"); + assertThat(release.getAssets()).hasSize(1); + } + + @Test + public void getReleaseRateLimit() { + server.enqueue(new MockResponse.Builder() + .code(403) + .addHeader("x-ratelimit-remaining", "0") + .addHeader("x-ratelimit-reset", Instant.now().plusSeconds(60 * 60).getEpochSecond()) // 1 hour from now + .body("{}") + .build()); + + LocationInfo pluginsList = new LocationInfo("jadx-decompiler", "jadx-plugins-list", "list"); + Assertions.assertThatThrownBy(() -> githubTools.getRelease(pluginsList)) + .hasMessageContaining("403") + .hasMessageContaining("Client Error") + .hasMessageContaining("rate limit reached"); + } + + private static String loadFromResource(String resName) { + try (InputStream stream = GithubToolsTest.class.getResourceAsStream("/github/" + resName)) { + return FileUtils.streamToString(stream); + } catch (IOException e) { + throw new RuntimeException("Failed to load resource: " + resName, e); + } + } +} diff --git a/jadx-plugins-tools/src/test/resources/github/plugins-list-good.json b/jadx-plugins-tools/src/test/resources/github/plugins-list-good.json new file mode 100644 index 000000000..77a909d4f --- /dev/null +++ b/jadx-plugins-tools/src/test/resources/github/plugins-list-good.json @@ -0,0 +1,38 @@ +{ + "assets": [ + { + "browser_download_url": "https://github.com/jadx-decompiler/jadx-plugins-list/releases/download/v15/jadx-plugins-list.zip", + "content_type": "application/zip", + "created_at": "2025-11-29T16:20:40Z", + "digest": "sha256:a2a45c3a22be56b6f9c7e24d52c6411c4d546f386d7ea1e4ba124d4d28b4cf75", + "download_count": 3412, + "id": 322246364, + "label": null, + "name": "jadx-plugins-list.zip", + "node_id": "RA_kwDOKEBYcM4TNRbc", + "size": 1260, + "state": "uploaded", + "updated_at": "2025-11-29T16:20:42Z", + "url": "https://api.github.com/repos/jadx-decompiler/jadx-plugins-list/releases/assets/322246364" + } + ], + "assets_url": "https://api.github.com/repos/jadx-decompiler/jadx-plugins-list/releases/266146702/assets", + "body": "What's Changed...", + "created_at": "2025-11-29T16:19:33Z", + "draft": false, + "html_url": "https://github.com/jadx-decompiler/jadx-plugins-list/releases/tag/v15", + "id": 266146702, + "immutable": false, + "mentions_count": 1, + "name": "v15", + "node_id": "RE_kwDOKEBYcM4P3ROO", + "prerelease": false, + "published_at": "2025-11-29T16:20:50Z", + "tag_name": "v15", + "tarball_url": "https://api.github.com/repos/jadx-decompiler/jadx-plugins-list/tarball/v15", + "target_commitish": "main", + "updated_at": "2025-11-29T16:20:50Z", + "upload_url": "https://uploads.github.com/repos/jadx-decompiler/jadx-plugins-list/releases/266146702/assets{?name,label}", + "url": "https://api.github.com/repos/jadx-decompiler/jadx-plugins-list/releases/266146702", + "zipball_url": "https://api.github.com/repos/jadx-decompiler/jadx-plugins-list/zipball/v15" +}