#!/usr/bin/env python3 """Verify the one reviewed Linux candidate, or the unchanged provider install. The reviewed-v2 lock is a repository input, never supplied by the package being checked. An absent lock deliberately prevents candidate configuration/packaging. """ from __future__ import annotations import argparse import json import os from pathlib import Path import re import sys from stage_pdfium_candidate import Tree, digest, json_bytes, relative, require ROOT = Path(__file__).resolve().parents[1] LOCK = 'cmake/pdfium-candidate-v2.lock.json' DOC = 'share/doc/docview/pdfium/' CANDIDATE = DOC + 'candidate/' SUPPLEMENT = DOC + 'supplemental/' MAX_METADATA = 4 * 1024 * 1024 MAX_TOTAL = 128 * 1024 * 1024 METADATA_PATHS = (CANDIDATE + 'BUILDINFO.json', SUPPLEMENT + 'sources.json') def root_path(root): return Path(root) if root is not None else ROOT def source_metadata(root=None): with Tree(root_path(root)) as tree: raw = tree.read('resources/licenses/pdfium-supplemental/sources.json', limit=65536) source = json_bytes(raw) upstream = json_bytes(tree.read('cmake/pdfium.lock.json', limit=65536)) require(source['pdfiumVersion'] == upstream['version'] and source['pdfiumUpstreamCommit'] == upstream['upstreamCommit'], 'Provider source pin differs from supplemental notices') return raw, source def reviewed_lock(root=None): with Tree(root_path(root)) as tree: try: raw = tree.read(LOCK, limit=65536) except FileNotFoundError as error: raise ValueError('The reviewed-v2 PDFium lock is not available; candidate rejected') from error lock = json_bytes(raw) hashes = ('librarySha256', 'buildInfoSha256', 'anchorRecordSha256', 'patchSha256', 'gnArgsSha256', 'supplementalSourceSha256') require(set(lock) == {'schemaVersion', 'candidateId', 'sourceRevision', 'anchorRecordPath', *hashes} and lock['schemaVersion'] == 1 and lock['candidateId'] == 'reviewed-v2', 'Unexpected reviewed PDFium candidate lock') for field in hashes: require(isinstance(lock[field], str) and re.fullmatch('[0-9a-f]{64}', lock[field]), 'Invalid candidate digest: ' + field) require(isinstance(lock['sourceRevision'], str) and re.fullmatch('[0-9a-f]{40}', lock['sourceRevision']), 'Invalid candidate revision') relative(lock['anchorRecordPath']) require(re.fullmatch(r'provenance/[A-Za-z0-9_-]+[.]json', lock['anchorRecordPath']), 'Invalid candidate anchor path') base_raw, base = source_metadata(root) require(lock['sourceRevision'] == base['pdfiumUpstreamCommit'] and lock['supplementalSourceSha256'] == digest(base_raw), 'Candidate notices do not match the reviewed source pin') return lock def buildinfo(raw, lock): require(len(raw) <= MAX_METADATA and digest(raw) == lock['buildInfoSha256'], 'Candidate BUILDINFO differs from reviewed-v2') info = json_bytes(raw) require(info['schemaVersion'] == 1 and info['candidateOnly'] is True and info['sourceRevision'] == lock['sourceRevision'] and info['librarySha256'] == lock['librarySha256'] and info['patchSha256'] == lock['patchSha256'] and info['gnArgsSha256'] == lock['gnArgsSha256'], 'Candidate BUILDINFO identity mismatch') files = info['files'] require(isinstance(files, dict) and 1 <= len(files) <= 256, 'Invalid candidate file list') total = 0 for name, row in files.items(): relative(name) require(re.fullmatch(r'[A-Za-z0-9_./+-]+', name) and name != 'BUILDINFO.json' and (name in ('lib/libpdfium.so', 'LICENSE', 'VERSION', 'args.gn') or name.startswith(('include/', 'licenses/', 'provenance/'))), 'Unexpected candidate file path') require(isinstance(row, dict) and isinstance(row.get('sha256'), str) and re.fullmatch('[0-9a-f]{64}', row['sha256']) and type(row.get('bytes')) is int and 0 <= row['bytes'] <= MAX_TOTAL, 'Invalid candidate file identity') total += row['bytes'] require(total <= MAX_TOTAL and 'include/fpdfview.h' in files and 'LICENSE' in files and 'VERSION' in files, 'Incomplete or oversized candidate prefix') for path, expected in { 'lib/libpdfium.so': lock['librarySha256'], 'args.gn': lock['gnArgsSha256'], lock['anchorRecordPath']: lock['anchorRecordSha256'], 'provenance/rendering-intent.patch': lock['patchSha256'], 'provenance/supplemental-notices.json': lock['supplementalSourceSha256']}.items(): require(files.get(path, {}).get('sha256') == expected, 'Missing candidate correspondence: ' + path) return info def installed_path(name): relative(name) if name == 'lib/libpdfium.so': return name if name == 'LICENSE' or name.startswith('licenses/'): return DOC + name return CANDIDATE + name def candidate_supplement(lock, base_raw): value = json_bytes(base_raw) value['pdfiumLibrarySha256'] = lock['librarySha256'] value['scope'] = ('Fixed-source supplemental notices for the reviewed-v2 PDFium candidate. ' 'The provider source notice record is retained unchanged in candidate/provenance. ' 'This is not human rendering approval or a complete license assessment.') value['candidateCorrespondence'] = {key: lock[key] for key in ('candidateId', 'buildInfoSha256', 'anchorRecordSha256', 'patchSha256', 'supplementalSourceSha256')} return (json.dumps(value, indent=2, sort_keys=True) + '\n').encode() def verify_rows(raw, rows, lock, installed=False): info = buildinfo(raw, lock) expected = {} for name, entry in info['files'].items(): path = installed_path(name) if installed else name expected[path] = {'sha256': entry['sha256'], 'size': entry['bytes']} expected[(CANDIDATE if installed else '') + 'BUILDINFO.json'] = { 'sha256': lock['buildInfoSha256'], 'size': len(raw)} for name, entry in expected.items(): actual = rows.get(name, {}) require(all(actual.get(field) == value for field, value in entry.items()), 'Candidate file missing or changed: ' + name) if installed: require({p for p in rows if p.startswith(CANDIDATE)} == {p for p in expected if p.startswith(CANDIDATE)}, 'Unregistered installed candidate metadata') else: require(set(rows) == set(expected), 'Unregistered candidate prefix file') return info def verify_payload(rows, contents, root=None): """Check hashed files plus bounded metadata read from an install or a deb.""" base_raw, base = source_metadata(root) library_hash = rows.get('lib/libpdfium.so', {}).get('sha256') is_candidate = any(name.startswith(CANDIDATE) for name in rows) if is_candidate: lock = reviewed_lock(root) verify_rows(contents.get(CANDIDATE + 'BUILDINFO.json', b''), rows, lock, installed=True) require(library_hash == lock['librarySha256'], 'Installed candidate library differs from reviewed-v2') expected_raw = candidate_supplement(lock, base_raw) identity = {'kind': 'reviewed-candidate', 'candidateId': lock['candidateId'], 'buildInfoSha256': lock['buildInfoSha256'], 'anchorRecordSha256': lock['anchorRecordSha256'], 'patchSha256': lock['patchSha256']} else: require(library_hash == base['pdfiumLibrarySha256'], 'Unknown PDFium library or missing candidate provenance') expected_raw = base_raw identity = {'kind': 'original-provider'} require(contents.get(SUPPLEMENT + 'sources.json') == expected_raw, 'Installed supplemental notice correspondence differs from reviewed input') require(len(base['files']) == 2 and {row['name'] for row in base['files']} == {'libcxx-LICENSE.txt', 'libcxxabi-LICENSE.txt'}, 'Unexpected supplemental notice set') for row in base['files']: item = rows.get(SUPPLEMENT + row['name'], {}) require(item.get('sha256') == row['sha256'] and item.get('size') == row['size'], 'Installed supplemental notice text differs from reviewed input') if is_candidate: candidate_item = rows.get(DOC + 'licenses/' + row['name'], {}) require(candidate_item.get('sha256') == row['sha256'] and candidate_item.get('size') == row['size'], 'Candidate notice copy differs from reviewed source') return {**identity, 'librarySha256': library_hash, 'sourceRevision': base['pdfiumUpstreamCommit'], 'supplementalManifestSha256': digest(expected_raw)} def files_beneath(path): """Finite list without following any directory or file links.""" pending, files, count = [Path(path)], [], 0 while pending: directory = pending.pop() with os.scandir(directory) as entries: for entry in entries: count += 1 require(count <= 512, 'Candidate file count exceeds limit') require(not entry.is_symlink(), 'Candidate links are not accepted') if entry.is_dir(follow_symlinks=False): pending.append(Path(entry.path)) else: require(entry.is_file(follow_symlinks=False), 'Candidate special file rejected') files.append(Path(entry.path).relative_to(path).as_posix()) return sorted(files) def verify_prefix(prefix, library, include_dir, root=None): prefix = Path(os.path.abspath(prefix)) require(Path(os.path.abspath(library)) == prefix / 'lib/libpdfium.so' and Path(os.path.abspath(include_dir)) == prefix / 'include', 'CMake PDFium library/headers differ from the selected candidate prefix') lock = reviewed_lock(root) rows = {} with Tree(prefix) as tree: raw = tree.read('BUILDINFO.json', limit=MAX_METADATA) info = buildinfo(raw, lock) names = files_beneath(prefix) require(set(names) == set(info['files']) | {'BUILDINFO.json'}, 'Unregistered candidate prefix file') for name in names: data = tree.read(name, limit=MAX_TOTAL) rows[name] = {'sha256': digest(data), 'size': len(data)} verify_rows(raw, rows, lock) base_raw, base = source_metadata(root) for row in base['files']: require(rows.get('licenses/' + row['name']) == {'sha256': row['sha256'], 'size': row['size']}, 'Candidate supplemental notice differs from fixed source') return {'candidateId': lock['candidateId'], 'librarySha256': lock['librarySha256'], 'buildInfoSha256': lock['buildInfoSha256'], 'installFiles': [{'source': name, 'destination': installed_path(name)} for name in sorted(info['files']) if name != 'lib/libpdfium.so' and name != 'LICENSE' and not name.startswith('licenses/')] + [{'source': 'BUILDINFO.json', 'destination': CANDIDATE + 'BUILDINFO.json'}]}, \ candidate_supplement(lock, base_raw) def verify_installed(prefix, root=None): prefix = Path(os.path.abspath(prefix)) rows, contents, total = {}, {}, 0 with Tree(prefix) as tree: paths = ['lib/libpdfium.so'] + [DOC + name for name in files_beneath(prefix / DOC)] for name in paths: data = tree.read(name, limit=MAX_TOTAL) total += len(data) require(total <= MAX_TOTAL, 'Installed PDFium correspondence exceeds limit') rows[name] = {'sha256': digest(data), 'size': len(data)} if name in METADATA_PATHS: require(len(data) <= MAX_METADATA, 'Installed PDFium metadata exceeds limit') contents[name] = data return verify_payload(rows, contents, root) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--prefix', type=Path, required=True) parser.add_argument('--library', type=Path, required=True) parser.add_argument('--include-dir', type=Path, required=True) parser.add_argument('--notice-output', type=Path, required=True) args = parser.parse_args() try: report, supplemental = verify_prefix(args.prefix, args.library, args.include_dir) # Only CMake's own generated metadata is written, after all inputs pass. args.notice_output.parent.mkdir(parents=True, exist_ok=True) args.notice_output.write_bytes(supplemental) print(json.dumps(report)) return 0 except (ValueError, OSError, KeyError, TypeError) as error: print('PDFium candidate rejected: ' + str(error), file=sys.stderr) return 1 if __name__ == '__main__': raise SystemExit(main())