diff --git a/src/main/java/ai/reveng/toolkit/ghidra/binarysimilarity/ui/analysiscreation/RevEngAIAnalysisOptionsDialog.java b/src/main/java/ai/reveng/toolkit/ghidra/binarysimilarity/ui/analysiscreation/RevEngAIAnalysisOptionsDialog.java index 99addae..08400dc 100644 --- a/src/main/java/ai/reveng/toolkit/ghidra/binarysimilarity/ui/analysiscreation/RevEngAIAnalysisOptionsDialog.java +++ b/src/main/java/ai/reveng/toolkit/ghidra/binarysimilarity/ui/analysiscreation/RevEngAIAnalysisOptionsDialog.java @@ -49,6 +49,9 @@ public class RevEngAIAnalysisOptionsDialog extends RevEngDialogComponentProvider private JComboBox architectureComboBox; private boolean okPressed = false; + private boolean fileSizeOk = false; + private boolean tierResolved = false; + private FunctionSelectionTableModel functionSelectionModel; private JTable functionSelectionTable; private JLabel includeCountLabel; @@ -108,7 +111,8 @@ private void buildInterface() { scopePanel.setLayout(new BoxLayout(scopePanel, BoxLayout.X_AXIS)); privateScope = new JRadioButton("Private to you"); publicScope = new JRadioButton("Public access"); - privateScope.setSelected(true); + publicScope.setSelected(true); + privateScope.setEnabled(false); var group = new ButtonGroup(); group.add(privateScope); @@ -227,7 +231,7 @@ private void buildInterface() { options.advancedAnalysis(advancedAnalysisCheckBox.isSelected()); options.dynamicExecution(dynamicExecutionCheckBox.isSelected()); - if (privateScope.isSelected()) { + if (privateScope.isSelected() && privateScope.isEnabled()) { options.scope(AnalysisScope.PRIVATE); } else { options.scope(AnalysisScope.PUBLIC); @@ -468,18 +472,29 @@ private void fetchUserTierAsync() { } private void handleUserResponse(@Nullable User user) { - if (user == null || user.getTier() != User.TierEnum.ENTHUSIAST) { + tierResolved = true; + + if (user != null && user.getTier() != User.TierEnum.ENTHUSIAST) { + privateScope.setEnabled(true); + privateScope.setToolTipText(null); + privateScopePanel.setToolTipText(null); + privateScope.setSelected(true); + updateOkButtonState(); return; } - String message = "Private analyses are not available on the Enthusiast tier.
" - + "Upgrade your plan to create private analyses."; + String message = user == null + ? "Could not verify your subscription tier.
" + + "Private analyses are unavailable until this can be confirmed." + : "Private analyses are not available on the Enthusiast tier.
" + + "Upgrade your plan to create private analyses."; privateScope.setSelected(false); publicScope.setSelected(true); privateScope.setEnabled(false); privateScope.setToolTipText(message); privateScopePanel.setToolTipText(message); + updateOkButtonState(); } private void handleConfigResponse(@Nullable ConfigResponse config) { @@ -487,7 +502,8 @@ private void handleConfigResponse(@Nullable ConfigResponse config) { if (config == null) { // Config fetch failed, allow upload attempt (server will reject if too large) - okButton.setEnabled(true); + fileSizeOk = true; + updateOkButtonState(); return; } @@ -499,7 +515,8 @@ private void validateFileSize(long maxFileSizeBytes) { long fileSize = getProgramFileSize(); if (fileSize < 0) { // Could not determine file size, allow upload attempt - okButton.setEnabled(true); + fileSizeOk = true; + updateOkButtonState(); return; } @@ -510,11 +527,16 @@ private void validateFileSize(long maxFileSizeBytes) { "
File size (%s) exceeds
server limit (%s)
" .formatted(fileSizeStr, maxSizeStr)); fileSizeWarningLabel.setVisible(true); - okButton.setEnabled(false); + fileSizeOk = false; } else { fileSizeWarningLabel.setVisible(false); - okButton.setEnabled(true); + fileSizeOk = true; } + updateOkButtonState(); + } + + private void updateOkButtonState() { + okButton.setEnabled(fileSizeOk && tierResolved); } private long getProgramFileSize() { diff --git a/src/test/java/ai/reveng/AnalysisOptionsDialogTest.java b/src/test/java/ai/reveng/AnalysisOptionsDialogTest.java index 0081d6f..4806951 100644 --- a/src/test/java/ai/reveng/AnalysisOptionsDialogTest.java +++ b/src/test/java/ai/reveng/AnalysisOptionsDialogTest.java @@ -17,6 +17,9 @@ import static org.junit.Assert.*; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + import javax.swing.*; import ai.reveng.model.User; @@ -137,4 +140,126 @@ public User getMe() { assertNotNull(options); assertEquals(AnalysisScope.PRIVATE, options.getScope()); } + + @Test + public void testPrivateScopeDisabledWhenTierFetchFails() throws Exception { + var reService = new GhidraRevengService(new MockApi() { + @Override + public User getMe() { + throw new RuntimeException("tier lookup failed"); + } + }); + var builder = new ProgramBuilder("mock", ProgramBuilder._X64, this); + var program = builder.getProgram(); + var dialog = RevEngAIAnalysisOptionsDialog.withModelsFromServer(program, reService); + SwingUtilities.invokeLater(() -> { + DockingWindowManager.showDialog(null, dialog); + }); + waitForSwing(); + + JRadioButton privateScope = (JRadioButton) getInstanceField("privateScope", dialog); + JRadioButton publicScope = (JRadioButton) getInstanceField("publicScope", dialog); + + waitFor(() -> { + JButton okButton = (JButton) getInstanceField("okButton", dialog); + return okButton.isEnabled(); + }); + + assertFalse("Private scope must fail safe to disabled when the tier can't be verified", + privateScope.isEnabled()); + assertFalse(privateScope.isSelected()); + assertTrue("Public scope must be selected when the tier can't be verified", + publicScope.isSelected()); + assertNotNull("A disabled private scope must explain why on hover", privateScope.getToolTipText()); + + runSwing(() -> { + JButton okButton = (JButton) getInstanceField("okButton", dialog); + okButton.doClick(); + }); + var options = dialog.getOptionsFromUI(); + assertNotNull(options); + assertEquals(AnalysisScope.PUBLIC, options.getScope()); + } + + @Test + public void testStartAnalysisBlockedUntilTierResolves() throws Exception { + CountDownLatch release = new CountDownLatch(1); + var reService = new GhidraRevengService(new MockApi() { + @Override + public User getMe() { + try { + release.await(10, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return new User().tier(User.TierEnum.REVERSER); + } + }); + var builder = new ProgramBuilder("mock", ProgramBuilder._X64, this); + var program = builder.getProgram(); + var dialog = RevEngAIAnalysisOptionsDialog.withModelsFromServer(program, reService); + SwingUtilities.invokeLater(() -> { + DockingWindowManager.showDialog(null, dialog); + }); + waitForSwing(); + + JButton okButton = (JButton) getInstanceField("okButton", dialog); + JRadioButton privateScope = (JRadioButton) getInstanceField("privateScope", dialog); + JLabel loadingLabel = (JLabel) getInstanceField("loadingLabel", dialog); + + // The file-size/config check resolves independently; wait for it so the only thing + // still holding the OK button disabled is the (blocked) tier fetch. + waitFor(() -> !loadingLabel.isVisible()); + + assertFalse("Start Analysis must stay disabled until the tier fetch resolves", + okButton.isEnabled()); + assertFalse("Private must stay disabled until the tier fetch resolves", + privateScope.isEnabled()); + + release.countDown(); + + waitFor(okButton::isEnabled); + assertTrue(okButton.isEnabled()); + assertTrue("Private becomes available once a non-enthusiast tier is confirmed", + privateScope.isEnabled()); + } + + @Test + public void testGetOptionsIgnoresDisabledPrivateSelection() throws Exception { + var reService = new GhidraRevengService(new MockApi() { + @Override + public User getMe() { + return new User().tier(User.TierEnum.ENTHUSIAST); + } + }); + var builder = new ProgramBuilder("mock", ProgramBuilder._X64, this); + var program = builder.getProgram(); + var dialog = RevEngAIAnalysisOptionsDialog.withModelsFromServer(program, reService); + SwingUtilities.invokeLater(() -> { + DockingWindowManager.showDialog(null, dialog); + }); + waitForSwing(); + + JRadioButton privateScope = (JRadioButton) getInstanceField("privateScope", dialog); + waitFor(() -> !privateScope.isEnabled()); + + // Force a stale private selection onto the disabled control and confirm the submit-time + // guard still reports the analysis as public. + runSwing(() -> privateScope.setSelected(true)); + assertTrue(privateScope.isSelected()); + assertFalse(privateScope.isEnabled()); + + waitFor(() -> { + JButton okButton = (JButton) getInstanceField("okButton", dialog); + return okButton.isEnabled(); + }); + runSwing(() -> { + JButton okButton = (JButton) getInstanceField("okButton", dialog); + okButton.doClick(); + }); + + var options = dialog.getOptionsFromUI(); + assertNotNull(options); + assertEquals(AnalysisScope.PUBLIC, options.getScope()); + } }