Coverage for graphica/core/plugin_install.py: 98.4%
46 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-12 16:38 +0000
1"""プラグインの zip を展開してインストールする(取得はしない。信頼できる配布元のものだけを入れること)。"""
2import os
3import shutil
4import tempfile
5import uuid
6import zipfile
8from graphica.core.app_paths import get_user_plugins_dir
11class PluginInstallError(Exception):
12 """メッセージはそのまま利用者に見せる。"""
15def _reject_unsafe_members(zf: zipfile.ZipFile) -> None:
16 # zip-slip: 正規化して .. で始まるか絶対パスなら、target_dir の外に書くので拒否する
17 for member in zf.namelist():
18 normalized = os.path.normpath(member)
19 if normalized.startswith("..") or os.path.isabs(normalized):
20 raise PluginInstallError(
21 f"安全でないパスを含むzipファイルです(不正なエントリ: '{member}')。"
22 )
25def _find_plugin_root(staging_dir: str, zip_path: str) -> tuple[str, str]:
26 """(プラグイン本体のフォルダの絶対パス, 採用するフォルダ名)"""
27 if os.path.exists(os.path.join(staging_dir, "__init__.py")):
28 # __init__.py が zip の直下にある
29 name = os.path.splitext(os.path.basename(zip_path))[0]
30 return staging_dir, name
32 entries = [e for e in os.listdir(staging_dir) if os.path.isdir(os.path.join(staging_dir, e))]
33 if len(entries) == 1:
34 candidate = os.path.join(staging_dir, entries[0])
35 if os.path.exists(os.path.join(candidate, "__init__.py")):
36 # 1つのフォルダの中に __init__.py がある
37 return candidate, entries[0]
39 raise PluginInstallError(
40 "zip内に __init__.py を持つプラグインが見つかりませんでした。"
41 "プラグインフォルダそのもの、またはそのフォルダを1つだけ含むzipを指定してください。"
42 )
45def install_plugin_zip(zip_path: str, target_dir: str | None = None) -> str:
46 """target_dir(省略時は get_user_plugins_dir())に入れ、フォルダ名を返す。失敗は PluginInstallError。"""
47 if target_dir is None:
48 target_dir = get_user_plugins_dir()
50 if not zipfile.is_zipfile(zip_path):
51 raise PluginInstallError(f"'{zip_path}' は有効なzipファイルではありません。")
53 # 展開先は target_dir と同じボリュームに作る(最後の os.replace() を原子的な改名にするため)。
54 # 展開先の直下には __init__.py が無いので、プラグインとして誤って見つかることはない
55 staging_dir = tempfile.mkdtemp(prefix=".tmp_install_", dir=target_dir)
56 try:
57 try:
58 with zipfile.ZipFile(zip_path) as zf:
59 _reject_unsafe_members(zf)
60 zf.extractall(staging_dir)
61 except zipfile.BadZipFile as e:
62 raise PluginInstallError(f"zipファイルの展開に失敗しました: {e}") from e
64 plugin_root, plugin_name = _find_plugin_root(staging_dir, zip_path)
65 final_path = os.path.join(target_dir, plugin_name)
67 stale_path = None
68 if os.path.exists(final_path):
69 # 上書きのときは、既存のものを先に退避して最後に消す
70 stale_path = f"{final_path}.old-{uuid.uuid4().hex}"
71 os.replace(final_path, stale_path)
73 # zip の直下に __init__.py があった場合、改名までの一瞬だけ展開先が半端なプラグインに見える。
74 # 1つのプロセスで入れる前提なので、そのままにしている
75 os.replace(plugin_root, final_path)
77 if stale_path is not None:
78 shutil.rmtree(stale_path, ignore_errors=True)
80 return plugin_name
81 finally:
82 shutil.rmtree(staging_dir, ignore_errors=True)