55import importlib
66import importlib .metadata
77import logging
8+ import os
9+ import shutil
810import sys
911from dataclasses import dataclass
1012from pathlib import Path
1517
1618from ggshield import __version__ as ggshield_version
1719from ggshield .core .config .enterprise_config import EnterpriseConfig
18- from ggshield .core .dirs import get_plugins_dir
20+ from ggshield .core .dirs import get_cache_dir , get_plugins_dir
1921from ggshield .core .plugin .base import GGShieldPlugin , PluginMetadata
2022from ggshield .core .plugin .registry import PluginRegistry
2123from ggshield .core .plugin .signature import (
@@ -106,6 +108,57 @@ def resolve_config_key(wheel_path: Path, fallback: str) -> str:
106108 return entry_point [0 ]
107109
108110
111+ def enable_installed_plugin (
112+ enterprise_config : EnterpriseConfig ,
113+ plugin_name : str ,
114+ version : str ,
115+ wheel_path : Path ,
116+ ) -> str :
117+ """Enable the canonical plugin key and migrate any stale aliases.
118+
119+ Older ggshield versions wrote the enable row under the wheel's
120+ distribution name; the loader now keys discovery on the
121+ ``ggshield.plugins`` entry-point name. After a fresh install or
122+ update we therefore need to:
123+
124+ 1. Write the canonical row under the entry-point name (so
125+ ``discover_plugins`` finds it enabled).
126+ 2. Remove any pre-existing row under a known alias (the caller's
127+ ``plugin_name`` — catalog reference or distribution name — and
128+ the wheel's distribution name as encoded in
129+ ``wheel_path.parent.name``).
130+ 3. Carry over the user's ``auto_update`` choice from any removed
131+ alias so an explicit ``auto_update: false`` survives the
132+ migration. When more than one alias exists, the strictest
133+ (``False``) wins so user intent is never silently relaxed.
134+
135+ Update.py passes the loader's discovered canonical name as
136+ ``plugin_name`` (already the entry-point name), so the legacy
137+ alias only becomes visible via ``wheel_path.parent.name``. Install
138+ paths pass either the catalog reference or the distribution name,
139+ both of which become aliases here.
140+ """
141+ config_key = resolve_config_key (wheel_path , fallback = plugin_name )
142+
143+ legacy_keys = {plugin_name , wheel_path .parent .name }
144+ legacy_keys .discard (config_key )
145+
146+ carried_auto_update : Optional [bool ] = None
147+ for legacy_key in legacy_keys :
148+ legacy = enterprise_config .plugins .get (legacy_key )
149+ if legacy is None :
150+ continue
151+ # Honor the strictest setting found across aliases.
152+ if carried_auto_update is None or not legacy .auto_update :
153+ carried_auto_update = legacy .auto_update
154+ enterprise_config .remove_plugin (legacy_key )
155+
156+ enterprise_config .enable_plugin (config_key , version = version )
157+ if carried_auto_update is not None :
158+ enterprise_config .plugins [config_key ].auto_update = carried_auto_update
159+ return config_key
160+
161+
109162@dataclass
110163class DiscoveredPlugin :
111164 """Information about a discovered plugin."""
@@ -147,17 +200,25 @@ def discover_plugins(self) -> List[DiscoveredPlugin]:
147200 wheel_version : str = wheel_info ["version" ]
148201 entry_point_name : Optional [str ] = wheel_info ["entry_point_name" ]
149202
150- # Use entry point name as key if available, otherwise package name
203+ # Use entry point name as key if available, otherwise package name.
204+ # Older installs may have enabled the wheel distribution/package name
205+ # before ggshield learned the entry-point key. Treat that package name
206+ # as an alias so an installed+enabled plugin does not silently miss its
207+ # top-level commands.
151208 key = entry_point_name if entry_point_name else plugin_name
152209 if entry_point_name :
153210 local_entry_point_names .add (entry_point_name )
154211
212+ is_enabled = self ._is_enabled (key )
213+ if key not in self .enterprise_config .plugins and plugin_name != key :
214+ is_enabled = self ._is_enabled (plugin_name )
215+
155216 discovered [key ] = DiscoveredPlugin (
156217 name = key ,
157218 entry_point = None ,
158219 wheel_path = wheel_path ,
159220 is_installed = True ,
160- is_enabled = self . _is_enabled ( key ) ,
221+ is_enabled = is_enabled ,
161222 version = wheel_version ,
162223 )
163224
@@ -272,8 +333,11 @@ def _load_from_wheel(self, wheel_path: Path) -> Optional[GGShieldPlugin]:
272333 )
273334 return None
274335
275- # Extract wheel to a directory alongside the wheel file
276- extract_dir = wheel_path .parent / f".{ wheel_path .stem } _extracted"
336+ # Extract wheel to a per-user cache directory instead of next to the
337+ # installed wheel. This keeps loading working when a wheel is installed
338+ # in a shared/root-owned data directory but the current user can still
339+ # read it.
340+ extract_dir = self ._get_extract_dir (wheel_path )
277341
278342 try :
279343 # In STRICT mode, always re-extract after verification so imports
@@ -282,15 +346,16 @@ def _load_from_wheel(self, wheel_path: Path) -> Optional[GGShieldPlugin]:
282346 not extract_dir .exists ()
283347 or wheel_path .stat ().st_mtime > extract_dir .stat ().st_mtime
284348 ):
285- import shutil
286-
287349 if extract_dir .exists ():
288350 shutil .rmtree (extract_dir )
351+ extract_dir .parent .mkdir (parents = True , exist_ok = True )
289352
290353 from ggshield .utils .archive import safe_unpack
291354
292355 safe_unpack (wheel_path , extract_dir )
293356
357+ self ._prune_stale_extract_dirs (extract_dir )
358+
294359 # Add extracted directory to sys.path
295360 extract_str = str (extract_dir )
296361 if extract_str not in sys .path :
@@ -309,6 +374,60 @@ def _load_from_wheel(self, wheel_path: Path) -> Optional[GGShieldPlugin]:
309374 logger .warning ("Failed to load wheel %s: %s" , wheel_path , e )
310375 return None
311376
377+ def _prune_stale_extract_dirs (self , keep_dir : Path ) -> None :
378+ """Remove stale extraction dirs for the same plugin cache bucket."""
379+ parent = keep_dir .parent
380+ if not parent .exists ():
381+ return
382+
383+ for path in parent .iterdir ():
384+ if path == keep_dir or not path .is_dir ():
385+ continue
386+ if not path .name .endswith ("_extracted" ):
387+ continue
388+ try :
389+ shutil .rmtree (path )
390+ except OSError as exc :
391+ logger .debug (
392+ "Failed to remove stale plugin extraction dir %s: %s" , path , exc
393+ )
394+
395+ def _get_extract_cache_dir (self ) -> Path :
396+ """Return the cache dir used for extracted plugin wheels.
397+
398+ `sudo -E` on Unix can preserve a non-root HOME, which makes platformdirs
399+ point root at the invoking user's cache dir. Do not create root-owned
400+ extraction trees there: use root's own cache unless GG_CACHE_DIR was set
401+ explicitly.
402+ """
403+ if os .environ .get ("GG_CACHE_DIR" ):
404+ return get_cache_dir ()
405+
406+ if sys .platform != "win32" and hasattr (os , "geteuid" ) and os .geteuid () == 0 :
407+ home = os .environ .get ("HOME" )
408+ try :
409+ if home and Path (home ).exists () and Path (home ).stat ().st_uid != 0 :
410+ import pwd
411+
412+ root_home = Path (pwd .getpwuid (0 ).pw_dir )
413+ if sys .platform == "darwin" :
414+ return root_home / "Library" / "Caches" / "ggshield"
415+ return root_home / ".cache" / "ggshield"
416+ except (KeyError , OSError ):
417+ pass
418+
419+ return get_cache_dir ()
420+
421+ def _get_extract_dir (self , wheel_path : Path ) -> Path :
422+ """Return the per-user extraction directory for an installed wheel."""
423+ wheel_hash = compute_file_sha256 (wheel_path )[:16 ]
424+ return (
425+ self ._get_extract_cache_dir ()
426+ / "plugins"
427+ / wheel_path .parent .name
428+ / f"{ wheel_path .stem } -{ wheel_hash } _extracted"
429+ )
430+
312431 def _is_trusted_unsigned_plugin (self , wheel_path : Path ) -> bool :
313432 """Return True when the current wheel hash matches a persisted trust record."""
314433 plugin_name = wheel_path .parent .name
0 commit comments