Files
docview/tests/test_qpdf_notice_package.py
T
2026-09-21 13:41:40 +09:00

140 lines
9.1 KiB
Python

#!/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()