#!/usr/bin/env python3 """Boundaries of Ubuntu staging and preservation of launcher arguments.""" import importlib.util import json import os from pathlib import Path import shlex import shutil import subprocess import sys import tempfile import unittest ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / 'tools')) import package_ubuntu as package class UbuntuPackageTests(unittest.TestCase): def supplement_inputs(self, root): source = ROOT / 'tests/results/qt-notice-supplement/collection' directory = root / 'supplement' shutil.copytree(source, directory) base = root / 'base.json' shutil.copyfile(ROOT / 'tests/results/source-archives/qt-sdk-notices-final/report.json', base) report = json.loads((directory / 'report.json').read_text()) known = {row['resolvedPath']: row['sha256'] for row in report['runtimeComparisons']} return directory, base, report, known def test_fixed_supplement_preserves_all_six_original_files(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp); directory, base, report, known = self.supplement_inputs(root) output = root / 'output' package.copy_sdk_supplement(directory, base, known, output) self.assertEqual(len(list(p for p in output.rglob('*') if p.is_file())), 7) self.assertEqual(package.sha(output / 'report.json'), package.SDK_SUPPLEMENT_REPORT_SHA) for row in report['files']: self.assertEqual((output / row['file']).read_bytes(), (directory / row['file']).read_bytes()) def test_supplement_rejects_changed_source_report_base_runtime_and_symlink(self): for change in ('source', 'report', 'base', 'runtime', 'symlink'): with self.subTest(change=change), tempfile.TemporaryDirectory() as tmp: root = Path(tmp); directory, base, report, known = self.supplement_inputs(root) source = directory / report['files'][0]['file'] if change == 'source': source.write_bytes(b'changed') elif change == 'report': (directory / 'report.json').write_text('{}') elif change == 'base': base.write_text('{}') elif change == 'runtime': known[next(iter(known))] = '0' * 64 else: outside = root / 'outside'; shutil.copyfile(source, outside) source.unlink(); source.symlink_to(outside) output = root / 'output' with self.assertRaises(ValueError): package.copy_sdk_supplement(directory, base, known, output) self.assertFalse(output.exists()) def test_destination_traversal_and_noncanonical_paths_rejected(self): for value in ('../x', '/x', 'a/../b', '.', '', 'a//b', './x', 'x\\y', 'x\0y'): with self.subTest(value=repr(value)), self.assertRaises(ValueError): package.canonical(value) self.assertEqual(str(package.canonical('qt/qml/QtQuick/qmldir')), 'qt/qml/QtQuick/qmldir') def test_hash_mismatch_rejected_before_destination_created(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp); source = root / 'input'; source.write_bytes(b'actual') target = root / 'output' with self.assertRaisesRegex(ValueError, 'Input changed'): package.copy_verified(source, target, '0' * 64) self.assertFalse(target.exists()) def test_conflicting_alias_is_not_overwritten(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp); source = root / 'input'; source.write_bytes(b'first') target = root / 'output' package.copy_verified(source, target, executable=False) package.copy_verified(source, target, executable=False) source.write_bytes(b'different') with self.assertRaisesRegex(ValueError, 'Conflicting'): package.copy_verified(source, target) self.assertEqual(target.read_bytes(), b'first') def test_launcher_preserves_literal_arguments_and_selects_private_runtime(self): with tempfile.TemporaryDirectory(prefix='docview launcher ') as tmp: root = Path(tmp); app = root / 'app'; (app / 'bin').mkdir(parents=True) binary = app / 'bin/docview' binary.write_text('#!' + sys.executable + '\nimport os,sys,json\nprint(json.dumps({"argv":sys.argv[1:],"lib":os.environ["LD_LIBRARY_PATH"],"plugins":os.environ["QT_PLUGIN_PATH"],"qml":os.environ["QML_IMPORT_PATH"],"helper":os.environ["QTWEBENGINEPROCESS_PATH"]}))\n') binary.chmod(0o755) source = (ROOT / 'resources/linux/docview-launcher').read_text() self.assertEqual(source.count('docview_root=/opt/docview'), 1) launcher = root / 'launcher' launcher.write_text(source.replace('docview_root=/opt/docview', 'docview_root=' + shlex.quote(str(app)))) literal = ['日本語 空白.pdf', 'a$(touch unexpected)', "quote'\";", '--config', 'a b.toml'] result = json.loads(subprocess.check_output(['sh', str(launcher), *literal], text=True, env={**os.environ, 'LD_LIBRARY_PATH':'/invalid', 'QT_PLUGIN_PATH':'/invalid'})) self.assertEqual(result['argv'], literal) self.assertEqual(result['lib'], str(app / 'lib') + ':' + str(app / 'qt/lib')) self.assertEqual(result['plugins'], str(app / 'qt/plugins')) self.assertEqual(result['qml'], str(app / 'qt/qml')) self.assertEqual(result['helper'], str(app / 'qt/libexec/QtWebEngineProcess')) def test_maintainer_scripts_have_valid_shell_syntax(self): for name in ('docview-launcher', 'deb-postinst', 'deb-prerm'): subprocess.run(['sh', '-n', str(ROOT / 'resources/linux' / name)], check=True) def test_staging_modes_do_not_depend_on_builder_umask(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp); regular = root / 'notice'; executable = root / 'launcher' regular.write_text('text'); executable.write_text('text') regular.chmod(0o666); executable.chmod(0o777); root.chmod(0o777) package.normalize_modes(root) self.assertEqual(regular.stat().st_mode & 0o7777, 0o644) self.assertEqual(executable.stat().st_mode & 0o7777, 0o755) self.assertEqual(root.stat().st_mode & 0o7777, 0o755) if __name__ == '__main__': unittest.main()