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