实现了渠道插件的全生命周期管理能力,包括: 1. 插件清单的加载、解析与存储 2. 依赖校验与拓扑排序安装/卸载 3. 插件发现与安全沙箱执行环境 4. 插件注册中心与目录管理能力
292 lines
10 KiB
Python
292 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
import importlib
|
|
import logging
|
|
import os
|
|
import pkgutil
|
|
import threading
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from yuxi.channel.protocols import MetaProtocol
|
|
|
|
from .manifest import ChannelPluginManifest
|
|
from .registry import ChannelPluginRegistry
|
|
from .sandbox import PluginSandbox, SandboxViolationError
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_CacheEntry = tuple[float, float, list[Path]]
|
|
|
|
|
|
class ChannelPluginDiscovery:
|
|
"""自动发现项目中的渠道插件。"""
|
|
|
|
_path_allowlist: list[str] | None = None
|
|
_path_allowlist_loaded: bool = False
|
|
_discovery_cache: dict[str, _CacheEntry] = {}
|
|
_discovery_cache_lock: threading.Lock | None = None
|
|
|
|
@classmethod
|
|
def _ensure_path_allowlist_loaded(cls) -> None:
|
|
if cls._path_allowlist_loaded:
|
|
return
|
|
cls._path_allowlist_loaded = True
|
|
cls._load_path_allowlist_from_env()
|
|
|
|
@classmethod
|
|
def _load_path_allowlist_from_env(cls) -> None:
|
|
allowed = []
|
|
|
|
env_val = os.getenv("YUXI_PLUGIN_ALLOWLIST")
|
|
if env_val:
|
|
for entry in env_val.split(os.pathsep):
|
|
entry = entry.strip()
|
|
if entry:
|
|
allowed.append(str(Path(entry).resolve()))
|
|
|
|
extensions_dir = Path(__file__).resolve().parent.parent / "extensions"
|
|
if extensions_dir.exists():
|
|
allowed.append(str(extensions_dir))
|
|
|
|
cls._path_allowlist = allowed
|
|
|
|
@classmethod
|
|
def set_path_allowlist(cls, paths: list[str]) -> None:
|
|
cls._path_allowlist = [str(Path(p).resolve()) for p in paths]
|
|
cls._path_allowlist_loaded = True
|
|
|
|
@classmethod
|
|
def get_path_allowlist(cls) -> list[str]:
|
|
cls._ensure_path_allowlist_loaded()
|
|
return list(cls._path_allowlist) if cls._path_allowlist else []
|
|
|
|
@classmethod
|
|
def clear_path_allowlist(cls) -> None:
|
|
cls._path_allowlist = None
|
|
cls._path_allowlist_loaded = False
|
|
|
|
@classmethod
|
|
def _ensure_cache_lock(cls) -> threading.Lock:
|
|
if cls._discovery_cache_lock is None:
|
|
cls._discovery_cache_lock = threading.Lock()
|
|
return cls._discovery_cache_lock
|
|
|
|
@classmethod
|
|
def clear_discovery_cache(cls) -> None:
|
|
with cls._ensure_cache_lock():
|
|
cls._discovery_cache.clear()
|
|
|
|
@classmethod
|
|
def _is_path_allowed(cls, path: Path) -> bool:
|
|
cls._ensure_path_allowlist_loaded()
|
|
if not cls._path_allowlist:
|
|
return False
|
|
resolved = str(path.resolve())
|
|
return any(resolved == allowed or resolved.startswith(allowed + os.sep) for allowed in cls._path_allowlist)
|
|
|
|
@classmethod
|
|
def validate_plugin_path(cls, path: str | Path, purpose: str = "load") -> Path:
|
|
"""验证插件路径安全性并返回解析后的路径。
|
|
|
|
校验规则:
|
|
1. 路径不能包含 ``..`` 组件(防御深度,防止目录遍历)
|
|
2. 路径必须在白名单目录内
|
|
3. 白名单为空时拒绝所有路径(安全默认)
|
|
|
|
Args:
|
|
path: 要验证的路径
|
|
purpose: 操作目的描述(用于日志和错误消息)
|
|
|
|
Returns:
|
|
解析后的绝对路径
|
|
|
|
Raises:
|
|
PermissionError: 路径校验失败,存在安全风险
|
|
"""
|
|
cls._ensure_path_allowlist_loaded()
|
|
|
|
raw_path = Path(path)
|
|
if ".." in raw_path.parts:
|
|
logger.warning(
|
|
"Path traversal attempt blocked for %s: '%s' contains '..' component",
|
|
purpose, path,
|
|
)
|
|
raise PermissionError(
|
|
f"Path traversal not allowed for plugin {purpose}: '{path}'"
|
|
)
|
|
|
|
resolved = raw_path.resolve()
|
|
|
|
if not cls._path_allowlist:
|
|
logger.warning(
|
|
"Plugin path rejected for %s (allowlist is empty): %s",
|
|
purpose, resolved,
|
|
)
|
|
raise PermissionError(
|
|
f"Plugin {purpose} rejected: no allowed directories configured"
|
|
)
|
|
|
|
if not cls._is_path_allowed(resolved):
|
|
logger.warning(
|
|
"Plugin path not in allowlist for %s: '%s' (resolved: %s)",
|
|
purpose, path, resolved,
|
|
)
|
|
raise PermissionError(
|
|
f"Plugin path not in allowlist for {purpose}: '{resolved}'"
|
|
)
|
|
|
|
return resolved
|
|
|
|
@classmethod
|
|
def discover_builtin(cls, package_name: str = "yuxi.channels") -> list[Any]:
|
|
"""扫描指定 Python 包下的所有模块,注册实现了 ConfigProtocol 的类。
|
|
|
|
导入前对每个模块源文件执行 AST 静态安全分析,
|
|
拦截包含 ``__import__()`` 调用、``from ... import *`` 等危险语法结构的模块。
|
|
"""
|
|
discovered = []
|
|
try:
|
|
package = importlib.import_module(package_name)
|
|
except ImportError:
|
|
logger.warning("Cannot import package '%s' for plugin discovery", package_name)
|
|
return discovered
|
|
|
|
package_path = getattr(package, "__path__", None)
|
|
if not package_path:
|
|
logger.warning("Package '%s' has no __path__", package_name)
|
|
return discovered
|
|
|
|
for _, module_name, is_pkg in pkgutil.iter_modules(package_path):
|
|
if is_pkg:
|
|
continue
|
|
full_name = f"{package_name}.{module_name}"
|
|
|
|
module_file = Path(package_path[0]) / f"{module_name}.py"
|
|
if module_file.exists():
|
|
try:
|
|
source = module_file.read_text(encoding="utf-8")
|
|
PluginSandbox.validate_source(source, str(module_file))
|
|
except SandboxViolationError:
|
|
logger.warning(
|
|
"Builtin plugin '%s' failed AST security validation: %s",
|
|
full_name, module_file,
|
|
)
|
|
continue
|
|
|
|
try:
|
|
module = importlib.import_module(full_name)
|
|
except Exception:
|
|
logger.exception("Failed to import module %s", full_name)
|
|
continue
|
|
|
|
for attr_name in dir(module):
|
|
obj = getattr(module, attr_name)
|
|
if not isinstance(obj, type):
|
|
continue
|
|
if getattr(obj, "__module__", None) != full_name:
|
|
continue
|
|
if not isinstance(obj, MetaProtocol):
|
|
continue
|
|
plugin_id = getattr(obj, "id", None)
|
|
if not plugin_id or not isinstance(plugin_id, str) or not plugin_id.strip():
|
|
continue
|
|
try:
|
|
ChannelPluginRegistry.register(obj)
|
|
discovered.append(obj)
|
|
except ValueError:
|
|
continue
|
|
|
|
return discovered
|
|
|
|
@classmethod
|
|
def discover_from_path(cls, plugin_path: str | Path) -> list[Any]:
|
|
"""从文件系统路径加载插件模块(带路径白名单校验、扫描缓存和沙箱执行)。"""
|
|
discovered = []
|
|
plugin_path = Path(plugin_path)
|
|
if not plugin_path.exists():
|
|
logger.warning("Plugin path does not exist: %s", plugin_path)
|
|
return discovered
|
|
|
|
if not cls._is_path_allowed(plugin_path):
|
|
logger.warning("Plugin path not in allowlist: %s", plugin_path)
|
|
return discovered
|
|
|
|
if plugin_path.is_file() and plugin_path.suffix == ".py":
|
|
module_files = [plugin_path]
|
|
elif plugin_path.is_dir():
|
|
module_files = cls._scan_with_cache(plugin_path)
|
|
else:
|
|
return discovered
|
|
|
|
sandbox = PluginSandbox()
|
|
|
|
for file_path in module_files:
|
|
try:
|
|
module = sandbox.load_module_from_file(file_path, file_path.stem)
|
|
except Exception:
|
|
logger.exception("Failed to load plugin from %s", file_path)
|
|
continue
|
|
|
|
for attr_name in dir(module):
|
|
obj = getattr(module, attr_name)
|
|
if not isinstance(obj, type):
|
|
continue
|
|
if getattr(obj, "__module__", None) != module.__name__:
|
|
continue
|
|
if not isinstance(obj, MetaProtocol):
|
|
continue
|
|
plugin_id = getattr(obj, "id", None)
|
|
if not plugin_id or not isinstance(plugin_id, str) or not plugin_id.strip():
|
|
continue
|
|
try:
|
|
ChannelPluginRegistry.register(obj)
|
|
discovered.append(obj)
|
|
except ValueError:
|
|
continue
|
|
|
|
return discovered
|
|
|
|
@classmethod
|
|
def _scan_with_cache(cls, plugin_path: Path) -> list[Path]:
|
|
resolved = str(plugin_path.resolve())
|
|
with cls._ensure_cache_lock():
|
|
cached = cls._discovery_cache.get(resolved)
|
|
if cached is not None:
|
|
_max_mtime, dir_mtime, files = cached
|
|
try:
|
|
if plugin_path.stat().st_mtime <= dir_mtime and cls._cache_still_valid(files, _max_mtime):
|
|
return list(files)
|
|
except OSError:
|
|
pass
|
|
|
|
module_files = list(plugin_path.rglob("*.py"))
|
|
max_mtime = max(
|
|
(f.stat().st_mtime for f in module_files),
|
|
default=0.0,
|
|
)
|
|
try:
|
|
dir_mtime = plugin_path.stat().st_mtime
|
|
except OSError:
|
|
dir_mtime = 0.0
|
|
with cls._ensure_cache_lock():
|
|
cls._discovery_cache[resolved] = (max_mtime, dir_mtime, module_files)
|
|
return module_files
|
|
|
|
@staticmethod
|
|
def _cache_still_valid(files: list[Path], cached_max_mtime: float) -> bool:
|
|
for f in files:
|
|
try:
|
|
if f.stat().st_mtime > cached_max_mtime:
|
|
return False
|
|
except OSError:
|
|
return False
|
|
return True
|
|
|
|
@classmethod
|
|
def discover_manifests(cls, manifest_dir: str | Path) -> list[ChannelPluginManifest]:
|
|
"""从清单目录加载插件元数据。"""
|
|
from .manifest import load_manifests_from_directory
|
|
|
|
return load_manifests_from_directory(manifest_dir)
|