diff --git a/angrmanagement/plugins/plugin_description.py b/angrmanagement/plugins/plugin_description.py index 1b390a81c..08b54e8e7 100644 --- a/angrmanagement/plugins/plugin_description.py +++ b/angrmanagement/plugins/plugin_description.py @@ -1,101 +1,75 @@ -from typing import List +import pathlib +from dataclasses import field +from typing import ClassVar, Dict, List, Optional, Type +import marshmallow.validate import tomlkit -from tomlkit.items import AoT, Integer, String, Table +from marshmallow import Schema +from marshmallow_dataclass import dataclass +@dataclass +class MetadataDescription: + Schema: ClassVar[Type[Schema]] = Schema # placate mypy + + version: int = field(metadata={"validate": marshmallow.validate.OneOf([0])}) + + +@dataclass +class PackageDescription: + """ + Describes a plugin package. + """ + + Schema: ClassVar[Type[Schema]] = Schema # placate mypy + + name: str = field() + version: str = field() + platforms: List[str] = field(default_factory=lambda: ["any"]) + site_packages: Optional[str] = field(default=None) + authors: List[str] = field(default_factory=list) + description: str = field(default="") + long_description: str = field(default="") + + +@dataclass class PluginDescription: """ Describes an angr management plugin. Can be generated from plugin.toml. """ - def __init__(self): - # Metadata - self.plugin_metadata_version: int = None - - # Plugin - self.name: str = "" - self.shortname: str = "" - self.version: str = "" - self.description: str = "" - self.long_description: str = "" - self.platforms: List[str] = [] - self.min_angr_vesion: str = "" - self.author = "" - self.entrypoints: List[str] = [] - self.require_workspace: bool = True - self.has_url_actions: bool = False - - # file path - self.plugin_file_path: str = "" - - @classmethod - def load_single_plugin(cls, data: Table) -> "PluginDescription": - desc = PluginDescription() - - desc.name = data.get("name", None) - if not isinstance(desc.name, String): - raise TypeError(f'"name" must be a String instance, not a {type(desc.name)}') - if not desc.name: - raise TypeError('"name" cannot be empty') - - desc.shortname = data.get("shortname", None) - if not isinstance(desc.shortname, String): - raise TypeError(f'"shortname" must be a String instance, not a {type(desc.shortname)}') - if not desc.shortname: - raise TypeError('"shortname" cannot be empty') - - desc.entrypoints = data.get("entrypoints", "") - if not isinstance(desc.entrypoints, List) or not all( - isinstance(entrypoint, String) for entrypoint in desc.entrypoints - ): - raise TypeError('"entrypoints" must be a List of String instances') - if not desc.entrypoints: - raise TypeError('"entrypoints" cannot be empty') - - # optional - desc.version = data.get("version", "") - desc.description = data.get("description", "") - desc.long_description = data.get("long_description", "") - desc.platforms = data.get("platforms", "") - desc.min_angr_vesion = data.get("min_angr_version", "") - desc.author = data.get("author", "") - desc.require_workspace = data.get("require_workspace", True) - desc.has_url_actions = data.get("has_url_actions", False) - - return desc - - @classmethod - def from_toml(cls, file_path: str) -> List["PluginDescription"]: - with open(file_path, encoding="utf-8") as f: - data = tomlkit.load(f) - - # load metadata - outer_desc = PluginDescription() - if "meta" in data and "plugin_metadata_version" in data["meta"]: - if isinstance(data["meta"]["plugin_metadata_version"], Integer): - outer_desc.plugin_metadata_version = data["meta"]["plugin_metadata_version"].unwrap() - - if outer_desc.plugin_metadata_version is None: - raise TypeError("Cannot find plugin_metadata_version") - if outer_desc.plugin_metadata_version != 0: - raise TypeError(f"Unsupported plugin metadata version {outer_desc.plugin_metadata_version}") - - descs = [] - # load plugin information - if "plugins" in data and isinstance(data["plugins"], AoT): - # multiple plugins to load! - for plugin in data["plugins"]: - desc = PluginDescription.load_single_plugin(plugin) - desc.plugin_metadata_version = outer_desc.plugin_metadata_version - desc.plugin_file_path = file_path - descs.append(desc) - elif "plugin" in data: - desc = PluginDescription.load_single_plugin(data["plugin"]) - desc.plugin_metadata_version = outer_desc.plugin_metadata_version - desc.plugin_file_path = file_path - descs.append(desc) - else: - raise TypeError('Cannot find any "plugin" or "plugins" table.') - - return descs + Schema: ClassVar[Type[Schema]] = Schema # placate mypy + + name: str = field() + entrypoint: str = field() + platforms: Optional[List[str]] = field(default=None) + description: str = field(default="") + requires_workspace: bool = field(default=False) + + +@dataclass +class PluginConfigFileDescription: + """ + Describes a plugin config file. + """ + + Schema: ClassVar[Type[Schema]] = Schema # placate mypy + + metadata: MetadataDescription = field() + package: PackageDescription = field() + plugins: Dict[str, PluginDescription] = field(default_factory=dict) + + +def from_toml_string(toml_string: str) -> PluginConfigFileDescription: + """ + Load a plugin config file from a TOML string. + """ + return PluginConfigFileDescription.Schema().load(tomlkit.parse(toml_string)) + + +def from_toml_file(toml_file: pathlib.Path) -> PluginConfigFileDescription: + """ + Load a plugin config file from a TOML file. + """ + with open(toml_file) as f: + return from_toml_string(f.read()) diff --git a/angrmanagement/py.typed b/angrmanagement/py.typed new file mode 100644 index 000000000..99d7bf605 --- /dev/null +++ b/angrmanagement/py.typed @@ -0,0 +1 @@ +PARTIAL diff --git a/angrmanagement/ui/widgets/qdisasm_statusbar.py b/angrmanagement/ui/widgets/qdisasm_statusbar.py index f9982a923..be07fe091 100644 --- a/angrmanagement/ui/widgets/qdisasm_statusbar.py +++ b/angrmanagement/ui/widgets/qdisasm_statusbar.py @@ -4,7 +4,7 @@ from PySide6.QtWidgets import QComboBox, QFileDialog, QFrame, QHBoxLayout, QLabel, QPushButton from angrmanagement.ui.menus.disasm_options_menu import DisasmOptionsMenu -from angrmanagement.ui.toolbars import NavToolbar +from angrmanagement.ui.toolbars.nav_toolbar import NavToolbar from .qdisasm_base_control import DisassemblyLevel from .qdisasm_graph import QDisassemblyGraph diff --git a/docs/TODO.md b/docs/TODO.md new file mode 100644 index 000000000..ca964ee71 --- /dev/null +++ b/docs/TODO.md @@ -0,0 +1 @@ +This directory should be adapted into a sphinx project to be hosted on readthedocs with other angr documentation. diff --git a/docs/plugin_metadata_spec.md b/docs/plugin_metadata_spec.md new file mode 100644 index 000000000..6562717b6 --- /dev/null +++ b/docs/plugin_metadata_spec.md @@ -0,0 +1,40 @@ +Plugin Metadata Specification +============================= + +Each plugin directory must have a plugin.toml file. A plugin metadata file has +three sections: `meta`, `package` and `plugins`. Each plugin.toml must contain +one meta section, but may contain Below are tables of keys for each sections. + +## Meta section +| Key | Type | Required | Notes | +| ------- | ------- | -------- | ------------------- | +| version | integer | yes | Currently version 0 | + + +## Package section +| Key | Type | Required | Default | Notes | +| ---------------- | ---------------- | -------- | ------- | --------- | +| name | string | yes | | | +| version | string | yes | | | +| platforms | list[string] | no | ["any"] | See below | +| site_packages | Optional[string] | no | None | | +| authors | list[string] | no | [] | | +| description | string | no | "" | | +| long-description | string | no | "" | | + + +### Values for `platforms` +angr management treats `"any"` as matching all platfoms. Otherwise, angr +management checks if Python's `sys.platform` starts with any of the listed +strings. See https://docs.python.org/3/library/sys.html#sys.platform to learn +more about the `sys.platform` value in Python. + + +## Plugins section +| Key | Type | Required | Default | Notes | +| ------------------ | ---------------------- | -------- | --------------------- | ------------------------------------------ | +| name | string | yes | | | +| entrypoint | string | yes | | Use file.py::ClassName syntax, like pytest | +| platforms | Optional[list[string]] | no | package.site-packages | overrides package default if configured | +| description | string | no | "" | | +| requires_workspace | bool | no | false | | diff --git a/docs/plugin_spec.md b/docs/plugin_spec.md new file mode 100644 index 000000000..c3d025a61 --- /dev/null +++ b/docs/plugin_spec.md @@ -0,0 +1,42 @@ +# Plugin specification + +angr management supports loading plugins that can extend and adapt the +functionality of angr management itself. In order to create an angr management +plugin, all that is needed is a directory containing two files: a `plugin.toml` +and a Python file where that plugin is implemented. For example, this is valid +plugin package directory layout: + +``` +example-plugin/ + example_plugin.py + plugin.toml +``` + +Inside `example_plugin.py`, a super minimal plugin example looks like: + +```py +import angrmanagement.plugins.BasePlugin + +class ExamplePlugin(BasePlugin): + pass +``` + +A valid `plugin.toml` that would allow this plugin to be loaded would look like +this: + +```toml +[metadata] +version = 0 + +[package] +name = "example-plugin" +version = "1.0" + +[plugin.example] +name = "Example Plugin" +version = "1.0" +entrypoints = ["example_plugin.py::ExamplePlugin"] +``` + +For more information what fields are available in a plugin.toml, se the +[plugin metadata specification](./plugin_metadata_spec.md). diff --git a/setup.cfg b/setup.cfg index 254f68d85..783d502fd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -29,6 +29,8 @@ install_requires = tomlkit pyobjc-framework-Cocoa;platform_system == "Darwin" thefuzz[speedup] + marshmallow~=3.19 + marshmallow-dataclass~=8.5 python_requires = >=3.8 include_package_data = True @@ -47,6 +49,7 @@ pyinstaller = [options.package_data] angrmanagement = + py.typed resources/fonts/*.ttf resources/images/* resources/themes/**/* diff --git a/tests/test_plugin_desciption_loading.py b/tests/test_plugin_desciption_loading.py new file mode 100644 index 000000000..cd5386ffd --- /dev/null +++ b/tests/test_plugin_desciption_loading.py @@ -0,0 +1,96 @@ +import unittest + +import tomlkit +from marshmallow import ValidationError + +from angrmanagement.plugins.plugin_description import ( + MetadataDescription, + PackageDescription, + PluginDescription, + from_toml_string, +) + + +class TestPluginDescriptionLoading(unittest.TestCase): + def test_metadta_section(self): + test_data = "version = 0" + MetadataDescription.Schema().load(tomlkit.parse(test_data)) + + def test_metadata_section_invalid_version(self): + test_data = "version = 1_000_000" + with self.assertRaises(ValidationError): + MetadataDescription.Schema().load(tomlkit.parse(test_data)) + + def test_minimal_package(self): + test_data = """ +name = "example" +version = "1.0" +""" + PackageDescription.Schema().load(tomlkit.parse(test_data)) + + def test_minimal_plugin(self): + test_data = """ +name = "Example" +entrypoint = "example.py::ExamplePlugin" +""" + PluginDescription.Schema().load(tomlkit.parse(test_data)) + + def test_no_plugins(self): + test_data = """ +[metadata] +version = 0 + +[package] +name = "example" +version = "1.0" +""" + from_toml_string(test_data) + + def test_minimal(self): + test_data = """ +[metadata] +version = 0 + +[package] +name = "example" +version = "1.0" + +[plugin.example] +name = "Example" +entrypoint = "example.py::ExamplePlugin" +""" + from_toml_string(test_data) + + def test_multiple(self): + test_data = """ +[metadata] +version = 0 + +[package] +name = "example" +version = "1.0" +platforms = ["any"] +site_packages = "site-packages" +authors = ["Example"] +description = "An example plugin package" +long_description = "An example plugin package for testing angr management" + +[plugins.example1] +name = "Example 1" +entrypoint = "example.py::ExamplePlugin1" +platforms = ["linux"] +description = "An example plugin for testing angr management on linuz" +requires_workspace = false + +[plugins.example2] +name = "Example 2" +entrypoint = "example.py::ExamplePlugin2" +platforms = ["win32", "cygwin"] +description = "An example plugin for testing angr management on windows" +requires_workspace = true +""" + from_toml_string(test_data) + + +if __name__ == "__main__": + unittest.main()