From 1af0b50a1f10f4e5c105fba33f685981be411287 Mon Sep 17 00:00:00 2001 From: Florian Magin Date: Fri, 20 Mar 2026 14:01:12 +0100 Subject: [PATCH 1/2] Properly publish CI results --- .github/workflows/build.yml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bb4c39f..4e48e98 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -67,4 +67,19 @@ jobs: run: $GHIDRA_INSTALL_DIR/support/sleigh $GHIDRA_INSTALL_DIR/Ghidra/Processors/x86/data/languages/x86-64.slaspec - name: Run tests - run: xvfb-run ./gradlew test --info + run: xvfb-run ./gradlew test + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-ghidra-${{ matrix.ghidra }} + path: build/reports/tests/test/ + + - name: Publish test report + if: always() + uses: dorny/test-reporter@v1 + with: + name: Tests (Ghidra ${{ matrix.ghidra }}) + path: build/test-results/test/*.xml + reporter: java-junit From a702537086c73f8a14e6648d3a74977f32be96e8 Mon Sep 17 00:00:00 2001 From: Florian Magin Date: Fri, 20 Mar 2026 13:37:42 +0100 Subject: [PATCH 2/2] Fix NPE when config is not available yet and add tests for initial setup This also fixes a few other issues that were hidden by the initial NPE. --- ghidra_scripts/RevEngExamplePostScript.java | 3 +- .../ui/collectiondialog/BinaryTableModel.java | 6 +- .../CollectionTableModel.java | 5 +- .../services/api/TypedApiImplementation.java | 8 +- .../core/services/api/types/ApiInfo.java | 36 +--- .../core/ui/wizard/CredentialValidator.java | 26 +++ .../core/ui/wizard/SetupWizardManager.java | 11 +- .../core/ui/wizard/SetupWizardStateKey.java | 1 + .../wizard/panels/UserCredentialsPanel.java | 21 ++- .../plugins/BinarySimilarityPlugin.java | 43 +++-- .../ghidra/plugins/ReaiAPIServicePlugin.java | 93 ++++------ .../ghidra/plugins/ReaiPluginPackage.java | 3 + .../ai/reveng/ReaiAPIServicePluginTest.java | 111 ++++++++++++ src/test/java/ai/reveng/SetupWizardTest.java | 169 +++++++++++++++++- .../reveng/TestableReaiAPIServicePlugin.java | 44 +++++ 15 files changed, 456 insertions(+), 124 deletions(-) create mode 100644 src/main/java/ai/reveng/toolkit/ghidra/core/ui/wizard/CredentialValidator.java create mode 100644 src/test/java/ai/reveng/ReaiAPIServicePluginTest.java create mode 100644 src/test/java/ai/reveng/TestableReaiAPIServicePlugin.java diff --git a/ghidra_scripts/RevEngExamplePostScript.java b/ghidra_scripts/RevEngExamplePostScript.java index dbdfe7e..e9ee7c4 100644 --- a/ghidra_scripts/RevEngExamplePostScript.java +++ b/ghidra_scripts/RevEngExamplePostScript.java @@ -1,6 +1,7 @@ import ai.reveng.toolkit.ghidra.core.services.api.AnalysisOptionsBuilder; import ai.reveng.toolkit.ghidra.core.services.api.GhidraRevengService; import ai.reveng.toolkit.ghidra.core.services.api.types.ApiInfo; +import ai.reveng.toolkit.ghidra.plugins.ReaiPluginPackage; import ghidra.app.plugin.core.analysis.AutoAnalysisManager; import ghidra.app.script.GhidraScript; @@ -8,7 +9,7 @@ public class RevEngExamplePostScript extends GhidraScript { @Override protected void run() throws Exception { // Services are not available in headless mode, so we have to instantiate it ourselves - var ghidraRevengService = new GhidraRevengService(ApiInfo.fromConfig()); + var ghidraRevengService = new GhidraRevengService(ApiInfo.fromConfig(ReaiPluginPackage.DEFAULT_CONFIG_PATH)); ghidraRevengService.upload(currentProgram); diff --git a/src/main/java/ai/reveng/toolkit/ghidra/binarysimilarity/ui/collectiondialog/BinaryTableModel.java b/src/main/java/ai/reveng/toolkit/ghidra/binarysimilarity/ui/collectiondialog/BinaryTableModel.java index 3e605cc..ca4b083 100644 --- a/src/main/java/ai/reveng/toolkit/ghidra/binarysimilarity/ui/collectiondialog/BinaryTableModel.java +++ b/src/main/java/ai/reveng/toolkit/ghidra/binarysimilarity/ui/collectiondialog/BinaryTableModel.java @@ -21,12 +21,12 @@ public BinaryTableModel(PluginTool tool) { @Override protected void doLoad(Accumulator accumulator, TaskMonitor monitor) throws CancelledException { - serviceProvider.getService(GhidraRevengService.class) - .getActiveAnalysisIDsFilter() + var service = serviceProvider.getService(GhidraRevengService.class); + if (service == null) return; + service.getActiveAnalysisIDsFilter() .forEach( analysisResult -> accumulator.add(new BinaryRowObject(analysisResult, true)) ); - } @Override diff --git a/src/main/java/ai/reveng/toolkit/ghidra/binarysimilarity/ui/collectiondialog/CollectionTableModel.java b/src/main/java/ai/reveng/toolkit/ghidra/binarysimilarity/ui/collectiondialog/CollectionTableModel.java index dde0e6c..a7c7b3e 100644 --- a/src/main/java/ai/reveng/toolkit/ghidra/binarysimilarity/ui/collectiondialog/CollectionTableModel.java +++ b/src/main/java/ai/reveng/toolkit/ghidra/binarysimilarity/ui/collectiondialog/CollectionTableModel.java @@ -26,12 +26,13 @@ public CollectionTableModel(PluginTool plugin) { @Override protected void doLoad(Accumulator accumulator, TaskMonitor monitor){ + var service = serviceProvider.getService(GhidraRevengService.class); + if (service == null) return; monitor.setProgress(0); monitor.setMessage("Loading collections"); - serviceProvider.getService(GhidraRevengService.class).getActiveCollections().forEach(collection -> { + service.getActiveCollections().forEach(collection -> { accumulator.add(new CollectionRowObject(collection, true)); }); - } @Override diff --git a/src/main/java/ai/reveng/toolkit/ghidra/core/services/api/TypedApiImplementation.java b/src/main/java/ai/reveng/toolkit/ghidra/core/services/api/TypedApiImplementation.java index 796eff7..be660f5 100644 --- a/src/main/java/ai/reveng/toolkit/ghidra/core/services/api/TypedApiImplementation.java +++ b/src/main/java/ai/reveng/toolkit/ghidra/core/services/api/TypedApiImplementation.java @@ -76,10 +76,12 @@ public TypedApiImplementation(String baseUrl, String apiKey) { try { // This file comes from the release.yml running in the CI var inputStream = ResourceManager.getResourceAsStream("reai_ghidra_plugin_version.txt"); - pluginVersion = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8).trim(); - inputStream.close(); + if (inputStream != null) { + pluginVersion = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8).trim(); + inputStream.close(); + } } catch (IOException e) { - + // ignore — fall back to "unknown" } // Looks like: // Ghidra/11.3.2-PUBLIC (LINUX(Linux) X86_64(amd64)) RevEng.AI_Plugin/v0.15 diff --git a/src/main/java/ai/reveng/toolkit/ghidra/core/services/api/types/ApiInfo.java b/src/main/java/ai/reveng/toolkit/ghidra/core/services/api/types/ApiInfo.java index 6f2205d..037e360 100644 --- a/src/main/java/ai/reveng/toolkit/ghidra/core/services/api/types/ApiInfo.java +++ b/src/main/java/ai/reveng/toolkit/ghidra/core/services/api/types/ApiInfo.java @@ -1,8 +1,6 @@ package ai.reveng.toolkit.ghidra.core.services.api.types; import ai.reveng.toolkit.ghidra.core.models.ReaiConfig; -import ai.reveng.toolkit.ghidra.core.services.api.TypedApiImplementation; -import ai.reveng.toolkit.ghidra.core.services.api.types.exceptions.InvalidAPIInfoException; import com.google.gson.Gson; import org.json.JSONException; @@ -10,7 +8,6 @@ import java.io.FileReader; import java.net.URI; import java.nio.file.Path; -import java.nio.file.Paths; public record ApiInfo( URI hostURI, @@ -21,21 +18,15 @@ public ApiInfo(String hostURI, String portalURI, String apiKey) { this(URI.create(hostURI), URI.create(portalURI), apiKey); } - public void checkCredentials() throws InvalidAPIInfoException { - if (hostURI == null || apiKey == null){ - throw new InvalidAPIInfoException("hostURI and apiKey must not be null"); - } - var api = new TypedApiImplementation(this); - - // Throws InvalidAPIInfoException if authentication fails - api.authenticate(); - } - public static ApiInfo fromConfig(Path configFilePath) throws FileNotFoundException { - // Read and parse the config file as JSON - FileReader reader = new FileReader(configFilePath.toString()); - Gson gson = new Gson(); - ReaiConfig config = gson.fromJson(reader, ReaiConfig.class); + ReaiConfig config; + try (FileReader reader = new FileReader(configFilePath.toString())) { + config = new Gson().fromJson(reader, ReaiConfig.class); + } catch (FileNotFoundException e) { + throw e; + } catch (java.io.IOException e) { + throw new RuntimeException("Failed to read config file: " + configFilePath, e); + } var apikey = config.getPluginSettings().getApiKey(); var hostname = config.getPluginSettings().getHostname(); var portalHostname = config.getPluginSettings().getPortalHostname(); @@ -46,15 +37,4 @@ public static ApiInfo fromConfig(Path configFilePath) throws FileNotFoundExcepti } return new ApiInfo(hostname, portalHostname, apikey); } - - public static ApiInfo fromConfig() throws FileNotFoundException { - String uHome = System.getProperty("user.home"); - String cDir = ".reai"; - String cFileName = "reai.json"; - Path configDirPath = Paths.get(uHome, cDir); - Path configFilePath = configDirPath.resolve(cFileName); - - return fromConfig(configFilePath); - - } } diff --git a/src/main/java/ai/reveng/toolkit/ghidra/core/ui/wizard/CredentialValidator.java b/src/main/java/ai/reveng/toolkit/ghidra/core/ui/wizard/CredentialValidator.java new file mode 100644 index 0000000..f72431c --- /dev/null +++ b/src/main/java/ai/reveng/toolkit/ghidra/core/ui/wizard/CredentialValidator.java @@ -0,0 +1,26 @@ +package ai.reveng.toolkit.ghidra.core.ui.wizard; + +import ai.reveng.toolkit.ghidra.core.services.api.TypedApiImplementation; +import ai.reveng.toolkit.ghidra.core.services.api.types.ApiInfo; +import ai.reveng.toolkit.ghidra.core.services.api.types.exceptions.InvalidAPIInfoException; + +/** + * Strategy for validating API credentials. + * Production code uses {@link #defaultValidator()} which makes a real API call; + * tests can supply a mock that avoids network calls. + */ +@FunctionalInterface +public interface CredentialValidator { + void validate(ApiInfo apiInfo) throws InvalidAPIInfoException; + + /** Default validator that makes a real API call. */ + static CredentialValidator defaultValidator() { + return apiInfo -> { + if (apiInfo.hostURI() == null || apiInfo.apiKey() == null) { + throw new InvalidAPIInfoException("hostURI and apiKey must not be null"); + } + var api = new TypedApiImplementation(apiInfo); + api.authenticate(); + }; + } +} diff --git a/src/main/java/ai/reveng/toolkit/ghidra/core/ui/wizard/SetupWizardManager.java b/src/main/java/ai/reveng/toolkit/ghidra/core/ui/wizard/SetupWizardManager.java index 23f4f2a..aaef0c2 100644 --- a/src/main/java/ai/reveng/toolkit/ghidra/core/ui/wizard/SetupWizardManager.java +++ b/src/main/java/ai/reveng/toolkit/ghidra/core/ui/wizard/SetupWizardManager.java @@ -4,7 +4,6 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; @@ -55,11 +54,11 @@ protected void doFinish() throws IllegalPanelStateException { tool.getOptions(REAI_OPTIONS_CATEGORY).setString(ReaiPluginPackage.OPTION_KEY_MODEL, model); tool.getOptions(REAI_OPTIONS_CATEGORY).setString(REAI_WIZARD_RUN_PREF, "true"); - String uHome = System.getProperty("user.home"); - String cDir = ".reai"; - String cFileName = "reai.json"; - Path configDirPath = Paths.get(uHome, cDir); - Path configFilePath = configDirPath.resolve(cFileName); + String configFileOverride = (String) getState().get(SetupWizardStateKey.CONFIGFILE); + Path configFilePath = configFileOverride != null + ? Path.of(configFileOverride) + : ReaiPluginPackage.DEFAULT_CONFIG_PATH; + Path configDirPath = configFilePath.getParent(); // check that our .reai directory exists if (!Files.exists(configDirPath)) { diff --git a/src/main/java/ai/reveng/toolkit/ghidra/core/ui/wizard/SetupWizardStateKey.java b/src/main/java/ai/reveng/toolkit/ghidra/core/ui/wizard/SetupWizardStateKey.java index f607ab2..d7c5c37 100644 --- a/src/main/java/ai/reveng/toolkit/ghidra/core/ui/wizard/SetupWizardStateKey.java +++ b/src/main/java/ai/reveng/toolkit/ghidra/core/ui/wizard/SetupWizardStateKey.java @@ -7,4 +7,5 @@ public enum SetupWizardStateKey { CREDENTIALS_VALIDATED, MODEL, CONFIGFILE, + CREDENTIAL_VALIDATOR, } diff --git a/src/main/java/ai/reveng/toolkit/ghidra/core/ui/wizard/panels/UserCredentialsPanel.java b/src/main/java/ai/reveng/toolkit/ghidra/core/ui/wizard/panels/UserCredentialsPanel.java index 5c49626..ff3355e 100644 --- a/src/main/java/ai/reveng/toolkit/ghidra/core/ui/wizard/panels/UserCredentialsPanel.java +++ b/src/main/java/ai/reveng/toolkit/ghidra/core/ui/wizard/panels/UserCredentialsPanel.java @@ -5,6 +5,7 @@ import ai.reveng.toolkit.ghidra.core.services.api.types.ApiInfo; import ai.reveng.toolkit.ghidra.core.services.api.types.exceptions.InvalidAPIInfoException; import ai.reveng.toolkit.ghidra.core.services.logging.ReaiLoggingService; +import ai.reveng.toolkit.ghidra.core.ui.wizard.CredentialValidator; import ai.reveng.toolkit.ghidra.core.ui.wizard.SetupWizardStateKey; import docking.wizard.AbstractMageJPanel; import docking.wizard.IllegalPanelStateException; @@ -26,6 +27,7 @@ public class UserCredentialsPanel extends AbstractMageJPanel { var apiInfo = new ApiInfo(tfApiHostname.getText(), tfPortalHostname.getText(), tfApiKey.getText()); try { - apiInfo.checkCredentials(); + credentialValidator.validate(apiInfo); credentialsValidated = true; - // TODO: Get the user for this key once the API exists notifyListenersOfValidityChanged(); - } catch (InvalidAPIInfoException ex) { credentialsValidated = false; notifyListenersOfStatusMessage("Problem with user info:\n" + ex.getMessage()); } - }); userDetailsPanel.add(runTestsButton, gbc); } @@ -186,6 +188,12 @@ public WizardPanelDisplayability getPanelDisplayabilityAndUpdateState(WizardStat @Override public void enterPanel(WizardState state) throws IllegalPanelStateException { + // Pick up credential validator from state if provided (e.g. by tests) + CredentialValidator validator = (CredentialValidator) state.get(SetupWizardStateKey.CREDENTIAL_VALIDATOR); + if (validator != null) { + this.credentialValidator = validator; + } + // Populate fields with existing state information if present String existingApiKey = (String) state.get(SetupWizardStateKey.API_KEY); String existingHostname = (String) state.get(SetupWizardStateKey.HOSTNAME); @@ -213,11 +221,6 @@ public void enterPanel(WizardState state) throws IllegalPan } } - private void validateCredentialsFromState() { - // This method is no longer used - validation state is preserved from wizard state - // Keeping for backwards compatibility but it should not be called - loggingService.warn("validateCredentialsFromState() called - this should not happen with new validation logic"); - } @Override public void leavePanel(WizardState state) { diff --git a/src/main/java/ai/reveng/toolkit/ghidra/plugins/BinarySimilarityPlugin.java b/src/main/java/ai/reveng/toolkit/ghidra/plugins/BinarySimilarityPlugin.java index d5b8fe8..106b1e3 100644 --- a/src/main/java/ai/reveng/toolkit/ghidra/plugins/BinarySimilarityPlugin.java +++ b/src/main/java/ai/reveng/toolkit/ghidra/plugins/BinarySimilarityPlugin.java @@ -4,9 +4,9 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -64,10 +64,11 @@ public class BinarySimilarityPlugin extends ProgramPlugin { private final AIDecompilationdWindow decompiledWindow; private final SimilarFunctionsWindow similarFunctionsWindow; - private GhidraRevengService apiService; - public final static String REVENG_AI_NAMESPACE = "RevEng.AI"; + private GhidraRevengService getApiService() { + return tool.getService(GhidraRevengService.class); + } @Override protected void locationChanged(ProgramLocation loc) { @@ -78,6 +79,11 @@ protected void locationChanged(ProgramLocation loc) { return; } + var apiService = getApiService(); + if (apiService == null) { + return; + } + // If no program, or not attached to a complete analysis, do not trigger location change events var program = loc.getProgram(); if (program == null || apiService.getAnalysedProgram(program).isEmpty()) { @@ -90,7 +96,7 @@ protected void locationChanged(ProgramLocation loc) { /** * Plugin constructor. - * + * * @param tool The plugin tool that this plugin is added to. */ public BinarySimilarityPlugin(PluginTool tool) { @@ -110,19 +116,13 @@ public BinarySimilarityPlugin(PluginTool tool) { collectionsComponent.addToTool(); } - /// In `init()` the services are guaranteed to be available - @Override - public void init() { - super.init(); - - apiService = tool.getService(GhidraRevengService.class); - } - private void setupActions() { new ActionBuilder("Auto Unstrip", this.getName()) .menuGroup(ReaiPluginPackage.NAME) .menuPath(ReaiPluginPackage.MENU_GROUP_NAME, "Auto Unstrip") .enabledWhen(context -> { + var apiService = getApiService(); + if (apiService == null) return false; var program = tool.getService(ProgramManager.class).getCurrentProgram(); if (program != null) { return apiService.getKnownProgram(program).isPresent(); @@ -132,6 +132,7 @@ private void setupActions() { } ) .onAction(context -> { + var apiService = getApiService(); var program = tool.getService(ProgramManager.class).getCurrentProgram(); if (apiService.getAnalysedProgram(program).isEmpty()) { Msg.showError(this, null, ReaiPluginPackage.WINDOW_PREFIX + "Auto Unstrip", @@ -155,6 +156,8 @@ private void setupActions() { .menuGroup(ReaiPluginPackage.NAME) .menuPath(ReaiPluginPackage.MENU_GROUP_NAME, "Function Matching") .enabledWhen(context -> { + var apiService = getApiService(); + if (apiService == null) return false; var program = tool.getService(ProgramManager.class).getCurrentProgram(); if (program != null) { return apiService.getAnalysedProgram(program).isPresent(); @@ -164,6 +167,7 @@ private void setupActions() { } ) .onAction(context -> { + var apiService = getApiService(); var program = tool.getService(ProgramManager.class).getCurrentProgram(); var knownProgram = apiService.getAnalysedProgram(program); if (knownProgram.isEmpty()){ @@ -181,6 +185,8 @@ private void setupActions() { new ActionBuilder("Match function", this.getName()) .withContext(ProgramLocationActionContext.class) .enabledWhen(context -> { + var apiService = getApiService(); + if (apiService == null) return false; var func = context.getProgram().getFunctionManager().getFunctionContaining(context.getAddress()); return func != null // Exclude thunks and external functions because we do not support them in the portal @@ -189,6 +195,7 @@ private void setupActions() { && apiService.getAnalysedProgram(context.getProgram()).isPresent(); }) .onAction(context -> { + var apiService = getApiService(); // We know analysed program is present due to enabledWhen var knownProgram = apiService.getAnalysedProgram(context.getProgram()).get(); @@ -206,6 +213,8 @@ private void setupActions() { new ActionBuilder("AI Decompilation", this.getName()) .withContext(ProgramLocationActionContext.class) .enabledWhen(context -> { + var apiService = getApiService(); + if (apiService == null) return false; var func = context.getProgram().getFunctionManager().getFunctionContaining(context.getAddress()); return func != null // Exclude thunks and external functions because we do not support them in the portal @@ -214,6 +223,7 @@ private void setupActions() { && apiService.getAnalysedProgram(context.getProgram()).isPresent(); }) .onAction(context -> { + var apiService = getApiService(); var func = context.getProgram().getFunctionManager().getFunctionContaining(context.getAddress()); var analysedProgram = apiService.getAnalysedProgram(context.getProgram()).get(); var functionWithId = analysedProgram.getIDForFunction(func); @@ -237,11 +247,14 @@ private void setupActions() { new ActionBuilder("View function in portal", this.getName()) .withContext(ProgramLocationActionContext.class) .enabledWhen(context -> { + var apiService = getApiService(); + if (apiService == null) return false; var func = context.getProgram().getFunctionManager().getFunctionContaining(context.getAddress()); return func != null && apiService.getAnalysedProgram(context.getProgram()).isPresent(); }) .onAction(context -> { + var apiService = getApiService(); var func = context.getProgram().getFunctionManager().getFunctionContaining(context.getAddress()); var analysedProgram = apiService.getAnalysedProgram(context.getProgram()).get(); var functionWithID = analysedProgram.getIDForFunction(func); @@ -263,6 +276,8 @@ private void setupActions() { @Override public void readDataState(SaveState saveState) { + var apiService = getApiService(); + if (apiService == null) return; int[] rawCollectionIDs = saveState.getInts("collectionIDs", new int[0]); var restoredCollections = Arrays.stream(rawCollectionIDs) .mapToObj(TypedApiInterface.CollectionID::new) @@ -273,6 +288,8 @@ public void readDataState(SaveState saveState) { @Override public void writeDataState(SaveState saveState) { + var apiService = getApiService(); + if (apiService == null) return; int[] collectionIDs = apiService.getActiveCollections().stream().map(Collection::collectionID).mapToInt(TypedApiInterface.CollectionID::id).toArray(); saveState.putInts("collectionIDs", collectionIDs); } diff --git a/src/main/java/ai/reveng/toolkit/ghidra/plugins/ReaiAPIServicePlugin.java b/src/main/java/ai/reveng/toolkit/ghidra/plugins/ReaiAPIServicePlugin.java index 8d9c6ee..9a4c49d 100644 --- a/src/main/java/ai/reveng/toolkit/ghidra/plugins/ReaiAPIServicePlugin.java +++ b/src/main/java/ai/reveng/toolkit/ghidra/plugins/ReaiAPIServicePlugin.java @@ -4,7 +4,6 @@ import ai.reveng.toolkit.ghidra.binarysimilarity.ui.help.HelpDialog; import ai.reveng.toolkit.ghidra.core.services.api.GhidraRevengService; import ai.reveng.toolkit.ghidra.core.services.api.types.ApiInfo; -import ai.reveng.toolkit.ghidra.core.services.logging.ReaiLoggingService; import ai.reveng.toolkit.ghidra.core.ui.wizard.SetupWizardManager; import ai.reveng.toolkit.ghidra.core.ui.wizard.SetupWizardStateKey; import docking.ActionContext; @@ -20,6 +19,7 @@ import org.json.JSONException; import java.io.FileNotFoundException; +import java.nio.file.Path; import java.util.Optional; import static ai.reveng.toolkit.ghidra.plugins.ReaiPluginPackage.REAI_OPTIONS_CATEGORY; @@ -33,39 +33,30 @@ servicesProvided = { GhidraRevengService.class } ) public class ReaiAPIServicePlugin extends Plugin { - private final GhidraRevengService revengService; - private ApiInfo apiInfo; + private GhidraRevengService revengService; private static final String REAI_PLUGIN_SETUP_MENU_GROUP = "RevEng.AI Setup"; public static final String REAI_WIZARD_RUN_PREF = "REAISetupWizardRun"; - - /** - * Construct a new Plugin. - * - * @param tool PluginTool that will host/contain this plugin. - */ public ReaiAPIServicePlugin(PluginTool tool) { super(tool); tool.getOptions(REAI_OPTIONS_CATEGORY).registerOption(REAI_WIZARD_RUN_PREF, "false", null, "If the setup wizard has been run"); - // Try to get API info from multiple sources before running setup wizard - // 1. First try from config file - // 2. Then try from tool options (previously entered in wizard) - // 3. Only run setup wizard if neither source has valid credentials + // Try to get API info from config file or tool options Optional apiInfoOpt = getApiInfoFromConfig() - .or(() -> getApiInfoFromToolOptions()); + .or(this::getApiInfoFromToolOptions); - if (apiInfoOpt.isPresent()) { - apiInfo = apiInfoOpt.get(); - } else { + if (apiInfoOpt.isEmpty()) { + // No config found — show the setup wizard runSetupWizard(); - // After wizard, try to get API info again from tool options or config - apiInfo = getApiInfoFromToolOptions() - .or(() -> getApiInfoFromConfig()) - .orElseThrow(() -> new RuntimeException("Setup wizard completed but no valid API info found")); + apiInfoOpt = getApiInfoFromToolOptions() + .or(this::getApiInfoFromConfig); } + + var apiInfo = apiInfoOpt.orElseThrow(() -> + new RuntimeException("No valid API credentials configured. " + + "Use RevEng.AI > Configure to set up, then activate the desired plugins again.")); revengService = new GhidraRevengService(apiInfo); registerServiceProvided(GhidraRevengService.class, revengService); @@ -73,48 +64,46 @@ public ReaiAPIServicePlugin(PluginTool tool) { } /** - * Attempts to generate an {@link ApiInfo} object from the config file - * @return + * Returns the path to the RevEng.AI configuration file. + * Protected to allow test subclasses to override. */ - private Optional getApiInfoFromConfig(){ - var loggingService = tool.getService(ReaiLoggingService.class); + protected Path getConfigPath() { + return ReaiPluginPackage.DEFAULT_CONFIG_PATH; + } + + private Optional getApiInfoFromConfig() { try { - return Optional.of(ApiInfo.fromConfig()); + return Optional.of(ApiInfo.fromConfig(getConfigPath())); } catch (FileNotFoundException e) { - loggingService.error(e.getMessage()); + Msg.info(this, "Config file not found: " + e.getMessage()); return Optional.empty(); - } catch (JSONException e) { - loggingService.error(e.getMessage()); + } catch (JSONException | com.google.gson.JsonSyntaxException e) { Msg.showError(this, null, "Load Config", "Unable to parse RevEng config file: " + e.getMessage()); return Optional.empty(); } } - private Optional getApiInfoFromToolOptions(){ + private Optional getApiInfoFromToolOptions() { var apikey = tool.getOptions(REAI_OPTIONS_CATEGORY).getString(ReaiPluginPackage.OPTION_KEY_APIKEY, "invalid"); var hostname = tool.getOptions(REAI_OPTIONS_CATEGORY).getString(ReaiPluginPackage.OPTION_KEY_HOSTNAME, "unknown"); var portalHostname = tool.getOptions(REAI_OPTIONS_CATEGORY).getString(ReaiPluginPackage.OPTION_KEY_PORTAL_HOSTNAME, "unknown"); - if (apikey.equals("invalid") || hostname.equals("unknown") || portalHostname.equals("unknown")){ + if (apikey.equals("invalid") || hostname.equals("unknown") || portalHostname.equals("unknown")) { return Optional.empty(); } - var apiInfo = new ApiInfo(hostname, portalHostname, apikey); - - return Optional.of(apiInfo); + return Optional.of(new ApiInfo(hostname, portalHostname, apikey)); } private void setupActions() { new ActionBuilder("Configure", this.toString()) .withContext(ActionContext.class) - .onAction(context -> { - runSetupWizard(); - }) + .onAction(context -> runSetupWizard()) .menuPath(new String[] { ReaiPluginPackage.MENU_GROUP_NAME, "Configure" }) .menuGroup(REAI_PLUGIN_SETUP_MENU_GROUP, "100") .buildAndInstall(tool); new ActionBuilder("Help", this.toString()) .withContext(ActionContext.class) - .onAction(context -> { + .onAction(context -> { var helpDialog = new HelpDialog(tool); tool.showDialog(helpDialog); }) @@ -124,21 +113,20 @@ private void setupActions() { new ActionBuilder("About", this.toString()) .withContext(ActionContext.class) - .onAction(context -> { + .onAction(context -> { var aboutDialog = new AboutDialog(tool); tool.showDialog(aboutDialog); }) .menuPath(new String[] { ReaiPluginPackage.MENU_GROUP_NAME, "About" }) .menuGroup(REAI_PLUGIN_SETUP_MENU_GROUP, "300") .buildAndInstall(tool); - } - private void runSetupWizard() { - tool.getService(ReaiLoggingService.class).info("Running setup wizard"); + protected void runSetupWizard() { + Msg.info(this, "Running setup wizard"); - // Create wizard state and populate with any existing credentials from tool options - WizardState wizardState = new WizardState(); + WizardState wizardState = new WizardState<>(); + wizardState.put(SetupWizardStateKey.CONFIGFILE, getConfigPath().toString()); // Pre-populate wizard state with existing credentials if available String existingApiKey = tool.getOptions(REAI_OPTIONS_CATEGORY).getString(ReaiPluginPackage.OPTION_KEY_APIKEY, null); @@ -149,13 +137,13 @@ private void runSetupWizard() { if ((existingApiKey == null || existingApiKey.equals("invalid")) && (existingHostname == null || existingHostname.equals("unknown"))) { try { - ApiInfo configApiInfo = ApiInfo.fromConfig(); + ApiInfo configApiInfo = ApiInfo.fromConfig(getConfigPath()); existingApiKey = configApiInfo.apiKey(); existingHostname = configApiInfo.hostURI().toString(); existingPortalHostname = configApiInfo.portalURI().toString(); - tool.getService(ReaiLoggingService.class).info("Loaded credentials from configuration file"); + Msg.info(this, "Loaded credentials from configuration file"); } catch (Exception e) { - tool.getService(ReaiLoggingService.class).info("No existing configuration file found or could not read it: " + e.getMessage()); + Msg.info(this, "No existing configuration file found or could not read it: " + e.getMessage()); } } @@ -172,17 +160,6 @@ private void runSetupWizard() { SetupWizardManager setupManager = new SetupWizardManager(wizardState, getTool()); WizardManager wizardManager = new WizardManager("RevEng.AI: Configuration", true, setupManager); wizardManager.showWizard(tool.getToolFrame()); - - return; - } - - private boolean hasSetupWizardRun() { - String value = tool.getOptions(REAI_OPTIONS_CATEGORY).getString(REAI_WIZARD_RUN_PREF, "false"); - return Boolean.parseBoolean(value); - } - - private void setWizardRun() { - tool.getOptions(REAI_OPTIONS_CATEGORY).setString(REAI_WIZARD_RUN_PREF, "true"); } } diff --git a/src/main/java/ai/reveng/toolkit/ghidra/plugins/ReaiPluginPackage.java b/src/main/java/ai/reveng/toolkit/ghidra/plugins/ReaiPluginPackage.java index 2b819b7..07a0523 100644 --- a/src/main/java/ai/reveng/toolkit/ghidra/plugins/ReaiPluginPackage.java +++ b/src/main/java/ai/reveng/toolkit/ghidra/plugins/ReaiPluginPackage.java @@ -4,6 +4,7 @@ import resources.ResourceManager; import javax.swing.*; +import java.nio.file.Path; /** * Top-level package object for RevEng.AI Ghidra Plugins @@ -28,6 +29,8 @@ public class ReaiPluginPackage extends PluginPackage { public static final String REAI_OPTIONS_CATEGORY = "RevEngAI Options"; + public static final Path DEFAULT_CONFIG_PATH = Path.of(System.getProperty("user.home"), ".reai", "reai.json"); + @Deprecated public static final Integer INVALID_BINARY_ID = -1; diff --git a/src/test/java/ai/reveng/ReaiAPIServicePluginTest.java b/src/test/java/ai/reveng/ReaiAPIServicePluginTest.java new file mode 100644 index 0000000..7fc07b6 --- /dev/null +++ b/src/test/java/ai/reveng/ReaiAPIServicePluginTest.java @@ -0,0 +1,111 @@ +package ai.reveng; + +import ai.reveng.toolkit.ghidra.core.services.api.GhidraRevengService; +import ghidra.framework.plugintool.util.PluginException; +import org.junit.After; +import org.junit.Test; + +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.*; + +/** + * Tests for {@link ai.reveng.toolkit.ghidra.plugins.ReaiAPIServicePlugin} initialization + * under different config file states. + */ +public class ReaiAPIServicePluginTest extends RevEngMockableHeadedIntegrationTest { + + @After + public void clearOverride() { + TestableReaiAPIServicePlugin.clearConfigPathOverride(); + } + + /** + * When no config file exists and no tool options are set, the plugin should + * fail to load with a PluginException — not a NullPointerException. + * Previously this crashed because the logging service was unavailable during + * the constructor. + */ + @Test + public void testPluginFailsToLoadWithoutConfigFile() throws Exception { + var tool = env.getTool(); + + Path nonExistent = createTempDirectory("reai-test").toPath().resolve("nonexistent").resolve("reai.json"); + assertFalse("Precondition: config file must not exist", Files.exists(nonExistent)); + + TestableReaiAPIServicePlugin.setConfigPathOverride(nonExistent); + + try { + tool.addPlugin(TestableReaiAPIServicePlugin.class.getName()); + } catch (PluginException e) { + // Expected — plugin should fail because no credentials were provided + assertNull("GhidraRevengService should not be registered", tool.getService(GhidraRevengService.class)); + return; + } + fail("Expected PluginException when no config file exists and wizard is cancelled"); + } + + /** + * When a valid config file exists, the plugin should load credentials from it + * and register a configured service. + */ + @Test + public void testPluginLoadsWithValidConfigFile() throws Exception { + var tool = env.getTool(); + + Path configFile = createTempDirectory("reai-test").toPath().resolve("reai.json"); + Files.writeString(configFile, """ + { + "pluginSettings": { + "apiKey": "test-api-key", + "hostname": "https://api.test.reveng.ai", + "portalHostname": "https://portal.test.reveng.ai" + } + } + """); + + TestableReaiAPIServicePlugin.setConfigPathOverride(configFile); + tool.addPlugin(TestableReaiAPIServicePlugin.class.getName()); + + var service = tool.getService(GhidraRevengService.class); + assertNotNull("GhidraRevengService should be registered", service); + assertEquals("Service should be configured with the provided hostname", + URI.create("https://api.test.reveng.ai"), service.getServer()); + } + + /** + * When the config file contains malformed JSON, the plugin should show an + * error dialog and then fail to load (not crash with NPE). + */ + @Test + public void testPluginFailsToLoadWithMalformedConfigFile() throws Exception { + // Malformed config will show an error dialog, so allow error GUIs for this test + setErrorGUIEnabled(true); + var tool = env.getTool(); + + Path configFile = createTempDirectory("reai-test").toPath().resolve("reai.json"); + Files.writeString(configFile, "{ not valid json }}}"); + + TestableReaiAPIServicePlugin.setConfigPathOverride(configFile); + + // addPlugin will block on Msg.showError's modal dialog, so run it non-blocking + runSwing(() -> { + try { + tool.addPlugin(TestableReaiAPIServicePlugin.class.getName()); + } catch (Exception e) { + // Expected — plugin should fail after showing error dialog + } + }, false); + + // Wait for the error dialog to appear, then dismiss it + var errorDialog = waitForDialogComponent("Load Config"); + assertNotNull("Error dialog should appear for malformed config", errorDialog); + close(errorDialog); + waitForSwing(); + + var service = tool.getService(GhidraRevengService.class); + assertNull("GhidraRevengService should not be registered with bad config", service); + } +} diff --git a/src/test/java/ai/reveng/SetupWizardTest.java b/src/test/java/ai/reveng/SetupWizardTest.java index edab4c8..a861290 100644 --- a/src/test/java/ai/reveng/SetupWizardTest.java +++ b/src/test/java/ai/reveng/SetupWizardTest.java @@ -1,13 +1,180 @@ package ai.reveng; +import ai.reveng.toolkit.ghidra.core.ui.wizard.CredentialValidator; import ai.reveng.toolkit.ghidra.core.ui.wizard.SetupWizardManager; import ai.reveng.toolkit.ghidra.core.ui.wizard.SetupWizardStateKey; -import ai.reveng.toolkit.ghidra.core.ui.wizard.panels.UserCredentialsPanel; +import ai.reveng.toolkit.ghidra.core.services.api.types.exceptions.InvalidAPIInfoException; +import ai.reveng.toolkit.ghidra.plugins.ReaiPluginPackage; import docking.wizard.WizardManager; import docking.wizard.WizardState; import org.junit.Test; +import javax.swing.*; + +import static ai.reveng.toolkit.ghidra.plugins.ReaiPluginPackage.REAI_OPTIONS_CATEGORY; +import static org.junit.Assert.*; + public class SetupWizardTest extends RevEngMockableHeadedIntegrationTest { + + /** + * Walk through the full wizard flow: fill in credentials, validate, and finish. + * Verifies that credentials are saved to tool options. + */ + @Test + public void testWizardCompletesSuccessfully() throws Exception { + var tool = env.getTool(); + + var wizardState = new WizardState(); + // Inject a mock validator that always succeeds + wizardState.put(SetupWizardStateKey.CREDENTIAL_VALIDATOR, + (CredentialValidator) apiInfo -> {}); + // Redirect config file to a temp directory so we don't overwrite the user's real config + var tempConfigFile = createTempDirectory("reai-wizard-test").toPath().resolve("reai.json"); + wizardState.put(SetupWizardStateKey.CONFIGFILE, tempConfigFile.toString()); + + SetupWizardManager setupManager = new SetupWizardManager(wizardState, tool); + WizardManager wizardManager = new WizardManager("RevEng.AI: Configuration", true, setupManager); + runSwing(() -> wizardManager.showWizard(tool.getToolFrame()), false); + var dialog = waitForDialogComponent("RevEng.AI: Configuration"); + assertNotNull(dialog); + + // Find text fields by name and fill them in + JTextField apiKeyField = (JTextField) findComponentByName(dialog.getComponent(), "apiKey"); + JTextField hostnameField = (JTextField) findComponentByName(dialog.getComponent(), "apiHostname"); + JTextField portalField = (JTextField) findComponentByName(dialog.getComponent(), "portalHostname"); + + assertNotNull("API key field should exist", apiKeyField); + assertNotNull("Hostname field should exist", hostnameField); + assertNotNull("Portal hostname field should exist", portalField); + + // Fill in credentials + setText(apiKeyField, "test-api-key-12345"); + setText(hostnameField, "https://api.test.example.com"); + setText(portalField, "https://portal.test.example.com"); + + // Finish button should be disabled before validation + JButton finishButton = findButtonByText(dialog.getComponent(), "Finish"); + assertNotNull("Finish button should exist", finishButton); + assertFalse("Finish should be disabled before validation", finishButton.isEnabled()); + + // Click Validate Credentials + JButton validateButton = findButtonByText(dialog.getComponent(), "Validate Credentials"); + assertNotNull("Validate button should exist", validateButton); + pressButton(validateButton); + waitForSwing(); + + // Finish button should now be enabled + assertTrue("Finish should be enabled after successful validation", finishButton.isEnabled()); + + // Click Finish + pressButton(finishButton); + waitForSwing(); + + // Verify credentials were saved to tool options + assertEquals("test-api-key-12345", + tool.getOptions(REAI_OPTIONS_CATEGORY).getString(ReaiPluginPackage.OPTION_KEY_APIKEY, null)); + assertEquals("https://api.test.example.com", + tool.getOptions(REAI_OPTIONS_CATEGORY).getString(ReaiPluginPackage.OPTION_KEY_HOSTNAME, null)); + assertEquals("https://portal.test.example.com", + tool.getOptions(REAI_OPTIONS_CATEGORY).getString(ReaiPluginPackage.OPTION_KEY_PORTAL_HOSTNAME, null)); + + // Verify config file was written to the temp path, not the user's home directory + assertTrue("Config file should be written to temp path", java.nio.file.Files.exists(tempConfigFile)); + } + + /** + * Verify that failed validation prevents the wizard from finishing. + */ + @Test + public void testValidationFailurePreventsFinish() throws Exception { + var tool = env.getTool(); + + var wizardState = new WizardState(); + // Inject a validator that always fails + wizardState.put(SetupWizardStateKey.CREDENTIAL_VALIDATOR, + (CredentialValidator) apiInfo -> { + throw new InvalidAPIInfoException("Invalid API key"); + }); + + SetupWizardManager setupManager = new SetupWizardManager(wizardState, tool); + WizardManager wizardManager = new WizardManager("RevEng.AI: Configuration", true, setupManager); + runSwing(() -> wizardManager.showWizard(tool.getToolFrame()), false); + var dialog = waitForDialogComponent("RevEng.AI: Configuration"); + + // Fill in all fields + JTextField apiKeyField = (JTextField) findComponentByName(dialog.getComponent(), "apiKey"); + JTextField hostnameField = (JTextField) findComponentByName(dialog.getComponent(), "apiHostname"); + JTextField portalField = (JTextField) findComponentByName(dialog.getComponent(), "portalHostname"); + setText(apiKeyField, "bad-key"); + setText(hostnameField, "https://api.test.example.com"); + setText(portalField, "https://portal.test.example.com"); + + // Click Validate — should fail + JButton validateButton = findButtonByText(dialog.getComponent(), "Validate Credentials"); + pressButton(validateButton); + waitForSwing(); + + // Finish button should remain disabled + JButton finishButton = findButtonByText(dialog.getComponent(), "Finish"); + assertFalse("Finish should remain disabled after failed validation", finishButton.isEnabled()); + + // Close the dialog + close(dialog); + } + + /** + * Verify that editing a field after validation resets the validated state, + * requiring re-validation before Finish is enabled. + */ + @Test + public void testEditingAfterValidationResetsState() throws Exception { + var tool = env.getTool(); + + var wizardState = new WizardState(); + wizardState.put(SetupWizardStateKey.CREDENTIAL_VALIDATOR, + (CredentialValidator) apiInfo -> {}); + var tempConfigFile = createTempDirectory("reai-wizard-test").toPath().resolve("reai.json"); + wizardState.put(SetupWizardStateKey.CONFIGFILE, tempConfigFile.toString()); + + SetupWizardManager setupManager = new SetupWizardManager(wizardState, tool); + WizardManager wizardManager = new WizardManager("RevEng.AI: Configuration", true, setupManager); + runSwing(() -> wizardManager.showWizard(tool.getToolFrame()), false); + var dialog = waitForDialogComponent("RevEng.AI: Configuration"); + + JTextField apiKeyField = (JTextField) findComponentByName(dialog.getComponent(), "apiKey"); + JTextField hostnameField = (JTextField) findComponentByName(dialog.getComponent(), "apiHostname"); + JTextField portalField = (JTextField) findComponentByName(dialog.getComponent(), "portalHostname"); + setText(apiKeyField, "test-api-key"); + setText(hostnameField, "https://api.test.example.com"); + setText(portalField, "https://portal.test.example.com"); + + // Validate successfully + JButton validateButton = findButtonByText(dialog.getComponent(), "Validate Credentials"); + pressButton(validateButton); + waitForSwing(); + + JButton finishButton = findButtonByText(dialog.getComponent(), "Finish"); + assertTrue("Finish should be enabled after validation", finishButton.isEnabled()); + + // Edit the API key — should reset validation + setText(apiKeyField, "modified-key"); + waitForSwing(); + + assertFalse("Finish should be disabled after editing a field", finishButton.isEnabled()); + + // Re-validate and finish + pressButton(validateButton); + waitForSwing(); + assertTrue("Finish should be re-enabled after re-validation", finishButton.isEnabled()); + + pressButton(finishButton); + waitForSwing(); + + // Verify the modified key was saved + assertEquals("modified-key", + tool.getOptions(REAI_OPTIONS_CATEGORY).getString(ReaiPluginPackage.OPTION_KEY_APIKEY, null)); + } + @Test public void testUserCredentialsPanel() throws Exception { var tool = env.getTool(); diff --git a/src/test/java/ai/reveng/TestableReaiAPIServicePlugin.java b/src/test/java/ai/reveng/TestableReaiAPIServicePlugin.java new file mode 100644 index 0000000..6cf8c3e --- /dev/null +++ b/src/test/java/ai/reveng/TestableReaiAPIServicePlugin.java @@ -0,0 +1,44 @@ +package ai.reveng; + +import ai.reveng.toolkit.ghidra.plugins.ReaiAPIServicePlugin; +import ghidra.framework.plugintool.PluginTool; + +import java.nio.file.Path; + +/** + * Subclass of {@link ReaiAPIServicePlugin} that overrides the config file path + * and suppresses the setup wizard to allow testing without filesystem or UI dependencies. + * + * Uses a static field for the config path because Ghidra may call init() on a + * different thread (e.g. Swing EDT) than the test thread. + */ +public class TestableReaiAPIServicePlugin extends ReaiAPIServicePlugin { + + private static volatile Path configPathOverride; + + public static void setConfigPathOverride(Path path) { + configPathOverride = path; + } + + public static void clearConfigPathOverride() { + configPathOverride = null; + } + + public TestableReaiAPIServicePlugin(PluginTool tool) { + super(tool); + } + + @Override + protected Path getConfigPath() { + Path override = configPathOverride; + if (override != null) { + return override; + } + return super.getConfigPath(); + } + + @Override + protected void runSetupWizard() { + // No-op in tests + } +}