from __future__ import annotations import logging import shutil import subprocess import sys from pathlib import Path from typing import Any from .dependency_validator import DependencyValidator from .discovery import ChannelPluginDiscovery from .manifest import ChannelPluginManifest logger = logging.getLogger(__name__) class SecurityError(Exception): """安全校验错误 —— 阻断不安全的插件安装。""" def install_channel_plugin( manifest: ChannelPluginManifest, validator: DependencyValidator | None = None, ) -> None: """安装一个渠道插件: 1. 安装依赖 (pip install) —— 带白名单校验 2. 复制/链接插件代码到目标目录 3. 发现并注册插件类 Args: manifest: 插件清单 validator: 依赖校验器,为 None 时使用默认校验器 """ logger.info("Installing channel plugin: %s", manifest.id) if manifest.dependencies: logger.info("Installing dependencies for %s: %s", manifest.id, manifest.dependencies) _install_dependencies_with_validation(manifest.dependencies, validator) if manifest.source_path: try: source = ChannelPluginDiscovery.validate_plugin_path(manifest.source_path, purpose="source copy") except PermissionError as e: raise SecurityError(str(e)) from e target = Path(manifest.target_path or f"yuxi/channels/{manifest.id}") if source.exists(): if target.exists(): shutil.rmtree(target) if source.is_dir(): shutil.copytree(source, target) else: target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(source, target) logger.info("Copied plugin source from %s to %s", source, target) if manifest.entry_point: try: entry_path = ChannelPluginDiscovery.validate_plugin_path(manifest.entry_point, purpose="entry point") except PermissionError as e: raise SecurityError(str(e)) from e ChannelPluginDiscovery.discover_from_path(entry_path) logger.info("Channel plugin '%s' installed successfully", manifest.id) def _install_dependencies_with_validation( dependencies: list[str], validator: DependencyValidator | None = None, ) -> None: """带白名单校验的依赖安装。 校验流程: 1. 使用 DependencyValidator 校验每个依赖 2. 收集所有校验错误 3. 如有错误,抛出 SecurityError 阻断安装 4. 校验通过后执行 pip install """ validator = validator or DependencyValidator.default_validator() results = validator.validate_all(dependencies) errors = [err for r in results for err in r.errors] if errors: for err in errors: logger.error("Dependency validation failed: %s", err) raise SecurityError(f"Dependency validation failed: {'; '.join(errors)}") allowed_deps = [r.requested_spec for r in results if r.is_valid] if not allowed_deps: logger.info("No valid dependencies to install after validation") return logger.info("Installing validated dependencies: %s", allowed_deps) try: pip_cmd = [ sys.executable, "-m", "pip", "install", "--no-build-isolation", *allowed_deps, ] subprocess.check_output( pip_cmd, stderr=subprocess.STDOUT, text=True, ) except subprocess.CalledProcessError as e: logger.error("pip install failed: %s", e.output) raise SecurityError(f"Failed to install dependencies: {e.output}") from e def uninstall_channel_plugin( plugin_id: str, target_dir: str | Path | None = None, dependencies: list[str] | None = None, ) -> bool: """卸载一个渠道插件: 1. 从 Registry 中注销 2. 清理文件系统中的插件文件 3. 卸载 pip 安装的依赖(尽力而为,不阻断卸载流程) Args: plugin_id: 插件 ID target_dir: 插件文件所在目录(从 manifest.target_path 提取) dependencies: 插件依赖列表(从 manifest.dependencies 提取) """ from .registry import ChannelPluginRegistry ChannelPluginRegistry.unregister(plugin_id) if target_dir: target = Path(target_dir) if target.exists(): shutil.rmtree(target) logger.info("Removed plugin directory: %s", target) if dependencies: _uninstall_dependencies(plugin_id, dependencies) logger.info("Channel plugin '%s' fully uninstalled", plugin_id) return True def _uninstall_dependencies(plugin_id: str, dependencies: list[str]) -> None: """卸载插件依赖(尽力而为:失败仅记录告警,不抛异常)。""" if not dependencies: return package_names = [] for dep in dependencies: name = dep.strip().split("=")[0].split(">")[0].split("<")[0].split("~")[0].split("!")[0].strip() name = name.split("[")[0].split(";")[0].strip() if name: package_names.append(name) if not package_names: return logger.info("Uninstalling dependencies for '%s': %s", plugin_id, package_names) try: subprocess.check_output( [sys.executable, "-m", "pip", "uninstall", "-y", *package_names], stderr=subprocess.STDOUT, text=True, ) logger.info("Successfully uninstalled dependencies for '%s'", plugin_id) except subprocess.CalledProcessError as e: logger.warning( "Failed to uninstall dependencies for '%s': %s (plugin already unregistered)", plugin_id, e.output.strip() if e.output else str(e), )