pluginforge 0.3.0__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pluginforge-0.3.0 → pluginforge-0.5.0}/PKG-INFO +12 -3
- {pluginforge-0.3.0 → pluginforge-0.5.0}/README.md +11 -2
- {pluginforge-0.3.0 → pluginforge-0.5.0}/pluginforge/__init__.py +8 -2
- {pluginforge-0.3.0 → pluginforge-0.5.0}/pluginforge/alembic_ext.py +11 -2
- {pluginforge-0.3.0 → pluginforge-0.5.0}/pluginforge/config.py +10 -0
- {pluginforge-0.3.0 → pluginforge-0.5.0}/pluginforge/lifecycle.py +11 -0
- {pluginforge-0.3.0 → pluginforge-0.5.0}/pluginforge/manager.py +156 -2
- pluginforge-0.5.0/pluginforge/security.py +55 -0
- {pluginforge-0.3.0 → pluginforge-0.5.0}/pyproject.toml +1 -1
- {pluginforge-0.3.0 → pluginforge-0.5.0}/LICENSE +0 -0
- {pluginforge-0.3.0 → pluginforge-0.5.0}/pluginforge/base.py +0 -0
- {pluginforge-0.3.0 → pluginforge-0.5.0}/pluginforge/discovery.py +0 -0
- {pluginforge-0.3.0 → pluginforge-0.5.0}/pluginforge/fastapi_ext.py +0 -0
- {pluginforge-0.3.0 → pluginforge-0.5.0}/pluginforge/i18n.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pluginforge
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Application-agnostic plugin framework built on pluggy
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -105,18 +105,24 @@ for plugin in pm.get_active_plugins():
|
|
|
105
105
|
# Mount FastAPI routes
|
|
106
106
|
from fastapi import FastAPI
|
|
107
107
|
app = FastAPI()
|
|
108
|
-
pm.mount_routes(app) # Routes
|
|
108
|
+
pm.mount_routes(app) # Routes under /api/ (configurable prefix)
|
|
109
109
|
```
|
|
110
110
|
|
|
111
111
|
## Features
|
|
112
112
|
|
|
113
113
|
- **YAML Configuration** - App config, per-plugin config, and i18n strings
|
|
114
114
|
- **Plugin Lifecycle** - init, activate, deactivate with error handling
|
|
115
|
+
- **Hot-Reload** - Swap plugins at runtime without app restart
|
|
115
116
|
- **Enable/Disable** - Control plugins via config lists
|
|
116
117
|
- **Dependency Resolution** - Topological sorting with circular dependency detection
|
|
117
|
-
- **
|
|
118
|
+
- **Extension Points** - Query plugins by interface with `get_extensions(type)`
|
|
119
|
+
- **Config Schema Validation** - Declare expected config types per plugin
|
|
120
|
+
- **Health Checks** - Monitor plugin status via `health_check()`
|
|
121
|
+
- **Pre-Activate Hooks** - Reject plugins before activation (license checks, etc.)
|
|
122
|
+
- **FastAPI Integration** - Mount plugin routes with configurable prefix
|
|
118
123
|
- **Alembic Support** - Collect migration directories from plugins
|
|
119
124
|
- **i18n** - Multi-language strings from YAML with fallback
|
|
125
|
+
- **Security** - Plugin name validation and path traversal prevention
|
|
120
126
|
|
|
121
127
|
For detailed documentation, see the [Wiki](https://github.com/astrapi69/pluginforge/wiki).
|
|
122
128
|
|
|
@@ -161,10 +167,13 @@ The full documentation is available in the [Wiki](https://github.com/astrapi69/p
|
|
|
161
167
|
- [Discovery and Dependencies](https://github.com/astrapi69/pluginforge/wiki/Discovery-and-Dependencies)
|
|
162
168
|
- [Lifecycle](https://github.com/astrapi69/pluginforge/wiki/Lifecycle)
|
|
163
169
|
- [Hooks](https://github.com/astrapi69/pluginforge/wiki/Hooks)
|
|
170
|
+
- [Extensions](https://github.com/astrapi69/pluginforge/wiki/Extensions)
|
|
164
171
|
- [FastAPI Integration](https://github.com/astrapi69/pluginforge/wiki/FastAPI-Integration)
|
|
165
172
|
- [Alembic Integration](https://github.com/astrapi69/pluginforge/wiki/Alembic-Integration)
|
|
166
173
|
- [i18n](https://github.com/astrapi69/pluginforge/wiki/i18n)
|
|
174
|
+
- [Security](https://github.com/astrapi69/pluginforge/wiki/Security)
|
|
167
175
|
- [Examples](https://github.com/astrapi69/pluginforge/wiki/Examples)
|
|
176
|
+
- [Changelog](https://github.com/astrapi69/pluginforge/wiki/Changelog)
|
|
168
177
|
|
|
169
178
|
## Development
|
|
170
179
|
|
|
@@ -80,18 +80,24 @@ for plugin in pm.get_active_plugins():
|
|
|
80
80
|
# Mount FastAPI routes
|
|
81
81
|
from fastapi import FastAPI
|
|
82
82
|
app = FastAPI()
|
|
83
|
-
pm.mount_routes(app) # Routes
|
|
83
|
+
pm.mount_routes(app) # Routes under /api/ (configurable prefix)
|
|
84
84
|
```
|
|
85
85
|
|
|
86
86
|
## Features
|
|
87
87
|
|
|
88
88
|
- **YAML Configuration** - App config, per-plugin config, and i18n strings
|
|
89
89
|
- **Plugin Lifecycle** - init, activate, deactivate with error handling
|
|
90
|
+
- **Hot-Reload** - Swap plugins at runtime without app restart
|
|
90
91
|
- **Enable/Disable** - Control plugins via config lists
|
|
91
92
|
- **Dependency Resolution** - Topological sorting with circular dependency detection
|
|
92
|
-
- **
|
|
93
|
+
- **Extension Points** - Query plugins by interface with `get_extensions(type)`
|
|
94
|
+
- **Config Schema Validation** - Declare expected config types per plugin
|
|
95
|
+
- **Health Checks** - Monitor plugin status via `health_check()`
|
|
96
|
+
- **Pre-Activate Hooks** - Reject plugins before activation (license checks, etc.)
|
|
97
|
+
- **FastAPI Integration** - Mount plugin routes with configurable prefix
|
|
93
98
|
- **Alembic Support** - Collect migration directories from plugins
|
|
94
99
|
- **i18n** - Multi-language strings from YAML with fallback
|
|
100
|
+
- **Security** - Plugin name validation and path traversal prevention
|
|
95
101
|
|
|
96
102
|
For detailed documentation, see the [Wiki](https://github.com/astrapi69/pluginforge/wiki).
|
|
97
103
|
|
|
@@ -136,10 +142,13 @@ The full documentation is available in the [Wiki](https://github.com/astrapi69/p
|
|
|
136
142
|
- [Discovery and Dependencies](https://github.com/astrapi69/pluginforge/wiki/Discovery-and-Dependencies)
|
|
137
143
|
- [Lifecycle](https://github.com/astrapi69/pluginforge/wiki/Lifecycle)
|
|
138
144
|
- [Hooks](https://github.com/astrapi69/pluginforge/wiki/Hooks)
|
|
145
|
+
- [Extensions](https://github.com/astrapi69/pluginforge/wiki/Extensions)
|
|
139
146
|
- [FastAPI Integration](https://github.com/astrapi69/pluginforge/wiki/FastAPI-Integration)
|
|
140
147
|
- [Alembic Integration](https://github.com/astrapi69/pluginforge/wiki/Alembic-Integration)
|
|
141
148
|
- [i18n](https://github.com/astrapi69/pluginforge/wiki/i18n)
|
|
149
|
+
- [Security](https://github.com/astrapi69/pluginforge/wiki/Security)
|
|
142
150
|
- [Examples](https://github.com/astrapi69/pluginforge/wiki/Examples)
|
|
151
|
+
- [Changelog](https://github.com/astrapi69/pluginforge/wiki/Changelog)
|
|
143
152
|
|
|
144
153
|
## Development
|
|
145
154
|
|
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
from pluginforge.base import BasePlugin
|
|
4
4
|
from pluginforge.discovery import CircularDependencyError
|
|
5
5
|
from pluginforge.manager import PluginManager
|
|
6
|
+
from pluginforge.security import InvalidPluginNameError
|
|
6
7
|
|
|
7
|
-
__version__ = "0.
|
|
8
|
-
__all__ = [
|
|
8
|
+
__version__ = "0.5.0"
|
|
9
|
+
__all__ = [
|
|
10
|
+
"BasePlugin",
|
|
11
|
+
"CircularDependencyError",
|
|
12
|
+
"InvalidPluginNameError",
|
|
13
|
+
"PluginManager",
|
|
14
|
+
]
|
|
@@ -28,6 +28,15 @@ def collect_migrations_dirs(plugins: list[BasePlugin]) -> dict[str, str]:
|
|
|
28
28
|
"Plugin '%s' migrations dir does not exist: %s", plugin.name, migrations_dir
|
|
29
29
|
)
|
|
30
30
|
continue
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
# Ensure resolved path doesn't escape via symlinks
|
|
32
|
+
resolved = path.resolve()
|
|
33
|
+
if not resolved.is_dir():
|
|
34
|
+
logger.warning(
|
|
35
|
+
"Plugin '%s' migrations dir resolves to non-directory: %s",
|
|
36
|
+
plugin.name,
|
|
37
|
+
resolved,
|
|
38
|
+
)
|
|
39
|
+
continue
|
|
40
|
+
migrations[plugin.name] = str(resolved)
|
|
41
|
+
logger.info("Collected migrations for plugin '%s': %s", plugin.name, resolved)
|
|
33
42
|
return migrations
|
|
@@ -46,6 +46,8 @@ def load_app_config(config_path: str | Path) -> dict[str, Any]:
|
|
|
46
46
|
def load_plugin_config(config_dir: str | Path, plugin_name: str) -> dict[str, Any]:
|
|
47
47
|
"""Load plugin-specific configuration from config/plugins/{name}.yaml.
|
|
48
48
|
|
|
49
|
+
Validates the plugin name to prevent path traversal attacks.
|
|
50
|
+
|
|
49
51
|
Args:
|
|
50
52
|
config_dir: Base config directory (parent of plugins/).
|
|
51
53
|
plugin_name: Name of the plugin.
|
|
@@ -53,6 +55,9 @@ def load_plugin_config(config_dir: str | Path, plugin_name: str) -> dict[str, An
|
|
|
53
55
|
Returns:
|
|
54
56
|
Plugin configuration dict.
|
|
55
57
|
"""
|
|
58
|
+
from pluginforge.security import validate_plugin_name
|
|
59
|
+
|
|
60
|
+
validate_plugin_name(plugin_name)
|
|
56
61
|
path = Path(config_dir) / "plugins" / f"{plugin_name}.yaml"
|
|
57
62
|
return load_yaml(path)
|
|
58
63
|
|
|
@@ -60,6 +65,8 @@ def load_plugin_config(config_dir: str | Path, plugin_name: str) -> dict[str, An
|
|
|
60
65
|
def load_i18n(config_dir: str | Path, lang: str) -> dict[str, Any]:
|
|
61
66
|
"""Load i18n strings for a specific language.
|
|
62
67
|
|
|
68
|
+
Validates the language code to prevent path traversal attacks.
|
|
69
|
+
|
|
63
70
|
Args:
|
|
64
71
|
config_dir: Base config directory (parent of i18n/).
|
|
65
72
|
lang: Language code (e.g. "en", "de").
|
|
@@ -67,5 +74,8 @@ def load_i18n(config_dir: str | Path, lang: str) -> dict[str, Any]:
|
|
|
67
74
|
Returns:
|
|
68
75
|
i18n strings dict.
|
|
69
76
|
"""
|
|
77
|
+
from pluginforge.security import validate_plugin_name
|
|
78
|
+
|
|
79
|
+
validate_plugin_name(lang)
|
|
70
80
|
path = Path(config_dir) / "i18n" / f"{lang}.yaml"
|
|
71
81
|
return load_yaml(path)
|
|
@@ -135,6 +135,17 @@ class PluginLifecycle:
|
|
|
135
135
|
"""
|
|
136
136
|
return self._initialized.get(name)
|
|
137
137
|
|
|
138
|
+
def remove_plugin(self, name: str) -> None:
|
|
139
|
+
"""Remove a plugin from all lifecycle tracking.
|
|
140
|
+
|
|
141
|
+
Used during hot-reload to clean up before re-instantiation.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
name: Plugin name.
|
|
145
|
+
"""
|
|
146
|
+
self._initialized.pop(name, None)
|
|
147
|
+
self._active.pop(name, None)
|
|
148
|
+
|
|
138
149
|
def is_active(self, name: str) -> bool:
|
|
139
150
|
"""Check if a plugin is currently active.
|
|
140
151
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"""Central PluginManager that orchestrates config, discovery, lifecycle, and hooks."""
|
|
2
2
|
|
|
3
|
+
import importlib
|
|
3
4
|
import logging
|
|
5
|
+
import sys
|
|
4
6
|
from pathlib import Path
|
|
5
7
|
from collections.abc import Callable
|
|
6
8
|
from typing import Any
|
|
@@ -18,6 +20,7 @@ from pluginforge.discovery import (
|
|
|
18
20
|
)
|
|
19
21
|
from pluginforge.i18n import I18n
|
|
20
22
|
from pluginforge.lifecycle import PluginLifecycle
|
|
23
|
+
from pluginforge.security import validate_plugin_name
|
|
21
24
|
|
|
22
25
|
logger = logging.getLogger(__name__)
|
|
23
26
|
|
|
@@ -165,6 +168,8 @@ class PluginManager:
|
|
|
165
168
|
plugin: An already instantiated plugin.
|
|
166
169
|
plugin_config: Optional config dict. If None, loaded from YAML.
|
|
167
170
|
"""
|
|
171
|
+
validate_plugin_name(plugin.name)
|
|
172
|
+
|
|
168
173
|
if plugin_config is None:
|
|
169
174
|
plugin_config = self.get_plugin_config(plugin.name)
|
|
170
175
|
|
|
@@ -208,6 +213,7 @@ class PluginManager:
|
|
|
208
213
|
order: Topologically sorted list of plugin names.
|
|
209
214
|
"""
|
|
210
215
|
for name in order:
|
|
216
|
+
validate_plugin_name(name)
|
|
211
217
|
cls = plugins[name]
|
|
212
218
|
plugin = cls()
|
|
213
219
|
plugin_config = self.get_plugin_config(name)
|
|
@@ -299,18 +305,90 @@ class PluginManager:
|
|
|
299
305
|
def call_hook(self, hook_name: str, **kwargs: Any) -> list[Any]:
|
|
300
306
|
"""Call a named hook on all registered plugins.
|
|
301
307
|
|
|
308
|
+
Individual hook implementations that raise exceptions are caught and
|
|
309
|
+
logged. Other implementations still execute (graceful degradation).
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
hook_name: Name of the hook to call.
|
|
313
|
+
**kwargs: Arguments to pass to hook implementations.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
List of results from all hook implementations (failed ones are skipped).
|
|
317
|
+
"""
|
|
318
|
+
hook = getattr(self._pm.hook, hook_name, None)
|
|
319
|
+
if hook is None:
|
|
320
|
+
logger.warning("Hook '%s' not found", hook_name)
|
|
321
|
+
return []
|
|
322
|
+
try:
|
|
323
|
+
return hook(**kwargs)
|
|
324
|
+
except Exception as e:
|
|
325
|
+
logger.error("Hook '%s' raised an exception: %s", hook_name, e)
|
|
326
|
+
return []
|
|
327
|
+
|
|
328
|
+
def call_hook_safe(self, hook_name: str, **kwargs: Any) -> list[Any]:
|
|
329
|
+
"""Call a hook, collecting results and skipping failed implementations.
|
|
330
|
+
|
|
331
|
+
Unlike call_hook(), this calls each implementation individually so one
|
|
332
|
+
failure does not prevent others from executing.
|
|
333
|
+
|
|
302
334
|
Args:
|
|
303
335
|
hook_name: Name of the hook to call.
|
|
304
336
|
**kwargs: Arguments to pass to hook implementations.
|
|
305
337
|
|
|
306
338
|
Returns:
|
|
307
|
-
List of results
|
|
339
|
+
List of successful results (failed implementations are logged and skipped).
|
|
308
340
|
"""
|
|
309
341
|
hook = getattr(self._pm.hook, hook_name, None)
|
|
310
342
|
if hook is None:
|
|
311
343
|
logger.warning("Hook '%s' not found", hook_name)
|
|
312
344
|
return []
|
|
313
|
-
|
|
345
|
+
results: list[Any] = []
|
|
346
|
+
callers = hook.get_hookimpls()
|
|
347
|
+
for impl in callers:
|
|
348
|
+
try:
|
|
349
|
+
result = impl.function(**kwargs)
|
|
350
|
+
results.append(result)
|
|
351
|
+
except Exception as e:
|
|
352
|
+
plugin_name = impl.plugin_name or "unknown"
|
|
353
|
+
logger.error(
|
|
354
|
+
"Hook '%s' implementation from '%s' failed: %s",
|
|
355
|
+
hook_name,
|
|
356
|
+
plugin_name,
|
|
357
|
+
e,
|
|
358
|
+
)
|
|
359
|
+
return results
|
|
360
|
+
|
|
361
|
+
def get_plugin_hooks(self, name: str) -> list[str]:
|
|
362
|
+
"""Return hook names implemented by a specific plugin.
|
|
363
|
+
|
|
364
|
+
Useful for debugging, settings UIs, and plugin marketplaces.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
name: Plugin name.
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
List of hook names the plugin implements, or empty list if not found.
|
|
371
|
+
"""
|
|
372
|
+
plugin = self._lifecycle.get_plugin(name)
|
|
373
|
+
if plugin is None:
|
|
374
|
+
return []
|
|
375
|
+
impl_marker = f"{self._entry_point_group}_impl"
|
|
376
|
+
hooks: list[str] = []
|
|
377
|
+
for attr_name in dir(plugin):
|
|
378
|
+
if attr_name.startswith("_"):
|
|
379
|
+
continue
|
|
380
|
+
method = getattr(plugin, attr_name, None)
|
|
381
|
+
if callable(method) and hasattr(method, impl_marker):
|
|
382
|
+
hooks.append(attr_name)
|
|
383
|
+
return hooks
|
|
384
|
+
|
|
385
|
+
def get_all_hook_names(self) -> list[str]:
|
|
386
|
+
"""Return all registered hook spec names.
|
|
387
|
+
|
|
388
|
+
Returns:
|
|
389
|
+
List of hook names from all registered specs.
|
|
390
|
+
"""
|
|
391
|
+
return [name for name in dir(self._pm.hook) if not name.startswith("_")]
|
|
314
392
|
|
|
315
393
|
def mount_routes(self, app: object, prefix: str = "/api") -> None:
|
|
316
394
|
"""Mount FastAPI routes from all active plugins.
|
|
@@ -358,3 +436,79 @@ class PluginManager:
|
|
|
358
436
|
except Exception as e:
|
|
359
437
|
results[plugin.name] = {"status": "error", "error": str(e)}
|
|
360
438
|
return results
|
|
439
|
+
|
|
440
|
+
def reload_plugin(self, name: str) -> bool:
|
|
441
|
+
"""Hot-reload a plugin: deactivate, re-import module, re-init, activate.
|
|
442
|
+
|
|
443
|
+
The plugin's module is reloaded from disk so code changes take effect
|
|
444
|
+
without restarting the application.
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
name: Name of the plugin to reload.
|
|
448
|
+
|
|
449
|
+
Returns:
|
|
450
|
+
True if reload succeeded, False otherwise.
|
|
451
|
+
"""
|
|
452
|
+
plugin = self._lifecycle.get_plugin(name)
|
|
453
|
+
if plugin is None:
|
|
454
|
+
logger.warning("Cannot reload unknown plugin '%s'", name)
|
|
455
|
+
return False
|
|
456
|
+
|
|
457
|
+
plugin_cls = type(plugin)
|
|
458
|
+
module_name = plugin_cls.__module__
|
|
459
|
+
|
|
460
|
+
# Deactivate and unregister
|
|
461
|
+
if self._lifecycle.is_active(name):
|
|
462
|
+
self._lifecycle.deactivate_plugin(plugin)
|
|
463
|
+
self._pm.unregister(name=name)
|
|
464
|
+
|
|
465
|
+
# Remove from lifecycle tracking
|
|
466
|
+
self._lifecycle.remove_plugin(name)
|
|
467
|
+
|
|
468
|
+
# Reload the module
|
|
469
|
+
try:
|
|
470
|
+
module = sys.modules.get(module_name)
|
|
471
|
+
if module is not None:
|
|
472
|
+
module = importlib.reload(module)
|
|
473
|
+
plugin_cls = getattr(module, plugin_cls.__name__)
|
|
474
|
+
except Exception as e:
|
|
475
|
+
logger.error("Failed to reload module '%s': %s", module_name, e)
|
|
476
|
+
self._load_errors[name] = f"Failed to reload module: {e}"
|
|
477
|
+
return False
|
|
478
|
+
|
|
479
|
+
# Re-instantiate and activate
|
|
480
|
+
new_plugin = plugin_cls()
|
|
481
|
+
plugin_config = self.get_plugin_config(name)
|
|
482
|
+
|
|
483
|
+
if not self._lifecycle.init_plugin(new_plugin, self._app_config, plugin_config):
|
|
484
|
+
self._load_errors[name] = "Failed to initialize after reload"
|
|
485
|
+
return False
|
|
486
|
+
|
|
487
|
+
if self._pre_activate is not None:
|
|
488
|
+
if not self._pre_activate(new_plugin, plugin_config):
|
|
489
|
+
self._load_errors[name] = "Rejected by pre-activate check after reload"
|
|
490
|
+
return False
|
|
491
|
+
|
|
492
|
+
self._pm.register(new_plugin, name=name)
|
|
493
|
+
|
|
494
|
+
if not self._lifecycle.activate_plugin(new_plugin):
|
|
495
|
+
self._load_errors[name] = "Failed to activate after reload"
|
|
496
|
+
self._pm.unregister(name=name)
|
|
497
|
+
return False
|
|
498
|
+
|
|
499
|
+
logger.info("Reloaded plugin '%s'", name)
|
|
500
|
+
return True
|
|
501
|
+
|
|
502
|
+
def get_extensions(self, extension_point: type) -> list[BasePlugin]:
|
|
503
|
+
"""Return all active plugins that implement a given extension point.
|
|
504
|
+
|
|
505
|
+
An extension point is any class or ABC. This method returns all active
|
|
506
|
+
plugins that are instances of that type.
|
|
507
|
+
|
|
508
|
+
Args:
|
|
509
|
+
extension_point: The extension point class to filter by.
|
|
510
|
+
|
|
511
|
+
Returns:
|
|
512
|
+
List of active plugins implementing the extension point.
|
|
513
|
+
"""
|
|
514
|
+
return [p for p in self.get_active_plugins() if isinstance(p, extension_point)]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Security utilities for plugin name validation and path traversal prevention."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
# Plugin names must be alphanumeric with underscores/hyphens, max 64 chars
|
|
9
|
+
_PLUGIN_NAME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9_-]{0,63}$")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InvalidPluginNameError(ValueError):
|
|
13
|
+
"""Raised when a plugin name contains invalid characters."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def validate_plugin_name(name: str) -> None:
|
|
17
|
+
"""Validate a plugin name for safety.
|
|
18
|
+
|
|
19
|
+
Plugin names are used to construct file paths (config/plugins/{name}.yaml),
|
|
20
|
+
so they must not contain path separators or traversal sequences.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
name: The plugin name to validate.
|
|
24
|
+
|
|
25
|
+
Raises:
|
|
26
|
+
InvalidPluginNameError: If the name is invalid.
|
|
27
|
+
"""
|
|
28
|
+
if not _PLUGIN_NAME_RE.match(name):
|
|
29
|
+
raise InvalidPluginNameError(
|
|
30
|
+
f"Invalid plugin name '{name}'. "
|
|
31
|
+
"Names must start with a letter, contain only letters, digits, "
|
|
32
|
+
"underscores, or hyphens, and be at most 64 characters."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def validate_safe_path(path: str, allowed_base: str) -> bool:
|
|
37
|
+
"""Check that a resolved path stays within the allowed base directory.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
path: The path to validate.
|
|
41
|
+
allowed_base: The base directory that the path must stay within.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
True if the path is safe, False otherwise.
|
|
45
|
+
"""
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
|
|
48
|
+
resolved = Path(path).resolve()
|
|
49
|
+
base = Path(allowed_base).resolve()
|
|
50
|
+
try:
|
|
51
|
+
resolved.relative_to(base)
|
|
52
|
+
return True
|
|
53
|
+
except ValueError:
|
|
54
|
+
logger.warning("Path traversal detected: '%s' escapes base '%s'", path, allowed_base)
|
|
55
|
+
return False
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|