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

264 lines
15 KiB
Python

#!/usr/bin/env python3
"""Focused offline regressions for the provenance evidence collector."""
import io
import json
from pathlib import Path
import shutil
import sys
import tarfile
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'tools'))
import record_dependency_provenance as provenance
class ProvenanceTest(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(prefix='docview-provenance-test-')
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
def package(self, version='2:1.2.3-1', build_version=None, with_build=True):
# The Arch cache collector deliberately uses Python's native zstd tar
# support (3.14+). Only these five real .pkg.tar.zst fixture cases need
# that capability; all package/notice boundaries still run on 3.11/3.12.
try:
with tarfile.open(fileobj=io.BytesIO(), mode='w:zst'):
pass
except (tarfile.CompressionError, ModuleNotFoundError):
self.skipTest('Arch .pkg.tar.zst metadata tests require Python tarfile zstd support (3.14+)')
package = ('pkgname = fixture\npkgbase = fixture\npkgver = ' + version +
'\narch = x86_64\nlicense = MIT\nlicense = BSD-3-Clause\n'
'builddate = 12345\npackager = Private Person <[email protected]>\n').encode()
build = ('format = 2\npkgname = fixture\npkgbase = fixture\npkgver = ' +
(build_version or version) + '\npkgarch = x86_64\npkgbuild_sha256sum = ' +
'a' * 64 + '\nbuildtool = devtools\nbuildtoolver = 1:1.2.3-1-any\n'
'builddate = 12345\nbuilddir = /home/private/work\n'
'packager = Private Person <[email protected]>\ninstalled = private-tool-1.0\n').encode()
path = self.root / f'fixture-{version}-x86_64.pkg.tar.zst'
with tarfile.open(path, 'w:zst') as archive:
entries = [('.PKGINFO', package)]
if with_build:
entries.insert(0, ('.BUILDINFO', build))
for name, data in entries:
info = tarfile.TarInfo(name)
info.size = len(data)
archive.addfile(info, io.BytesIO(data))
return path, package, build
def pdfium_fixture(self, supplemental=False):
"""Small physical package with the legacy 15 notices, optionally plus two."""
project, local, payload = [self.root / name for name in ('project', 'pdfium', 'payload')]
(project / 'cmake').mkdir(parents=True)
(local / 'lib').mkdir(parents=True)
(payload / 'lib').mkdir(parents=True)
lock = json.loads((ROOT / 'cmake/pdfium.lock.json').read_text())
(project / 'cmake/pdfium.lock.json').write_bytes(provenance.canonical(lock))
(local / 'VERSION').write_text(''.join(f'{key}={value}\n' for key, value in
zip(('MAJOR', 'MINOR', 'BUILD', 'PATCH'), lock['version'].split('.'))))
(local / 'args.gn').write_text(''.join(f'{key} = {json.dumps(value)}\n'
for key, value in lock['buildOptions'].items()))
library = b'fixture library whose bytes are bound to every metadata layer'
for path in (local / 'lib/libpdfium.so', payload / 'lib/libpdfium.so'):
path.write_bytes(library)
component = {'version': lock['version'], 'path': 'lib/libpdfium.so',
'sha256': provenance.digest(library), 'size': len(library),
'source': {'upstreamCommit': lock['upstreamCommit'],
'archiveSha256': lock['linuxX64Sha256']}, 'licenseTexts': []}
for index in range(15):
suffix = 'LICENSE' if index == 0 else f'licenses/dependency-{index}.txt'
data = f'Provider notice fixture {index}\n'.encode()
target = payload / 'share/doc/docview/pdfium' / suffix
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data)
original = local / suffix
original.parent.mkdir(parents=True, exist_ok=True)
original.write_bytes(data)
component['licenseTexts'].append({'path': target.relative_to(payload).as_posix(),
'size': len(data), 'sha256': provenance.digest(data)})
if supplemental:
source = project / 'resources/licenses/pdfium-supplemental'
shutil.copytree(ROOT / 'resources/licenses/pdfium-supplemental', source)
metadata = json.loads((source / 'sources.json').read_text())
metadata['pdfiumLibrarySha256'] = provenance.digest(library)
metadata_bytes = provenance.canonical(metadata)
(source / 'sources.json').write_bytes(metadata_bytes)
relative = 'share/doc/docview/pdfium/supplemental'
shutil.copytree(source, payload / relative)
rows = []
for row in metadata['files']:
path = relative + '/' + row['name']
rows.append({**row, 'path': path})
component['licenseTexts'].append({'path': path, 'sha256': row['sha256'], 'size': row['size']})
component['supplementalNotices'] = {'metadataPath': relative + '/sources.json',
'metadataSha256': provenance.digest(metadata_bytes), 'scope': metadata['scope'],
'providerRecipeCommit': metadata['providerRecipeCommit'],
'pdfiumLibrarySha256': provenance.digest(library), 'files': rows}
return payload, {'components': {'PDFium-independent-worker': component}}, local, project
def test_legacy_pdfium_fifteen_notices_without_supplemental_are_accepted(self):
result = provenance.pdfium_record(*self.pdfium_fixture())
self.assertEqual(len(result['licenseTexts']), 15)
self.assertTrue(result['localLibraryMatchesPayload'])
self.assertEqual(result['supplementalNotices']['status'], 'not-in-this-package')
self.assertTrue(any('see separate source-correspondence' in row for row in result['remaining']))
self.assertFalse(any('not collected' in row for row in result['remaining']))
def test_pdfium_supplemental_binds_original_metadata_pin_library_and_two_notices(self):
result = provenance.pdfium_record(*self.pdfium_fixture(supplemental=True))
supplements = result['supplementalNotices']
self.assertEqual(len(result['licenseTexts']), 17)
self.assertEqual(supplements['status'], 'repository-original-and-package-matched')
self.assertEqual(supplements['pdfiumLibrarySha256'], result['library']['sha256'])
self.assertEqual(supplements['metadataSha256'], supplements['repositoryMetadata']['sha256'])
self.assertEqual({row['component'] for row in supplements['files']}, {'libc++', 'libc++abi'})
self.assertTrue(all(len(row['sourceRevision']) == 40 for row in supplements['files']))
def test_supplemental_packaged_metadata_change_rejected(self):
args = self.pdfium_fixture(supplemental=True)
metadata = args[0] / 'share/doc/docview/pdfium/supplemental/sources.json'
metadata.write_bytes(metadata.read_bytes() + b'\n')
with self.assertRaisesRegex(ValueError, 'metadata differs from repository'):
provenance.pdfium_record(*args)
def test_supplemental_changed_original_notice_rejected(self):
args = self.pdfium_fixture(supplemental=True)
notice = args[3] / 'resources/licenses/pdfium-supplemental/libcxx-LICENSE.txt'
notice.write_bytes(b'changed upstream notice')
with self.assertRaisesRegex(ValueError, 'notice differs from original'):
provenance.pdfium_record(*args)
def test_supplemental_changed_packaged_notice_and_inventory_cannot_hide_mismatch(self):
args = self.pdfium_fixture(supplemental=True)
component = args[1]['components']['PDFium-independent-worker']
row = component['licenseTexts'][-1]
data = b'changed notice with a matching outer inventory hash'
(args[0] / row['path']).write_bytes(data)
row.update(size=len(data), sha256=provenance.digest(data))
with self.assertRaisesRegex(ValueError, 'notice differs from original'):
provenance.pdfium_record(*args)
def test_supplemental_library_substitution_with_matching_outer_hash_rejected(self):
args = self.pdfium_fixture(supplemental=True)
library = b'a different library with copied notice files'
for directory in (args[0], args[2]):
(directory / 'lib/libpdfium.so').write_bytes(library)
args[1]['components']['PDFium-independent-worker']['sha256'] = provenance.digest(library)
with self.assertRaisesRegex(ValueError, 'source pin or library hash mismatch'):
provenance.pdfium_record(*args)
def test_supplemental_source_pin_substitution_rejected(self):
args = self.pdfium_fixture(supplemental=True)
for path in (args[0] / 'share/doc/docview/pdfium/supplemental/sources.json',
args[3] / 'resources/licenses/pdfium-supplemental/sources.json'):
metadata = json.loads(path.read_text())
metadata['pdfiumUpstreamCommit'] = '0' * 40
path.write_bytes(provenance.canonical(metadata))
with self.assertRaisesRegex(ValueError, 'source pin or library hash mismatch'):
provenance.pdfium_record(*args)
def test_supplemental_manifest_cannot_claim_a_different_component_revision(self):
args = self.pdfium_fixture(supplemental=True)
args[1]['components']['PDFium-independent-worker']['supplementalNotices']['files'][0]['sourceRevision'] = '0' * 40
with self.assertRaisesRegex(ValueError, 'manifest metadata mismatch'):
provenance.pdfium_record(*args)
def test_supplemental_payload_without_manifest_metadata_is_not_legacy(self):
args = self.pdfium_fixture(supplemental=True)
del args[1]['components']['PDFium-independent-worker']['supplementalNotices']
with self.assertRaisesRegex(ValueError, 'lacks manifest metadata'):
provenance.pdfium_record(*args)
def test_supplemental_metadata_cannot_omit_notice_from_license_inventory(self):
args = self.pdfium_fixture(supplemental=True)
args[1]['components']['PDFium-independent-worker']['licenseTexts'].pop()
with self.assertRaisesRegex(ValueError, 'license inventory differs'):
provenance.pdfium_record(*args)
def test_supplemental_duplicate_license_row_rejected(self):
args = self.pdfium_fixture(supplemental=True)
rows = args[1]['components']['PDFium-independent-worker']['licenseTexts']
rows.append(dict(rows[-1]))
with self.assertRaisesRegex(ValueError, 'Duplicate PDFium license'):
provenance.pdfium_record(*args)
def test_supplemental_unknown_file_rejected(self):
args = self.pdfium_fixture(supplemental=True)
(args[0] / 'share/doc/docview/pdfium/supplemental/extra.txt').write_bytes(b'unknown')
with self.assertRaisesRegex(ValueError, 'payload contents'):
provenance.pdfium_record(*args)
def test_identity_matched_metadata_omits_personal_fields(self):
path, package, build = self.package()
result = provenance.cache_metadata(path, ('fixture', '2:1.2.3-1', 'x86_64', 'fixture'))
encoded = provenance.canonical(result)
for token in (b'/home/', b'private', b'packager', b'builddir', b'installed ='):
self.assertNotIn(token, encoded)
self.assertEqual(result['packageInfo']['rawSha256'], provenance.digest(package))
self.assertEqual(result['buildInfo']['rawSha256'], provenance.digest(build))
self.assertEqual(result['buildInfo']['selectedFields']['pkgbuild_sha256sum'], 'a' * 64)
self.assertEqual(result['packageInfo']['selectedFields']['license'], ['BSD-3-Clause', 'MIT'])
self.assertEqual(result['signatureVerification'], 'not-performed')
self.assertEqual(result['installedFileToCachedPayloadComparison'], 'not-performed')
self.assertIsNone(result['recipeCommit'])
def test_epoch_is_part_of_cache_identity(self):
self.package()
item = {'version': '2:1.2.3-1', 'architecture': 'x86_64',
'source': {'packageBase': 'fixture'}}
result = provenance.cache_record(self.root, 'fixture', item)
self.assertEqual(result['status'], 'identity-matched-metadata')
item['version'] = '1.2.3-1'
self.assertEqual(provenance.cache_record(self.root, 'fixture', item),
{'status': 'not-present-in-selected-cache'})
def test_build_version_mismatch_rejected(self):
path, _, _ = self.package(build_version='2:1.2.4-1')
with self.assertRaisesRegex(ValueError, 'identity'):
provenance.cache_metadata(path, ('fixture', '2:1.2.3-1', 'x86_64', 'fixture'))
def test_package_architecture_mismatch_rejected(self):
path, _, _ = self.package()
with self.assertRaisesRegex(ValueError, 'identity'):
provenance.cache_metadata(path, ('fixture', '2:1.2.3-1', 'aarch64', 'fixture'))
def test_missing_build_metadata_not_success(self):
path, _, _ = self.package(with_build=False)
with self.assertRaisesRegex(ValueError, 'lacks'):
provenance.cache_metadata(path, ('fixture', '2:1.2.3-1', 'x86_64', 'fixture'))
def test_duplicate_scalar_rejected(self):
with self.assertRaisesRegex(ValueError, 'Duplicate'):
provenance.parse_metadata(b'pkgver = 1\npkgver = 2\n', provenance.PKG_FIELDS)
def test_sensitive_selected_value_rejected(self):
with self.assertRaisesRegex(ValueError, 'path or address'):
provenance.parse_metadata(b'pkgbase = /home/private\n', provenance.PKG_FIELDS)
def test_metadata_bounds_and_nul(self):
for data in (b'a' * (provenance.MAX_METADATA + 1), b'pkgname = bad\0name\n'):
with self.assertRaises(ValueError):
provenance.parse_metadata(data, provenance.PKG_FIELDS)
def test_license_hash_mismatch_rejected(self):
(self.root / 'LICENSE').write_bytes(b'changed license')
with self.assertRaisesRegex(ValueError, 'disagrees'):
provenance.license_evidence(self.root,
{'path': 'LICENSE', 'sha256': '0' * 64, 'size': 15}, bundled=True)
def test_license_path_traversal_rejected(self):
with self.assertRaisesRegex(ValueError, 'Unsafe'):
provenance.license_evidence(self.root,
{'path': '../LICENSE', 'sha256': '0' * 64, 'size': 1}, bundled=True)
def test_canonical_bytes_ignore_dictionary_insertion_order(self):
first = {'z': 1, 'a': {'b': 3, 'a': 2}}
second = {'a': {'a': 2, 'b': 3}, 'z': 1}
self.assertEqual(provenance.canonical(first), provenance.canonical(second))
if __name__ == '__main__':
unittest.main(verbosity=2)