165 lines
7.0 KiB
Python
165 lines
7.0 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:
|
|
url = settings.ARTEMIS_PLUGIN_MARKETPLACE_URL
|
|
# Older hosts reject schema-v3 entries before checking version constraints.
|
|
# Keep their index intact, and route only the exact former official URL.
|
|
if url == "https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog.json":
|
|
url = url.removesuffix("catalog.json") + "catalog-v3.json"
|
|
data = download(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)
|