mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-13 10:36:35 +00:00
Update Code, the automatic update's health check and Install Base Requirements install web_interface/requirements.txt through safe_pip_install.sh, which only allowed the root requirements.txt. The first commit changing that file would fail its dependency install, and the automatic updater rolls back any update whose dependencies did not install -- on every device, for every newer commit. The wrapper now lists both core requirement files. Only their folders are resolved, so a requirements.txt symlinked out of the project is compared by its target and refused (previously the root file's own symlink target was what got allowed). The updater's file list is a named constant, and a test runs the real wrapper (pip stubbed) on every file Update Code and the rollback install. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
129 lines
5.1 KiB
Python
129 lines
5.1 KiB
Python
"""scripts/fix_perms/safe_pip_install.sh must accept what the updaters install.
|
|
|
|
Update Code and the automatic update's health check install the core's own
|
|
requirement files through that root wrapper, and the wrapper refuses any path
|
|
it does not list. It used to list only requirements.txt, so an update that
|
|
changed web_interface/requirements.txt failed its dependency install -- and
|
|
the automatic updater rolls back any update whose dependencies did not
|
|
install, on every device, every week.
|
|
|
|
The real script runs here with only its final pip command swapped for an
|
|
echo, so the path checks under test are the shipped ones.
|
|
"""
|
|
import importlib.util
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
WRAPPER_REL = Path('scripts') / 'fix_perms' / 'safe_pip_install.sh'
|
|
PIP_LINE = 'exec "$PYTHON_PATH" -m pip install'
|
|
|
|
|
|
def _bash():
|
|
"""A bash whose realpath understands --canonicalize-missing, or None."""
|
|
candidates = [shutil.which('bash')]
|
|
if os.name == 'nt':
|
|
candidates.insert(0, r'C:\Program Files\Git\bin\bash.exe')
|
|
for bash in candidates:
|
|
if not bash or not os.path.exists(bash):
|
|
continue
|
|
try:
|
|
probe = subprocess.run([bash, '-c', 'realpath --canonicalize-missing /no/such/x'],
|
|
capture_output=True, text=True, timeout=30)
|
|
except (OSError, subprocess.SubprocessError):
|
|
continue
|
|
if probe.returncode == 0:
|
|
return bash
|
|
return None
|
|
|
|
|
|
BASH = _bash()
|
|
pytestmark = pytest.mark.skipif(BASH is None, reason='no bash with GNU realpath')
|
|
|
|
|
|
@pytest.fixture
|
|
def project(tmp_path):
|
|
"""A project tree holding the real wrapper, with pip stubbed out."""
|
|
root = tmp_path / 'LEDMatrix'
|
|
wrapper = root / WRAPPER_REL
|
|
wrapper.parent.mkdir(parents=True)
|
|
text = (ROOT / WRAPPER_REL).read_text(encoding='utf-8').replace('\r\n', '\n')
|
|
lines = text.split('\n')
|
|
pip = [i for i, line in enumerate(lines) if line.startswith(PIP_LINE)]
|
|
assert len(pip) == 1, 'the wrapper no longer ends in the pip install this test stubs'
|
|
lines[pip[0]] = 'echo "WOULD INSTALL $RESOLVED_TARGET"; exit 0'
|
|
wrapper.write_text('\n'.join(lines), encoding='utf-8', newline='\n')
|
|
for rel in ('requirements.txt', 'web_interface/requirements.txt',
|
|
'plugin-repos/clock/requirements.txt', 'src/requirements.txt',
|
|
'web_interface/extra/requirements.txt'):
|
|
(root / rel).parent.mkdir(parents=True, exist_ok=True)
|
|
(root / rel).write_text('requests\n', encoding='utf-8')
|
|
return root
|
|
|
|
|
|
def run_wrapper(root, rel):
|
|
"""Run the wrapper on ``rel``, with the path spelled the way bash sees the tree."""
|
|
return subprocess.run(
|
|
[BASH, '-c', 'cd "$1" && bash scripts/fix_perms/safe_pip_install.sh "$PWD/$2"',
|
|
'_', str(root), rel],
|
|
capture_output=True, text=True, timeout=60)
|
|
|
|
|
|
def _core_requirement_files():
|
|
from web_interface.blueprints.api_v3 import system
|
|
spec = importlib.util.spec_from_file_location(
|
|
'auto_update_verify_for_allowlist', ROOT / 'scripts' / 'utils' / 'auto_update_verify.py')
|
|
verifier = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(verifier)
|
|
assert set(verifier.REQUIREMENT_FILES) == set(system.CORE_REQUIREMENT_FILES), (
|
|
'the rollback must reinstall the same files Update Code installs')
|
|
return system.CORE_REQUIREMENT_FILES
|
|
|
|
|
|
@pytest.mark.parametrize('rel', _core_requirement_files())
|
|
def test_every_core_requirement_file_the_updaters_install_is_allowed(project, rel):
|
|
result = run_wrapper(project, rel)
|
|
assert result.returncode == 0, result.stderr
|
|
assert 'WOULD INSTALL' in result.stdout
|
|
|
|
|
|
def test_plugin_requirements_are_still_allowed(project):
|
|
assert run_wrapper(project, 'plugin-repos/clock/requirements.txt').returncode == 0
|
|
|
|
|
|
@pytest.mark.parametrize('rel', [
|
|
'src/requirements.txt', # repo-owned folder, but not listed
|
|
'web_interface/extra/requirements.txt', # below an allowed file's folder
|
|
'web_interface/requirements.txt.bak', # not a requirements.txt
|
|
])
|
|
def test_other_paths_are_still_refused(project, rel):
|
|
if rel.endswith('.bak'):
|
|
(project / rel).write_text('requests\n', encoding='utf-8')
|
|
result = run_wrapper(project, rel)
|
|
assert result.returncode == 2, result.stdout + result.stderr
|
|
assert 'DENIED' in result.stderr
|
|
|
|
|
|
@pytest.mark.parametrize('rel', ['requirements.txt', 'web_interface/requirements.txt'])
|
|
def test_a_core_requirement_file_symlinked_out_of_the_project_is_refused(project, tmp_path, rel):
|
|
"""Only the allowed files' folders are resolved, so the link's target is
|
|
compared, and refused, rather than allowed as the file itself."""
|
|
outside = tmp_path / 'elsewhere' / 'requirements.txt'
|
|
outside.parent.mkdir()
|
|
outside.write_text('evil\n', encoding='utf-8')
|
|
link = project / rel
|
|
link.unlink()
|
|
try:
|
|
link.symlink_to(outside)
|
|
except (OSError, NotImplementedError):
|
|
pytest.skip('symlinks unavailable')
|
|
result = run_wrapper(project, rel)
|
|
assert result.returncode == 2, result.stdout + result.stderr
|