Files
Artemis-Eapil/artemis/plugins/installer.py
T

160 lines
6.7 KiB
Python

"""Install immutable, verified plugin packages without executing package hooks."""
import hashlib
import io
from pathlib import Path, PurePosixPath
import shutil
import stat
import tempfile
import time
from typing import Callable
import zipfile
import httpx
from artemis.config.settings import settings
from artemis.plugins.models import CatalogEntry, PluginCatalog, PluginManifest
from artemis.plugins.store import PluginStore, contained_path
def download(url: str, progress: Callable[[str, int], None]) -> bytes:
if httpx.URL(url).scheme != "https":
raise ValueError("插件下载必须使用 HTTPS")
deadline = time.monotonic() + settings.ARTEMIS_PLUGIN_DOWNLOAD_TIMEOUT_SECONDS
chunks: list[bytes] = []
size = 0
limit = settings.ARTEMIS_PLUGIN_DOWNLOAD_MAX_BYTES
with httpx.stream("GET", url, follow_redirects=True, timeout=30) as response:
response.raise_for_status()
if response.url.scheme != "https":
raise ValueError("插件下载不能降级为明文 HTTP")
total = int(response.headers.get("content-length", 0))
if total > limit:
raise ValueError("插件下载大小超过限制")
for chunk in response.iter_bytes():
if time.monotonic() > deadline:
raise ValueError("插件下载超时,请检查网络后重试")
size += len(chunk)
if size > limit:
raise ValueError("插件下载大小超过限制")
chunks.append(chunk)
progress("下载中", min(70, int(size / total * 70)) if total else 0)
return b"".join(chunks)
def load_catalog() -> PluginCatalog:
data = download(settings.ARTEMIS_PLUGIN_MARKETPLACE_URL, lambda *_: None)
if len(data) > 2 * 1024 * 1024:
raise ValueError("市场索引过大")
return PluginCatalog.model_validate_json(data)
def _extract(data: bytes, target: Path) -> None:
with zipfile.ZipFile(io.BytesIO(data)) as archive:
members = archive.infolist()
if (
len(members) > 20000
or sum(m.file_size for m in members) > settings.ARTEMIS_PLUGIN_EXTRACT_MAX_BYTES
):
raise ValueError("插件解压大小或文件数量超过限制")
seen: set[str] = set()
for member in members:
relative = member.filename.replace("\\", "/")
parts = PurePosixPath(relative).parts
reserved = {
"con",
"prn",
"aux",
"nul",
*(f"com{i}" for i in range(1, 10)),
*(f"lpt{i}" for i in range(1, 10)),
}
if (
not parts
or relative.startswith("/")
or any(p in {"..", "."} or ":" in p or p.endswith((".", " ")) for p in parts)
or any(p.split(".")[0].casefold() in reserved for p in parts)
or stat.S_ISLNK(member.external_attr >> 16)
):
raise ValueError("插件包包含不安全的路径或链接")
key = relative.rstrip("/").casefold()
if key in seen:
raise ValueError("插件包包含重复文件路径")
seen.add(key)
path = contained_path(target, relative)
if member.is_dir():
path.mkdir(parents=True, exist_ok=True)
else:
path.parent.mkdir(parents=True, exist_ok=True)
with archive.open(member) as source, path.open("wb") as output:
shutil.copyfileobj(source, output)
def install_archive(
store: PluginStore,
data: bytes,
*,
entry: CatalogEntry | None = None,
progress: Callable[[str, int], None] = lambda *_: None,
) -> dict:
if len(data) > settings.ARTEMIS_PLUGIN_DOWNLOAD_MAX_BYTES:
raise ValueError("插件包大小超过限制")
progress("校验安装包", 75)
if entry and hashlib.sha256(data).hexdigest() != entry.sha256:
raise ValueError("安装包 SHA-256 校验失败,原版本保持不变")
with tempfile.TemporaryDirectory(prefix="install-", dir=store.root) as temp:
staged = Path(temp) / "package"
staged.mkdir()
_extract(data, staged)
manifest = PluginManifest.model_validate_json(
(staged / "plugin.json").read_text(encoding="utf-8")
)
manifest.check_compatible()
if entry and manifest != entry.manifest:
raise ValueError("安装包清单与市场索引不一致")
if manifest.skill:
skill = contained_path(staged, manifest.skill)
if not skill.is_file() or skill.stat().st_size > 256 * 1024:
raise ValueError("Skill 文件缺失或超过大小限制")
with store.mutation_lock:
store.assert_idle(manifest.id)
destination = store.package_dir(manifest.id, manifest.version)
if destination.exists():
raise ValueError("此插件版本已经安装;请使用新版本号,不能覆盖任务已固定的文件")
progress("安装中", 90)
destination.parent.mkdir(parents=True, exist_ok=True)
staged.replace(destination)
try:
store.register(manifest, "marketplace" if entry else "local")
except Exception:
# Restore the staging directory; TemporaryDirectory removes this failed install.
destination.replace(staged)
raise
progress("安装完成,请配置并启用", 100)
return store.get(manifest.id)
def install_local(store: PluginStore, path: Path, progress=lambda *_: None) -> dict:
if path.is_file():
if path.stat().st_size > settings.ARTEMIS_PLUGIN_DOWNLOAD_MAX_BYTES:
raise ValueError("插件包大小超过限制")
return install_archive(store, path.read_bytes(), progress=progress)
if not path.is_dir():
raise ValueError("本地插件路径不存在")
output = io.BytesIO()
size = 0
count = 0
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive:
for source in path.rglob("*"):
if source.is_symlink():
raise ValueError("本地插件不能包含符号链接")
if source.is_file():
count += 1
size += source.stat().st_size
if count > 20000 or size > settings.ARTEMIS_PLUGIN_EXTRACT_MAX_BYTES:
raise ValueError("本地插件目录过大")
archive.write(source, source.relative_to(path).as_posix())
if output.tell() > settings.ARTEMIS_PLUGIN_DOWNLOAD_MAX_BYTES:
raise ValueError("插件包大小超过限制")
return install_archive(store, output.getvalue(), progress=progress)