#!/usr/bin/env python3 """Synthetic trust-boundary/CMake/deb tests; never adopts a real PDFium candidate.""" import json import os from pathlib import Path import shutil import subprocess import sys import tempfile 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 verifier import verify_ubuntu_package as deb from package_ubuntu import MANIFEST def write(path, data): path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(data) return {'sha256': verifier.digest(data), 'size': len(data)} def encoded(data): return (json.dumps(data, sort_keys=True, indent=2) + '\n').encode() class CandidateIntegrationTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory(prefix='docview candidate integration ') self.addCleanup(self.temp.cleanup) self.base = Path(self.temp.name) self.repo, self.prefix = self.base / 'repo', self.base / 'prefix' self.original, self.library = b'provider-library', b'reviewed-candidate-library' self.source = {'schemaVersion': 1, 'pdfiumVersion': '155.0.8057.0', 'pdfiumUpstreamCommit': 'a' * 40, 'pdfiumLibrarySha256': verifier.digest(self.original), 'scope': 'Synthetic original provider', 'files': []} for name in ('libcxx-LICENSE.txt', 'libcxxabi-LICENSE.txt'): text = ('License ' + name).encode() row = write(self.repo / 'resources/licenses/pdfium-supplemental' / name, text) self.source['files'].append({'name': name, **row}) self.source_raw = encoded(self.source) write(self.repo / 'resources/licenses/pdfium-supplemental/sources.json', self.source_raw) write(self.repo / 'cmake/pdfium.lock.json', encoded({'version': '155.0.8057.0', 'upstreamCommit': 'a' * 40})) self.files = { 'lib/libpdfium.so': self.library, 'include/fpdfview.h': b'fixed public header', 'LICENSE': b'provider license', 'VERSION': b'fixed VERSION', 'args.gn': b'pdf_enable_v8=false\n', 'provenance/master6.json': b'fixed immutable record', 'provenance/rendering-intent.patch': b'fixed v2 patch', 'provenance/supplemental-notices.json': self.source_raw} for row in self.source['files']: self.files['licenses/' + row['name']] = (self.repo / 'resources/licenses/pdfium-supplemental' / row['name']).read_bytes() self.info = {'schemaVersion': 1, 'candidateOnly': True, 'sourceRevision': 'a' * 40, 'librarySha256': verifier.digest(self.library), 'patchSha256': verifier.digest(self.files['provenance/rendering-intent.patch']), 'gnArgsSha256': verifier.digest(self.files['args.gn']), 'files': {name: {'bytes': len(data), 'sha256': verifier.digest(data)} for name, data in self.files.items()}} self.raw = encoded(self.info) self.lock = {'schemaVersion': 1, 'candidateId': 'reviewed-v2', 'sourceRevision': 'a' * 40, 'librarySha256': self.info['librarySha256'], 'buildInfoSha256': verifier.digest(self.raw), 'anchorRecordPath': 'provenance/master6.json', 'anchorRecordSha256': verifier.digest(self.files['provenance/master6.json']), 'patchSha256': self.info['patchSha256'], 'gnArgsSha256': self.info['gnArgsSha256'], 'supplementalSourceSha256': verifier.digest(self.source_raw)} write(self.repo / verifier.LOCK, encoded(self.lock)) for name, data in {**self.files, 'BUILDINFO.json': self.raw}.items(): write(self.prefix / name, data) def verify(self): return verifier.verify_prefix(self.prefix, self.prefix / 'lib/libpdfium.so', self.prefix / 'include', self.repo) def install(self, provider=False): path = self.base / ('provider-install' if provider else 'install') if provider: write(path / 'lib/libpdfium.so', self.original) write(path / verifier.DOC / 'LICENSE', b'provider license') raw = self.source_raw else: _, raw = self.verify() for name, data in {**self.files, 'BUILDINFO.json': self.raw}.items(): write(path / verifier.installed_path(name), data) write(path / verifier.SUPPLEMENT / 'sources.json', raw) for row in self.source['files']: write(path / verifier.SUPPLEMENT / row['name'], self.files['licenses/' + row['name']]) return path def test_candidate_prefix_and_installed_correspondence(self): report, supplemental = self.verify() self.assertEqual(report['candidateId'], 'reviewed-v2') self.assertEqual(json.loads(supplemental)['pdfiumLibrarySha256'], verifier.digest(self.library)) result = verifier.verify_installed(self.install(), self.repo) self.assertEqual(result['kind'], 'reviewed-candidate') self.assertEqual(result['anchorRecordSha256'], self.lock['anchorRecordSha256']) def test_absent_lock_rejects_candidate_but_preserves_original_provider(self): (self.repo / verifier.LOCK).unlink() with self.assertRaisesRegex(ValueError, 'not available'): self.verify() self.assertEqual(verifier.verify_installed(self.install(provider=True), self.repo)['kind'], 'original-provider') def test_wrong_library_header_patch_record_notice_args_and_buildinfo(self): for name in self.files.keys() | {'BUILDINFO.json'}: with self.subTest(name=name): path = self.prefix / name; original = path.read_bytes(); path.write_bytes(original + b'changed') with self.assertRaises(ValueError): self.verify() path.write_bytes(original) def test_self_consistent_forged_buildinfo_does_not_change_trust_anchor(self): info = json.loads(self.raw) replacement = b'replacement-library' row = write(self.prefix / 'lib/libpdfium.so', replacement) info['librarySha256'] = row['sha256'] info['files']['lib/libpdfium.so'] = {'sha256': row['sha256'], 'bytes': row['size']} write(self.prefix / 'BUILDINFO.json', encoded(info)) with self.assertRaisesRegex(ValueError, 'BUILDINFO'): self.verify() def test_extra_missing_link_and_special_prefix_files(self): extra = self.prefix / 'include/extra.h'; extra.write_text('unregistered') with self.assertRaisesRegex(ValueError, 'Unregistered'): self.verify() extra.unlink() header = self.prefix / 'include/fpdfview.h'; header.unlink() with self.assertRaises(ValueError): self.verify() outside = self.base / 'outside'; outside.write_bytes(self.files['include/fpdfview.h']) header.symlink_to(outside) with self.assertRaises(ValueError): self.verify() header.unlink(); os.mkfifo(header) with self.assertRaisesRegex(ValueError, 'special'): self.verify() def test_cmake_cache_cannot_select_library_or_headers_outside_prefix(self): for library, includes in ((self.base / 'old/libpdfium.so', self.prefix / 'include'), (self.prefix / 'lib/libpdfium.so', self.base / 'old/include')): with self.subTest(library=library, includes=includes), self.assertRaisesRegex(ValueError, 'selected candidate'): verifier.verify_prefix(self.prefix, library, includes, self.repo) def test_missing_or_changed_installed_provenance_and_notice_rejected(self): installed = self.install() for name in (verifier.CANDIDATE + 'BUILDINFO.json', verifier.CANDIDATE + 'provenance/master6.json', verifier.SUPPLEMENT + 'sources.json', verifier.SUPPLEMENT + 'libcxx-LICENSE.txt'): with self.subTest(name=name): path = installed / name; raw = path.read_bytes(); path.write_bytes(raw + b'changed') with self.assertRaises(ValueError): verifier.verify_installed(installed, self.repo) path.write_bytes(raw) shutil.rmtree(installed / verifier.CANDIDATE) with self.assertRaisesRegex(ValueError, 'missing candidate'): verifier.verify_installed(installed, self.repo) def test_archive_hash_inventory_cannot_rebind_candidate_or_use_provider_notice(self): installed = self.install() rows = {path.relative_to(installed).as_posix(): {'sha256': verifier.digest(path.read_bytes()), 'size': path.stat().st_size} for path in installed.rglob('*') if path.is_file()} contents = {path: (installed / path).read_bytes() for path in verifier.METADATA_PATHS} self.assertEqual(verifier.verify_payload(rows, contents, self.repo)['kind'], 'reviewed-candidate') wrong_rows = dict(rows) wrong_rows['lib/libpdfium.so'] = {'sha256': '0' * 64, 'size': 12} with self.assertRaisesRegex(ValueError, 'Candidate file'): verifier.verify_payload(wrong_rows, contents, self.repo) wrong_metadata = dict(contents) wrong_metadata[verifier.SUPPLEMENT + 'sources.json'] = self.source_raw with self.assertRaisesRegex(ValueError, 'correspondence'): verifier.verify_payload(rows, wrong_metadata, self.repo) def test_original_provider_rejects_unknown_library_and_altered_notice(self): installed = self.install(provider=True) (installed / 'lib/libpdfium.so').write_bytes(b'unknown binary') with self.assertRaisesRegex(ValueError, 'Unknown PDFium'): verifier.verify_installed(installed, self.repo) (installed / 'lib/libpdfium.so').write_bytes(self.original) (installed / verifier.SUPPLEMENT / 'libcxx-LICENSE.txt').write_bytes(b'wrong notice') with self.assertRaisesRegex(ValueError, 'notice text'): verifier.verify_installed(installed, self.repo) def cmake_project(self, provider=False): for name in ('verify_pdfium_candidate.py', 'stage_pdfium_candidate.py'): write(self.repo / 'tools' / name, (ROOT / 'tools' / name).read_bytes()) write(self.repo / 'cmake/PdfiumSupplementalNotices.cmake', (ROOT / 'cmake/PdfiumSupplementalNotices.cmake').read_bytes()) if provider: (self.prefix / 'BUILDINFO.json').unlink() (self.prefix / 'lib/libpdfium.so').write_bytes(self.original) (self.repo / verifier.LOCK).unlink() cmake = '''cmake_minimum_required(VERSION 3.24) project(candidate_fixture NONE) set(DOCVIEW_PDFIUM_ROOT "@PREFIX@") set(DOCVIEW_PDFIUM_LIBRARY "@PREFIX@/lib/libpdfium.so") set(DOCVIEW_PDFIUM_INCLUDE_DIR "@PREFIX@/include") include(cmake/PdfiumSupplementalNotices.cmake) install(FILES "${DOCVIEW_PDFIUM_LIBRARY}" DESTINATION lib) install(FILES "${DOCVIEW_PDFIUM_ROOT}/LICENSE" DESTINATION share/doc/docview/pdfium) install(DIRECTORY "${DOCVIEW_PDFIUM_ROOT}/licenses/" DESTINATION share/doc/docview/pdfium/licenses) '''.replace('@PREFIX@', str(self.prefix)) write(self.repo / 'CMakeLists.txt', cmake.encode()) build = self.base / 'cmake-build' result = subprocess.run(['cmake', '-S', str(self.repo), '-B', str(build)], capture_output=True, text=True, timeout=45) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) return build @unittest.skipUnless(shutil.which('cmake'), 'CMake is required') def test_cmake_candidate_install_and_revalidation(self): build = self.cmake_project() target = self.base / 'cmake-install' command = ['cmake', '--install', str(build), '--prefix', str(target)] result = subprocess.run(command, capture_output=True, text=True, timeout=45) self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertEqual(verifier.verify_installed(target, self.repo)['kind'], 'reviewed-candidate') (self.prefix / 'include/fpdfview.h').write_bytes(b'changed after configure') result = subprocess.run(command, capture_output=True, text=True, timeout=45) self.assertNotEqual(result.returncode, 0) self.assertIn('changed before install', result.stdout + result.stderr) @unittest.skipUnless(shutil.which('cmake'), 'CMake is required') def test_cmake_original_provider_configures_without_candidate_lock(self): self.cmake_project(provider=True) @unittest.skipUnless(shutil.which('cmake'), 'CMake is required') def test_install_rejects_changed_library_before_touching_existing_destination(self): # Keep the small executable fixture's install ordering tied to the # product: its verification must precede every product install rule. product = (ROOT / 'CMakeLists.txt').read_text() guard = product.index('include(cmake/PdfiumSupplementalNotices.cmake)') first_install = product.index('install(') self.assertLess(guard, first_install, 'Candidate guard must run before product copies') build = self.cmake_project() target = self.base / 'existing-valid-install' command = ['cmake', '--install', str(build), '--prefix', str(target)] first = subprocess.run(command, capture_output=True, text=True, timeout=45) self.assertEqual(first.returncode, 0, first.stdout + first.stderr) self.assertEqual(verifier.verify_installed(target, self.repo)['kind'], 'reviewed-candidate') def snapshot(): return {path.relative_to(target).as_posix(): (path.read_bytes(), path.stat().st_mode, path.stat().st_mtime_ns) for path in target.rglob('*') if path.is_file()} before = snapshot() source = self.prefix / 'lib/libpdfium.so' source.write_bytes(b'changed candidate after successful configure and install') # CMake may skip a same-second copy. Make the dependency observably # newer so this test catches publishing corrupt bytes before rejection. newer = (target / 'lib/libpdfium.so').stat().st_mtime_ns + 2_000_000_000 os.utime(source, ns=(newer, newer)) rejected = subprocess.run(command, capture_output=True, text=True, timeout=45) self.assertNotEqual(rejected.returncode, 0) self.assertIn('changed before install', rejected.stdout + rejected.stderr) self.assertEqual(snapshot(), before, 'Rejected candidate modified the installed payload') def make_deb(self, install, summary): stage = self.base / 'deb-stage' shutil.copytree(install, stage / 'opt/docview') write(stage / 'DEBIAN/control', b'Package: docview\nVersion: 0.1\nArchitecture: amd64\nMaintainer: Fixture\nDescription: synthetic trust test\n') for path in stage.rglob('*'): path.chmod(0o755 if path.is_dir() else 0o644) rows = [{'path': path.relative_to(stage).as_posix(), 'sha256': verifier.digest(path.read_bytes()), 'size': path.stat().st_size, 'mode': '0o644'} for path in sorted(stage.rglob('*')) if path.is_file()] manifest = {'schemaVersion': 1, 'package': 'docview', 'version': '0.1', 'files': rows, 'pdfiumCorrespondence': summary} write(stage / MANIFEST, encoded(manifest)) (stage / MANIFEST).chmod(0o644) archive = self.base / 'fixture.deb' subprocess.run(['dpkg-deb', '--root-owner-group', '-Znone', '--build', str(stage), str(archive)], capture_output=True, check=True, timeout=30) return archive, stage, manifest @unittest.skipUnless(shutil.which('dpkg-deb'), 'dpkg-deb is required') def test_deb_verifies_candidate_and_rejects_forged_self_consistent_manifest(self): installed = self.install() summary = verifier.verify_installed(installed, self.repo) archive, stage, manifest = self.make_deb(installed, summary) with patch.object(verifier, 'ROOT', self.repo), patch.object(deb, 'verify_qpdf_payload', return_value={'status': 'synthetic-qpdf-out-of-scope'}): self.assertEqual(deb.verify(archive)['pdfiumCorrespondence'], summary) path = stage / 'opt/docview/lib/libpdfium.so' path.write_bytes(b'changed packaged library') row = next(row for row in manifest['files'] if row['path'] == 'opt/docview/lib/libpdfium.so') row.update(sha256=verifier.digest(path.read_bytes()), size=path.stat().st_size) write(stage / MANIFEST, encoded(manifest)) subprocess.run(['dpkg-deb', '--root-owner-group', '-Znone', '--build', str(stage), str(archive)], capture_output=True, check=True, timeout=30) with self.assertRaisesRegex(ValueError, 'Candidate file'): deb.verify(archive) if __name__ == '__main__': unittest.main()