mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 05:23:41 +08:00
535 lines
22 KiB
Python
535 lines
22 KiB
Python
"""Real temporary files, independent profile copies and interrupted application/profile transactions."""
|
|
|
|
from copy import deepcopy
|
|
import fcntl
|
|
import hashlib
|
|
import importlib.util
|
|
import io
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
from types import SimpleNamespace as NS
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
|
|
from openpilot.tools.arabesque_firefox.test_setup import setup
|
|
|
|
spec = importlib.util.spec_from_file_location('browser_session', Path(__file__).resolve().parents[2] /
|
|
'selfdrive/ui/layouts/arabesque/tools/browser_session.py')
|
|
assert spec and spec.loader
|
|
runtime = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(runtime)
|
|
|
|
|
|
def bundle(path, value):
|
|
(path / 'firefox').mkdir(parents=True)
|
|
path.chmod(0o755)
|
|
(path / 'firefox').chmod(0o755)
|
|
(path / 'firefox/firefox').write_text(value['version'])
|
|
(path / 'firefox/firefox').chmod(0o755)
|
|
(path / 'ready.json').write_text(json.dumps(value))
|
|
(path / 'ready.json').chmod(0o644)
|
|
|
|
|
|
@pytest.fixture
|
|
def installed(tmp_path, monkeypatch):
|
|
root = tmp_path / 'browser'
|
|
root.mkdir(mode=0o755)
|
|
current = setup.approved()
|
|
target = deepcopy(current)
|
|
target.update(version='153.7.0esr', bundle_revision=1)
|
|
bundle(root / 'runtime', current)
|
|
(root / 'state/profile').mkdir(parents=True, mode=0o700)
|
|
(root / 'state').chmod(0o700)
|
|
(root / 'state/profile/prefs.js').write_text('before update')
|
|
(root / 'state/downloads').mkdir()
|
|
(root / 'state/downloads/document').write_text('download preserved')
|
|
(root / '.prepare.lock').touch()
|
|
(root / '.prepare.lock').chmod(0o644)
|
|
real_safe = setup.safe
|
|
monkeypatch.setattr(setup, 'safe', lambda path, uid=0, private=False: real_safe(path, os.getuid(), private))
|
|
monkeypatch.setattr(setup, 'BROWSER_UID', os.getuid())
|
|
monkeypatch.setattr(runtime, 'BROWSER_UID', os.getuid())
|
|
monkeypatch.setattr(setup, 'ROOT', root)
|
|
monkeypatch.setattr(runtime, 'STATE', root / 'state')
|
|
monkeypatch.setattr(setup, 'approved', lambda: deepcopy(target))
|
|
monkeypatch.setattr(setup, 'check_host', Mock())
|
|
monkeypatch.setattr(setup, 'quiescent', Mock())
|
|
monkeypatch.setattr(setup, 'build', bundle_builder := Mock(side_effect=lambda value, path: bundle(path, value)))
|
|
monkeypatch.setattr(setup.shutil, 'disk_usage', lambda _: NS(free=128 * 1024**3))
|
|
monkeypatch.setattr(runtime, 'load_setup', lambda: setup)
|
|
jobs = []
|
|
|
|
def job(kind, identity, lock, garbage):
|
|
# Passing this same open-file description must not lose the controller's lock.
|
|
with (root / '.prepare.lock').open('rb') as competitor:
|
|
with pytest.raises(BlockingIOError):
|
|
fcntl.flock(competitor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
duplicate = os.dup(lock)
|
|
try:
|
|
fcntl.flock(duplicate, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
finally:
|
|
os.close(duplicate)
|
|
jobs.append(kind)
|
|
if kind == 'qualify':
|
|
tx = root / 'updates' / identity
|
|
live = root / 'state/profile/prefs.js'
|
|
snapshot, probe = tx / 'profile-backup/profile/prefs.js', tx / 'probe-state/profile/prefs.js'
|
|
assert len({path.stat().st_ino for path in (live, snapshot, probe)}) == 3
|
|
probe.write_text('candidate migrated this copy')
|
|
assert live.read_text() == snapshot.read_text() == 'before update'
|
|
else:
|
|
runtime.profile_job(kind, identity, garbage, Mock(), updates=root / 'updates', state=root / 'state', restore_state=root / 'restore-state')
|
|
|
|
return NS(root=root, current=current, target=target, job=job, jobs=jobs, builder=bundle_builder)
|
|
|
|
|
|
def update(device):
|
|
setup.maintain('update', setup.bundle_id(device.target), device.job)
|
|
|
|
|
|
def test_update_qualifies_copy_then_keeps_one_previous_pair(installed):
|
|
update(installed)
|
|
record = setup.journal()
|
|
tx = installed.root / 'updates' / record['id']
|
|
assert installed.jobs == ['copy', 'qualify', 'cleanup']
|
|
assert record['phase'] == 'committed'
|
|
assert setup.receipt(installed.root / 'runtime') == installed.target
|
|
assert setup.receipt(tx / 'previous') == installed.current
|
|
assert (tx / 'profile-backup/profile/prefs.js').read_text() == 'before update'
|
|
assert (installed.root / 'state/profile/prefs.js').read_text() == 'before update'
|
|
assert not list((tx / 'probe-state').iterdir())
|
|
assert setup.inspect()['restorable']
|
|
installed.target.update(version='153.8.0esr', bundle_revision=2)
|
|
update(installed)
|
|
assert len(list((installed.root / 'updates').iterdir())) == 1
|
|
assert (installed.root / 'state/downloads/document').read_text() == 'download preserved'
|
|
|
|
|
|
@pytest.mark.parametrize('after_move', [1, 2])
|
|
def test_power_loss_finishes_application_forward_without_profile_rewind(installed, monkeypatch, after_move):
|
|
move = setup.move
|
|
count = 0
|
|
|
|
def interrupted(source, target):
|
|
nonlocal count
|
|
move(source, target)
|
|
count += 1
|
|
if count == after_move:
|
|
raise InterruptedError('power lost')
|
|
|
|
monkeypatch.setattr(setup, 'move', interrupted)
|
|
with pytest.raises(InterruptedError):
|
|
update(installed)
|
|
assert setup.inspect()['action'] == 'recover'
|
|
if after_move == 1:
|
|
with pytest.raises(RuntimeError, match='pending recovery'):
|
|
setup.prepare_locked(installed.target)
|
|
# An older branch may have used the newly activated browser before seeing the journal.
|
|
(installed.root / 'state/profile/prefs.js').write_text('newer user data')
|
|
monkeypatch.setattr(setup, 'move', move)
|
|
setup.maintain('recover', '', installed.job)
|
|
setup.maintain('recover', '', installed.job)
|
|
assert setup.journal()['phase'] == 'committed'
|
|
assert setup.receipt(installed.root / 'runtime') == installed.target
|
|
assert (installed.root / 'state/profile/prefs.js').read_text() == 'newer user data'
|
|
|
|
|
|
def test_qualification_failure_recovers_old_app_without_touching_profile(installed):
|
|
def reject(kind, *args):
|
|
if kind == 'qualify':
|
|
raise RuntimeError('blank candidate frame')
|
|
installed.job(kind, *args)
|
|
|
|
with pytest.raises(RuntimeError, match='blank'):
|
|
setup.maintain('update', setup.bundle_id(installed.target), reject)
|
|
assert setup.journal()['phase'] == 'qualifying'
|
|
assert setup.receipt(installed.root / 'runtime') == installed.current
|
|
setup.maintain('recover', '', installed.job)
|
|
assert setup.journal() is None
|
|
assert (installed.root / 'state/profile/prefs.js').read_text() == 'before update'
|
|
|
|
|
|
def test_failed_second_update_retains_the_previous_committed_pair(installed):
|
|
update(installed)
|
|
previous_record = setup.journal()
|
|
installed.target.update(version='153.8.0esr', bundle_revision=2)
|
|
|
|
def reject(kind, *args):
|
|
if kind == 'qualify':
|
|
raise InterruptedError('closed')
|
|
installed.job(kind, *args)
|
|
|
|
with pytest.raises(InterruptedError):
|
|
setup.maintain('update', setup.bundle_id(installed.target), reject)
|
|
setup.maintain('recover', '', installed.job)
|
|
assert setup.journal() == previous_record
|
|
assert setup.inspect()['restorable']
|
|
|
|
|
|
def test_corrupt_prior_metadata_cannot_replace_the_current_recovery_record(installed):
|
|
update(installed)
|
|
installed.target.update(version='153.8.0esr', bundle_revision=2)
|
|
|
|
def reject(kind, *args):
|
|
if kind == 'qualify':
|
|
raise InterruptedError('closed')
|
|
installed.job(kind, *args)
|
|
|
|
with pytest.raises(InterruptedError):
|
|
setup.maintain('update', setup.bundle_id(installed.target), reject)
|
|
record = setup.journal()
|
|
tx = installed.root / 'updates' / record['id']
|
|
(tx / 'prior.json').write_text('{"phase":"committed"}')
|
|
with pytest.raises(ValueError):
|
|
setup.maintain('recover', '', installed.job)
|
|
assert setup.journal() == record
|
|
assert (tx / 'candidate').is_dir()
|
|
|
|
|
|
@pytest.mark.parametrize('after_move', [0, 1, 2, 3, 4])
|
|
def test_explicit_restore_preserves_newer_state_and_recovers_each_rename(installed, monkeypatch, after_move):
|
|
update(installed)
|
|
(installed.root / 'state/profile/prefs.js').write_text('newer data to preserve')
|
|
move, count = setup.move, 0
|
|
|
|
def interrupted(source, target):
|
|
nonlocal count
|
|
move(source, target)
|
|
count += 1
|
|
if count == after_move:
|
|
raise InterruptedError('power lost')
|
|
|
|
monkeypatch.setattr(setup, 'move', interrupted)
|
|
if after_move:
|
|
with pytest.raises(InterruptedError):
|
|
setup.maintain('restore', '', installed.job)
|
|
monkeypatch.setattr(setup, 'move', move)
|
|
setup.maintain('recover', '', installed.job)
|
|
else:
|
|
setup.maintain('restore', '', installed.job)
|
|
setup.maintain('recover', '', installed.job)
|
|
assert setup.receipt(installed.root / 'runtime') == installed.current
|
|
assert (installed.root / 'state/profile/prefs.js').read_text() == 'before update'
|
|
assert (installed.root / 'recovery-state/profile/prefs.js').read_text() == 'newer data to preserve'
|
|
assert (installed.root / 'recovery-state/downloads/document').read_text() == 'download preserved'
|
|
assert setup.journal()['phase'] == 'restored'
|
|
with pytest.raises(RuntimeError, match='No complete previous'):
|
|
setup.maintain('restore', '', installed.job)
|
|
|
|
|
|
def test_interrupted_restore_copy_keeps_original_backup_and_current_profile(installed):
|
|
update(installed)
|
|
record = setup.journal()
|
|
(installed.root / 'state/profile/prefs.js').write_text('newer data')
|
|
|
|
def reject(kind, *args):
|
|
installed.job(kind, *args)
|
|
if kind == 'restore-copy':
|
|
raise InterruptedError('closed')
|
|
|
|
with pytest.raises(InterruptedError):
|
|
setup.maintain('restore', '', reject)
|
|
setup.maintain('recover', '', installed.job)
|
|
assert setup.journal() == record
|
|
assert (installed.root / 'state/profile/prefs.js').read_text() == 'newer data'
|
|
assert (installed.root / 'updates' / record['id'] / 'profile-backup/profile/prefs.js').read_text() == 'before update'
|
|
assert not (installed.root / 'recovery').exists()
|
|
assert not (installed.root / 'restore-state').exists()
|
|
|
|
|
|
@pytest.mark.parametrize('after_move', [1, 2, 3, 4])
|
|
def test_restored_profile_is_never_exposed_to_the_newer_runtime(installed, monkeypatch, after_move):
|
|
update(installed)
|
|
(installed.root / 'state/profile/prefs.js').write_text('newer Firefox profile')
|
|
move, count = setup.move, 0
|
|
|
|
def interrupt(source, destination):
|
|
nonlocal count
|
|
move(source, destination)
|
|
count += 1
|
|
if count == after_move:
|
|
raise InterruptedError('power lost')
|
|
|
|
monkeypatch.setattr(setup, 'move', interrupt)
|
|
with pytest.raises(InterruptedError):
|
|
setup.maintain('restore', '', installed.job)
|
|
profile = installed.root / 'state/profile/prefs.js'
|
|
if profile.exists() and profile.read_text() == 'before update':
|
|
assert setup.receipt(installed.root / 'runtime') == installed.current
|
|
profile.write_text('recent browsing from a branch ignoring the journal')
|
|
monkeypatch.setattr(setup, 'move', move)
|
|
setup.maintain('recover', '', installed.job)
|
|
assert setup.receipt(installed.root / 'runtime') == installed.current
|
|
assert profile.read_text() == ('recent browsing from a branch ignoring the journal' if after_move == 4 else 'before update')
|
|
assert (installed.root / 'recovery-state/profile/prefs.js').read_text() == 'newer Firefox profile'
|
|
|
|
|
|
def test_ambiguous_state_first_restore_is_preserved_without_rewinding_data(installed, monkeypatch):
|
|
update(installed)
|
|
original_restore = setup.restore
|
|
monkeypatch.setattr(setup, 'restore', Mock(side_effect=InterruptedError('before first rename')))
|
|
with pytest.raises(InterruptedError):
|
|
setup.maintain('restore', '', installed.job)
|
|
monkeypatch.setattr(setup, 'restore', original_restore)
|
|
(installed.root / 'state').rename(installed.root / 'recovery-state')
|
|
(installed.root / 'restore-state').rename(installed.root / 'state')
|
|
profile = installed.root / 'state/profile/prefs.js'
|
|
profile.write_text('data changed outside the recorded restore order')
|
|
with pytest.raises(RuntimeError, match='restoration order'):
|
|
setup.maintain('recover', '', installed.job)
|
|
assert profile.read_text() == 'data changed outside the recorded restore order'
|
|
assert setup.receipt(installed.root / 'runtime') == installed.target
|
|
assert setup.journal()['phase'] == 'restore'
|
|
|
|
|
|
def test_saved_recovery_state_is_never_replaced_even_without_its_metadata_directory(installed):
|
|
update(installed)
|
|
saved = installed.root / 'recovery-state'
|
|
saved.mkdir(mode=0o700)
|
|
(saved / 'data').write_text('saved browsing data')
|
|
assert not setup.inspect()['restorable']
|
|
with pytest.raises(RuntimeError, match='saved recovery'):
|
|
setup.maintain('restore', '', installed.job)
|
|
assert (saved / 'data').read_text() == 'saved browsing data'
|
|
|
|
|
|
def test_first_update_cleanup_flushes_journal_removal_before_deleting_transaction(installed, monkeypatch):
|
|
def reject(kind, *args):
|
|
if kind == 'qualify':
|
|
raise InterruptedError('owner closed')
|
|
installed.job(kind, *args)
|
|
|
|
with pytest.raises(InterruptedError):
|
|
setup.maintain('update', setup.bundle_id(installed.target), reject)
|
|
journal = installed.root / 'update.json'
|
|
unlink, sync, remove = Path.unlink, setup.sync_dir, setup.remove_transactions
|
|
events = []
|
|
|
|
def tracked_unlink(path, *args, **kwargs):
|
|
unlink(path, *args, **kwargs)
|
|
if path == journal:
|
|
events.append('unlinked journal')
|
|
|
|
def tracked_sync(path):
|
|
sync(path)
|
|
if path == installed.root and 'unlinked journal' in events:
|
|
events.append('flushed journal removal')
|
|
|
|
def tracked_remove(identities):
|
|
assert events[-1] == 'flushed journal removal'
|
|
events.append('removed transaction')
|
|
remove(identities)
|
|
|
|
monkeypatch.setattr(Path, 'unlink', tracked_unlink)
|
|
monkeypatch.setattr(setup, 'sync_dir', tracked_sync)
|
|
monkeypatch.setattr(setup, 'remove_transactions', tracked_remove)
|
|
setup.maintain('recover', '', installed.job)
|
|
assert events[:3] == ['unlinked journal', 'flushed journal removal', 'removed transaction']
|
|
assert setup.journal() is None
|
|
assert not list((installed.root / 'updates').iterdir())
|
|
|
|
|
|
def test_unknown_activation_layout_is_preserved(installed, monkeypatch):
|
|
original = setup.activate
|
|
monkeypatch.setattr(setup, 'activate', Mock(side_effect=InterruptedError('power lost')))
|
|
with pytest.raises(InterruptedError):
|
|
update(installed)
|
|
monkeypatch.setattr(setup, 'activate', original)
|
|
(installed.root / 'runtime/ready.json').write_text('{}')
|
|
with pytest.raises(ValueError):
|
|
setup.maintain('recover', '', installed.job)
|
|
assert setup.journal()['phase'] == 'activate'
|
|
assert (installed.root / 'runtime/ready.json').read_text() == '{}'
|
|
|
|
|
|
def test_ambiguous_restore_does_not_move_profile_before_validating_applications(installed, monkeypatch):
|
|
update(installed)
|
|
(installed.root / 'state/profile/prefs.js').write_text('current data')
|
|
restore = setup.restore
|
|
monkeypatch.setattr(setup, 'restore', Mock(side_effect=InterruptedError('power lost before first rename')))
|
|
with pytest.raises(InterruptedError):
|
|
setup.maintain('restore', '', installed.job)
|
|
monkeypatch.setattr(setup, 'restore', restore)
|
|
tx = installed.root / 'updates' / setup.journal()['id']
|
|
(tx / 'previous/ready.json').write_text('{}')
|
|
with pytest.raises(ValueError):
|
|
setup.maintain('recover', '', installed.job)
|
|
assert (installed.root / 'state/profile/prefs.js').read_text() == 'current data'
|
|
assert not (installed.root / 'recovery').exists()
|
|
|
|
|
|
def test_local_detection_legacy_current_component_update_and_no_downgrade(installed, monkeypatch):
|
|
old = deepcopy(installed.current)
|
|
old.pop('bundle_revision')
|
|
old.pop('bundle_format')
|
|
(installed.root / 'runtime/ready.json').write_text(json.dumps(old))
|
|
monkeypatch.setattr(setup.urllib.request, 'urlopen', Mock(side_effect=AssertionError('network during inspection')))
|
|
installed.target.update(installed.current)
|
|
assert setup.inspect()['action'] == 'browse'
|
|
installed.target['bundle_revision'] = 1
|
|
assert setup.inspect()['action'] == 'update'
|
|
installed.target.update(version='152.9.0esr', bundle_revision=2)
|
|
assert setup.inspect()['action'] == 'browse'
|
|
installed.target.update(version='153.6.0esr', bundle_revision=0)
|
|
installed.target['packages'][0]['size'] += 1
|
|
with pytest.raises(RuntimeError, match='revision'):
|
|
setup.inspect()
|
|
|
|
|
|
def test_target_is_frozen_and_quiescence_is_checked_before_activation(installed):
|
|
with pytest.raises(RuntimeError, match='changed'):
|
|
setup.maintain('update', '0' * 64, installed.job)
|
|
installed.builder.assert_not_called()
|
|
setup.quiescent.side_effect = [None, None, RuntimeError('browser still running')]
|
|
with pytest.raises(RuntimeError, match='still running'):
|
|
update(installed)
|
|
assert setup.journal()['phase'] == 'qualifying'
|
|
assert setup.receipt(installed.root / 'runtime') == installed.current
|
|
|
|
|
|
def test_owner_cancellation_after_qualification_cannot_begin_activation(installed, monkeypatch):
|
|
def report(message):
|
|
if message == 'Activating verified Firefox...':
|
|
raise InterruptedError('owner closed')
|
|
|
|
monkeypatch.setattr(setup, 'report', report)
|
|
with pytest.raises(InterruptedError):
|
|
update(installed)
|
|
assert setup.journal()['phase'] == 'qualifying'
|
|
assert setup.receipt(installed.root / 'runtime') == installed.current
|
|
|
|
|
|
def test_quiescence_checks_actual_browser_uid_and_conflicting_units(tmp_path, monkeypatch):
|
|
proc = tmp_path / 'proc'
|
|
proc.mkdir()
|
|
group = tmp_path / 'cgroup'
|
|
group.write_text('0::/system.slice/arabesque-firefox-controller.service\n')
|
|
real_path = setup.Path
|
|
monkeypatch.setattr(setup, 'Path', lambda value: proc if value == '/proc' else group if value == '/proc/self/cgroup' else real_path(value))
|
|
monkeypatch.setattr(setup.pwd, 'getpwuid', Mock(side_effect=KeyError))
|
|
monkeypatch.setattr(setup, 'BROWSER_UID', os.getuid())
|
|
(proc / '1234').mkdir()
|
|
with pytest.raises(RuntimeError, match='running processes'):
|
|
setup.quiescent()
|
|
(proc / '1234').rmdir()
|
|
monkeypatch.setattr(setup.subprocess, 'check_output', query := Mock(return_value='arabesque-firefox-controller.service loaded active running\n'))
|
|
setup.quiescent()
|
|
query.return_value += 'arabesque-firefox-job.service loaded active running\n'
|
|
with pytest.raises(RuntimeError, match='Close all Firefox'):
|
|
setup.quiescent()
|
|
|
|
|
|
def test_profile_copy_preserves_internal_links_and_rejects_escape_and_special_files(tmp_path, monkeypatch):
|
|
source = tmp_path / 'source'
|
|
source.mkdir()
|
|
(source / 'prefs.js').write_text('data')
|
|
(source / 'internal').symlink_to('prefs.js')
|
|
(source / '.parentlock').symlink_to('missing-host-lock')
|
|
runtime.copy_profile(source, tmp_path / 'copy', Mock())
|
|
assert (tmp_path / 'copy/internal').is_symlink()
|
|
assert not (tmp_path / 'copy/.parentlock').exists()
|
|
assert (source / 'prefs.js').stat().st_ino != (tmp_path / 'copy/prefs.js').stat().st_ino
|
|
(source / 'external').symlink_to('../secret')
|
|
with pytest.raises(RuntimeError, match='outside'):
|
|
runtime.copy_profile(source, tmp_path / 'bad-copy', Mock())
|
|
(source / 'external').unlink()
|
|
os.mkfifo(source / 'pipe')
|
|
with pytest.raises(RuntimeError, match='special'):
|
|
runtime.copy_profile(source, tmp_path / 'special-copy', Mock())
|
|
(tmp_path / 'dangling').symlink_to('absent-profile')
|
|
with pytest.raises(RuntimeError, match='real directory'):
|
|
runtime.copy_profile(tmp_path / 'dangling', tmp_path / 'root-link-copy', Mock())
|
|
|
|
|
|
def test_low_space_refuses_profile_copy_before_writing(installed, monkeypatch):
|
|
monkeypatch.setattr(setup.shutil, 'disk_usage', lambda _: NS(free=setup.FLOOR))
|
|
tx = installed.root / 'updates' / ('a' * 32)
|
|
tx.mkdir(parents=True)
|
|
(tx / 'profile-backup').mkdir()
|
|
(tx / 'probe-state').mkdir()
|
|
with pytest.raises(RuntimeError, match='storage'):
|
|
runtime.profile_job('copy', tx.name, [], Mock(), updates=tx.parent, state=installed.root / 'state')
|
|
assert not list((tx / 'profile-backup').iterdir())
|
|
|
|
|
|
@pytest.mark.parametrize('kind', ['empty', 'populated', 'unsafe', 'symlink'])
|
|
def test_private_directory_repairs_only_interrupted_empty_root_owned_creation(tmp_path, monkeypatch, kind):
|
|
path = tmp_path / 'private'
|
|
path.mkdir(mode=0o700)
|
|
if kind == 'populated':
|
|
(path / 'data').write_text('preserved')
|
|
elif kind == 'unsafe':
|
|
path.chmod(0o777)
|
|
elif kind == 'symlink':
|
|
path.rmdir()
|
|
path.symlink_to(tmp_path, target_is_directory=True)
|
|
lstat, chown = Path.lstat, os.chown
|
|
pending = True
|
|
|
|
def interrupted_owner(value, *args, **kwargs):
|
|
info = lstat(value, *args, **kwargs)
|
|
if value == path and pending:
|
|
fields = list(info)
|
|
fields[4] = 0
|
|
return os.stat_result(fields)
|
|
return info
|
|
|
|
def assign(value, uid, gid):
|
|
nonlocal pending
|
|
chown(value, uid, gid)
|
|
pending = False
|
|
|
|
monkeypatch.setattr(setup, 'BROWSER_UID', os.getuid())
|
|
monkeypatch.setattr(Path, 'lstat', interrupted_owner)
|
|
monkeypatch.setattr(setup.os, 'chown', assign_owner := Mock(side_effect=assign))
|
|
if kind == 'empty':
|
|
setup.private_directory(path)
|
|
assign_owner.assert_called_once_with(path, os.getuid(), os.getuid())
|
|
else:
|
|
with pytest.raises(RuntimeError, match='Unsafe'):
|
|
setup.private_directory(path)
|
|
assign_owner.assert_not_called()
|
|
if kind == 'populated':
|
|
assert (path / 'data').read_text() == 'preserved'
|
|
|
|
|
|
def test_private_directory_requires_a_directory_even_with_correct_owner(tmp_path, monkeypatch):
|
|
path = tmp_path / 'not-a-directory'
|
|
path.write_text('preserved')
|
|
path.chmod(0o700)
|
|
monkeypatch.setattr(setup, 'BROWSER_UID', os.getuid())
|
|
with pytest.raises(RuntimeError, match='private directory'):
|
|
setup.private_directory(path)
|
|
assert path.read_text() == 'preserved'
|
|
|
|
|
|
def test_digest_cache_recovers_stale_alias_and_corrupt_digest_entry(tmp_path, monkeypatch):
|
|
data = b'new GTK library'
|
|
package = {'name': 'libgtk.deb', 'url': 'https://example.invalid/package', 'size': len(data), 'sha256': hashlib.sha256(data).hexdigest()}
|
|
(tmp_path / package['name']).write_bytes(b'old package')
|
|
digest_path = tmp_path / (package['sha256'] + '-' + package['name'])
|
|
digest_path.write_bytes(b'corrupt')
|
|
response = io.BytesIO(data)
|
|
response.url = package['url']
|
|
monkeypatch.setattr(setup, 'safe', Mock())
|
|
monkeypatch.setattr(setup.urllib.request, 'urlopen', fetch := Mock(return_value=response))
|
|
assert setup.download(package, tmp_path) == digest_path
|
|
assert digest_path.read_bytes() == data
|
|
fetch.assert_called_once()
|
|
|
|
|
|
@pytest.mark.parametrize('value', ['../escape', '/data/runtime', '', 1, 'g' * 32])
|
|
def test_transaction_paths_reject_arbitrary_input(value):
|
|
with pytest.raises(ValueError):
|
|
setup.transaction_id(value)
|
|
|
|
|
|
@pytest.mark.parametrize('patch', [{'bundle_format': True}, {'bundle_format': 2}, {'bundle_revision': -1}, {'packages': ['bad']}, {'version': 'latest'}])
|
|
def test_manifest_boundary_rejects_unsupported_or_malformed_bundles(patch):
|
|
value = setup.approved()
|
|
value.update(patch)
|
|
with pytest.raises(ValueError):
|
|
setup.manifest(value)
|