Merge audit16-core/autoupdate into the September 16 core audit fixes

This commit is contained in:
Chuck
2026-09-17 09:18:01 -04:00
10 changed files with 581 additions and 70 deletions
+167 -3
View File
@@ -72,7 +72,7 @@ class Repo:
def real_pull(device, **extra):
def core_update():
def core_update(**kwargs):
before = git(device, 'rev-parse', 'HEAD')
r = subprocess.run(['git', 'pull', '-q', '--rebase', '--autostash'],
cwd=str(device), capture_output=True, text=True)
@@ -339,6 +339,140 @@ class TestPreflightRefuses:
assert repo.head() == old and h.sudo == []
# -- what counts as a local change ---------------------------------------------
def _device_with_plugin_and_script(tmp_path):
"""A clone carrying a bundled plugin and an executable script, as LEDMatrix does."""
repo = Repo(tmp_path)
for rel, text in (('plugin-repos/web-ui-info/manager.py', 'x = 1\n'),
('web_interface/static/v3/js/plugins/store.js', 'var a;\n'),
('scripts/run.sh', 'echo hi\n')):
(repo.seed / rel).parent.mkdir(parents=True, exist_ok=True)
(repo.seed / rel).write_text(text)
git(repo.seed, 'add', '.')
git(repo.seed, 'update-index', '--chmod=+x', 'scripts/run.sh')
git(repo.seed, 'commit', '-qm', 'layout')
git(repo.seed, 'push', '-q', 'origin', 'main')
git(repo.device, 'pull', '-q')
return repo
def _chmod_like_the_installer(device):
"""first_time_install.sh sets tracked 100755 files to 644: a mode-only change.
Windows has no exec bit, so there core.fileMode=true alone shows it."""
import os
git(device, 'config', 'core.fileMode', 'true')
os.chmod(device / 'scripts' / 'run.sh', 0o644)
assert 'mode change 100755 => 100644 scripts/run.sh' in git(device, 'diff', '--summary')
@needs_git
class TestLocalChangesAreCountedOnce:
"""The preflight promises the pull will not stash. That holds only if both
count local changes the same way (auto_update.local_changes)."""
def test_mode_changes_and_plugin_folders_do_not_count(self, tmp_path):
repo = _device_with_plugin_and_script(tmp_path)
_chmod_like_the_installer(repo.device)
(repo.device / 'plugin-repos/web-ui-info/manager.py').write_text('x = 2 # store update\n')
assert au.local_changes(repo.device) == []
def test_a_core_folder_named_plugins_still_counts(self, tmp_path):
"""The old filter matched 'plugins/' anywhere in the line."""
repo = _device_with_plugin_and_script(tmp_path)
(repo.device / 'web_interface/static/v3/js/plugins/store.js').write_text('var mine;\n')
assert au.local_changes(repo.device) == ['web_interface/static/v3/js/plugins/store.js']
def test_a_staged_rename_is_reported_by_its_new_name(self, tmp_path):
repo = Repo(tmp_path)
git(repo.device, 'mv', 'app.py', 'main.py')
assert au.local_changes(repo.device) == ['main.py']
def test_the_preflight_refuses_a_core_edit_under_a_plugins_folder(self, tmp_path):
repo = _device_with_plugin_and_script(tmp_path)
old = repo.head()
repo.publish()
(repo.device / 'web_interface/static/v3/js/plugins/store.js').write_text('var mine;\n')
result = Harness(tmp_path, repo).updater.run()
assert result['core_outcome'] == 'blocked'
assert 'store.js' in result['core_message']
assert repo.head() == old
@pytest.fixture
def core_update(self, monkeypatch):
"""The real perform_core_update, pointed at a test clone."""
from web_interface.blueprints import api_v3 as pkg
from web_interface.blueprints.api_v3 import system
def point_at(device):
monkeypatch.setattr(system, 'PROJECT_ROOT', device)
monkeypatch.setattr(pkg.api_v3, 'plugin_store_manager', None, raising=False)
monkeypatch.setattr(system, '_pip_install_requirements',
lambda *a, **k: pytest.fail('no requirements changed'))
return system.perform_core_update
return point_at
@pytest.mark.parametrize('change', [
'bundled_plugin_edit',
pytest.param('installer_chmod', marks=pytest.mark.skipif(
sys.platform == 'win32',
reason='no exec bit: the reset inside --autostash cannot clear a mode change, '
'so git will not reapply it (on Linux it reapplies cleanly)')),
])
def test_an_update_that_passed_the_preflight_leaves_no_stash(self, tmp_path, core_update, change):
"""Found by audit: a bundled-plugin edit or the installer's chmods
passed the preflight, then perform_core_update stashed them for good.
Now --autostash carries them across the pull and puts them back."""
repo = _device_with_plugin_and_script(tmp_path)
new = repo.publish()
if change == 'installer_chmod':
_chmod_like_the_installer(repo.device)
else:
(repo.device / 'plugin-repos/web-ui-info/manager.py').write_text('x = 2 # store update\n')
h = Harness(tmp_path, repo, core_update=core_update(repo.device))
assert h.updater.preflight({})[0] == 'ready'
result = h.updater.run()
assert result['core_outcome'] == 'verifying', result['core_message']
assert repo.head() == new
assert git(repo.device, 'stash', 'list') == ''
if change == 'installer_chmod':
assert 'mode change 100755 => 100644 scripts/run.sh' in git(repo.device, 'diff', '--summary')
else:
assert 'store update' in (repo.device / 'plugin-repos/web-ui-info/manager.py').read_text()
def test_edits_made_after_the_preflight_are_refused_not_stashed(self, tmp_path, core_update):
repo = Repo(tmp_path)
old = repo.head()
repo.publish()
perform = core_update(repo.device)
def edit_then_update(**kwargs):
(repo.device / 'app.py').write_text('mine\n')
return perform(**kwargs)
result = Harness(tmp_path, repo, core_update=edit_then_update).updater.run()
assert result['core_outcome'] == 'blocked'
assert 'app.py' in result['core_message'] and 'will not stash' in result['core_message']
assert repo.head() == old
assert git(repo.device, 'stash', 'list') == ''
assert (repo.device / 'app.py').read_text() == 'mine\n'
def test_update_code_still_stashes_core_edits_but_not_plugin_folders(self, tmp_path, core_update):
repo = _device_with_plugin_and_script(tmp_path)
new = repo.publish()
(repo.device / 'scripts/run.sh').write_text('echo mine\n')
(repo.device / 'plugin-repos/web-ui-info/manager.py').write_text('x = 2 # store update\n')
payload = core_update(repo.device)()
assert payload['status'] == 'success', payload['message']
assert 'stashed' in payload['message']
assert repo.head() == new
assert 'LEDMatrix auto-stash before update' in git(repo.device, 'stash', 'list')
stashed = git(repo.device, 'stash', 'show', '--name-only', 'stash@{0}')
assert stashed.split() == ['scripts/run.sh']
assert 'store update' in (repo.device / 'plugin-repos/web-ui-info/manager.py').read_text()
# -- the update and its hand-off to the health check ---------------------------
@needs_git
@@ -395,7 +529,7 @@ class TestUpdateIsVerified:
old = repo.head()
repo.publish()
h = Harness(tmp_path, repo,
core_update=lambda: {'status': 'error', 'message': 'Update failed: network'})
core_update=lambda **kw: {'status': 'error', 'message': 'Update failed: network'})
result = h.updater.run()
assert result['core_outcome'] == 'error'
assert repo.head() == old
@@ -407,13 +541,43 @@ class TestUpdateIsVerified:
repo.publish()
pull = real_pull(repo.device)
def half_failed():
def half_failed(**kwargs):
pull()
return {'status': 'error', 'message': 'Update failed: interrupted'}
result = Harness(tmp_path, repo, core_update=half_failed).updater.run()
assert repo.head() == old
assert 'rolled back' in result['core_message']
@pytest.mark.parametrize('how', ['partial_pull', 'check_never_started'])
def test_a_failed_rollback_leaves_plugins_and_the_display_alone(self, tmp_path, how):
"""update_core returns rollback_failed itself on two paths. The device
is then in an unknown state, exactly as when the health check reports
rollback_failed, so no plugin updates and no restart may follow."""
repo = Repo(tmp_path)
repo.publish()
store = FakeStore(tmp_path / 'plugins', {'clock': '1.0.0'}, bump={'clock'})
pull = real_pull(repo.device)
def half_failed(**kwargs):
pull()
return {'status': 'error', 'message': 'Update failed: interrupted'}
h = Harness(tmp_path, repo, store=store, pickup=how != 'check_never_started',
core_update=half_failed if how == 'partial_pull' else None)
real_run = h.updater.run_command
def reset_fails(args, **kwargs):
if args[:3] == ['git', 'reset', '--hard']:
return subprocess.CompletedProcess(args, 1, stdout='', stderr='index.lock exists')
return real_run(args, **kwargs)
h.updater.run_command = reset_fails
result = h.updater.run()
assert result['core_outcome'] == 'rollback_failed'
assert store.updated_calls == [], "plugins must not pile onto a core in an unknown state"
assert h.restarts == []
assert h.state['plugins_pending'] is False and result['plugins_deferred'] is False
assert h.state['alert']
# -- reporting the health check's outcome --------------------------------------
+76
View File
@@ -69,6 +69,8 @@ class FakeHost:
self.running_head = None # not restarted yet: still the old, healthy code
self.restarts = [] # (unit, commit it was restarted onto)
self.pip_installs = []
self.pip_timeouts = []
self.pip = None # optional (args, host) -> result, or raises, instead of pip_ok
self.now = 0.0
self.nrestarts = 0
@@ -90,6 +92,9 @@ class FakeHost:
return done(args, f'{self.nrestarts}\n')
if args[0] == 'sudo' and any(a.endswith('safe_pip_install.sh') for a in args):
self.pip_installs.append(args[-1])
self.pip_timeouts.append(kwargs.get('timeout'))
if self.pip:
return self.pip(args, self)
return done(args, rc=0 if self.pip_ok else 1)
if args[:4] == ['sudo', '-n', 'systemctl', 'restart']:
if self.restart_failures:
@@ -171,6 +176,77 @@ def test_a_failed_dependency_reinstall_is_reported(tmp_path):
assert 'Install Base Requirements' in result['detail']
def _rollback_with_pip(tmp_path, pip, web_requirements_too=False):
"""Roll back an update that changed the requirements, with pip faked by ``pip``."""
repo, old, new = updated_repo(tmp_path, new_requirements=True)
if web_requirements_too:
(repo / 'web_interface').mkdir()
(repo / 'web_interface' / 'requirements.txt').write_text('flask\n')
git(repo, 'add', '.')
git(repo, 'commit', '-qm', 'web reqs')
(repo / 'web_interface' / 'requirements.txt').write_text('flask\nnew\n')
(repo / 'requirements.txt').write_text('requests\nnewer\n')
git(repo, 'commit', '-qam', 'bump both')
old, new = git(repo, 'rev-parse', 'HEAD~1'), git(repo, 'rev-parse', 'HEAD')
host = FakeHost(repo, new)
host.pip = pip
ok, detail = host.verifier().rollback({'old_head': old, 'new_head': new})
return ok, detail, host
def test_a_failed_pip_is_not_run_again_with_the_other_bash(tmp_path):
"""Retrying after pip itself ran only repeats it, and every repeat can
take PIP_TIMEOUT_SECONDS of the unit's time limit."""
ok, detail, host = _rollback_with_pip(
tmp_path, lambda args, h: subprocess.CompletedProcess(args, 1, '', 'ERROR: No matching distribution'))
assert ok and 'requirements.txt' in detail
assert len(host.pip_installs) == 1
def test_a_pip_timeout_is_not_retried(tmp_path):
def slow(args, h):
h.now += h.pip_timeouts[-1]
raise subprocess.TimeoutExpired(args, h.pip_timeouts[-1])
ok, detail, host = _rollback_with_pip(tmp_path, slow)
assert ok and 'Install Base Requirements' in detail
assert len(host.pip_installs) == 1
def test_a_sudo_refusal_tries_the_next_bash(tmp_path):
def refused_once(args, h):
if len(h.pip_installs) == 1:
return subprocess.CompletedProcess(args, 1, '', 'sudo: a password is required')
return done(args)
ok, detail, host = _rollback_with_pip(tmp_path, refused_once)
assert ok and detail == ''
assert len(host.pip_installs) == 2
def test_reinstalls_share_one_time_budget(tmp_path):
def hangs(args, h):
h.now += h.pip_timeouts[-1]
raise subprocess.TimeoutExpired(args, h.pip_timeouts[-1])
ok, detail, host = _rollback_with_pip(tmp_path, hangs, web_requirements_too=True)
assert ok and 'requirements.txt' in detail and 'web_interface/requirements.txt' in detail
assert sum(host.pip_timeouts) <= av.PIP_BUDGET_SECONDS
assert len(host.pip_installs) == 1, "the first file used the whole budget"
def test_the_worst_case_fits_the_unit_time_limit():
"""systemd kills the check at TimeoutStartSec, mid-rollback, and the update
then sits in "verifying" until the web interface calls it lost."""
from web_interface import auto_update as au
service = (ROOT / 'systemd' / 'ledmatrix-update-verify.service').read_text(encoding='utf-8')
minutes = int(re.search(r'^TimeoutStartSec=(\d+)min$', service, re.M).group(1))
assert av.WORST_CASE_SECONDS < minutes * 60
assert minutes * 60 < au.VERIFY_LOST_SECONDS, "the web UI must not call a running check lost"
def test_sudo_refusal_wording_matches_permission_utils():
from src.common import permission_utils
assert set(av.SUDO_REFUSAL_PHRASES) == set(permission_utils.SUDO_REFUSAL_PHRASES)
def test_still_broken_after_rolling_back_is_rollback_failed(tmp_path):
code, result, host, head, old, new = check(tmp_path, 'always')
assert code == 1 and result['status'] == 'rollback_failed'
+128
View File
@@ -0,0 +1,128 @@
"""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