#!/usr/bin/env python3 """qpdf notice/package boundaries with a synthetic pin; real Ubuntu tests use dpkg-deb.""" import copy import hashlib import json from pathlib import Path import shutil import subprocess import sys import tempfile from types import SimpleNamespace import unittest from unittest.mock import patch ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / 'tools')) import collect_ubuntu_validation_runtime as collector import package_ubuntu as package import verify_ubuntu_package as verifier def digest(data): return {'sha256': hashlib.sha256(data).hexdigest(), 'size': len(data)} class QpdfNoticePackageTests(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory(); self.addCleanup(self.tmp.cleanup) self.root = Path(self.tmp.name) self.library = b'synthetic reviewed qpdf runtime' self.texts = {'LICENSE.txt': b'synthetic license', 'NOTICE.md': b'synthetic notice'} self.pin = {'version': '12.4.1', 'archive': {'name': 'fixed.tar.gz', **digest(b'source archive')}, 'sourceFiles': 2, 'sourceBytes': 27, 'sourceInventorySha256': 'a' * 64, 'library': digest(self.library), 'notices': {k: digest(v) for k, v in self.texts.items()}} self.summary = {k: copy.deepcopy(self.pin[k]) for k in ('version', 'archive', 'sourceFiles', 'sourceBytes', 'sourceInventorySha256')} self.summary.update(status='verified', sourceRoot='source-0:', library={'path': 'dependencies:lib/libqpdf.so.30', **self.pin['library']}, notices=[{'source': k, **v, 'copiedPath': 'notices/' + v['sha256'] + '.txt'} for k, v in self.pin['notices'].items()]) self.payload = {'opt/docview/lib/libqpdf.so.30': self.library} for row in self.summary['notices']: self.payload['opt/docview/share/doc/docview/third-party/runtime/' + row['copiedPath']] = self.texts[row['source']] self.patch = patch.object(collector, 'QPDF_NOTICE_PIN', self.pin); self.patch.start(); self.addCleanup(self.patch.stop) def inputs(self, summary=None): summary = copy.deepcopy(self.summary if summary is None else summary) return ({name: digest(data) for name, data in self.payload.items()}, {verifier.RUNTIME_RECORD: json.dumps({'qpdfNoticeCorrespondence': summary}).encode()}, {'version': '0.1.0~validation5', 'qpdfNoticeCorrespondence': summary}) def test_correspondence_binds_runtime_and_both_notice_texts(self): self.assertEqual(verifier.verify_qpdf_payload(*self.inputs()), self.summary) def test_missing_or_changed_notice_and_library_rejected_even_with_new_payload_hashes(self): original = copy.deepcopy(self.payload) for name in original: for mode in ('missing', 'changed'): with self.subTest(name=name, mode=mode): self.payload = dict(original) if mode == 'missing': del self.payload[name] else: self.payload[name] += b'changed' with self.assertRaises(ValueError): verifier.verify_qpdf_payload(*self.inputs()) self.payload = original def test_forged_source_identity_and_notice_claims_rejected(self): for key in ('archive', 'sourceFiles', 'sourceBytes', 'sourceInventorySha256', 'version'): with self.subTest(key=key): summary = copy.deepcopy(self.summary); summary[key] = 'forged' with self.assertRaises(ValueError): verifier.verify_qpdf_payload(*self.inputs(summary)) for mutate in ('duplicate', 'escape', 'name'): summary = copy.deepcopy(self.summary) if mutate == 'duplicate': summary['notices'][1] = summary['notices'][0] elif mutate == 'escape': summary['notices'][0]['copiedPath'] = '../../NOTICE.md' else: summary['notices'][0]['source'] = 'OTHER' with self.assertRaises(ValueError): verifier.verify_qpdf_payload(*self.inputs(summary)) def test_correct_unused_alias_does_not_authorize_changed_loaded_soname(self): self.payload['opt/docview/lib/libqpdf.so.30.4.1'] = self.library summary = copy.deepcopy(self.summary) summary['library']['path'] = 'dependencies:lib/libqpdf.so.30.4.1' self.payload['opt/docview/lib/libqpdf.so.30'] = b'changed actual loader dependency' with self.assertRaisesRegex(ValueError, 'SONAME'): verifier.verify_qpdf_payload(*self.inputs(summary)) def test_runtime_record_cannot_disagree_with_summary(self): data, metadata, manifest = self.inputs(); metadata[verifier.RUNTIME_RECORD] = b'{}' with self.assertRaisesRegex(ValueError, 'differs'): verifier.verify_qpdf_payload(data, metadata, manifest) def test_all_versions_require_correspondence_for_current_verification(self): for version in ('0.1.0~validation5', '0.1.0~validation4', 'forged'): with self.subTest(version=version), self.assertRaises(ValueError): verifier.verify_qpdf_payload({}, {}, {'version': version}) def test_package_passes_archive_to_collector(self): class ReachedCollector(Exception): pass args = SimpleNamespace(version='0.1.0~validation5', output=self.root/'new-package', prefix=self.root/'install', qtpaths=self.root/'qtpaths', dependency_prefix=self.root/'deps', source_root=[], qpdf_source_archive=self.root/'fixed.tar.gz') with patch.object(package.platform, 'freedesktop_os_release', return_value={'ID':'ubuntu','VERSION_ID':'24.04'}), \ patch.object(package.subprocess, 'check_output', return_value='amd64\n'), \ patch.object(package, 'Collector', side_effect=ReachedCollector) as construct: with self.assertRaises(ReachedCollector): package.create(args) self.assertEqual(construct.call_args.kwargs['qpdf_source_archive'], args.qpdf_source_archive) args.version = '0.1.0~unreviewed' with self.assertRaisesRegex(ValueError, 'Unsupported'): package.create(args) def test_cli_accepts_fixed_new_version_and_archive(self): args = ['package_ubuntu.py', '--prefix','p','--qtpaths','q','--dependency-prefix','d', '--input-manifest','i', '--sdk-notices','n','--sdk-supplement','s','--qt-licenses','l','--output','o', '--version','0.1.0~validation5','--qpdf-source-archive','fixed.tar.gz'] with patch.object(sys, 'argv', args), patch.object(package, 'create') as create: package.main() self.assertEqual(create.call_args.args[0].qpdf_source_archive, Path('fixed.tar.gz')) self.assertEqual(create.call_args.args[0].version, '0.1.0~validation5') @unittest.skipUnless(shutil.which('dpkg-deb'), 'dpkg-deb is required') def test_real_deb_rejects_omitted_notice_with_self_consistent_manifest(self): stage = self.root/'stage'; stage.mkdir() def make(manifest_version='0.1.0~validation5', control_version='0.1.0~validation5', summary=True): for name, data in self.payload.items(): p=stage/name; p.parent.mkdir(parents=True,exist_ok=True);p.write_bytes(data);p.chmod(0o644) record=stage/'opt/docview'/verifier.RUNTIME_RECORD; record.parent.mkdir(parents=True,exist_ok=True) record.write_text(json.dumps({'qpdfNoticeCorrespondence': self.summary}));record.chmod(0o644) control=stage/'DEBIAN/control';control.parent.mkdir(exist_ok=True) control.write_text('Package: docview\nVersion: ' + control_version + '\nArchitecture: amd64\nMaintainer: Fixture\nDescription: qpdf notice boundary\n');control.chmod(0o644) rows=[{'path':p.relative_to(stage).as_posix(), **digest(p.read_bytes()), 'mode':'0o644'} for p in sorted(stage.rglob('*')) if p.is_file() and p.relative_to(stage).as_posix()!=package.MANIFEST] manifest=stage/package.MANIFEST;manifest.write_text(json.dumps({'schemaVersion':1,'package':'docview','version':manifest_version,'files':rows, **({'qpdfNoticeCorrespondence':self.summary} if summary else {})}));manifest.chmod(0o644) archive=self.root/'fixture.deb';subprocess.run(['dpkg-deb','--root-owner-group','-Znone','--build',str(stage),str(archive)],capture_output=True,check=True,timeout=30) return archive with patch.object(verifier, 'verify_pdfium_payload', return_value={'kind':'original-provider'}): self.assertEqual(verifier.verify(make())['qpdfNoticeCorrespondence'],self.summary) with self.assertRaisesRegex(ValueError, 'control identity'): verifier.verify(make(manifest_version='0.1.0~validation4')) with self.assertRaisesRegex(ValueError, 'Missing verified'): verifier.verify(make(manifest_version='0.1.0~validation4', control_version='0.1.0~validation4', summary=False)) missing=next(name for name in self.payload if digest(self.texts['NOTICE.md'])['sha256'] in name) del self.payload[missing];(stage/missing).unlink() with self.assertRaisesRegex(ValueError,'absent or changed'):verifier.verify(make()) if __name__ == '__main__': unittest.main()