Skip to content

Commit c420354

Browse files
fuzyllpsifertex
authored andcommitted
Add plugin dependency conflict detection APIs.
1 parent f60d749 commit c420354

8 files changed

Lines changed: 418 additions & 4 deletions

File tree

‎binaryninjaapi.h‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20822,6 +20822,21 @@ namespace BinaryNinja {
2082220822
typedef BNPluginOrigin PluginOrigin;
2082320823
typedef BNPluginStatus PluginStatus;
2082420824
typedef BNPluginType PluginType;
20825+
typedef BNPluginDependencyConflictStatus PluginDependencyConflictStatus;
20826+
20827+
struct DependencyConflictRequirement
20828+
{
20829+
std::string pluginName;
20830+
std::string requirement;
20831+
};
20832+
20833+
struct DependencyConflict
20834+
{
20835+
PluginDependencyConflictStatus status;
20836+
std::string packageName;
20837+
std::vector<DependencyConflictRequirement> candidateRequirements;
20838+
std::vector<DependencyConflictRequirement> installedRequirements;
20839+
};
2082520840

2082620841
struct ExtensionVersion
2082720842
{
@@ -20856,6 +20871,7 @@ namespace BinaryNinja {
2085620871
std::string GetPath() const;
2085720872
std::string GetSubdir() const;
2085820873
std::string GetDependencies() const;
20874+
std::vector<DependencyConflict> GetDependencyConflicts() const;
2085920875
std::string GetPluginDirectory() const;
2086020876
std::string GetAuthor() const;
2086120877
std::string GetDescription() const;

‎binaryninjacore.h‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3324,6 +3324,34 @@ extern "C"
33243324
PluginDependencyLoadAnyway
33253325
};
33263326

3327+
BN_ENUM(uint8_t, BNPluginDependencyConflictStatus)
3328+
{
3329+
PluginDependencyProvenConflict,
3330+
PluginDependencyUnknownCompatibility
3331+
};
3332+
3333+
typedef struct BNPluginDependencyInfo
3334+
{
3335+
const char* name;
3336+
const char* dependencies;
3337+
} BNPluginDependencyInfo;
3338+
3339+
typedef struct BNPluginDependencyRequirement
3340+
{
3341+
char* pluginName;
3342+
char* requirement;
3343+
} BNPluginDependencyRequirement;
3344+
3345+
typedef struct BNPluginDependencyConflict
3346+
{
3347+
BNPluginDependencyConflictStatus status;
3348+
char* packageName;
3349+
BNPluginDependencyRequirement* candidateRequirements;
3350+
size_t candidateRequirementCount;
3351+
BNPluginDependencyRequirement* installedRequirements;
3352+
size_t installedRequirementCount;
3353+
} BNPluginDependencyConflict;
3354+
33273355
typedef struct BNPluginDependencyIssue
33283356
{
33293357
const char* repository;
@@ -3345,6 +3373,13 @@ extern "C"
33453373
bool (*moduleInstalled)(void* ctxt, const char* modules);
33463374
} BNScriptingProviderModuleInstalledCallbacks;
33473375

3376+
typedef struct BNScriptingProviderDependencyConflictCallbacks
3377+
{
3378+
void* context;
3379+
char* (*getDependencyConflicts)(void* ctxt, const BNPluginDependencyInfo* candidate,
3380+
const BNPluginDependencyInfo* installedPlugins, size_t installedPluginCount);
3381+
} BNScriptingProviderDependencyConflictCallbacks;
3382+
33483383

33493384
typedef struct BNScriptingInstanceCallbacks
33503385
{
@@ -8654,6 +8689,8 @@ extern "C"
86548689
BINARYNINJACOREAPI bool BNIsScriptingProviderModuleInstalled(BNScriptingProvider* provider, const char* modules);
86558690
BINARYNINJACOREAPI void BNSetScriptingProviderModuleInstalledCallback(BNScriptingProvider* provider,
86568691
BNScriptingProviderModuleInstalledCallbacks* callbacks);
8692+
BINARYNINJACOREAPI void BNSetScriptingProviderDependencyConflictCallback(BNScriptingProvider* provider,
8693+
BNScriptingProviderDependencyConflictCallbacks* callbacks);
86578694
BINARYNINJACOREAPI void BNSetPluginDependencyStartupCallback(BNPluginDependencyStartupCallbacks* callbacks);
86588695

86598696
BINARYNINJACOREAPI BNScriptingInstance* BNInitScriptingInstance(
@@ -8839,6 +8876,8 @@ extern "C"
88398876
BINARYNINJACOREAPI const char* BNPluginGetPath(BNPlugin* p);
88408877
BINARYNINJACOREAPI const char* BNPluginGetSubdir(BNPlugin* p);
88418878
BINARYNINJACOREAPI const char* BNPluginGetDependencies(BNPlugin* p);
8879+
BINARYNINJACOREAPI BNPluginDependencyConflict* BNPluginGetDependencyConflicts(BNPlugin* p, size_t* count);
8880+
BINARYNINJACOREAPI void BNFreePluginDependencyConflicts(BNPluginDependencyConflict* conflicts, size_t count);
88428881
BINARYNINJACOREAPI const char* BNPluginGetLongdescription(BNPlugin* p);
88438882
BINARYNINJACOREAPI uint64_t BNPluginGetLastUpdate(BNPlugin* p);
88448883
BINARYNINJACOREAPI bool BNPluginIsInstalled(BNPlugin* p);

‎pluginmanager.cpp‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,33 @@ string Extension::GetDependencies() const
3232
RETURN_STRING(BNPluginGetDependencies(m_object));
3333
}
3434

35+
36+
vector<DependencyConflict> Extension::GetDependencyConflicts() const
37+
{
38+
vector<DependencyConflict> result;
39+
size_t count = 0;
40+
BNPluginDependencyConflict* conflicts = BNPluginGetDependencyConflicts(m_object, &count);
41+
if (!conflicts)
42+
return result;
43+
44+
result.reserve(count);
45+
for (size_t i = 0; i < count; i++)
46+
{
47+
DependencyConflict conflict;
48+
conflict.status = conflicts[i].status;
49+
conflict.packageName = conflicts[i].packageName ? conflicts[i].packageName : "";
50+
for (size_t j = 0; j < conflicts[i].candidateRequirementCount; j++)
51+
conflict.candidateRequirements.push_back({conflicts[i].candidateRequirements[j].pluginName ? conflicts[i].candidateRequirements[j].pluginName : "",
52+
conflicts[i].candidateRequirements[j].requirement ? conflicts[i].candidateRequirements[j].requirement : ""});
53+
for (size_t j = 0; j < conflicts[i].installedRequirementCount; j++)
54+
conflict.installedRequirements.push_back({conflicts[i].installedRequirements[j].pluginName ? conflicts[i].installedRequirements[j].pluginName : "",
55+
conflicts[i].installedRequirements[j].requirement ? conflicts[i].installedRequirements[j].requirement : ""});
56+
result.push_back(std::move(conflict));
57+
}
58+
BNFreePluginDependencyConflicts(conflicts, count);
59+
return result;
60+
}
61+
3562
bool Extension::IsInstalled() const
3663
{
3764
return BNPluginIsInstalled(m_object);

‎python/extensionmanager.py‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
import binaryninja
2828
from . import _binaryninjacore as core
2929
from . import deprecation
30-
from .enums import PluginType
30+
from .enums import PluginDependencyConflictStatus, PluginType
3131

3232

3333
@dataclass(frozen=True)
@@ -48,6 +48,20 @@ class ExtensionVersion:
4848
created: str
4949

5050

51+
@dataclass(frozen=True)
52+
class PluginDependencyRequirement:
53+
plugin_name: str
54+
requirement: str
55+
56+
57+
@dataclass(frozen=True)
58+
class PluginDependencyConflict:
59+
status: PluginDependencyConflictStatus
60+
package_name: str
61+
candidate_requirements: List[PluginDependencyRequirement]
62+
installed_requirements: List[PluginDependencyRequirement]
63+
64+
5165
class Extension:
5266
"""
5367
``Extension`` is mostly read-only, however you can install/uninstall enable/disable plugins. Extensions are
@@ -84,6 +98,32 @@ def dependencies(self) -> str:
8498
assert result is not None, "core.BNPluginGetDependencies returned None"
8599
return result
86100

101+
@property
102+
def dependency_conflicts(self) -> List[PluginDependencyConflict]:
103+
"""Dependency conflicts with installed plugins, or an empty list for custom Python environments."""
104+
count = ctypes.c_ulonglong()
105+
conflicts = core.BNPluginGetDependencyConflicts(self.handle, count)
106+
if conflicts is None:
107+
return []
108+
result = []
109+
try:
110+
for i in range(count.value):
111+
conflict = conflicts[i]
112+
result.append(PluginDependencyConflict(
113+
PluginDependencyConflictStatus(conflict.status),
114+
conflict.packageName,
115+
[PluginDependencyRequirement(
116+
conflict.candidateRequirements[j].pluginName,
117+
conflict.candidateRequirements[j].requirement)
118+
for j in range(conflict.candidateRequirementCount)],
119+
[PluginDependencyRequirement(
120+
conflict.installedRequirements[j].pluginName,
121+
conflict.installedRequirements[j].requirement)
122+
for j in range(conflict.installedRequirementCount)]))
123+
finally:
124+
core.BNFreePluginDependencyConflicts(conflicts, count.value)
125+
return result
126+
87127
@property
88128
def installed(self) -> bool:
89129
"""Boolean True if the plugin is installed, False otherwise"""

‎python/requirementcheck.py‎

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121

2222
import json
2323
import re
24-
from typing import Dict, Iterable, List, Optional
24+
from dataclasses import dataclass
25+
from typing import Dict, Iterable, List, Optional, Sequence
2526

2627

2728
def pip_requirements_from_dependency_metadata(dependencies: bytes) -> List[str]:
@@ -89,3 +90,160 @@ def pip_requirements_satisfied(requirements: Iterable[str], installed_versions:
8990
return False
9091

9192
return True
93+
94+
95+
@dataclass(frozen=True)
96+
class DependencyRequirement:
97+
plugin_name: str
98+
requirement: str
99+
100+
101+
@dataclass(frozen=True)
102+
class DependencyConflict:
103+
status: str
104+
package_name: str
105+
candidate_requirements: Sequence[DependencyRequirement]
106+
installed_requirements: Sequence[DependencyRequirement]
107+
108+
109+
def _requirement_parts(requirement_text: str):
110+
# Parse only the active dependency and version specifier forms we can
111+
# compare safely. See the following specifications for more information:
112+
# https://packaging.python.org/en/latest/specifications/dependency-specifiers/#dependency-specifiers
113+
# https://packaging.python.org/en/latest/specifications/version-specifiers/#version-specifiers
114+
try:
115+
from packaging.requirements import Requirement
116+
from packaging.specifiers import Specifier
117+
from packaging.utils import canonicalize_name
118+
from packaging.version import Version, InvalidVersion
119+
except Exception:
120+
from pip._vendor.packaging.requirements import Requirement
121+
from pip._vendor.packaging.specifiers import Specifier
122+
from pip._vendor.packaging.utils import canonicalize_name
123+
from pip._vendor.packaging.version import Version, InvalidVersion
124+
125+
try:
126+
requirement = Requirement(requirement_text)
127+
if requirement.marker is not None and not requirement.marker.evaluate():
128+
return None
129+
if requirement.url is not None:
130+
return (canonicalize_name(requirement.name), None, None)
131+
bounds = []
132+
for specifier in requirement.specifier:
133+
if specifier.operator not in {"<", "<=", ">", ">=", "=="} or "*" in specifier.version:
134+
return (canonicalize_name(requirement.name), None, None)
135+
try:
136+
bounds.append((specifier.operator, Version(specifier.version)))
137+
except InvalidVersion:
138+
return (canonicalize_name(requirement.name), None, None)
139+
return (canonicalize_name(requirement.name), bounds, Specifier)
140+
except Exception:
141+
return ("<unknown>", None, None)
142+
143+
144+
def _constraint_status(bounds, specifier_type) -> str:
145+
lower = None
146+
upper = None
147+
exacts = []
148+
specifiers = [specifier_type(f"{operator}{version}") for operator, version in bounds]
149+
for operator, version in bounds:
150+
if operator == "==":
151+
exacts.append(version)
152+
elif operator in {">", ">="}:
153+
candidate = (version, operator == ">=")
154+
if lower is None or candidate[0] > lower[0] or (candidate[0] == lower[0] and not candidate[1]):
155+
lower = candidate
156+
else:
157+
candidate = (version, operator == "<=")
158+
if upper is None or candidate[0] < upper[0] or (candidate[0] == upper[0] and not candidate[1]):
159+
upper = candidate
160+
if exacts:
161+
return "compatible" if any(all(specifier.contains(version, prereleases=True)
162+
for specifier in specifiers) for version in exacts) else "conflict"
163+
if lower is None or upper is None:
164+
return "compatible"
165+
if lower[0] > upper[0] or (lower[0] == upper[0] and not (lower[1] and upper[1])):
166+
return "conflict"
167+
if lower[0] == upper[0]:
168+
return "compatible"
169+
if not lower[1] or not upper[1]:
170+
# Strict bounds have special pre- and post-release exclusions under the
171+
# version-specifier rules. We need to check the actual specifiers to
172+
# prove compatibility. Otherwise, just conservatively say we don't know.
173+
version_type = type(lower[0])
174+
candidates = [lower[0], upper[0]]
175+
for boundary in (lower[0], upper[0]):
176+
release = list(boundary.release)
177+
candidates.append(version_type(".".join(map(str, release[:-1] + [release[-1] + 1]))))
178+
for depth in range(1, 4):
179+
candidates.append(version_type(".".join(map(str, release + ([0] * depth) + [1]))))
180+
if any(all(specifier.contains(version, prereleases=True) for specifier in specifiers)
181+
for version in candidates):
182+
return "compatible"
183+
return "unknown_compatibility"
184+
return "compatible"
185+
186+
187+
def pip_dependency_conflicts(candidate_name: str, candidate_dependencies: bytes,
188+
installed_plugins: Iterable[tuple[str, bytes]]) -> List[DependencyConflict]:
189+
"""Compare active requirements for a candidate plugin.
190+
191+
See the following for more on dependency and version specifiers:
192+
193+
- https://packaging.python.org/en/latest/specifications/dependency-specifiers/#dependency-specifiers
194+
- https://packaging.python.org/en/latest/specifications/version-specifiers/#version-specifiers
195+
196+
Markers that do not apply are ignored. Only a subset of the possible operators are
197+
handled and the result we hand back is conservative if we can't prove a conflict.
198+
"""
199+
def requirements(plugin_name: str, dependencies: bytes):
200+
try:
201+
return [(DependencyRequirement(plugin_name, text), _requirement_parts(text))
202+
for text in pip_requirements_from_dependency_metadata(dependencies)]
203+
except Exception:
204+
return [(DependencyRequirement(plugin_name, dependencies.decode("utf-8", errors="replace")),
205+
("<unknown>", None, None))]
206+
207+
candidate = requirements(candidate_name, candidate_dependencies)
208+
installed_groups = [requirements(name, dependencies) for name, dependencies in installed_plugins]
209+
installed = [requirement for group in installed_groups for requirement in group]
210+
result = []
211+
package_names = dict.fromkeys(parts[0] for _, parts in candidate if parts is not None)
212+
for package_name in package_names:
213+
candidate_same_package = [(requirement, parts) for requirement, parts in candidate
214+
if parts is not None and parts[0] == package_name]
215+
matching = [(requirement, parts) for requirement, parts in installed
216+
if parts is not None and parts[0] == package_name]
217+
candidate_unknown = any(parts[1] is None for _, parts in candidate_same_package)
218+
candidate_bounds = [bound for _, parts in candidate_same_package if parts[1] is not None for bound in parts[1]]
219+
specifier_type = next((parts[2] for _, parts in candidate_same_package + matching if parts[2] is not None), None)
220+
candidate_status = _constraint_status(candidate_bounds, specifier_type)
221+
if candidate_status == "conflict":
222+
result.append(DependencyConflict("proven_conflict", package_name,
223+
[requirement for requirement, _ in candidate_same_package], []))
224+
continue
225+
proven = []
226+
unknown = []
227+
for installed_group in installed_groups:
228+
installed_same_plugin = [(requirement, parts) for requirement, parts in installed_group
229+
if parts is not None and parts[0] == package_name]
230+
if not installed_same_plugin:
231+
continue
232+
installed_bounds = [bound for _, parts in installed_same_plugin if parts[1] is not None for bound in parts[1]]
233+
installed_unknown = any(parts[1] is None for _, parts in installed_same_plugin)
234+
installed_status = _constraint_status(installed_bounds, specifier_type)
235+
combined_status = _constraint_status(candidate_bounds + installed_bounds, specifier_type)
236+
if installed_status != "conflict" and combined_status == "conflict":
237+
proven.extend(requirement for requirement, _ in installed_same_plugin)
238+
elif installed_unknown or combined_status == "unknown_compatibility":
239+
unknown.extend(requirement for requirement, _ in installed_same_plugin)
240+
if proven:
241+
result.append(DependencyConflict("proven_conflict", package_name,
242+
[requirement for requirement, _ in candidate_same_package],
243+
proven))
244+
continue
245+
if candidate_unknown or candidate_status == "unknown_compatibility" or unknown:
246+
result.append(DependencyConflict("unknown_compatibility", package_name,
247+
[requirement for requirement, _ in candidate_same_package],
248+
unknown))
249+
return result

0 commit comments

Comments
 (0)