Files
LEDMatrix/src/common/permission_utils.py
T
ChuckandClaude Opus 5.5 f6c0fe55d9 fix(core): font zip cache, monotonic timers, resolver back-off, and other core/common fixes (#654)
* fix(core): font zip cache, monotonic timers, resolver back-off, and other core/common fixes

- font_manager: a .zip font URL is served as its extracted font after a
  restart (the cached-file check returned the archive first); downloads
  use requests with a 30s timeout into a temp file + os.replace.
- api_helper / sync_manager: rate-limit and heartbeat/leader timeouts use
  time.monotonic(); last_request_time and the status file's ts stay
  wall-clock. set_on_new_cycle docstring no longer claims core uses it.
- logo_helper: the placeholder uses the same scaled box as a real logo.
- permission_utils: one _sudo_bash_candidates() helper (with the sudoers
  exact-argv rationale) shared by sudo_remove_directory, which now retries
  the next bash path on a sudo refusal, and install_requirements_file.
- dynamic_team_resolver: failed/empty fetch backs off 5 min; duplicate
  INFO log and contradictory docstring example fixed.
- element_style: scale default looked up through element aliases.
- background_data_service: cache-hit callback runs outside the lock.
- config_arrays: union-aware type check (["array","null"]); stale
  dotToNested() reference removed.
- auto_update_setup: non-dict auto_update reads as off; temp result file
  unlinked when the write fails.
- exceptions: constructors copy the caller's context dict.
- logging_config: StructuredFormatter json.dumps(default=str).
- error_aggregator: removed unused export_path/export_to_file/_auto_export.
- Docstrings: validate_file_upload max_size_mb, raise_on_errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(sync): retry the status-file rename like the other atomic writers

On Windows os.replace can fail with "Access is denied" while a scanner
briefly holds the target open; config_manager_atomic._replace already
retries that (and re-raises at once on other platforms). The sync status
writer called os.replace directly, which made
test_concurrent_writers_each_use_their_own_temp_file flaky on Windows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:40:16 -04:00

517 lines
20 KiB
Python

"""
Permission Utilities
Centralized utility functions for managing file and directory permissions
across the LEDMatrix codebase. Ensures consistent permission handling for
files that need to be accessible by both root service and web user.
"""
import os
import logging
import re
import shutil as _shutil
import subprocess
import sys
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
# Matches the credentials portion of a "scheme://user:pass@host" URL, so pip's
# own error output can be logged/displayed without echoing back a private
# index URL's embedded basic-auth secret verbatim (e.g. from a
# requirements.txt --index-url line or the PIP_INDEX_URL env var).
_URL_CREDENTIALS_RE = re.compile(r'://[^/\s@:]+:[^/\s@]+@')
def _redact_url_credentials(text: Optional[str]) -> str:
"""Replace embedded user:pass@ URL credentials in text with a placeholder.
Safe to call on any subprocess output destined for logs: it only ever
shortens/replaces the credential substring, never changes the presence
or absence of the specific fixed phrases callers check for
(e.g. "a password is required"), so it can't affect control flow.
"""
if not text:
return text or ""
return _URL_CREDENTIALS_RE.sub('://***:***@', text)
# System directories that should never have their permissions modified
# These directories have special system-level permissions that must be preserved
PROTECTED_SYSTEM_DIRECTORIES = { # nosec B108 - these are checked to PREVENT permission changes, not to use as temp paths
'/tmp',
'/var/tmp',
'/dev',
'/proc',
'/sys',
'/run',
'/var/run',
'/etc',
'/boot',
'/var',
'/usr',
'/lib',
'/lib64',
'/bin',
'/sbin',
}
def ensure_directory_permissions(path: Path, mode: int = 0o775) -> None:
"""
Create directory and set permissions.
If the directory already exists and we cannot change its permissions,
we check if it's usable (readable/writable). If so, we continue without
raising an exception. This allows the system to work even when running
as a non-root user who cannot change permissions on existing directories.
Protected system directories (like /tmp, /etc, /var) are never modified
to prevent breaking system functionality.
Args:
path: Directory path to create/ensure
mode: Permission mode (default: 0o775 for group-writable directories)
Raises:
OSError: If directory creation fails or directory exists but is not usable
"""
try:
# Never modify permissions on system directories
path_str = str(path.resolve() if path.is_absolute() else path)
if path_str in PROTECTED_SYSTEM_DIRECTORIES:
logger.debug(f"Skipping permission modification on protected system directory: {path_str}")
# Verify the directory is usable
if path.exists() and os.access(path, os.R_OK | os.W_OK):
return
elif path.exists():
logger.warning(f"Protected system directory {path_str} exists but is not writable")
return
else:
raise OSError(f"Protected system directory {path_str} does not exist")
# Create directory if it doesn't exist
path.mkdir(parents=True, exist_ok=True)
# Try to set permissions
try:
os.chmod(path, mode)
logger.debug(f"Set directory permissions {oct(mode)} on {path}")
except (OSError, PermissionError) as perm_error:
# If we can't set permissions, check if directory is usable
if path.exists():
# Check if directory is readable and writable
if os.access(path, os.R_OK | os.W_OK):
logger.warning(
f"Could not set permissions on {path} (may be owned by different user), "
f"but directory is usable (readable/writable). Continuing."
)
return
else:
# Directory exists but is not usable
logger.error(
f"Directory {path} exists but is not readable/writable. "
f"Permission change failed: {perm_error}"
)
raise OSError(
f"Directory {path} exists but is not usable: {perm_error}"
) from perm_error
else:
# Directory doesn't exist and we couldn't create it
raise
except OSError as e:
logger.error(f"Failed to ensure directory {path}: {e}")
raise
def ensure_file_permissions(path: Path, mode: int = 0o644) -> None:
"""
Set file permissions after creation.
Args:
path: File path to set permissions on
mode: Permission mode (default: 0o644 for readable files)
Raises:
OSError: If permission setting fails
"""
try:
if path.exists():
os.chmod(path, mode)
logger.debug(f"Set file permissions {oct(mode)} on {path}")
else:
logger.warning(f"File does not exist, cannot set permissions: {path}")
except OSError as e:
logger.error(f"Failed to set file permissions on {path}: {e}")
raise
_shared_group_gid_cache: Optional[int] = None
def get_shared_group_gid() -> Optional[int]:
"""
Return the gid that should own config/secrets files shared between the
root-run ``ledmatrix.service`` (main display) and the non-root user that
``ledmatrix-web.service`` runs as (see install_web_service.sh, which sets
``User=$SUDO_USER``).
Resolved once from the project root directory's current group (normally
the login user's group from the initial ``git clone``), since that user
is stable across reinstalls unlike any single file's ownership.
Returns:
The gid, or None if it cannot be determined.
"""
global _shared_group_gid_cache
if _shared_group_gid_cache is not None:
return _shared_group_gid_cache
try:
project_root = Path(__file__).resolve().parent.parent.parent
_shared_group_gid_cache = project_root.stat().st_gid
return _shared_group_gid_cache
except OSError:
return None
def ensure_shared_group_ownership(path: Path) -> None:
"""
Best-effort chgrp of ``path`` to the shared group (see
:func:`get_shared_group_gid`) when running as root.
Only root can change a file's group to one the calling process isn't a
member of, which is exactly the case that causes the web interface
(running as a non-root user) to get ``PermissionError`` reading files
the root-run display service just wrote with a 0o640/2775 mode: the mode
is group-readable, but without this the group is root's, not the web
user's. Silently does nothing if not running as root or on any error —
this is a hardening step, not a required one.
``os.geteuid``/``os.chown`` only exist on POSIX. On Windows there is no
root and no shared group to move the file to, so the whole step is moot —
but looking the names up unguarded raises ``AttributeError``, which is not
an ``OSError`` and so escapes every caller's error handling. That took
``ConfigManager.load_config()`` down on any Windows checkout that has a
``config/config_secrets.json``, i.e. every developer machine that has ever
run the app, and with it the import of ``web_interface.app``.
"""
if not hasattr(os, 'geteuid') or not hasattr(os, 'chown'):
return
if os.geteuid() != 0:
return
gid = get_shared_group_gid()
if gid is None:
return
try:
if path.exists() and path.stat().st_gid != gid:
os.chown(path, -1, gid)
logger.debug(f"Set shared group ownership (gid {gid}) on {path}")
except OSError as e:
logger.debug(f"Could not set shared group ownership on {path}: {e}")
def get_config_file_mode(file_path: Path) -> int:
"""
Return appropriate permission mode for config files.
Args:
file_path: Path to config file
Returns:
Permission mode: 0o640 for secrets files, 0o644 for regular config
"""
if 'secrets' in str(file_path):
return 0o640 # rw-r-----
else:
return 0o644 # rw-r--r--
def get_assets_file_mode() -> int:
"""
Return permission mode for asset files (logos, images, etc.).
Returns:
Permission mode: 0o664 (rw-rw-r--) for group-writable assets
"""
return 0o664 # rw-rw-r--
def get_assets_dir_mode() -> int:
"""
Return permission mode for asset directories.
Returns:
Permission mode: 0o2775 (rwxrwsr-x): group-writable, and setgid so
entries created in it take the directory's group
"""
return 0o2775 # rwxrwsr-x (setgid + group writable)
def get_config_dir_mode() -> int:
"""
Return permission mode for config directory.
Returns:
Permission mode: 0o2775 (rwxrwsr-x): group-writable, and setgid so
entries created in it take the directory's group
"""
return 0o2775 # rwxrwsr-x (setgid + group writable)
def get_plugin_file_mode() -> int:
"""
Return permission mode for plugin files.
Returns:
Permission mode: 0o664 (rw-rw-r--) for group-writable plugin files
"""
return 0o664 # rw-rw-r--
def get_plugin_dir_mode() -> int:
"""
Return permission mode for plugin directories.
Returns:
Permission mode: 0o2775 (rwxrwsr-x): group-writable, and setgid so
entries created in it take the directory's group
"""
return 0o2775 # rwxrwsr-x (setgid + group writable)
def get_cache_dir_mode() -> int:
"""
Return permission mode for cache directories.
Returns:
Permission mode: 0o2775 (rwxrwsr-x): group-writable, and setgid so
entries created in it take the directory's group
"""
return 0o2775 # rwxrwsr-x (setgid + group writable)
def _sudo_bash_candidates() -> list:
"""Bash paths to try, in order, when running a vetted helper via sudo.
sudoers matches the exact argv, so ``sudo -n <bash> <helper> ...`` only
works if <bash> is the same path configure_web_sudo.sh wrote into the
rule -- whatever ``command -v bash`` said on the machine that ran it.
On merged-/usr systems /usr/bin/bash and /bin/bash are the same file but
different strings to sudo, and the web user's PATH can differ from the
installer's, so no single guess is reliable. Callers try each in turn and
move on only when sudo refused the command line (SUDO_REFUSAL_PHRASES).
The helper is invoked through bash rather than its shebang for the same
reason: the rule names bash, not the script.
"""
candidates = []
for candidate in ("/usr/bin/bash", "/bin/bash", _shutil.which("bash")):
if candidate and candidate not in candidates:
candidates.append(candidate)
return candidates
def sudo_remove_directory(path: Path, allowed_bases: Optional[list] = None) -> bool:
"""
Remove a directory using sudo as a last resort.
Used when normal removal fails due to root-owned files (e.g., __pycache__
directories created by the root ledmatrix service). Delegates to the
safe_plugin_rm.sh helper which validates the path is inside allowed
plugin directories.
Before invoking sudo, this function also validates that the resolved
path is a descendant of at least one allowed base directory.
Args:
path: Directory path to remove
allowed_bases: List of allowed parent directories. If None, defaults
to plugin-repos/ and plugins/ under the project root.
Returns:
True if removal succeeded, False otherwise
"""
# Determine project root (permission_utils.py is at src/common/)
project_root = Path(__file__).resolve().parent.parent.parent
if allowed_bases is None:
allowed_bases = [
project_root / "plugin-repos",
project_root / "plugins",
]
# Resolve the target path to prevent symlink/traversal tricks
try:
resolved = path.resolve()
except (OSError, ValueError) as e:
logger.error(f"Cannot resolve path {path}: {e}")
return False
# Validate the resolved path is a strict child of an allowed base
is_allowed = False
for base in allowed_bases:
try:
base_resolved = base.resolve()
if resolved != base_resolved and resolved.is_relative_to(base_resolved):
is_allowed = True
break
except (OSError, ValueError):
continue
if not is_allowed:
logger.error(
f"sudo_remove_directory DENIED: {resolved} is not inside "
f"allowed bases {[str(b) for b in allowed_bases]}"
)
return False
# Use the safe_plugin_rm.sh helper which does its own validation
helper_script = project_root / "scripts" / "fix_perms" / "safe_plugin_rm.sh"
if not helper_script.exists():
logger.error(f"Safe removal helper not found: {helper_script}")
return False
try:
for bash_path in _sudo_bash_candidates():
result = subprocess.run(
['sudo', '-n', bash_path, str(helper_script), str(resolved)],
capture_output=True,
text=True,
timeout=30
)
if result.returncode == 0 and not resolved.exists():
logger.info(f"Successfully removed {path} via sudo helper")
return True
# Only a refused command line is worth another bash path; if the
# helper itself ran and failed, a retry would just repeat it.
if result.returncode == 0 or not any(
phrase in (result.stderr or '') for phrase in SUDO_REFUSAL_PHRASES):
break
stderr = (result.stderr or '').strip()
logger.error(f"sudo helper failed for {path}: {stderr}")
return False
except subprocess.TimeoutExpired:
logger.error(f"sudo helper timed out for {path}")
return False
except FileNotFoundError:
logger.error("sudo command not found on system")
return False
except Exception as e:
logger.error(f"Unexpected error during sudo helper for {path}: {e}")
return False
#: What sudo prints when it refuses a command line outright (not in sudoers for
#: that exact argv, or it wants a password). Only then is another bash path
#: worth trying: after pip itself ran, a retry just repeats the failure. The
#: automatic update's health check keeps its own copy of this list
#: (scripts/utils/auto_update_verify.py runs without importing src/).
SUDO_REFUSAL_PHRASES = ("a password is required", "is not allowed to run", "no tty present")
def install_requirements_file(req_file: Path, timeout: int = 300) -> subprocess.CompletedProcess:
"""
Install a requirements.txt file for a plugin (or the project itself).
Prefers the vetted sudo wrapper (scripts/fix_perms/safe_pip_install.sh) so
packages end up visible to root-run ledmatrix.service, not just to
whichever non-root user happens to run the calling process (e.g. the web
interface). Falls back to installing with the calling process's own
interpreter if the wrapper isn't set up yet (the admin hasn't run
scripts/install/configure_web_sudo.sh), so dependency installation still
does *something* useful rather than hard-failing.
Always installs with the interpreter that will actually run the code
(``sys.executable`` in the fallback path, the wrapper's ``python3`` in the
sudo path) rather than a bare ``pip``/``pip3`` off PATH, which can
silently resolve to a different Python installation (e.g. system Python
vs. a virtualenv) than the one importing the package at runtime.
Args:
req_file: Path to a requirements.txt file
timeout: Subprocess timeout in seconds
Returns:
subprocess.CompletedProcess from the pip (or wrapper) invocation.
Never raises on a non-zero exit; callers should check ``returncode``.
``stdout`` is prefixed with an explanatory note when the root wrapper
was unavailable and the fallback path was used.
"""
project_root = Path(__file__).resolve().parent.parent.parent
wrapper = project_root / "scripts" / "fix_perms" / "safe_pip_install.sh"
if wrapper.exists():
# See _sudo_bash_candidates for why bash is invoked by explicit path
# and why there is more than one to try.
result = None
for bash_path in _sudo_bash_candidates():
# bash_path and wrapper are fixed, known-good paths, and
# safe_pip_install.sh independently re-validates req_file is an
# allowed requirements.txt before installing anything as root.
result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
["sudo", "-n", bash_path, str(wrapper), str(req_file)],
capture_output=True, text=True, timeout=timeout, cwd=str(project_root)
)
# Redact immediately: pip can echo a private index URL's embedded
# basic-auth credentials back in its own error/progress output
# (e.g. from a requirements.txt --index-url line). Doesn't affect
# the fixed-phrase "denied" check below -- those phrases never
# overlap with URL syntax.
result.stderr = _redact_url_credentials(result.stderr)
result.stdout = _redact_url_credentials(result.stdout)
if result.returncode == 0:
return result
# Distinguish "sudo rejected this exact command line" (worth
# trying the next bash candidate) from "sudo ran it but pip
# itself failed" (a real error — stop and surface it).
denied = any(phrase in result.stderr for phrase in SUDO_REFUSAL_PHRASES)
if not denied:
# Deliberately don't interpolate req_file or the pip output here:
# this log line is scanner-visible, and a static analyzer can't
# tell "already redacted above" from "still raw" just by looking
# at this call in isolation. The full (redacted) text is still
# available to callers via the returned CompletedProcess.
logger.warning(
"Root pip install failed (rc=%s); see the returned "
"CompletedProcess.stderr for details.",
result.returncode,
)
return result
# Same reasoning as above: no req_file / pip-output interpolation in
# this log line, only in the returned note/CompletedProcess.
logger.warning(
"Root pip install wrapper denied via sudo for all candidates; "
"falling back to user-level install. See the returned "
"CompletedProcess.stderr for details."
)
note = (
f"[Root install unavailable ({(result.stderr.strip() if result else 'sudo denied') or 'sudo denied'}); "
"installed for the current process's user only. Packages may not be "
"visible to ledmatrix.service if it runs as a different user — "
"run scripts/install/configure_web_sudo.sh to fix this.]\n"
)
else:
logger.warning(
"safe_pip_install.sh not found; falling back to user-level install."
)
note = (
"[safe_pip_install.sh not found; installed for the current process's "
"user only. Run scripts/install/configure_web_sudo.sh to enable "
"root installs visible to ledmatrix.service.]\n"
)
# sys.executable is this process's own interpreter (not
# attacker-influenced), and req_file is a Path built internally by callers
# (store_manager.py plugin paths, PROJECT_ROOT/requirements.txt), never
# raw external/user input. --ignore-installed matches safe_pip_install.sh:
# apt-managed packages (e.g. python3-requests) ship no pip RECORD file, so
# upgrading them would otherwise abort with "uninstall-no-record-file".
result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
[sys.executable, "-m", "pip", "install", "--break-system-packages", "--ignore-installed", "-r", str(req_file)],
capture_output=True, text=True, timeout=timeout, cwd=str(project_root)
)
result.stderr = _redact_url_credentials(result.stderr)
result.stdout = note + _redact_url_credentials(result.stdout)
return result