2 Commits

Author SHA1 Message Date
Chuck
b44ff079c9 fix(plugin-loader): address CodeQL path expression and I/O error handling
- Add explicit relative_to() containment check after path resolution so
  CodeQL recognizes the plugin directory boundary (fixes 4 CodeQL alerts:
  Uncontrolled data used in path expression, lines 168/172/189/205)
- Wrap requirements_file.read_bytes() in try/except OSError — on Raspberry
  Pi with flaky SD card storage this can fail; returns False with a clear log
- Wrap marker_path.read_text() in try/except OSError — a corrupted marker
  falls through to a clean reinstall instead of crashing
- Wrap both marker_path.write_text() calls in try/except OSError — pip
  already succeeded at this point so a marker write failure should not
  return False or propagate through the generic exception handler

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 15:42:03 -04:00
Chuck
6c4700583b fix(plugin-loader): detect new deps via requirements.txt hash instead of empty marker
The .dependencies_installed marker was an empty file, so adding a new
package to requirements.txt (e.g. astral in ledmatrix-weather v2.3.0)
never triggered a pip re-install on existing installs — the file existed
so the check returned early.

The marker now stores a SHA-256 hash of requirements.txt. On every plugin
load, the loader compares the current hash to the stored one; a mismatch
(or missing marker) triggers pip install and writes the new hash.
store_manager._install_dependencies() also writes the hash marker after a
store install/update so the loader skips a redundant pip run on next boot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 13:38:19 -04:00
2 changed files with 47 additions and 11 deletions

View File

@@ -5,6 +5,7 @@ Handles plugin module imports, dependency installation, and class instantiation.
Extracted from PluginManager to improve separation of concerns. Extracted from PluginManager to improve separation of concerns.
""" """
import hashlib
import json import json
import importlib import importlib
import importlib.util import importlib.util
@@ -165,10 +166,34 @@ class PluginLoader:
return True # No dependencies needed return True # No dependencies needed
marker_path = plugin_dir_resolved / ".dependencies_installed" marker_path = plugin_dir_resolved / ".dependencies_installed"
# Check if already installed # Validate both paths stay within the plugin directory (path containment check)
try:
requirements_file.relative_to(plugin_dir_resolved)
marker_path.relative_to(plugin_dir_resolved)
except ValueError:
self.logger.error("Dependency paths outside plugin directory for %s", plugin_id)
return False
try:
current_hash = hashlib.sha256(requirements_file.read_bytes()).hexdigest()
except OSError as e:
self.logger.error("Failed to read requirements.txt for %s: %s", plugin_id, e)
return False
# Skip if requirements.txt hasn't changed since last install
if marker_path.exists(): if marker_path.exists():
self.logger.debug("Dependencies already installed for %s", plugin_id) try:
return True stored_hash = marker_path.read_text(encoding='utf-8').strip()
except OSError as e:
self.logger.warning(
"Could not read dependency marker for %s (%s), will reinstall dependencies",
plugin_id, e
)
else:
if stored_hash == current_hash:
self.logger.debug("Dependencies already installed for %s (requirements unchanged)", plugin_id)
return True
self.logger.info("Requirements changed for %s, reinstalling dependencies", plugin_id)
try: try:
self.logger.info("Installing dependencies for plugin %s...", plugin_id) self.logger.info("Installing dependencies for plugin %s...", plugin_id)
@@ -181,10 +206,11 @@ class PluginLoader:
) )
if result.returncode == 0: if result.returncode == 0:
# Mark as installed try:
marker_path.touch() marker_path.write_text(current_hash, encoding='utf-8')
# Set proper file permissions after creating marker ensure_file_permissions(marker_path, get_plugin_file_mode())
ensure_file_permissions(marker_path, get_plugin_file_mode()) except OSError as marker_err:
self.logger.debug("Could not write dependency marker for %s: %s", plugin_id, marker_err)
self.logger.info("Dependencies installed successfully for %s", plugin_id) self.logger.info("Dependencies installed successfully for %s", plugin_id)
return True return True
else: else:
@@ -199,8 +225,11 @@ class PluginLoader:
"Assuming they are satisfied: %s", "Assuming they are satisfied: %s",
plugin_id, stderr.strip() plugin_id, stderr.strip()
) )
marker_path.touch() try:
ensure_file_permissions(marker_path, get_plugin_file_mode()) marker_path.write_text(current_hash, encoding='utf-8')
ensure_file_permissions(marker_path, get_plugin_file_mode())
except OSError as marker_err:
self.logger.debug("Could not write dependency marker for %s: %s", plugin_id, marker_err)
return True return True
self.logger.warning( self.logger.warning(
"Dependency installation returned non-zero exit code for %s: %s", "Dependency installation returned non-zero exit code for %s: %s",

View File

@@ -5,6 +5,7 @@ Handles plugin discovery, installation, updates, and uninstallation
from both the official registry and custom GitHub repositories. from both the official registry and custom GitHub repositories.
""" """
import hashlib
import os import os
import json import json
import stat import stat
@@ -1755,6 +1756,12 @@ class PluginStoreManager:
timeout=300 timeout=300
) )
self.logger.info(f"Dependencies installed successfully for {plugin_path.name}") self.logger.info(f"Dependencies installed successfully for {plugin_path.name}")
# Write hash marker so plugin_loader skips redundant pip run on next startup
try:
current_hash = hashlib.sha256(requirements_file.read_bytes()).hexdigest()
(plugin_path / ".dependencies_installed").write_text(current_hash, encoding='utf-8')
except OSError as marker_err:
self.logger.debug("Could not write dependency marker for %s: %s", plugin_path.name, marker_err)
return True return True
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e: