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)