278 lines
16 KiB
Python
278 lines
16 KiB
Python
#!/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()
|