Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Plugin refactor #1026

Draft
wants to merge 3 commits into
base: master
Choose a base branch
from
Draft
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
158 changes: 66 additions & 92 deletions angrmanagement/plugins/plugin_description.py
Original file line number Diff line number Diff line change
@@ -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())
1 change: 1 addition & 0 deletions angrmanagement/py.typed
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
PARTIAL
2 changes: 1 addition & 1 deletion angrmanagement/ui/widgets/qdisasm_statusbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
This directory should be adapted into a sphinx project to be hosted on readthedocs with other angr documentation.
40 changes: 40 additions & 0 deletions docs/plugin_metadata_spec.md
Original file line number Diff line number Diff line change
@@ -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 | |
42 changes: 42 additions & 0 deletions docs/plugin_spec.md
Original file line number Diff line number Diff line change
@@ -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).
3 changes: 3 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -47,6 +49,7 @@ pyinstaller =

[options.package_data]
angrmanagement =
py.typed
resources/fonts/*.ttf
resources/images/*
resources/themes/**/*
96 changes: 96 additions & 0 deletions tests/test_plugin_desciption_loading.py
Original file line number Diff line number Diff line change
@@ -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()