initial commit

This commit is contained in:
2026-09-21 13:41:40 +09:00
commit 855c7328df
411 changed files with 85352 additions and 0 deletions
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""Read a local DocView deb and verify every data/control payload before installation."""
import argparse
import hashlib
import json
import re
from pathlib import Path
import subprocess
import tarfile
import threading
from package_ubuntu import MANIFEST, canonical, sha
from verify_pdfium_candidate import METADATA_PATHS as PDFIUM_METADATA_PATHS, MAX_METADATA, verify_payload as verify_pdfium_payload
RUNTIME_RECORD = 'share/doc/docview/third-party/runtime/runtime.json'
METADATA_PATHS = (*PDFIUM_METADATA_PATHS, RUNTIME_RECORD)
def inspect(archive, flag):
rows, manifest, total, metadata = {}, None, 0, {}
process = subprocess.Popen(['dpkg-deb', flag, str(archive)], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
timer = threading.Timer(180, process.kill); timer.start()
try:
with tarfile.open(fileobj=process.stdout, mode='r|') as tar:
for member in tar:
if member.name == '.' and member.isdir(): continue
name = str(canonical(member.name.removeprefix('./').rstrip('/')))
if member.uid or member.gid:
raise ValueError('Non-root archive ownership')
if member.isdir(): continue
if name in rows or len(rows) >= 10000 or not member.isfile() or member.mode not in (0o644, 0o755):
raise ValueError('Unsafe or duplicate deb payload entry: ' + name + ' mode=' + oct(member.mode))
total += member.size
if member.size > 512 * 1024**2 or total > 2 * 1024**3:
raise ValueError('Deb payload exceeds inspection limits')
digest = hashlib.sha256(); chunks = []
with tar.extractfile(member) as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b''):
digest.update(chunk)
if name == MANIFEST or name.removeprefix('opt/docview/') in METADATA_PATHS or (flag == '--ctrl-tarfile' and name == 'control'):
maximum = 8 * 1024**2 if name == MANIFEST else (65536 if name == 'control' else MAX_METADATA)
if member.size > maximum: raise ValueError('Oversized manifest or PDFium correspondence')
chunks.append(chunk)
rows[name] = {'path': name, 'size': member.size, 'sha256': digest.hexdigest(), 'mode': oct(member.mode)}
if name == MANIFEST:
manifest = json.loads(b''.join(chunks))
elif flag == '--ctrl-tarfile' and name == 'control':
metadata['control'] = b''.join(chunks)
elif name.removeprefix('opt/docview/') in METADATA_PATHS:
metadata[name.removeprefix('opt/docview/')] = b''.join(chunks)
if process.wait(timeout=15):
raise ValueError('dpkg-deb failed reading package')
finally:
timer.cancel()
if process.poll() is None: process.kill(); process.wait()
process.stdout.close()
process.stderr.close()
return rows, manifest, metadata
def verify_qpdf_payload(data, metadata, manifest):
"""Bind qpdf notice claims to fixed source identities and actual bytes.
Earlier deficient archives remain historical evidence. Raw inventory can
compare them, but this current verifier does not certify their omission.
"""
summary = manifest.get('qpdfNoticeCorrespondence')
from collect_ubuntu_validation_runtime import QPDF_NOTICE_PIN as pin
if not isinstance(summary, dict) or summary.get('status') != 'verified':
raise ValueError('Missing verified qpdf notice correspondence')
try:
runtime = json.loads(metadata[RUNTIME_RECORD])
except (KeyError, ValueError, TypeError) as error:
raise ValueError('Missing or invalid packaged runtime record') from error
if runtime.get('qpdfNoticeCorrespondence') != summary:
raise ValueError('qpdf correspondence differs between manifest and runtime record')
for key in ('version', 'archive', 'sourceFiles', 'sourceBytes', 'sourceInventorySha256'):
if summary.get(key) != pin[key]:
raise ValueError('qpdf fixed source correspondence differs: ' + key)
source_root = summary.get('sourceRoot')
if not isinstance(source_root, str) or not re.fullmatch(r'source-[0-9]{1,3}:', source_root):
raise ValueError('Invalid qpdf source root identity')
library = summary.get('library')
if not isinstance(library, dict):
raise ValueError('Missing qpdf library identity')
path = library.get('path', '')
if not isinstance(path, str) or not re.fullmatch(r'dependencies:lib/libqpdf[.]so(?:[.][0-9]+)*', path):
raise ValueError('Invalid qpdf runtime path')
library_name = 'opt/docview/' + path.split(':', 1)[1]
def matches(row, expected):
return isinstance(row, dict) and all(row.get(key) == expected[key] for key in ('sha256', 'size'))
if not matches(library, pin['library']) or not matches(data.get(library_name), pin['library']):
raise ValueError('Packaged qpdf library differs from the reviewed runtime')
# The inspected executables load SONAME libqpdf.so.30. A correct, unused
# alias cannot authorize a different library at that actual loader path.
if not matches(data.get('opt/docview/lib/libqpdf.so.30'), pin['library']):
raise ValueError('Packaged qpdf SONAME library differs from the reviewed runtime')
for name, row in data.items():
if re.fullmatch(r'opt/docview/lib/libqpdf[.]so(?:[.][0-9]+)*', name) and not matches(row, pin['library']):
raise ValueError('Conflicting packaged qpdf runtime alias')
notices = summary.get('notices')
if not isinstance(notices, list) or len(notices) != len(pin['notices']):
raise ValueError('Missing qpdf source notices')
by_name = {row.get('source'): row for row in notices if isinstance(row, dict)}
if len(by_name) != len(notices):
raise ValueError('Invalid or duplicate qpdf source notice')
for source, expected in pin['notices'].items():
row = by_name.get(source)
expected_path = 'notices/' + expected['sha256'] + '.txt'
if not matches(row, expected) or row.get('copiedPath') != expected_path:
raise ValueError('qpdf source notice correspondence differs')
name = 'opt/docview/share/doc/docview/third-party/runtime/' + expected_path
if not matches(data.get(name), expected):
raise ValueError('Packaged qpdf source notice is absent or changed')
return summary
def verify(archive):
data, manifest, metadata = inspect(archive, '--fsys-tarfile')
controls, _, control_metadata = inspect(archive, '--ctrl-tarfile')
actual = dict(data)
actual.pop(MANIFEST)
actual.update({'DEBIAN/' + name: dict(row, path='DEBIAN/' + name) for name, row in controls.items()})
if not manifest or manifest['schemaVersion'] != 1 or manifest['package'] != 'docview':
raise ValueError('Missing or invalid DocView manifest')
control_text = control_metadata.get('control', b'').decode('utf-8', 'strict')
for field, expected_value in [('Package', 'docview'), ('Version', manifest.get('version'))]:
values = re.findall(r'^' + field + r': ([^\r\n]+)$', control_text, re.M)
if not isinstance(expected_value, str) or values != [expected_value]:
raise ValueError('Deb control identity differs from manifest: ' + field)
expected = {str(canonical(row['path'])): row for row in manifest['files']}
if len(expected) != len(manifest['files']) or actual != expected:
raise ValueError('Deb payload and manifest differ')
pdfium = verify_pdfium_payload({name.removeprefix('opt/docview/'): row for name, row in data.items()
if name.startswith('opt/docview/')}, metadata)
# Legacy provider packages predate this field; they still undergo the same
# pinned library/notice check. A candidate never gets that legacy exception.
if (pdfium['kind'] == 'reviewed-candidate' or 'pdfiumCorrespondence' in manifest) and manifest.get('pdfiumCorrespondence') != pdfium:
raise ValueError('Deb PDFium correspondence summary differs from reviewed payload')
archive_sha = sha(archive)
qpdf = verify_qpdf_payload(data, metadata, manifest)
return {'success': True, 'archiveSha256': archive_sha, 'archiveBytes': archive.stat().st_size,
'filesVerified': len(actual) + 1, 'dataFiles': len(data), 'controlFiles': len(controls),
'packageManifestSha256': data[MANIFEST]['sha256'], 'rootOwnership': True,
'linksOrSpecialFiles': False, 'allSizesModesAndHashesMatch': True,
'pdfiumCorrespondence': pdfium, 'qpdfNoticeCorrespondence': qpdf}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--archive', required=True, type=Path)
parser.add_argument('--output', required=True, type=Path)
args = parser.parse_args()
report = verify(args.archive.resolve(strict=True))
with args.output.open('x') as stream:
json.dump(report, stream, indent=2); stream.write('\n')
print(json.dumps(report))
if __name__ == '__main__':
main()