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

1"""プラグインの zip を展開してインストールする(取得はしない。信頼できる配布元のものだけを入れること)。""" 

2import os 

3import shutil 

4import tempfile 

5import uuid 

6import zipfile 

7 

8from graphica.core.app_paths import get_user_plugins_dir 

9 

10 

11class PluginInstallError(Exception): 

12 """メッセージはそのまま利用者に見せる。""" 

13 

14 

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 ) 

23 

24 

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 

31 

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] 

38 

39 raise PluginInstallError( 

40 "zip内に __init__.py を持つプラグインが見つかりませんでした。" 

41 "プラグインフォルダそのもの、またはそのフォルダを1つだけ含むzipを指定してください。" 

42 ) 

43 

44 

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() 

49 

50 if not zipfile.is_zipfile(zip_path): 

51 raise PluginInstallError(f"'{zip_path}' は有効なzipファイルではありません。") 

52 

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 

63 

64 plugin_root, plugin_name = _find_plugin_root(staging_dir, zip_path) 

65 final_path = os.path.join(target_dir, plugin_name) 

66 

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) 

72 

73 # zip の直下に __init__.py があった場合、改名までの一瞬だけ展開先が半端なプラグインに見える。 

74 # 1つのプロセスで入れる前提なので、そのままにしている 

75 os.replace(plugin_root, final_path) 

76 

77 if stale_path is not None: 

78 shutil.rmtree(stale_path, ignore_errors=True) 

79 

80 return plugin_name 

81 finally: 

82 shutil.rmtree(staging_dir, ignore_errors=True)