Files
docview/tools/verify_pdfium_candidate.py
2026-09-21 13:41:40 +09:00

260 lines
13 KiB
Python

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