diff options
| -rw-r--r-- | docs/dev/plugins.md | 2 | ||||
| -rw-r--r-- | python/examples/mock_extension_server.py | 389 |
2 files changed, 390 insertions, 1 deletions
diff --git a/docs/dev/plugins.md b/docs/dev/plugins.md index 335bb7cb..f83de6c7 100644 --- a/docs/dev/plugins.md +++ b/docs/dev/plugins.md @@ -25,7 +25,7 @@ For future releases all you need to do is increment the version and create a new ### Using Your Own Plugin Repository -The simplest way to run your own plugin repository is to duplicate the structure of [https://github.com/vector35/community-plugins](https://github.com/vector35/community-plugins). Specifically, the [plugins.json](https://github.com/Vector35/community-plugins/blob/master/plugins.json), as [listing.json](https://github.com/Vector35/community-plugins/blob/master/listing.json) is used along with [generate_index.py](https://github.com/Vector35/community-plugins/blob/master/generate_index.py) to create that file. +The simplest way to run your own plugin repository using the new V2 plugin manager is to use the [mock server](https://github.com/Vector35/binaryninja-api/blob/dev/python/examples/mock_extension_server.py) helper script (a copy is available offline as well in the install path, in the python exapmle scripts subfolder). Once you've created your test repository, use the `pluginManager.unofficialName` and `pluginManager.unofficialUrl` settings to add your third-party repository. diff --git a/python/examples/mock_extension_server.py b/python/examples/mock_extension_server.py new file mode 100644 index 00000000..36e925d8 --- /dev/null +++ b/python/examples/mock_extension_server.py @@ -0,0 +1,389 @@ +#!/usr/bin/env python3 +""" +Mock Binary Ninja Extension Manager API server + +Usage: + python mock_extension_server.py [--name NAME] [--host HOST] [--port PORT] plugin.json [plugin.json ...] +""" + +import argparse +import hashlib +import http.server +import io +import json +import logging +import re +import uuid +import zipfile +from datetime import datetime, timezone +from pathlib import Path +from urllib.parse import parse_qs, urlencode, urlparse + +log = logging.getLogger(__name__) + +PLATFORMS = [ + "darwin-x64", + "darwin-arm64", + "linux-x64", + "linux-arm64", + "win-x64", + "win-arm64", +] + +# Module-level state populated at startup via CLI args (read at request time by route handlers) +plugin_files: list[Path] = [] +manifest_name: str = "Mock Manifest" +manifest_uuid: uuid.UUID = uuid.UUID("00000000-0000-0000-0000-000000000001") +manifest_slug: str = "mock-manifest" + + +# --- ID helpers --- + + +def _str_uuid(s: str) -> uuid.UUID: + """Deterministic UUID from an arbitrary string via MD5.""" + return uuid.UUID(hashlib.md5(s.encode()).hexdigest()) + + +def _ext_id(plugin: dict) -> uuid.UUID: + return _str_uuid(f"extension:{plugin['name']}") + + +def _ver_id(ext_id: uuid.UUID, version: str) -> uuid.UUID: + return _str_uuid(f"version:{ext_id}:{version}") + + +def _pv_id(ver_id: uuid.UUID, platform: str) -> uuid.UUID: + return _str_uuid(f"platform_version:{ver_id}:{platform}") + + +def _category_id(name: str) -> int: + return int(hashlib.md5(name.encode()).hexdigest()[:8], 16) % 9999 + 1 + + +# --- Data helpers --- + + +def _name_to_path(name: str) -> str: + p = re.sub(r"[^a-zA-Z0-9]", "_", name.lower()) + return re.sub(r"_+", "_", p).strip("_") + + +def _load_plugin(path: Path) -> dict: + data = json.loads(path.read_text()) + if "plugin" in data and isinstance(data["plugin"], dict): + data = data["plugin"] + return data + + +# --- URL helpers --- + + +def _url(base: str, path: str) -> str: + return f"{base}{path}" + + +def _paginate_url(request_url: str, page: int) -> str: + parsed = urlparse(request_url) + qs = parse_qs(parsed.query) + qs["page"] = [str(page)] + return parsed._replace(query=urlencode({k: v[0] for k, v in qs.items()})).geturl() + + +# --- Response builders --- + + +def _manifest_response(base_url: str) -> dict: + mid = str(manifest_uuid) + return { + "id": mid, + "name": manifest_name, + "extensions_url": _url(base_url, f"/v2/manifests/{mid}/extensions"), + } + + +def _extension_response(plugin: dict, base_url: str) -> dict: + ext_id = _ext_id(plugin) + mid = str(manifest_uuid) + ext_pk = str(ext_id) + version_string = str(plugin.get("version", "1.0.0")) + ver_id = _ver_id(ext_id, version_string) + + license_data = plugin.get("license", {}) + if isinstance(license_data, dict): + license_name = license_data.get("name", "") + license_text = license_data.get("text", "") + else: + license_name = str(license_data) + license_text = str(license_data) + + return { + "id": ext_pk, + "name": plugin["name"], + "type": 0, + "short_description": plugin.get("description", ""), + "author_name": plugin.get("author", ""), + "categories": [ + {"id": _category_id(t), "name": t} for t in plugin.get("type", []) + ], + "homepage": plugin.get("projectUrl", ""), + "path": _name_to_path(plugin["name"]), + "using_apis": plugin.get("api", []), + "state": 0, + "versions_url": _url( + base_url, f"/v2/manifests/{mid}/extensions/{ext_pk}/versions" + ), + "official": False, + "is_paid": False, + "latest_version_id": str(ver_id), + "view_only": False, + "license_name": license_name, + "license": license_text, + } + + +def _version_response(plugin: dict, base_url: str, path: Path) -> dict: + ext_id = _ext_id(plugin) + mid = str(manifest_uuid) + ext_pk = str(ext_id) + version_string = str(plugin.get("version", "1.0.0")) + ver_id = _ver_id(ext_id, version_string) + ver_pk = str(ver_id) + + platform_versions = [] + for platform in PLATFORMS: + pv_pk = str(_pv_id(ver_id, platform)) + dl_url = _url( + base_url, + f"/v2/manifests/{mid}/extensions/{ext_pk}/versions/{ver_pk}/platforms/{pv_pk}/download", + ) + platform_versions.append( + { + "platform": {"name": platform}, + "download_url": dl_url, + "untracked_download_url": dl_url + "?notrack=1", + } + ) + + raw_deps = plugin.get("dependencies", {}) + deps: dict = {} + if isinstance(raw_deps, dict): + for k, v in raw_deps.items(): + deps[k] = "\n".join(v) if isinstance(v, list) else str(v) + + min_ver = ( + plugin.get("minimumbinaryninjaversion") + or plugin.get("minimumBinaryNinjaVersion") + or 0 + ) + + return { + "id": ver_pk, + "version_string": version_string, + "long_description": plugin.get("longdescription", ""), + "changelog": "", + "dependencies": deps, + "minimum_client_version": int(min_ver), + "created": datetime.fromtimestamp( + path.stat().st_mtime, tz=timezone.utc + ).isoformat(), + "platform_versions": platform_versions, + "subdir": "", + } + + +def _paginate(items: list, page: int, request_url: str) -> dict: + page_size = 100 + p = max(1, page) + start = (p - 1) * page_size + end = start + page_size + return { + "count": len(items), + "next": _paginate_url(request_url, p + 1) if end < len(items) else None, + "previous": _paginate_url(request_url, p - 1) if p > 1 else None, + "results": items[start:end], + } + + +# --- HTTP handler --- + +ROUTES = [ + (re.compile(r"^/v2/manifests/?$"), "list_manifests"), + ( + re.compile(r"^/v2/manifests/(?P<manifest_pk>[^/]+)/extensions/?$"), + "list_extensions", + ), + ( + re.compile( + r"^/v2/manifests/(?P<manifest_pk>[^/]+)/extensions/(?P<extension_pk>[^/]+)/versions/?$" + ), + "list_versions", + ), + ( + re.compile( + r"^/v2/manifests/(?P<manifest_pk>[^/]+)/extensions/(?P<extension_pk>[^/]+)/versions/(?P<version_pk>[^/]+)/platforms/(?P<pk>[^/]+)/download/?$" + ), + "download", + ), +] + + +class _HTTPError(Exception): + def __init__(self, status: int, detail: str): + self.status = status + self.detail = detail + + +class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, format, *args): + log.info(f"{self.address_string()} - {format % args}") + + @property + def base_url(self) -> str: + addr: tuple[str, int] = self.server.server_address # type: ignore[assignment] + host = self.headers.get("Host") or f"{addr[0]}:{addr[1]}" + return f"http://{host}" + + def do_GET(self): + parsed = urlparse(self.path) + qs = parse_qs(parsed.query) + page = int(qs.get("page", ["1"])[0]) + request_url = self.base_url + self.path + + for pattern, handler_name in ROUTES: + m = pattern.match(parsed.path) + if m: + try: + getattr(self, f"_handle_{handler_name}")( + m.groupdict(), page, request_url + ) + except _HTTPError as e: + self._send_json(e.status, {"detail": e.detail}) + return + + self._send_json(404, {"detail": "Not found"}) + + def _resolve_manifest(self, manifest_pk: str) -> None: + if manifest_pk not in (str(manifest_uuid), manifest_slug, manifest_name): + raise _HTTPError(404, "Manifest not found") + + def _handle_list_manifests(self, *_) -> None: + self._send_json(200, [_manifest_response(self.base_url)]) + + def _handle_list_extensions( + self, kwargs: dict, page: int, request_url: str + ) -> None: + self._resolve_manifest(kwargs["manifest_pk"]) + extensions = [] + for path in plugin_files: + try: + extensions.append( + _extension_response(_load_plugin(path), self.base_url) + ) + except Exception as e: + log.warning("Failed to load %s: %s", path, e) + self._send_json( + 200, + _paginate(sorted(extensions, key=lambda e: e["name"]), page, request_url), + ) + + def _handle_list_versions(self, kwargs: dict, page: int, request_url: str) -> None: + self._resolve_manifest(kwargs["manifest_pk"]) + for path in plugin_files: + try: + plugin = _load_plugin(path) + if str(_ext_id(plugin)) == kwargs["extension_pk"]: + self._send_json( + 200, + _paginate( + [_version_response(plugin, self.base_url, path)], + page, + request_url, + ), + ) + return + except Exception as e: + log.warning("Failed to load %s: %s", path, e) + raise _HTTPError(404, "Extension not found") + + def _handle_download(self, kwargs: dict, *_) -> None: + for path in plugin_files: + try: + plugin = _load_plugin(path) + if str(_ext_id(plugin)) != kwargs["extension_pk"]: + continue + plugin_dir = path.parent + folder_name = plugin_dir.name + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + for file_path in sorted(plugin_dir.rglob("*")): + if ( + file_path.is_file() + and file_path.relative_to(plugin_dir).parts[0] != ".git" + ): + zf.write( + file_path, + Path(folder_name) / file_path.relative_to(plugin_dir), + ) + body = buf.getvalue() + self.send_response(200) + self.send_header("Content-Type", "application/zip") + self.send_header("Content-Length", str(len(body))) + self.send_header( + "Content-Disposition", f'attachment; filename="{folder_name}.zip"' + ) + self.end_headers() + self.wfile.write(body) + return + except Exception as e: + log.warning("Failed to load %s: %s", path, e) + raise _HTTPError(404, "Extension not found") + + def _send_json(self, status: int, data) -> None: + body = json.dumps(data).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Mock Binary Ninja Extension Manager API" + ) + parser.add_argument( + "--name", + default="Mock Manifest", + help="Manifest name (default: 'Mock Manifest')", + ) + parser.add_argument( + "--host", default="127.0.0.1", help="Host to bind to (default: 127.0.0.1)" + ) + parser.add_argument( + "--port", type=int, default=8000, help="Port to listen on (default: 8000)" + ) + parser.add_argument( + "plugin_files", + nargs="+", + metavar="plugin.json", + help="plugin.json files to serve", + ) + args = parser.parse_args() + + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + + plugin_files[:] = [Path(f) for f in args.plugin_files] + manifest_name = args.name + manifest_uuid = _str_uuid(f"manifest:{args.name}") + manifest_slug = re.sub( + r"-+", "-", re.sub(r"[^a-z0-9]", "-", args.name.lower()) + ).strip("-") + + log.info(f"Manifest: {manifest_name}") + log.info(f"UUID: {manifest_uuid}") + log.info(f"Slug: {manifest_slug}") + log.info(f"Plugins: {len(plugin_files)}") + log.info(f"Serving on http://{args.host}:{args.port}") + + http.server.HTTPServer((args.host, args.port), Handler).serve_forever() |
