Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .revengai/features.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
"data_types_sync": {
"status": "yes"
},
"disable_private_analyses": {
"note": "Disable Private Analyses - Enthusiast Tier Only. Disables private analyses creations for the Enthusiast Tier.",
"status": "yes"
},
"fs_binary_filter": {
"status": "yes"
},
Expand Down
21 changes: 20 additions & 1 deletion reai_toolkit/features/upload/upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,29 @@
from reai_toolkit.utils import PeriodicChecker
from reai_toolkit.utils.core.sync import AnalysisSyncService

ENTHUSIAST_TIER = "ENTHUSIAST"

class BinaryUploader:
def __init__(self, config):
self.config = config


def get_user_tier(self):
"""Fetch the authenticated user's subscription tier via GET /v2/iam/me.

Returns (success, tier) where ``tier`` is the raw tier string
(e.g. "ENTHUSIAST") or None when the lookup fails.
"""
try:
with self.config.create_api_client() as api_client:
api_instance = revengai.IAMUsersApi(api_client)
user = api_instance.get_me()
tier = user.tier
log_info(f"RevEng.AI | User tier: {tier}")
return True, tier
except Exception as e:
log_error(f"RevEng.AI | Failed to get user tier: {str(e)}")
return False, None

def get_models(self, bv: BinaryView):
try:
with self.config.create_api_client() as api_client:
Expand Down
27 changes: 25 additions & 2 deletions reai_toolkit/features/upload/upload_dialog.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
from binaryninja import log_error
from PySide6.QtCore import QCoreApplication
from reai_toolkit.utils import create_progress_dialog, DataThread
from reai_toolkit.features.upload.upload import ENTHUSIAST_TIER
from PySide6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QComboBox, QPushButton, QRadioButton, QButtonGroup, QLineEdit, QGroupBox, QFileDialog, QMessageBox

PRIVATE_DISABLED_TOOLTIP = (
"Private analyses aren't available on the Enthusiast tier.\n"
"Upgrade your plan to create private analyses."
)

class UploadDialog(QDialog):
def __init__(self, config, uploader, bv):
super().__init__()
Expand Down Expand Up @@ -55,7 +61,9 @@ def init_ui(self):
privacy_layout.addWidget(self.public_radio)
privacy_group.setLayout(privacy_layout)
layout.addWidget(privacy_group)


self._apply_tier_restrictions()

button_layout = QHBoxLayout()
self.process_button = QPushButton("Process")
self.process_button.setStyleSheet("""
Expand Down Expand Up @@ -89,7 +97,22 @@ def init_ui(self):

self.show()
QCoreApplication.processEvents()


def _apply_tier_restrictions(self):
"""Disable private-analysis creation for tiers that aren't allowed it.

Enthusiast-tier accounts can't create private analyses, so the
"Private to you" option is disabled and the upload is forced to
public. A tooltip explains why on hover. If the tier lookup fails the
option is left enabled (the backend still enforces the restriction).
"""
ok, tier = self.uploader.get_user_tier()
if ok and tier == ENTHUSIAST_TIER:
self.private_radio.setChecked(False)
self.private_radio.setEnabled(False)
self.private_radio.setToolTip(PRIVATE_DISABLED_TOOLTIP)
self.public_radio.setChecked(True)

def upload_binary(self):
self.progress = create_progress_dialog(self, "RevEng.AI Upload", "Uploading binary to RevEng.AI...")
self.progress.show()
Expand Down
4 changes: 4 additions & 0 deletions scripts/emit_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
"comment_sync": {"status": "partial"},
"search": {"status": "yes"},
"ai_decompilation_summary": {"status": "yes"},
"disable_private_analyses": {
"status": "yes",
"note": "Disable Private Analyses - Enthusiast Tier Only. Disables private analyses creations for the Enthusiast Tier.",
},
}

MANIFEST_PATH = Path(__file__).resolve().parent.parent / ".revengai" / "features.json"
Expand Down
49 changes: 49 additions & 0 deletions tests/unit/upload/test_dialog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from unittest.mock import MagicMock

import pytest

pytest.importorskip("binaryninja")

from reai_toolkit.features.upload import upload_dialog as dialog_mod


def _fake_dialog(tier_result):
"""A stand-in for UploadDialog exposing only what _apply_tier_restrictions touches.

Real QWidget construction aborts under Binary Ninja headless CI, so we drive
the method with a mock self instead of instantiating the dialog.
"""
dlg = MagicMock()
dlg.uploader.get_user_tier.return_value = tier_result
return dlg


def test_enthusiast_disables_private():
dlg = _fake_dialog((True, dialog_mod.ENTHUSIAST_TIER))

dialog_mod.UploadDialog._apply_tier_restrictions(dlg)

dlg.private_radio.setChecked.assert_called_once_with(False)
dlg.private_radio.setEnabled.assert_called_once_with(False)
dlg.private_radio.setToolTip.assert_called_once_with(dialog_mod.PRIVATE_DISABLED_TOOLTIP)
dlg.public_radio.setChecked.assert_called_once_with(True)


def test_non_enthusiast_keeps_private_enabled():
dlg = _fake_dialog((True, "REVERSER"))

dialog_mod.UploadDialog._apply_tier_restrictions(dlg)

dlg.private_radio.setEnabled.assert_not_called()
dlg.private_radio.setToolTip.assert_not_called()
dlg.public_radio.setChecked.assert_not_called()


def test_tier_lookup_failure_leaves_private_enabled():
dlg = _fake_dialog((False, None))

dialog_mod.UploadDialog._apply_tier_restrictions(dlg)

dlg.private_radio.setEnabled.assert_not_called()
dlg.private_radio.setToolTip.assert_not_called()
dlg.public_radio.setChecked.assert_not_called()
21 changes: 21 additions & 0 deletions tests/unit/upload/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,24 @@ def test_upload_file_surfaces_error(uploader, mocker, tmp_path):

assert ok is False
assert "boom" in message


def test_get_user_tier_returns_tier(uploader, mocker):
api = mocker.patch.object(upload_mod.revengai, "IAMUsersApi").return_value
api.get_me.return_value.tier = upload_mod.ENTHUSIAST_TIER

ok, tier = uploader.get_user_tier()

assert ok is True
assert tier == upload_mod.ENTHUSIAST_TIER
api.get_me.assert_called_once_with()


def test_get_user_tier_surfaces_failure(uploader, mocker):
api = mocker.patch.object(upload_mod.revengai, "IAMUsersApi").return_value
api.get_me.side_effect = Exception("boom")

ok, tier = uploader.get_user_tier()

assert ok is False
assert tier is None
Loading