#!/usr/bin/env python3 """Offline boundaries for Ubuntu installed-runtime inventory; no host ldd/dpkg.""" import importlib.util import io import json from pathlib import Path import sys import tempfile import tarfile import unittest from unittest.mock import patch ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / 'tools')) import verify_pdfium_candidate as candidate spec = importlib.util.spec_from_file_location('ubuntu_runtime', ROOT / 'tools/collect_ubuntu_validation_runtime.py') module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) class UbuntuRuntimeTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory(prefix='docview-ubuntu-test-') self.addCleanup(self.temp.cleanup) self.root = Path(self.temp.name) self.install, self.sdk, self.deps, self.system = [self.root / x for x in ('install', 'sdk', 'dependencies', 'system')] self.doc = self.root / 'system-doc'; self.output = self.root / 'output' for p in (self.install, self.sdk, self.deps, self.system, self.doc): p.mkdir() self.query = {'QT_VERSION':'6.11.2', 'QT_INSTALL_PREFIX':str(self.sdk), **{'QT_INSTALL_' + k:str(self.sdk / v) for k,v in { 'LIBS':'lib', 'LIBEXECS':'libexec', 'QML':'qml', 'PLUGINS':'plugins', 'DATA':'.', 'TRANSLATIONS':'translations'}.items()}} for k,v in self.query.items(): if k != 'QT_VERSION': Path(v).mkdir(parents=True, exist_ok=True) for name in module.EXECUTABLES: self.write(self.install / 'bin' / name, b'\x7fELFfake') (self.install / 'bin' / name).chmod(0o755) self.write(self.install / 'lib/libpdfium.so', b'\x7fELFpdfium') self.qtpaths = self.sdk / 'bin/qtpaths'; self.write(self.qtpaths, b'fixture'); self.qtpaths.chmod(0o755) self.write(self.sdk / 'libexec/QtWebEngineProcess', b'\x7fELFhelper') (self.sdk / 'libexec/QtWebEngineProcess').chmod(0o755) for name in ('libqxcb.so', 'libqwayland-generic.so'): self.write(self.sdk / 'plugins/platforms' / name, b'\x7fELFplugin') for name in module.QML_MODULES: self.write(self.sdk / 'qml' / name / 'qmldir', b'module fixture') for name in ('qtwebengine_resources.pak', 'qtwebengine_resources_100p.pak', 'qtwebengine_resources_200p.pak', 'icudtl.dat'): self.write(self.sdk / 'resources' / name, b'fixed resources') for name in ('qtbase_ja.qm', 'qtdeclarative_ja.qm', 'qtwebengine_locales/en-US.pak', 'qtwebengine_locales/ja.pak'): self.write(self.sdk / 'translations' / name, b'translation') self.lib = self.system / 'libexample.so.1'; self.write(self.lib, b'\x7fELFlibrary') self.write(self.doc / 'example/copyright', b'Example copyright text') self.write(self.sdk / 'LICENSES/MIT.txt', b'SDK license text') self.write(self.sdk / 'sbom/qt.spdx.json', b'{"fixture":"metadata only"}') self.write(self.install / 'share/doc/docview/pdfium/LICENSE', b'PDFium license') # The runtime fixture uses a tiny, explicitly pinned synthetic provider; # candidate correspondence and tamper cases have their own tests. self.pins = self.root / 'pins' base = {'schemaVersion': 1, 'pdfiumVersion': '1', 'pdfiumUpstreamCommit': 'a' * 40, 'pdfiumLibrarySha256': module.digest(b'\x7fELFpdfium'), 'files': []} for name in ('libcxx-LICENSE.txt', 'libcxxabi-LICENSE.txt'): text = name.encode() base['files'].append({'name': name, 'sha256': module.digest(text), 'size': len(text)}) self.write(self.install / candidate.SUPPLEMENT / name, text) metadata = json.dumps(base).encode() self.write(self.pins / 'resources/licenses/pdfium-supplemental/sources.json', metadata) self.write(self.pins / 'cmake/pdfium.lock.json', json.dumps({'version': '1', 'upstreamCommit': 'a' * 40}).encode()) self.write(self.install / candidate.SUPPLEMENT / 'sources.json', metadata) pin_root = patch.object(candidate, 'ROOT', self.pins) pin_root.start(); self.addCleanup(pin_root.stop) self.unresolved = False; self.calls = [] def write(self, path, data): path.parent.mkdir(parents=True, exist_ok=True); path.write_bytes(data) def runner(self, args, env): self.calls.append(args) self.assertNotIn('LD_PRELOAD', env); self.assertNotIn('LD_AUDIT', env) if args[0] == str(self.qtpaths): return 0, '\n'.join(k+':'+v for k,v in self.query.items()), '' if args[0] == 'ldd': qpdf = ('libqpdf.so.30 => ' + str(self.qpdf_library) + ' (0x5678)\n') if hasattr(self, 'qpdf_library') else '' return 0, ('libmissing.so => not found\n' if self.unresolved else '') + 'libexample.so.1 => '+str(self.lib)+' (0x1234)\n' + qpdf, '' if args[0] == 'readelf': return 0, ' 0x1 (RUNPATH) Library runpath: [$ORIGIN/../lib]\n', '' if args[:2] == ['dpkg-query','--search']: return 0, 'example:amd64: '+args[-1]+'\n', '' if args[:2] == ['dpkg-query','--show']: return 0, 'example:amd64\t1.2-3\tamd64\n', '' self.fail('Unexpected command: '+str(args)) def collector(self, **kwargs): return module.Collector(self.install,self.qtpaths,self.deps,self.output, runner=self.runner,system_roots=(self.system,),system_doc=self.doc,**kwargs) def collect(self, **kwargs): return self.collector(**kwargs).collect({'ID':'ubuntu','VERSION_ID':'24.04','PRETTY_NAME':'Fixture Ubuntu'}) def test_valid_runtime_and_partial_notice_scope(self): result=self.collect() self.assertTrue(result['runtimeValidationSuccess']) self.assertEqual(result['systemPackages']['example:amd64']['version'],'1.2-3') self.assertEqual(len(result['installedRpaths']),3) self.assertTrue(any(x['origin']=='dpkg:example:amd64' for x in result['notices'])) self.assertTrue(any(x['category']=='sdk-sbom-metadata-not-license-text' for x in result['notices'])) self.assertFalse(result['completeChromiumNotices']);self.assertFalse(result['completeCorrespondingSources']) self.assertFalse(result['archNoticePinReused']);self.assertFalse(result['runtimeBinariesCopied']) self.assertTrue(result['noticeGaps']) for x in result['notices']: self.assertEqual(module.digest((self.output/x['copiedPath']).read_bytes()),x['sha256']) def test_missing_japanese_catalog_and_helper_fail_runtime(self): (self.sdk/'translations/qtbase_ja.qm').unlink() (self.sdk/'libexec/QtWebEngineProcess').unlink() result=self.collect();self.assertFalse(result['runtimeValidationSuccess']) self.assertEqual(len(result['runtimeFailures']),2) def test_desktop_input_and_wayland_plugins_are_inspected(self): paths = [self.sdk / 'plugins' / directory / 'fixture.so' for directory in ('platforminputcontexts', 'platformthemes', 'wayland-shell-integration', 'wayland-decoration-client', 'iconengines', 'tls', 'networkinformation')] for path in paths: self.write(path, b'\x7fELFdesktop-plugin') result = self.collect() files = {row['path'] for row in result['files']} for path in paths: self.assertIn('qt-sdk:' + str(path.relative_to(self.sdk)), files) self.assertIn(['ldd', str(path)], self.calls) def test_required_executable_is_not_silently_non_elf(self): (self.install/'bin/docview').write_bytes(b'not ELF') result=self.collect();self.assertFalse(result['runtimeValidationSuccess']) self.assertTrue(any('ELF header' in x for x in result['runtimeFailures'])) def test_nonexecutable_helper_fails_runtime(self): (self.sdk/'libexec/QtWebEngineProcess').chmod(0o644) result=self.collect();self.assertFalse(result['runtimeValidationSuccess']) self.assertTrue(any('permission missing' in x for x in result['runtimeFailures'])) def test_unresolved_ldd_is_failed_runtime(self): self.unresolved=True result=self.collect();self.assertFalse(result['runtimeValidationSuccess']) self.assertTrue(any('libmissing.so' in x for x in result['runtimeFailures'])) def test_external_ldd_dependency_rejected_before_read(self): self.lib=self.root/'private-document';self.lib.write_bytes(b'private') with self.assertRaisesRegex(ValueError,'outside allowed'):self.collect() def test_qtpaths_escaped_sdk_rejected(self): self.query['QT_INSTALL_QML']=str(self.root) with self.assertRaisesRegex(ValueError,'escaped'):self.collect() def test_qtpaths_duplicate_key_and_wrong_version_rejected(self): text='\n'.join(k+':'+v for k,v in self.query.items()) with self.assertRaisesRegex(ValueError,'Duplicate'):module.validate_query(text+'\nQT_VERSION:6.11.2',self.sdk) with self.assertRaisesRegex(ValueError,'6.11.2'):module.validate_query(text.replace('6.11.2','6.10.0'),self.sdk) def test_inside_file_symlink_preserves_target_identity(self): p=self.sdk/'qml/QtQuick/helper.so';p.symlink_to(self.sdk/'plugins/platforms/libqxcb.so') result=self.collect();row=next(x for x in result['files'] if x['path'].endswith('helper.so')) self.assertTrue(row['symlinkResolution']);self.assertEqual(row['resolvedPath'],'qt-sdk:plugins/platforms/libqxcb.so') def test_escape_file_symlink_rejected(self): p=self.root/'private';p.write_bytes(b'private') (self.sdk/'qml/QtQuick/private').symlink_to(p) with self.assertRaisesRegex(ValueError,'outside allowed'):self.collect() def test_directory_symlink_is_not_traversed(self): (self.sdk/'qml/QtQuick/loop').symlink_to(self.sdk/'qml',target_is_directory=True) with self.assertRaisesRegex(ValueError,'Directory symlink'):self.collect() def test_old_output_not_reused(self): self.output.mkdir();old=self.output/'runtime.json';old.write_text('old success') with self.assertRaises(FileExistsError):self.collect() self.assertEqual(old.read_text(),'old success') def test_output_inside_input_rejected(self): self.output=self.sdk/'new-output' with self.assertRaisesRegex(ValueError,'outside inspected'):self.collector() def test_notice_symlink_cannot_collect_unrelated_document(self): secret=self.root/'unrelated';secret.write_bytes(b'private contents') (self.sdk/'LICENSES/secret').symlink_to(secret) with self.assertRaisesRegex(ValueError,'outside allowed'):self.collect() def test_os_mismatch_does_not_create_output(self): with self.assertRaisesRegex(ValueError,'Ubuntu 24.04'): self.collector().collect({'ID':'arch','VERSION_ID':'rolling'}) self.assertFalse(self.output.exists()) def test_bounded_walk_rejects_over_count_and_special_files(self): with self.assertRaisesRegex(ValueError,'limit'):module.bounded_walk(self.sdk,(self.sdk,),1) import os os.mkfifo(self.sdk/'qml/QtQuick/fifo') with self.assertRaisesRegex(ValueError,'Special'):self.collect() def test_file_size_bound(self): with self.assertRaisesRegex(ValueError,'size limit'):module.checked_file(self.lib,(self.system,),2) def test_sdk_metadata_has_separate_finite_size_budget(self): collector = self.collector() self.output.mkdir() metadata = self.sdk / 'sbom/large.spdx' notice = self.sdk / 'LICENSES/small.txt' metadata.write_bytes(b'12345678') notice.write_bytes(b'1234') with patch.object(module, 'MAX_NOTICE_BYTES', 4), \ patch.object(module, 'MAX_SDK_METADATA_BYTES', 12), \ patch.object(module, 'MAX_SDK_METADATA_FILE_BYTES', 8): collector.notice(metadata, (self.sdk,), 'SDK', 'sdk-sbom-metadata-not-license-text') collector.notice(notice, (self.sdk,), 'SDK') self.assertEqual(collector.notice_total, 4) self.assertEqual(collector.sdk_metadata_total, 8) with self.assertRaisesRegex(ValueError, 'collection limit'): collector.notice(metadata, (self.sdk,), 'SDK', 'sdk-sbom-metadata-not-license-text') metadata.write_bytes(b'123456789') with self.assertRaisesRegex(ValueError, 'size limit'): collector.notice(metadata, (self.sdk,), 'SDK', 'sdk-sbom-metadata-not-license-text') def test_input_manifest_filters_unrelated_private_fields(self): path=self.root/'inputs.json' row={'name':'qt.7z','sha256':'a'*64,'size':123,'url':'https://example.invalid/qt.7z', 'kind':'qt','private':'do not copy'} path.write_text(json.dumps([row]));result=module.manifest_record(path) self.assertNotIn('private',result['inputs'][0]) row['name']='../private';path.write_text(json.dumps([row])) with self.assertRaisesRegex(ValueError,'identity'):module.manifest_record(path) def test_ldd_parser_rejects_unknown_output(self): paths,missing=module.parse_ldd('linux-vdso.so.1 (0x1234)\nlibx.so => not found\n/lib/ld.so (0x5678)\n') self.assertEqual(paths,[Path('/lib/ld.so')]);self.assertEqual(missing,['libx.so']) with self.assertRaisesRegex(ValueError,'Unrecognized'):module.parse_ldd('unexpected arbitrary text') def qpdf_fixture(self): # Tiny code-owned pins are injected only inside this test. Production # callers cannot replace the reviewed release/library identities. self.qpdf_root = self.root / 'qpdf-12.4.1' self.qpdf_archive = self.root / 'qpdf-12.4.1.tar.gz' self.qpdf_library = self.deps / 'lib/libqpdf.so.30.4.1' files = {'LICENSE.txt': b'fixed Apache license\n', 'NOTICE.md': b'qpdf copyright\nembedded dependency notice\n', 'libqpdf/source.cc': b'original source\n'} with tarfile.open(self.qpdf_archive, 'w:gz') as archive: for name, data in files.items(): self.write(self.qpdf_root / name, data) info = tarfile.TarInfo('qpdf-12.4.1/' + name); info.size = len(data) archive.addfile(info, io.BytesIO(data)) self.write(self.qpdf_library, b'\x7fELFfixed-qpdf') inventory = [{'path': name, 'sha256': module.digest(data), 'size': len(data)} for name, data in sorted(files.items())] pin = {'version': '12.4.1', 'archiveRoot': 'qpdf-12.4.1', 'archive': {'name': self.qpdf_archive.name, 'size': self.qpdf_archive.stat().st_size, 'sha256': module.digest(self.qpdf_archive.read_bytes())}, 'library': {'size': self.qpdf_library.stat().st_size, 'sha256': module.digest(self.qpdf_library.read_bytes())}, 'sourceFiles': len(files), 'sourceBytes': sum(len(v) for v in files.values()), 'sourceInventorySha256': module.digest(json.dumps(inventory, sort_keys=True, separators=(',', ':')).encode()), 'notices': {name: {'size': len(files[name]), 'sha256': module.digest(files[name])} for name in ('LICENSE.txt', 'NOTICE.md')}} pins = patch.object(module, 'QPDF_NOTICE_PIN', pin); pins.start(); self.addCleanup(pins.stop) self.qpdf_inputs = self.root / 'qpdf-inputs.json' self.qpdf_inputs.write_text(json.dumps([{**pin['archive'], 'kind': 'source', 'url': 'https://example.invalid/qpdf.tar.gz'}])) return pin def collect_qpdf(self): return self.collector(sources=(self.qpdf_root,), qpdf_source_archive=self.qpdf_archive).collect( {'ID': 'ubuntu', 'VERSION_ID': '24.04'}, self.qpdf_inputs) def test_qpdf_notice_full_text_and_source_runtime_correspondence(self): pin = self.qpdf_fixture(); result = self.collect_qpdf() proof = result['qpdfNoticeCorrespondence'] self.assertEqual(proof['status'], 'verified') self.assertEqual(proof['archive'], pin['archive']) self.assertEqual(proof['sourceInventorySha256'], pin['sourceInventorySha256']) self.assertEqual(proof['sourceFiles'], 3) self.assertEqual(proof['library']['sha256'], pin['library']['sha256']) notice = next(x for x in proof['notices'] if x['source'] == 'NOTICE.md') self.assertEqual((self.output / notice['copiedPath']).read_bytes(), (self.qpdf_root / 'NOTICE.md').read_bytes()) self.assertFalse(result['completeChromiumNotices']); self.assertFalse(result['completeCorrespondingSources']) def test_qpdf_runtime_requires_archive_even_when_notice_present(self): self.qpdf_fixture() with self.assertRaisesRegex(ValueError, 'fixed source archive'): self.collect(sources=(self.qpdf_root,)) def test_qpdf_modified_library_cannot_skip_known_digest_branch(self): self.qpdf_fixture(); self.qpdf_library.write_bytes(b'\x7fELFunknown-qpdf') with self.assertRaisesRegex(ValueError, 'runtime differs'): self.collect_qpdf() def test_qpdf_tampered_archive_and_self_updated_manifest_rejected(self): self.qpdf_fixture(); self.qpdf_archive.write_bytes(self.qpdf_archive.read_bytes() + b'changed') metadata = json.loads(self.qpdf_inputs.read_text()); metadata[0]['sha256'] = module.digest(self.qpdf_archive.read_bytes()) metadata[0]['size'] = self.qpdf_archive.stat().st_size; self.qpdf_inputs.write_text(json.dumps(metadata)) with self.assertRaisesRegex(ValueError, 'archive differs'): self.collect_qpdf() def test_qpdf_manifest_identity_mismatch_rejected(self): self.qpdf_fixture(); data = json.loads(self.qpdf_inputs.read_text()); data[0]['sha256'] = '0' * 64 self.qpdf_inputs.write_text(json.dumps(data)) with self.assertRaisesRegex(ValueError, 'declared input manifest'): self.collect_qpdf() def test_qpdf_missing_or_modified_notice_rejected(self): self.qpdf_fixture(); (self.qpdf_root / 'NOTICE.md').write_bytes(b'incomplete notice') with self.assertRaisesRegex(ValueError, 'source root with LICENSE and NOTICE'): self.collect_qpdf() def test_qpdf_source_code_change_rejected_even_when_notices_match(self): self.qpdf_fixture(); (self.qpdf_root / 'libqpdf/source.cc').write_bytes(b'modified source') with self.assertRaisesRegex(ValueError, 'source file differs'): self.collect_qpdf() def test_qpdf_extra_source_file_rejected(self): self.qpdf_fixture(); (self.qpdf_root / 'extra.cc').write_bytes(b'new source') with self.assertRaisesRegex(ValueError, 'source root differs'): self.collect_qpdf() def test_qpdf_symlink_notice_cannot_read_unrelated_file(self): self.qpdf_fixture(); path = self.qpdf_root / 'NOTICE.md'; private = self.root / 'private' private.write_bytes(path.read_bytes()); path.unlink(); path.symlink_to(private) with self.assertRaisesRegex(ValueError, 'outside allowed'): self.collect_qpdf() def test_qpdf_notice_mutated_after_source_verification_rejected(self): self.qpdf_fixture(); collector = self.collector(sources=(self.qpdf_root,), qpdf_source_archive=self.qpdf_archive) original = collector.source_notices def change(directory, origin, sdk=False): if directory == self.qpdf_root: (directory / 'NOTICE.md').write_bytes(b'late substitution') return original(directory, origin, sdk) with patch.object(collector, 'source_notices', change), self.assertRaisesRegex(ValueError, 'notice changed'): collector.collect({'ID': 'ubuntu', 'VERSION_ID': '24.04'}, self.qpdf_inputs) def test_generic_notice_markdown_basename_is_collected(self): source = self.root / 'other-source'; self.write(source / 'NOTICE.md', b'other bounded notice') result = self.collect(sources=(source,)) self.assertEqual(result['qpdfNoticeCorrespondence']['status'], 'not-applicable') self.assertTrue(any(x['path'] == 'source-0:NOTICE.md' for x in result['notices'])) if __name__=='__main__':unittest.main()