- Add optional modifier-only Super binding whose tap is cancelled by other input, focus changes, grabs, locks, and session teardown. - Ship the native ferese-rime user service, Rime defaults, installer inventory, package dependencies, session environment, and docs. - Vendor Smithay 8a27023 with the SHM texture-cache format fix and add GPU regressions for format changes and resize snapshots. - Release reused CRTC owners before staging output modes, then restore changed outputs before former owners when a transaction fails. - Wait for resize-capture GPU completion, expand filtered surface damage, and preserve IME popup origin under fractional scaling. - Mark weekends like holidays in both calendar layouts.
609 lines
31 KiB
Python
609 lines
31 KiB
Python
"""Run complete installer commands against disposable destination filesystems."""
|
|
import fcntl
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
REPO = Path(__file__).resolve().parents[2]
|
|
HELPER = REPO / 'scripts/installer/install.py'
|
|
spec = importlib.util.spec_from_file_location('ferese_install', HELPER)
|
|
installer = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(installer)
|
|
PREFIX = installer.PREFIX.lstrip('/')
|
|
PORTAL = 'usr/share/xdg-desktop-portal/ferese-portals.conf'
|
|
UNIT = 'usr/local/lib/systemd/user/xdg-desktop-portal-ferese.service'
|
|
DESKTOP = 'usr/share/wayland-sessions/ferese.desktop'
|
|
|
|
|
|
class InstallerTest(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.shared = tempfile.TemporaryDirectory()
|
|
cls.source = Path(cls.shared.name) / 'source'
|
|
for rule in installer.INVENTORY['files']:
|
|
if rule['source'].startswith('target/release/'):
|
|
paths = [REPO / rule['source']]
|
|
else:
|
|
paths = sorted(REPO.glob(rule['source']))
|
|
|
|
for path in paths:
|
|
target = cls.source / path.relative_to(REPO)
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_bytes(b'#!/bin/sh\nexit 0\n' if rule['source'].startswith('target/release/') else path.read_bytes())
|
|
target.chmod(0o755 if rule['source'].startswith('target/release/') else path.stat().st_mode & 0o777)
|
|
|
|
cls.bundles = []
|
|
for name in ('first', 'second'):
|
|
for relative in ('portal/ferese-portals.conf', 'systemd/xdg-desktop-portal-ferese.service', 'ferese.desktop'):
|
|
with (cls.source / 'packaging' / relative).open('a') as stream:
|
|
stream.write(f'\n# {name}\n')
|
|
|
|
output = Path(cls.shared.name) / name
|
|
subprocess.run([sys.executable, str(HELPER), 'bundle', '--source-root', str(cls.source),
|
|
'--output', str(output), '--release-id', name], check=True, capture_output=True)
|
|
cls.bundles.append(output)
|
|
|
|
@classmethod
|
|
def tearDownClass(cls):
|
|
cls.shared.cleanup()
|
|
|
|
def setUp(self):
|
|
self.temporary = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.temporary.cleanup)
|
|
self.root = Path(self.temporary.name) / 'root'
|
|
pam = self.root / 'etc/pam.d/login'
|
|
pam.parent.mkdir(parents=True)
|
|
pam.write_text('test PAM stack\n')
|
|
self.base = self.root / PREFIX
|
|
|
|
def run_command(self, *args, success=True, fault=None, fault_after=4):
|
|
command = [sys.executable, str(HELPER)]
|
|
if fault:
|
|
# Inject failure at the filesystem boundary, without shipping production test hooks.
|
|
code = f'''import importlib.util, os, sys
|
|
spec = importlib.util.spec_from_file_location('installer', {str(HELPER)!r})
|
|
m = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(m)
|
|
original = m.Installation.write_state
|
|
count = 0
|
|
def write(self, *args, **kwargs):
|
|
global count
|
|
original(self, *args, **kwargs)
|
|
count += 1
|
|
if count == {fault_after}:
|
|
{fault}
|
|
m.Installation.write_state = write
|
|
m.main()
|
|
'''
|
|
command = [sys.executable, '-c', code]
|
|
|
|
result = subprocess.run([*command, *map(str, args), '--root', str(self.root)], capture_output=True, text=True)
|
|
if success:
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
else:
|
|
self.assertNotEqual(result.returncode, 0, result.stdout)
|
|
|
|
return result
|
|
|
|
def install(self, index=0, **kwargs):
|
|
return self.run_command('install', self.bundles[index], **kwargs)
|
|
|
|
def snapshot(self):
|
|
result = {}
|
|
for path in self.root.rglob('*'):
|
|
if path.is_relative_to(self.base / 'transactions') or path.is_relative_to(self.base / 'releases'):
|
|
continue
|
|
if path.is_symlink():
|
|
result[str(path.relative_to(self.root))] = os.readlink(path)
|
|
elif path.is_file() and path.name != '.install.lock':
|
|
result[str(path.relative_to(self.root))] = path.read_bytes()
|
|
|
|
return result
|
|
|
|
def test_launcher_permissions_are_assigned_when_bundling(self):
|
|
self.assertEqual((self.source / 'packaging/ferese-session').stat().st_mode & 0o111, 0)
|
|
for name in ('ferese-session', 'ferese-session-shell', 'ferese-screenshot'):
|
|
self.assertEqual((self.bundles[0] / name).stat().st_mode & 0o777, 0o755)
|
|
|
|
def without_appearance_wallpapers(self, bundle):
|
|
self.without_files(bundle, {'wallpapers/ferese-wallpaper-dark.png', 'wallpapers/ferese-wallpaper-light.png'})
|
|
|
|
def without_files(self, bundle, removed):
|
|
manifest_path = bundle / 'manifest.json'
|
|
manifest = json.loads(manifest_path.read_text())
|
|
for target in removed:
|
|
(bundle / target).unlink()
|
|
|
|
manifest['files'] = [item for item in manifest['files'] if item['target'] not in removed]
|
|
manifest_path.write_text(json.dumps(manifest))
|
|
|
|
def test_license_is_bundled_and_required_for_new_releases(self):
|
|
self.assertEqual((self.bundles[0] / 'licenses/Ferese-LICENSE.txt').read_bytes(), (REPO / 'LICENSE').read_bytes())
|
|
incomplete = Path(self.temporary.name) / 'incomplete'
|
|
shutil.copytree(self.bundles[0], incomplete)
|
|
self.without_files(incomplete, {'licenses/Ferese-LICENSE.txt'})
|
|
result = self.run_command('install', incomplete, success=False)
|
|
self.assertIn('Bundle is incomplete', result.stderr)
|
|
self.assertFalse((self.base / 'current').exists())
|
|
|
|
def test_upgrade_and_rollback_accept_installed_release_before_license(self):
|
|
self.install()
|
|
self.without_files(self.base / 'releases/first', {'licenses/Ferese-LICENSE.txt'})
|
|
self.install(1)
|
|
self.run_command('rollback')
|
|
self.assertEqual(os.readlink(self.base / 'current'), 'releases/first')
|
|
self.assertEqual(installer.Installation(self.root).manifest('first')['release'], 'first')
|
|
|
|
def test_upgrade_and_rollback_accept_installed_release_before_native_rime(self):
|
|
self.install()
|
|
self.without_files(self.base / 'releases/first', {'ferese-rime', 'rime/default.custom.yaml'})
|
|
# Older releases did not publish the native input command either.
|
|
(self.root / 'usr/local/bin/ferese-rime').unlink()
|
|
self.install(1)
|
|
self.assertTrue((self.root / 'usr/local/bin/ferese-rime').is_symlink())
|
|
self.assertTrue((self.base / 'releases/second/ferese-rime').is_file())
|
|
self.run_command('rollback')
|
|
self.assertFalse((self.root / 'usr/local/bin/ferese-rime').exists())
|
|
self.assertEqual(installer.Installation(self.root).manifest('first')['release'], 'first')
|
|
|
|
def test_upgrade_and_rollback_accept_installed_release_before_appearance_wallpapers(self):
|
|
self.install()
|
|
legacy = self.base / 'releases/first'
|
|
self.without_appearance_wallpapers(legacy)
|
|
self.install(1)
|
|
self.run_command('rollback')
|
|
self.assertEqual(os.readlink(self.base / 'current'), 'releases/first')
|
|
self.assertEqual(installer.Installation(self.root).manifest('first')['release'], 'first')
|
|
(legacy / 'ferese').write_bytes(b'corrupt')
|
|
with self.assertRaisesRegex(ValueError, 'Checksum mismatch'):
|
|
installer.Installation(self.root).manifest('first')
|
|
|
|
def replace_dark_wallpaper_with_jpeg(self, bundle):
|
|
source = bundle / 'wallpapers/ferese-wallpaper-dark.png'
|
|
source.rename(source.with_suffix('.jpg'))
|
|
path = bundle / 'manifest.json'
|
|
manifest = json.loads(path.read_text())
|
|
for item in manifest['files']:
|
|
if item['target'] == 'wallpapers/ferese-wallpaper-dark.png':
|
|
item['target'] = 'wallpapers/ferese-wallpaper-dark.jpg'
|
|
path.write_text(json.dumps(manifest))
|
|
|
|
def test_upgrade_and_rollback_verify_installed_jpeg_wallpaper(self):
|
|
self.install()
|
|
installed = self.base / 'releases/first'
|
|
self.replace_dark_wallpaper_with_jpeg(installed)
|
|
self.assertEqual(installer.Installation(self.root).manifest('first')['release'], 'first')
|
|
self.install(1)
|
|
self.assertTrue((self.base / 'current/wallpapers/ferese-wallpaper-dark.png').is_file())
|
|
self.assertFalse((self.base / 'current/wallpapers/ferese-wallpaper-dark.jpg').exists())
|
|
self.run_command('rollback')
|
|
self.assertTrue((self.base / 'current/wallpapers/ferese-wallpaper-dark.jpg').is_file())
|
|
(installed / 'wallpapers/ferese-wallpaper-dark.jpg').write_bytes(b'corrupt asset')
|
|
with self.assertRaisesRegex(ValueError, 'Checksum mismatch: wallpapers/ferese-wallpaper-dark.jpg'):
|
|
installer.Installation(self.root).manifest('first')
|
|
|
|
def test_new_bundle_rejects_jpeg_in_place_of_required_png(self):
|
|
changed = Path(self.temporary.name) / 'jpeg-bundle'
|
|
shutil.copytree(self.bundles[0], changed)
|
|
self.replace_dark_wallpaper_with_jpeg(changed)
|
|
with self.assertRaisesRegex(ValueError, 'Invalid inventory entry: wallpapers/ferese-wallpaper-dark.jpg'):
|
|
installer.validate_bundle(changed)
|
|
|
|
def test_new_bundle_requires_appearance_wallpapers(self):
|
|
incomplete = Path(self.temporary.name) / 'incomplete'
|
|
shutil.copytree(self.bundles[0], incomplete)
|
|
self.without_appearance_wallpapers(incomplete)
|
|
result = self.run_command('install', incomplete, success=False)
|
|
self.assertIn('Bundle is incomplete', result.stderr)
|
|
self.assertFalse((self.base / 'current').exists())
|
|
|
|
def test_fresh_upgrade_and_rollback_restore_all_integration(self):
|
|
self.install()
|
|
first = [(self.root / path).read_bytes() for path in (PORTAL, UNIT, DESKTOP)]
|
|
self.assertEqual(os.readlink(self.base / 'current'), 'releases/first')
|
|
self.assertEqual(os.readlink(self.root / 'usr/local/bin/ferese'), '/usr/local/lib/ferese/current/ferese')
|
|
self.install(1)
|
|
self.assertEqual(os.readlink(self.base / 'previous'), 'releases/first')
|
|
for path, old in zip((PORTAL, UNIT, DESKTOP), first):
|
|
self.assertNotEqual((self.root / path).read_bytes(), old)
|
|
|
|
self.run_command('rollback')
|
|
self.assertEqual(os.readlink(self.base / 'current'), 'releases/first')
|
|
self.assertEqual(os.readlink(self.base / 'previous'), 'releases/second')
|
|
self.assertEqual([(self.root / path).read_bytes() for path in (PORTAL, UNIT, DESKTOP)], first)
|
|
|
|
def test_upgrade_and_rollback_accept_retired_wallpaper_svg_with_verified_checksum(self):
|
|
legacy = Path(self.temporary.name) / 'legacy'
|
|
shutil.copytree(self.bundles[0], legacy)
|
|
svg = legacy / 'wallpapers/ferese.svg'
|
|
svg.write_text('<svg xmlns="http://www.w3.org/2000/svg"/>\n')
|
|
svg.chmod(0o644)
|
|
manifest_path = legacy / 'manifest.json'
|
|
manifest = json.loads(manifest_path.read_text())
|
|
manifest['files'].append({'target': 'wallpapers/ferese.svg', 'mode': '0644',
|
|
'sha256': installer.digest(svg)})
|
|
manifest_path.write_text(json.dumps(manifest))
|
|
|
|
self.run_command('install', legacy)
|
|
self.assertTrue((self.base / 'current/wallpapers/ferese.svg').is_file())
|
|
self.install(1)
|
|
self.assertEqual(os.readlink(self.base / 'current'), 'releases/second')
|
|
self.assertFalse((self.base / 'current/wallpapers/ferese.svg').exists())
|
|
self.run_command('rollback')
|
|
self.assertEqual(os.readlink(self.base / 'current'), 'releases/first')
|
|
self.assertEqual((self.base / 'current/wallpapers/ferese.svg').read_bytes(), svg.read_bytes())
|
|
|
|
svg.write_text('corrupt legacy wallpaper')
|
|
with self.assertRaisesRegex(ValueError, 'Checksum mismatch: wallpapers/ferese.svg'):
|
|
installer.validate_bundle(legacy)
|
|
|
|
def test_upgrade_uses_previous_installer_ownership_receipts(self):
|
|
self.install()
|
|
release = self.base / 'releases/first'
|
|
(release / 'manifest.json').unlink()
|
|
for rule in installer.INVENTORY['files']:
|
|
if 'destination' in rule and ('portal/' in rule['source'] or 'systemd/' in rule['source']):
|
|
receipt = release / 'installer-files' / rule['source'].removeprefix('packaging/')
|
|
receipt.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copyfile(self.root / rule['destination'].lstrip('/'), receipt)
|
|
|
|
# The old installer saved no desktop receipt: only identical incoming files are safe.
|
|
(self.root / DESKTOP).write_bytes((self.bundles[1] / ('integration/' + DESKTOP)).read_bytes())
|
|
self.install(1)
|
|
result = self.run_command('rollback', success=False)
|
|
self.assertIn('predates verified bundles', result.stderr)
|
|
self.assertEqual(os.readlink(self.base / 'current'), 'releases/second')
|
|
|
|
def test_reinstall_is_idempotent_and_keeps_previous(self):
|
|
self.install()
|
|
self.install(1)
|
|
before = self.snapshot()
|
|
self.install(1)
|
|
self.assertEqual(before, self.snapshot())
|
|
self.assertEqual(len(list((self.base / 'transactions').iterdir())), 2)
|
|
|
|
def test_modified_configuration_is_preserved_on_upgrade_and_rollback(self):
|
|
self.install()
|
|
self.install(1)
|
|
(self.root / UNIT).write_text('administrator override\n')
|
|
before = self.snapshot()
|
|
self.run_command('rollback', success=False)
|
|
self.assertEqual(before, self.snapshot())
|
|
self.install(success=False)
|
|
self.assertEqual(before, self.snapshot())
|
|
|
|
def test_explicit_portal_replacement_keeps_backup(self):
|
|
self.install()
|
|
(self.root / PORTAL).write_text('administrator preferences\n')
|
|
self.install(1, success=False)
|
|
self.run_command('install', self.bundles[1], '--replace-portal-config')
|
|
backups = list((self.base / 'transactions').glob('*/backup/*'))
|
|
self.assertTrue(any(path.read_text() == 'administrator preferences\n' for path in backups))
|
|
|
|
def test_normal_failure_restores_previous_files_and_can_retry(self):
|
|
self.install()
|
|
before = self.snapshot()
|
|
self.install(1, success=False, fault="raise OSError('injected write failure')")
|
|
self.assertEqual(before, self.snapshot())
|
|
self.assertFalse((self.base / '.transaction').exists())
|
|
self.install(1)
|
|
|
|
def test_killed_upgrade_recovers_before_next_install(self):
|
|
self.install()
|
|
self.install(1, success=False, fault='os._exit(91)')
|
|
self.assertTrue((self.base / '.transaction/journal.json').is_file())
|
|
result = self.install(1)
|
|
self.assertIn('Recovered interrupted', result.stdout)
|
|
self.assertFalse((self.base / '.transaction').exists())
|
|
self.assertEqual(os.readlink(self.base / 'current'), 'releases/second')
|
|
|
|
def test_failure_after_switching_current_restores_previous_release(self):
|
|
self.install()
|
|
before = self.snapshot()
|
|
self.install(1, success=False, fault="raise OSError('failure after current switch')", fault_after=5)
|
|
self.assertEqual(before, self.snapshot())
|
|
|
|
def test_killed_install_after_current_switch_is_recovered(self):
|
|
self.install()
|
|
before = self.snapshot()
|
|
self.install(1, success=False, fault='os._exit(91)', fault_after=5)
|
|
self.assertEqual(os.readlink(self.base / 'current'), 'releases/second')
|
|
self.run_command('recover')
|
|
self.assertEqual(before, self.snapshot())
|
|
|
|
def test_killed_fresh_install_recovers_absent_files(self):
|
|
before = self.snapshot()
|
|
self.install(success=False, fault='os._exit(91)')
|
|
self.run_command('recover')
|
|
self.assertEqual(before, self.snapshot())
|
|
|
|
def test_recovery_preserves_edits_made_after_interruption(self):
|
|
self.install()
|
|
self.install(1, success=False, fault='os._exit(91)')
|
|
(self.root / UNIT).write_text('edited after crash\n')
|
|
result = self.run_command('recover', success=False)
|
|
self.assertIn('Recovery conflict', result.stderr)
|
|
self.assertEqual((self.root / UNIT).read_text(), 'edited after crash\n')
|
|
self.assertTrue((self.base / '.transaction').exists())
|
|
|
|
def test_concurrent_install_is_rejected(self):
|
|
self.base.mkdir(parents=True)
|
|
with (self.base / '.install.lock').open('w') as lock:
|
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
result = self.install(success=False)
|
|
|
|
self.assertIn('in progress', result.stderr)
|
|
self.assertFalse((self.base / 'current').exists())
|
|
|
|
def test_restrictive_umask_does_not_make_installed_release_private(self):
|
|
result = subprocess.run([sys.executable, '-c',
|
|
f"import os, runpy; os.umask(0o077); runpy.run_path({str(HELPER)!r}, run_name='__main__')",
|
|
'install', str(self.bundles[0]), '--root', str(self.root)],
|
|
capture_output=True, text=True)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
for relative in ('usr', 'usr/local', PREFIX, PREFIX + '/releases', PREFIX + '/releases/first',
|
|
PREFIX + '/releases/first/wallpapers', 'usr/share/wayland-sessions'):
|
|
self.assertEqual((self.root / relative).stat().st_mode & 0o777, 0o755, relative)
|
|
|
|
def test_dry_run_does_not_write(self):
|
|
before = self.snapshot()
|
|
self.run_command('install', self.bundles[0], '--dry-run')
|
|
self.assertEqual(before, self.snapshot())
|
|
self.assertFalse(self.base.exists())
|
|
|
|
def test_killed_directory_creation_can_retry_with_public_permissions(self):
|
|
# Cover persistent parents, including creation before the lock/journal and
|
|
# during publication. Staging trees are unpublished and rebuilt on retry.
|
|
directories = {'usr', 'usr/local', 'usr/local/lib', PREFIX, PREFIX + '/releases',
|
|
PREFIX + '/transactions', 'usr/local/bin'}
|
|
for rule in installer.INVENTORY['files']:
|
|
if 'destination' in rule:
|
|
parent = Path(rule['destination'].lstrip('/')).parent
|
|
directories.update(str(path) for path in (parent, *parent.parents) if str(path) != '.')
|
|
for relative in sorted(directories):
|
|
with self.subTest(directory=relative), tempfile.TemporaryDirectory() as temporary:
|
|
root = Path(temporary) / 'root'
|
|
pam = root / 'etc/pam.d/login'
|
|
pam.parent.mkdir(parents=True)
|
|
pam.write_text('test PAM stack\n')
|
|
target = root / relative
|
|
code = f'''import importlib.util, os
|
|
from pathlib import Path
|
|
spec = importlib.util.spec_from_file_location('installer', {str(HELPER)!r})
|
|
m = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(m)
|
|
original = Path.mkdir
|
|
def mkdir(self, *args, **kwargs):
|
|
original(self, *args, **kwargs)
|
|
if self == Path({str(target)!r}):
|
|
os._exit(91)
|
|
Path.mkdir = mkdir
|
|
os.umask(0o077)
|
|
m.main()
|
|
'''
|
|
args = ['install', str(self.bundles[0]), '--root', str(root)]
|
|
result = subprocess.run([sys.executable, '-c', code, *args], capture_output=True, text=True)
|
|
self.assertEqual(result.returncode, 91, result.stderr)
|
|
mode = 0o700 if relative.endswith('/transactions') else 0o755
|
|
created_mode = target.stat().st_mode & 0o777
|
|
retry = f"import os, runpy; os.umask(0o077); runpy.run_path({str(HELPER)!r}, run_name='__main__')"
|
|
result = subprocess.run([sys.executable, '-c', retry, *args], capture_output=True, text=True)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertEqual(created_mode, mode)
|
|
self.assertEqual(target.stat().st_mode & 0o777, mode)
|
|
self.assertEqual(os.readlink(root / PREFIX / 'current'), 'releases/first')
|
|
self.assertFalse((root / PREFIX / '.transaction').exists())
|
|
|
|
def test_existing_directory_permissions_are_preserved(self):
|
|
for relative in ('usr', 'usr/local', 'usr/local/lib', PREFIX,
|
|
PREFIX + '/releases', 'usr/share/wayland-sessions'):
|
|
path = self.root / relative
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
path.chmod(0o750)
|
|
self.install()
|
|
for relative in ('usr', 'usr/local', 'usr/local/lib', PREFIX,
|
|
PREFIX + '/releases', 'usr/share/wayland-sessions'):
|
|
self.assertEqual((self.root / relative).stat().st_mode & 0o777, 0o750)
|
|
|
|
def test_directory_creation_restores_umask_on_success_and_failure(self):
|
|
previous = os.umask(0o077)
|
|
try:
|
|
installer.ensure_directory(self.root / 'new')
|
|
self.assertEqual(os.umask(0o077), 0o077)
|
|
with mock.patch.object(Path, 'mkdir', side_effect=OSError('mkdir failed')):
|
|
with self.assertRaises(OSError):
|
|
installer.ensure_directory(self.root / 'failed')
|
|
self.assertEqual(os.umask(0o077), 0o077)
|
|
finally:
|
|
os.umask(previous)
|
|
|
|
def test_custom_pam_is_preserved(self):
|
|
pam = self.root / 'etc/pam.d/ferese-lock'
|
|
pam.write_text('administrator authentication\n')
|
|
self.install()
|
|
self.install(1)
|
|
self.run_command('rollback')
|
|
self.assertEqual(pam.read_text(), 'administrator authentication\n')
|
|
|
|
def test_missing_pam_stack_fails_before_publication(self):
|
|
(self.root / 'etc/pam.d/login').unlink()
|
|
self.install(success=False)
|
|
self.assertFalse((self.base / 'current').exists())
|
|
self.assertFalse((self.root / UNIT).exists())
|
|
|
|
def test_symlinked_destination_parent_is_rejected(self):
|
|
outside = Path(self.temporary.name) / 'outside'
|
|
outside.mkdir()
|
|
(self.root / 'usr').symlink_to(outside)
|
|
self.install(success=False)
|
|
self.assertEqual(list(outside.iterdir()), [])
|
|
|
|
def test_uninstall_preserves_releases_pam_and_unrelated_files(self):
|
|
self.install()
|
|
unrelated = self.root / 'usr/local/bin/unrelated'
|
|
unrelated.write_text('keep\n')
|
|
self.run_command('uninstall')
|
|
self.assertFalse(os.path.lexists(self.base / 'current'))
|
|
self.assertFalse(os.path.lexists(self.root / 'usr/local/bin/ferese'))
|
|
self.assertFalse((self.root / PORTAL).exists())
|
|
self.assertTrue((self.base / 'releases/first/ferese').is_file())
|
|
self.assertTrue((self.root / 'etc/pam.d/ferese-lock').is_file())
|
|
self.assertEqual(unrelated.read_text(), 'keep\n')
|
|
|
|
def test_corrupt_incomplete_unlisted_or_symlink_bundle_is_rejected(self):
|
|
for mutation in ('checksum', 'missing', 'extra', 'symlink', 'mode', 'traversal'):
|
|
with self.subTest(mutation=mutation):
|
|
copy = Path(self.temporary.name) / mutation
|
|
shutil.copytree(self.bundles[0], copy)
|
|
binary = copy / 'ferese'
|
|
if mutation == 'checksum':
|
|
binary.write_text('corrupt')
|
|
elif mutation == 'missing':
|
|
binary.unlink()
|
|
elif mutation == 'extra':
|
|
(copy / 'unexpected').write_text('extra')
|
|
elif mutation == 'symlink':
|
|
binary.unlink()
|
|
binary.symlink_to('/etc/passwd')
|
|
elif mutation == 'mode':
|
|
binary.chmod(0o777)
|
|
else:
|
|
manifest = json.loads((copy / 'manifest.json').read_text())
|
|
manifest['files'][0]['target'] = '../../etc/passwd'
|
|
(copy / 'manifest.json').write_text(json.dumps(manifest))
|
|
|
|
self.run_command('install', copy, success=False)
|
|
self.assertFalse((self.base / 'current').exists())
|
|
|
|
def test_release_id_collision_does_not_change_current(self):
|
|
self.install()
|
|
changed = Path(self.temporary.name) / 'changed'
|
|
shutil.copytree(self.bundles[0], changed)
|
|
manifest = json.loads((changed / 'manifest.json').read_text())
|
|
manifest['features'] = 'different-build'
|
|
(changed / 'manifest.json').write_text(json.dumps(manifest))
|
|
before = self.snapshot()
|
|
self.run_command('install', changed, success=False)
|
|
self.assertEqual(before, self.snapshot())
|
|
|
|
|
|
class FrontendTest(unittest.TestCase):
|
|
def test_elevation_preserves_arguments_for_sudo_and_pkexec(self):
|
|
import shlex
|
|
for elevation in ('sudo', 'pkexec'):
|
|
with self.subTest(elevation=elevation), tempfile.TemporaryDirectory() as directory:
|
|
bin_dir = Path(directory)
|
|
for tool in ('python3', 'dirname', 'uname', 'date'):
|
|
(bin_dir / tool).symlink_to(shutil.which(tool))
|
|
elevate = bin_dir / elevation
|
|
elevate.write_text('#!/bin/sh\nexit 99\n')
|
|
elevate.chmod(0o755)
|
|
result = subprocess.run(['/bin/bash', str(REPO / 'scripts/install.sh'), '--bundle',
|
|
'/tmp/bundle with spaces', '--replace-portal-config', '--dry-run'],
|
|
env=dict(os.environ, PATH=str(bin_dir)), capture_output=True, text=True)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
command = shlex.split(result.stdout.removeprefix('Install: '))
|
|
# Building is prohibited as root, but installing an existing bundle is allowed.
|
|
expected = ([] if os.geteuid() == 0 else [elevation]) + [
|
|
'/usr/bin/bash', str(REPO / 'scripts/install-session.sh'), 'install',
|
|
'/tmp/bundle with spaces', '--replace-portal-config']
|
|
self.assertEqual(command, expected)
|
|
|
|
def test_incompatible_or_missing_options_are_rejected(self):
|
|
for options in (['--bundle'], ['--release-id'], ['--bundle-only'],
|
|
['--bundle', '/tmp/bundle', '--offline'],
|
|
['--bundle', '/tmp/bundle', '--resize-metrics'],
|
|
['--bundle', '/tmp/bundle', '--release-id', 'another'],
|
|
['--skip-build']):
|
|
with self.subTest(options=options):
|
|
result = subprocess.run(['/bin/bash', str(REPO / 'scripts/install.sh'), *options],
|
|
capture_output=True)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
|
|
|
|
class UserSetupTest(unittest.TestCase):
|
|
def test_activity_setup_detects_shell_and_preserves_later_config_edits(self):
|
|
for shell in ('fish', 'bash', 'zsh'):
|
|
with self.subTest(shell=shell), tempfile.TemporaryDirectory(prefix="ferese user's ") as directory:
|
|
home = Path(directory)
|
|
config = home / 'custom config'
|
|
startup = (config / 'fish/conf.d/ferese-activities.fish' if shell == 'fish' else
|
|
home / ('.bashrc' if shell == 'bash' else 'zdir/.zshrc'))
|
|
startup.parent.mkdir(parents=True, exist_ok=True)
|
|
if shell != 'fish':
|
|
original = '# My existing shell configuration\nexport MY_VALUE=kept\n'
|
|
real = home / 'dotfile'
|
|
real.write_text(original)
|
|
startup.symlink_to(real)
|
|
env = dict(HOME=str(home), SHELL='/usr/bin/' + shell, ZDOTDIR=str(home / 'zdir'))
|
|
with mock.patch.dict(os.environ, env), mock.patch.object(installer.subprocess, 'run') as run:
|
|
installer.setup_activities(config, ctl=Path('/test/feresectl'), assets=REPO / 'contrib/activities')
|
|
first = startup.read_text()
|
|
installer.setup_activities(config, ctl=Path('/test/feresectl'), assets=REPO / 'contrib/activities')
|
|
self.assertEqual(run.call_count, 1, 'later installs must not reset edited config')
|
|
self.assertEqual(startup.read_text(), first)
|
|
self.assertTrue((config / f'ferese/activities/ferese.{shell}').exists())
|
|
if shell != 'fish':
|
|
self.assertTrue(first.startswith(original))
|
|
self.assertTrue(startup.is_symlink())
|
|
self.assertEqual(len(list(home.glob('dotfile.before-activities-*'))), 1)
|
|
if shutil.which(shell):
|
|
check = subprocess.run([shell, '-n', str(startup)], capture_output=True)
|
|
self.assertEqual(check.returncode, 0, check.stderr)
|
|
if shell == 'bash':
|
|
check = subprocess.run(['bash', '--rcfile', str(startup), '-i', '-c',
|
|
'[[ ${preexec_functions[*]} == *_ferese_preexec* ]]'],
|
|
env=dict(os.environ, **env), capture_output=True)
|
|
self.assertEqual(check.returncode, 0, check.stderr)
|
|
|
|
def test_failed_activity_configuration_can_be_retried(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
config = Path(directory)
|
|
with mock.patch.dict(os.environ, SHELL='/usr/bin/fish'), mock.patch.object(installer.subprocess, 'run') as run:
|
|
run.side_effect = subprocess.CalledProcessError(1, 'feresectl')
|
|
with self.assertRaises(subprocess.CalledProcessError):
|
|
installer.setup_activities(config, assets=REPO / 'contrib/activities')
|
|
self.assertFalse((config / 'ferese/activities/installed.json').exists())
|
|
run.side_effect = None
|
|
installer.setup_activities(config, assets=REPO / 'contrib/activities')
|
|
self.assertTrue((config / 'ferese/activities/installed.json').exists())
|
|
|
|
def test_repeatable_migration_and_reload_after_failure(self):
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
home = Path(directory)
|
|
override = home / '.config/systemd/user/xdg-desktop-portal-ferese.service.d/override.conf'
|
|
override.parent.mkdir(parents=True)
|
|
override.write_text(f'[Service]\nExecStart=\nExecStart={home}/.local/libexec/ferese/xdg-desktop-portal-ferese\n')
|
|
bin_dir = home / 'bin'
|
|
bin_dir.mkdir()
|
|
systemctl = bin_dir / 'systemctl'
|
|
systemctl.write_text('#!/bin/sh\nexit 1\n')
|
|
systemctl.chmod(0o755)
|
|
env = dict(os.environ, HOME=str(home), XDG_CONFIG_HOME=str(home / '.config'), PATH=str(bin_dir))
|
|
# User setup intentionally refuses root; emulate the user identity in root-run CI.
|
|
command = [sys.executable, '-c', f"import runpy, os; os.geteuid = lambda: 1000; runpy.run_path({str(HELPER)!r}, run_name='__main__')", 'user-setup', '--no-activities']
|
|
result = subprocess.run(command, env=env, capture_output=True)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertFalse(override.exists())
|
|
self.assertEqual(len(list(override.parent.glob('*.before-*'))), 1)
|
|
systemctl.write_text('#!/bin/sh\nexit 0\n')
|
|
for _ in range(2):
|
|
result = subprocess.run(command, env=env, capture_output=True)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertEqual(len(list(override.parent.glob('*.before-*'))), 1)
|
|
override.write_text('custom configuration\n')
|
|
result = subprocess.run(command, env=env, capture_output=True)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertEqual(override.read_text(), 'custom configuration\n')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|