initial commit

This commit is contained in:
2026-09-21 13:41:40 +09:00
commit 855c7328df
411 changed files with 85352 additions and 0 deletions
+383
View File
@@ -0,0 +1,383 @@
#!/usr/bin/env python3
"""Portable packaging-boundary tests; no Qt, GUI or production sandbox bypass."""
import io
import json
import os
from pathlib import Path
import runpy
import shutil
import subprocess
import sys
import tarfile
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'tools'))
from collect_linux_dependencies import (ldd_paths, license_candidates, license_supplements,
inventory, PackageDatabase, pacman_fields,
pdfium_supplemental_notices, qt_embedded_notices,
qt_runtime_files, sha256)
from package_linux_development import (MANIFEST, PACKAGE_NAME, copy_install, describe_payload,
payload_files, verify_and_extract, write_archive)
class LinuxPackageTest(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(prefix='docview-package-test-')
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.payload = self.root / 'payload'
(self.payload / 'bin').mkdir(parents=True)
(self.payload / 'bin/docview').write_bytes(b'fixture executable\n')
(self.payload / 'bin/docview').chmod(0o755)
(self.payload / 'share/doc/docview').mkdir(parents=True)
(self.payload / 'share/doc/docview/NOTICE.txt').write_text('Synthetic test material.\n')
self.manifest()
def manifest(self):
(self.payload / MANIFEST).write_text(json.dumps(describe_payload(self.payload, 0), sort_keys=True))
def archive(self, name='valid.tar.gz'):
path = self.root / name
write_archive(self.payload, path, 0)
return path
def test_failed_smoke_removes_previous_success_aggregates(self):
archive = self.archive()
invalid_archive = self.root / 'invalid.tar.gz'
invalid_archive.write_bytes(b'not a tar archive')
cases = [
('direct-missing', 'smoke.py', '--binary', self.root / 'missing', FileNotFoundError),
('direct-launch', 'smoke.py', '--binary', self.payload / 'bin/docview', subprocess.CalledProcessError),
('package-missing', 'smoke_linux_package.py', '--archive', self.root / 'missing', FileNotFoundError),
('package-invalid', 'smoke_linux_package.py', '--archive', invalid_archive, tarfile.ReadError),
('package-launch', 'smoke_linux_package.py', '--archive', archive, subprocess.CalledProcessError),
('package-sandbox', 'smoke_linux_package.py', '--archive', archive, SystemExit),
]
for name, script, option, source, exception in cases:
with self.subTest(name=name):
output = self.root / name
output.mkdir()
for aggregate in ('results.json', 'package-smoke.json'):
(output / aggregate).write_text('{"success":true}')
screenshot = output / 'previous-image.png'
screenshot.write_bytes(b'previous image is not success evidence')
arguments = [str(ROOT / 'tests' / script), option, str(source), '--output', str(output)]
environment = {'QTWEBENGINE_DISABLE_SANDBOX': '1' if name == 'package-sandbox' else '0',
'QTWEBENGINE_CHROMIUM_FLAGS': ''}
with patch.object(sys, 'argv', arguments), patch.dict(os.environ, environment), \
patch('subprocess.run', side_effect=subprocess.CalledProcessError(7, 'synthetic-failed-launch')) as launch:
with self.assertRaises(exception):
runpy.run_path(arguments[0], run_name='__main__')
self.assertEqual(launch.call_count, 1 if name.endswith('-launch') else 0)
self.assertFalse((output / 'results.json').exists())
self.assertFalse((output / 'package-smoke.json').exists())
self.assertEqual(screenshot.read_bytes(), b'previous image is not success evidence')
def test_reproducible_bytes_ignore_mtime_and_creation_order(self):
first = self.archive()
for path in payload_files(self.payload):
os.utime(path, (123456, 654321))
second = self.archive('second.tar.gz')
self.assertEqual(sha256(first), sha256(second))
relocated = verify_and_extract(first, self.root / 'relocated')
third = self.root / 'third.tar.gz'
write_archive(relocated, third, 0)
self.assertEqual(sha256(first), sha256(third))
self.assertEqual((relocated / 'bin/docview').stat().st_mode & 0o777, 0o755)
def test_modified_payload_rejected_against_manifest(self):
(self.payload / 'bin/docview').write_bytes(b'modified executable\n')
with self.assertRaisesRegex(ValueError, 'integrity'):
verify_and_extract(self.archive(), self.root / 'extract')
def test_unlisted_file_rejected(self):
(self.payload / 'unexpected').write_text('not listed')
with self.assertRaisesRegex(ValueError, 'entries differ'):
verify_and_extract(self.archive(), self.root / 'extract')
def test_duplicate_manifest_entry_rejected(self):
path = self.payload / MANIFEST
value = json.loads(path.read_text())
value['files'].append(value['files'][0])
path.write_text(json.dumps(value))
with self.assertRaisesRegex(ValueError, 'manifest file entry'):
verify_and_extract(self.archive(), self.root / 'extract')
def test_archive_path_link_and_duplicate_rejected(self):
for kind in ('traversal', 'symlink', 'duplicate'):
with self.subTest(kind=kind):
archive = self.root / (kind + '.tar.gz')
with tarfile.open(archive, 'w:gz') as tar:
info = tarfile.TarInfo(PACKAGE_NAME + ('/../escape' if kind == 'traversal' else '/entry'))
info.mode = 0o644
if kind == 'symlink':
info.type = tarfile.SYMTYPE
info.linkname = '/tmp'
tar.addfile(info, io.BytesIO())
if kind == 'duplicate':
tar.addfile(info, io.BytesIO())
with self.assertRaisesRegex(ValueError, 'Unsafe or duplicate'):
verify_and_extract(archive, self.root / (kind + '-out'))
def test_staging_rejects_symlink_and_hardlink(self):
target = self.payload / 'bin/docview'
link = self.payload / 'alias'
link.symlink_to(target)
with self.assertRaisesRegex(ValueError, 'link or special'):
payload_files(self.payload)
link.unlink()
os.link(target, link)
with self.assertRaisesRegex(ValueError, 'multiply-linked'):
payload_files(self.payload)
def test_install_whitelist_and_missing_binary(self):
with self.assertRaisesRegex(ValueError, 'missing'):
copy_install(self.payload, self.root / 'copy')
for name in ('docview-pdf-worker', 'docview-archive-worker'):
(self.payload / 'bin' / name).write_bytes(b'test worker')
(self.payload / 'lib').mkdir()
(self.payload / 'lib/libpdfium.so').write_bytes(b'test library')
(self.payload / 'user-state.json').write_text('must not be packaged')
with self.assertRaisesRegex(ValueError, 'Unexpected file'):
copy_install(self.payload, self.root / 'copy')
def test_ldd_missing_and_unknown_output_fail_closed(self):
output = 'linux-vdso.so.1 (0x123)\nlibx.so => /usr/lib/libx.so (0x456)\n/lib64/ld.so (0x789)\n'
self.assertEqual(ldd_paths(output), {Path('/usr/lib/libx.so'), Path('/lib64/ld.so')})
for invalid in ('libx.so => not found\n', 'unexpected output\n'):
with self.assertRaises(ValueError):
ldd_paths(invalid)
def test_package_license_labels_preserved_and_shared_text_collected(self):
desc = pacman_fields('%NAME%\nexample\n\n%LICENSE%\nLGPL-2.1-only\nMIT OR BSD-3-Clause\n')
self.assertEqual(desc['LICENSE'], ['LGPL-2.1-only', 'MIT OR BSD-3-Clause'])
licenses = self.root / 'licenses'
(licenses / 'spdx').mkdir(parents=True)
(licenses / 'spdx/LGPL-2.1-only.txt').write_text('shared license fixture')
(licenses / 'example').mkdir()
(licenses / 'example/COPYING').write_text('specific copyright fixture')
self.assertEqual(len(license_candidates('example', desc, licenses)), 2)
def qt_fixture(self):
query = {'QT_VERSION': '6.11.2'}
for name in ('LIBEXECS', 'QML', 'PLUGINS', 'DATA', 'TRANSLATIONS'):
path = self.root / 'qt' / name.lower()
path.mkdir(parents=True, exist_ok=True)
query['QT_INSTALL_' + name] = str(path)
(Path(query['QT_INSTALL_LIBEXECS']) / 'QtWebEngineProcess').write_bytes(b'fixture helper')
for name in ('qtbase_ja.qm', 'qtdeclarative_ja.qm'):
(Path(query['QT_INSTALL_TRANSLATIONS']) / name).write_bytes(b'fixture ' + name.encode())
return query
def test_japanese_qt_catalogs_required_independently(self):
query = self.qt_fixture()
directory = Path(query['QT_INSTALL_TRANSLATIONS'])
required = {directory / name for name in ('qtbase_ja.qm', 'qtdeclarative_ja.qm')}
self.assertTrue(required <= qt_runtime_files(query))
# Unrelated languages or a merged deployment catalog must not hide a
# missing required catalog in this Arch system-runtime package.
(directory / 'qt_ja.qm').write_bytes(b'fixture merged catalog')
(directory / 'qtbase_en.qm').write_bytes(b'fixture other language')
for path in sorted(required):
with self.subTest(catalog=path.name):
data = path.read_bytes()
path.unlink()
with self.assertRaisesRegex(ValueError, 'Required Japanese Qt translation is missing: ' + path.name):
qt_runtime_files(query)
path.write_bytes(data)
def test_translation_owner_license_and_source_are_in_inventory(self):
query = self.qt_fixture()
database_root = self.root / 'pacman'
database_root.mkdir()
catalogs = [Path(query['QT_INSTALL_TRANSLATIONS']) / name
for name in ('qtbase_ja.qm', 'qtdeclarative_ja.qm')]
helper = Path(query['QT_INSTALL_LIBEXECS']) / 'QtWebEngineProcess'
for name in ('qt6-base', 'qt6-declarative', 'qt6-webengine', 'qt6-translations', 'tomlplusplus', 'glibc'):
directory = database_root / name
directory.mkdir()
owned = catalogs if name == 'qt6-translations' else [helper] if name == 'qt6-webengine' else []
directory.joinpath('desc').write_text(
f'%NAME%\n{name}\n\n%BASE%\n{name}\n\n%VERSION%\n6.11.2-1\n\n'
'%ARCH%\nany\n\n%URL%\nhttps://example.invalid/qt\n\n%LICENSE%\nLGPL-3.0-only\n')
directory.joinpath('files').write_text('%FILES%\n' + ''.join(str(path)[1:] + '\n' for path in owned))
database = PackageDatabase(database_root)
self.assertEqual([database.owner(path) for path in catalogs], ['qt6-translations'] * 2)
project = self.root / 'project'
for directory in (project / '.deps/pdfium/lib', project / 'cmake', self.payload / 'lib',
self.payload / 'share/doc/docview/pdfium'):
directory.mkdir(parents=True, exist_ok=True)
for name in ('docview-pdf-worker', 'docview-archive-worker'):
(self.payload / 'bin' / name).write_bytes(b'fixture worker')
for path in (project / '.deps/pdfium/lib/libpdfium.so', self.payload / 'lib/libpdfium.so'):
path.write_bytes(b'fixture PDFium')
(self.payload / 'share/doc/docview/pdfium/LICENSE').write_text('fixture PDFium notice')
lock = {name: 'fixture' for name in ('version', 'upstream', 'upstreamCommit', 'binaryProvider',
'linuxX64Archive', 'linuxX64Sha256', 'buildOptions')}
(project / 'cmake/pdfium.lock.json').write_text(json.dumps(lock))
notice = self.root / 'LGPL-3.0-only.txt'
notice.write_text('synthetic translation license notice')
notice_source = {'url': 'https://example.invalid/qttranslations/COPYING',
'version': '6.11.2', 'downloadSha256': sha256(notice)}
def supplements(name, version):
return [(notice, notice_source)] if name == 'qt6-translations' else []
def command(args):
return '\n'.join(key + ':' + value for key, value in query.items()) if args[-1] == '--query' else ''
output = self.root / 'inventory'
embedded_metadata = ROOT / 'resources/licenses/qt-embedded-notices/sources.json'
with patch('collect_linux_dependencies.ROOT', project), \
patch('collect_linux_dependencies.PackageDatabase', return_value=database), \
patch('collect_linux_dependencies.run', side_effect=command), \
patch('collect_linux_dependencies.license_candidates', return_value=[]), \
patch('collect_linux_dependencies.license_supplements', side_effect=supplements), \
patch('collect_linux_dependencies.qt_embedded_notices',
return_value=(embedded_metadata, json.loads(embedded_metadata.read_text()))), \
patch('collect_linux_dependencies.pdfium_supplemental_notices', return_value={}), \
patch('collect_linux_dependencies.sys.platform', 'linux'), \
patch('collect_linux_dependencies.platform.machine', return_value='x86_64'), \
patch('collect_linux_dependencies.platform.freedesktop_os_release', return_value={'ID': 'arch'}):
result = inventory(self.payload, output)
component = result['components']['qt6-translations']
self.assertEqual(component['distribution'], 'system-not-bundled')
self.assertEqual(component['version'], '6.11.2-1')
self.assertEqual(component['architecture'], 'any')
self.assertEqual(component['licenseLabelsFromPackage'], ['LGPL-3.0-only'])
self.assertEqual(component['source']['packageBase'], 'qt6-translations')
self.assertEqual(component['source']['recipeRepository'],
'https://gitlab.archlinux.org/archlinux/packaging/packages/qt6-translations')
self.assertEqual(component['source']['status'], 'not-collected')
self.assertEqual(component['noticeStatus'], 'collected-package-or-upstream-texts-not-a-compliance-verdict')
self.assertEqual(len(component['licenseTexts']), 1)
collected = component['licenseTexts'][0]
self.assertEqual((output / collected['path']).read_bytes(), notice.read_bytes())
self.assertEqual(collected['sha256'], sha256(notice))
rows = [row for row in result['systemFiles'] if row['package'] == 'qt6-translations']
self.assertEqual({row['path'] for row in rows}, {str(path) for path in catalogs})
for row in rows:
self.assertEqual(row['sha256'], sha256(Path(row['path'])))
self.assertEqual(row['size'], Path(row['path']).stat().st_size)
self.assertIn('qt6-translations 6.11.2-1 [system-not-bundled]', (output / 'NOTICE.txt').read_text())
self.assertFalse(any(path.suffix == '.qm' for path in output.rglob('*')))
embedded = result['components']['qt6-webengine']['embeddedResourceNotices']
self.assertEqual(embedded['noticeCount'], 7)
self.assertEqual(embedded['sourceResourceCount'], 13)
self.assertIs(embedded['completeChromiumNotices'], False)
self.assertEqual(embedded['runtimeDistribution'], 'system-not-bundled')
self.assertEqual(sha256(output / embedded['metadataPath']), sha256(embedded_metadata))
for row in result['components']['qt6-webengine']['licenseTexts']:
self.assertEqual(sha256(output / row['path']), row['sha256'])
def qt_notice_fixture(self):
destination = self.root / 'qt-notices'
shutil.copytree(ROOT / 'resources/licenses/qt-embedded-notices', destination)
metadata = json.loads((destination / 'sources.json').read_text())
return destination, metadata, [dict(row) for row in metadata['dataPacks']]
def test_qt_embedded_notices_bind_exact_target_and_seven_texts(self):
base, metadata, inventory_rows = self.qt_notice_fixture()
source, result = qt_embedded_notices('6.11.2', '6.11.2-1', inventory_rows, base)
self.assertEqual(source, base / 'sources.json')
self.assertEqual(result, metadata)
self.assertEqual(len(result['files']), 7)
self.assertEqual(sum(len(row['sourceResources']) for row in result['files']), 13)
self.assertIs(result['completeChromiumNotices'], False)
def test_qt_embedded_notices_reject_different_versions(self):
base, _, inventory_rows = self.qt_notice_fixture()
for qt, package in [('6.11.3', '6.11.2-1'), ('6.11.2', '6.11.2-2')]:
with self.subTest(qt=qt, package=package), self.assertRaisesRegex(ValueError, 'target version'):
qt_embedded_notices(qt, package, inventory_rows, base)
def test_qt_embedded_notices_reject_missing_duplicate_or_other_datapacks(self):
base, _, rows = self.qt_notice_fixture()
cases = [rows[1:], rows + [rows[0]]]
for key, wrong in [('path', '/usr/share/qt6/resources/other.pak'), ('package', 'other-package'),
('sha256', '0' * 64), ('size', rows[0]['size'] + 1)]:
cases.append([{**rows[0], key: wrong}, rows[1]])
for changed in cases:
with self.subTest(rows=changed), self.assertRaisesRegex(ValueError, 'DataPack inventory mismatch'):
qt_embedded_notices('6.11.2', '6.11.2-1', changed, base)
def test_qt_embedded_notices_reject_missing_or_modified_text(self):
base, metadata, rows = self.qt_notice_fixture()
path = base / metadata['files'][0]['name']
original = path.read_bytes()
path.unlink()
with self.assertRaisesRegex(ValueError, 'text missing'):
qt_embedded_notices('6.11.2', '6.11.2-1', rows, base)
for data in (b'X' + original[1:], original + b'\n'):
path.write_bytes(data)
with self.assertRaisesRegex(ValueError, 'digest/size mismatch'):
qt_embedded_notices('6.11.2', '6.11.2-1', rows, base)
def test_qt_embedded_notices_reject_unreviewed_metadata_changes(self):
base, metadata, rows = self.qt_notice_fixture()
path = base / 'sources.json'
for changed in ({**metadata, 'completeChromiumNotices': True},
{**metadata, 'files': metadata['files'][:-1]},
{**metadata, 'files': metadata['files'] + [metadata['files'][0]]},
{**metadata, 'files': [{**metadata['files'][0], 'name': '../outside'}] + metadata['files'][1:]},
{**metadata, 'dataPacks': [{**rows[0], 'sha256': '0' * 64}, rows[1]]}):
path.write_text(json.dumps(changed))
with self.assertRaisesRegex(ValueError, 'metadata changed'):
qt_embedded_notices('6.11.2', '6.11.2-1', rows, base)
path.unlink()
with self.assertRaisesRegex(ValueError, 'metadata missing'):
qt_embedded_notices('6.11.2', '6.11.2-1', rows, base)
path.write_bytes(b'x' * 65537)
with self.assertRaisesRegex(ValueError, 'oversized'):
qt_embedded_notices('6.11.2', '6.11.2-1', rows, base)
def test_upstream_license_supplement_requires_version_and_hash(self):
root = self.root / 'supplement'
(root / 'example').mkdir(parents=True)
path = root / 'example/COPYING'
path.write_text('upstream text fixture')
(root / 'sources.json').write_text(json.dumps([{'package': 'example', 'version': '1.2.3',
'files': {'COPYING': sha256(path)}}]))
self.assertEqual(len(license_supplements('example', '2:1.2.3-4', root)), 1)
with self.assertRaisesRegex(ValueError, 'version needs review'):
license_supplements('example', '1.2.4-1', root)
path.write_text('altered')
with self.assertRaisesRegex(ValueError, 'digest mismatch'):
license_supplements('example', '1.2.3-4', root)
def test_pdfium_notices_are_bound_to_binary_source_and_exact_files(self):
folder = self.payload / 'share/doc/docview/pdfium/supplemental'
folder.mkdir(parents=True)
(self.payload / 'lib').mkdir()
binary = self.payload / 'lib/libpdfium.so'
binary.write_bytes(b'synthetic PDFium')
lock = {'version': '1.2.3.4', 'upstreamCommit': 'a' * 40}
rows = []
for name in ['libcxx-LICENSE.txt', 'libcxxabi-LICENSE.txt']:
path = folder / name
path.write_bytes(b'synthetic notice\n')
rows.append({'name': name, 'sha256': sha256(path), 'size': path.stat().st_size})
metadata = {'schemaVersion': 1, 'pdfiumVersion': lock['version'],
'pdfiumUpstreamCommit': lock['upstreamCommit'], 'pdfiumLibrarySha256': sha256(binary),
'files': rows, 'scope': 'fixture', 'providerRecipeCommit': 'b' * 40}
manifest = folder / 'sources.json'
manifest.write_text(json.dumps(metadata))
self.assertEqual(len(pdfium_supplemental_notices(self.payload, lock)['files']), 2)
for key, wrong in [('version', '9.9.9.9'), ('upstreamCommit', 'c' * 40)]:
with self.subTest(key=key), self.assertRaisesRegex(ValueError, 'needs review'):
pdfium_supplemental_notices(self.payload, {**lock, key: wrong})
binary.write_bytes(b'different PDFium')
with self.assertRaisesRegex(ValueError, 'needs review'):
pdfium_supplemental_notices(self.payload, lock)
binary.write_bytes(b'synthetic PDFium')
(folder / rows[0]['name']).write_bytes(b'changed notice')
with self.assertRaisesRegex(ValueError, 'digest or size mismatch'):
pdfium_supplemental_notices(self.payload, lock)
for replacement in [[rows[0]], [rows[0], rows[0]], [{**rows[0], 'name': '../outside'}, rows[1]]]:
manifest.write_text(json.dumps({**metadata, 'files': replacement}))
with self.subTest(files=replacement), self.assertRaisesRegex(ValueError, 'notice set'):
pdfium_supplemental_notices(self.payload, lock)
if __name__ == '__main__':
unittest.main()