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

613 lines
45 KiB
Python

#!/usr/bin/env python3
"""Record the tested local build without collecting user documents or paths."""
import argparse
from datetime import datetime, timezone
import hashlib
import json
import os
from pathlib import Path
import platform
import re
import subprocess
import xml.etree.ElementTree as ET
root = Path(__file__).resolve().parents[1]
parser = argparse.ArgumentParser()
parser.add_argument('--build', type=Path, default=root / 'build')
parser.add_argument('--output', type=Path, default=root / 'docs/validation-record.json')
parser.add_argument('--runtime-results', type=Path,
help='Results from this build; older evidence remains hashed separately')
parser.add_argument('--ctest-results', type=Path,
help='Frozen full CTest directory containing tests.xml and LastTest.log')
args = parser.parse_args()
build = args.build.resolve()
results = root / 'tests/results'
runtime = args.runtime_results.resolve() if args.runtime_results else results
if not runtime.is_dir() or not runtime.is_relative_to(results):
raise SystemExit('--runtime-results must be an existing directory within tests/results')
ctest_directory = args.ctest_results.resolve() if args.ctest_results else None
if ctest_directory and (not ctest_directory.is_dir() or not ctest_directory.is_relative_to(results)):
raise SystemExit('--ctest-results must be an existing directory within tests/results')
junit_path = ctest_directory / 'tests.xml' if ctest_directory else build / 'tests.xml'
test_log_path = ctest_directory / 'LastTest.log' if ctest_directory else build / 'Testing/Temporary/LastTest.log'
tree = ET.parse(junit_path)
cases = tree.findall('.//testcase')
if not cases or any(case.find('failure') is not None or case.find('skipped') is not None for case in cases):
raise SystemExit('A complete passing CTest JUnit file is required.')
def sha(path):
digest = hashlib.sha256()
with path.open('rb') as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b''):
digest.update(chunk)
return digest.hexdigest()
def compiled_source(name):
# Debian desktop/profile/maintainer files are package inputs, separately
# validated against the deb manifest; they are not compiled into the GUI.
return (name.startswith(('src/', 'qml/', 'cmake/', 'resources/'))
and not name.startswith('resources/linux/')) or name in ('CMakeLists.txt', 'resources.qrc')
def recorded_runner_matches(name, digest, frozen):
return (sha(root / name) == digest or
(name == 'tests/run_wayland_validation.py' and sha(frozen) == digest))
source_paths = sorted([p for folder in ['src', 'qml', 'resources', 'cmake', 'tools'] for p in (root / folder).rglob('*')
if p.is_file() and '__pycache__' not in p.parts] + [root / 'CMakeLists.txt', root / 'resources.qrc'])
sources = {str(p.relative_to(root)): sha(p) for p in source_paths}
hardware = {'logicalCpus': os.cpu_count(), 'architecture': platform.machine()}
if Path('/proc/cpuinfo').is_file():
found = re.search(r'^model name\s*:\s*(.+)$', Path('/proc/cpuinfo').read_text(), re.M)
if found:
hardware['cpu'] = found.group(1)
if Path('/proc/meminfo').is_file():
found = re.search(r'MemTotal:\s+(\d+)', Path('/proc/meminfo').read_text())
if found:
hardware['physicalMemoryBytes'] = int(found.group(1)) * 1024
record = {'recordedAtUtc': datetime.now(timezone.utc).isoformat(), 'os': platform.freedesktop_os_release(),
'kernel': platform.release(), 'hardware': hardware,
'compiler': subprocess.check_output(['c++', '--version'], text=True).splitlines()[0],
'cmake': subprocess.check_output(['cmake', '--version'], text=True).splitlines()[0],
'qt': subprocess.check_output(['pkg-config', '--modversion', 'Qt6Core'], text=True).strip(),
'fontconfig': subprocess.check_output(['pkg-config', '--modversion', 'fontconfig'], text=True).strip(),
'qpdf': subprocess.check_output(['pkg-config', '--modversion', 'libqpdf'], text=True).strip(),
'pdfium': json.loads((root / 'cmake/pdfium.lock.json').read_text()),
'display': 'Xvfb / xcb; Qt Quick software; WebEngine --disable-gpu; internal sandboxes enabled',
'acceptance': 'Development evidence on Arch (including AMD OpenGL through private headless Wayland) and Ubuntu 24.04 KVM (X11 US/JP XKB and X11/Wayland IBus/Mozc over D-Bus); Windows native, target-OS GPU, physical input/display, reference hardware and release distribution gates incomplete',
'windowsPort': {'status': 'Integrated source only; native MSVC/Qt build and execution unavailable',
'unexecutedNativeSuites': ['windows_pipe', 'windows_resources', 'windows_worker'],
'portableEvidence': ['runtime_manifest', 'runtime_staging'],
'limits': 'Synthetic MZ and Wine-header syntax checks do not establish PE scanning, native ABI, LPAC protection or Windows rendering.'},
'executables': {name: sha(build / name) for name in ['docview', 'docview-pdf-worker', 'docview-archive-worker']},
'runtimeResults': str(runtime.relative_to(root)),
'sourceSha256': sources,
'ctest': {'suites': len(cases), 'failures': 0, 'skipped': 0,
'cases': [{key: case.attrib.get(key) for key in ['name', 'time', 'status']} for case in cases]}}
log = test_log_path.read_text()
record['ctest']['evidenceDirectory'] = str((ctest_directory or build).relative_to(root))
record['qtTestTotalsIncludingSetupAndCleanup'] = re.findall(r'Totals: .*', log)
record['installedExecutableSha256'] = {name: sha(build / 'install/bin' / name) for name in
['docview', 'docview-pdf-worker', 'docview-archive-worker']
if (build / 'install/bin' / name).is_file()}
record['testSourceSha256'] = {str(p.relative_to(root)): sha(p) for p in sorted((root / 'tests').rglob('*'))
if p.is_file() and p.suffix in {'.cpp', '.h', '.py', '.qml', '.html'}
and 'results' not in p.parts and '__pycache__' not in p.parts}
record['fixtureSha256'] = {str(p.relative_to(root)): sha(p) for p in sorted((root / 'tests/fixtures').rglob('*'))
if p.is_file() and '__pycache__' not in p.parts}
record['designSha256'] = {str(p.relative_to(root)): sha(p) for p in sorted((root / 'docs/design').glob('*.md'))}
record['documentationSha256'] = {str(p.relative_to(root)): sha(p) for p in
sorted(list((root / 'docs').rglob('*.md')) + list((root / 'tests').glob('*VALIDATION.md')) + [root / 'README.md'])}
record['evidenceSha256'] = {str(p.relative_to(root)): sha(p) for p in
[junit_path, test_log_path]}
if args.runtime_results:
snapshot = runtime / 'build-record.json'
if snapshot.is_file():
record['canonicalBuildRecord'] = json.loads(snapshot.read_text())
recorded_binaries = record['canonicalBuildRecord'].get('binaries', {})
if any(recorded_binaries.get(name) != value for name, value in record['executables'].items()):
raise SystemExit('Canonical CTest build differs from the current production executables')
if (record['canonicalBuildRecord'].get('junitSha256') != sha(junit_path) or
record['canonicalBuildRecord'].get('logSha256') != sha(test_log_path)):
raise SystemExit('Canonical CTest evidence changed after its build record was captured')
focused_scope = record.get('canonicalBuildRecord', {}).get('focusedTestScope')
if focused_scope:
record['focusedTestScope'] = focused_scope
canvas_snapshot = results / 'canvas-scene-graph/build-record.json'
if canvas_snapshot.is_file():
value = json.loads(canvas_snapshot.read_text())
frozen_runner = runtime / 'wayland/canvas-software/runner-at-start.py'
if (any(value['binaries'].get(name) != digest for name, digest in record['executables'].items())
or value['binaries'].get('test_pdf_canvas') != sha(build / 'test_pdf_canvas')
or any(not recorded_runner_matches(name, digest, frozen_runner) for name, digest in value['sourceSha256'].items())
or any(sha(root / name) != digest for name, digest in value['evidenceSha256'].items())):
raise SystemExit('Supplemental Canvas evidence differs from the recorded files/build')
value['currentSourceDifferences'] = [name for name, digest in value['sourceSha256'].items() if sha(root / name) != digest]
value['frozenWaylandRunner'] = str(frozen_runner.relative_to(root))
record['canvasFollowup'] = value
for name in ['gui', 'gui-final', 'smoke', 'gpu-smoke', 'gpu-smoke-logged', 'gpu-gui', 'canvas-software']:
wayland = runtime / 'wayland' / name / 'report.json'
if wayland.is_file():
value = json.loads(wayland.read_text())
value['matchesRecordedBuild'] = all(
value.get('executables', {}).get(binary) == digest
for binary, digest in record['executables'].items())
if value.get('mode') == 'gui':
value['testExecutablesMatchCurrent'] = all(
value.get('executables', {}).get(binary) == sha(build / binary)
for binary in ['test_app', 'test_web_qml', 'test_pdf_canvas'])
correction = wayland.parent / 'scope-correction.json'
if correction.is_file():
value['scopeCorrection'] = json.loads(correction.read_text())
graphics = wayland.parent / 'graphics-evidence.json'
if graphics.is_file():
value['additionalGraphicsEvidence'] = json.loads(graphics.read_text())
record.setdefault('wayland', {})[name] = value
component_evidence = runtime / 'unchanged-component-evidence.json'
if component_evidence.is_file():
value = json.loads(component_evidence.read_text())
for name in ['docview-pdf-worker', 'docview-archive-worker', 'test_pdf', 'test_core',
'test_worker_deadlines', 'libdocview_common.a']:
row = value.get('binaries', {}).get(name, {})
if row.get('currentSha256') != sha(build / name):
raise SystemExit('Unchanged-component record does not match the current build: ' + name)
record['unchangedComponentEvidence'] = value
ubuntu = runtime / 'ubuntu'
if (ubuntu / 'build-record.json').is_file():
guest = json.loads((ubuntu / 'build-record.json').read_text())
guest_cases = ET.parse(ubuntu / 'ctest-font-final/tests.xml').findall('.//testcase')
if (len(guest_cases) != guest['ctest']['groups'] or any(
c.find('failure') is not None or c.find('skipped') is not None for c in guest_cases) or
sha(ubuntu / 'ctest-font-final/tests.xml') != guest['ctest']['junitSha256'] or
sha(ubuntu / 'ctest-font-final/LastTest.log') != guest['ctest']['logSha256']):
raise SystemExit('Ubuntu CTest evidence does not match its build record')
guest['productionSourceDifferencesFromCurrent'] = [name for name, value in sources.items()
if compiled_source(name)
and guest['sourceSha256'].get(name) != value]
guest['productionSourceMatchesCurrent'] = not guest['productionSourceDifferencesFromCurrent']
followup_path = ubuntu / 'canvas-build-record.json'
if followup_path.is_file():
followup = json.loads(followup_path.read_text())
followup_ctest = ubuntu / 'canvas-ctest'
selected = ET.parse(followup_ctest / 'tests.xml').findall('.//testcase')
if (len(selected) != 2 or followup['ctest']['groups'] != 2 or any(
c.find('failure') is not None or c.find('skipped') is not None for c in selected)
or sha(followup_ctest / 'tests.xml') != followup['ctest']['junitSha256']
or sha(followup_ctest / 'LastTest.log') != followup['ctest']['logSha256']):
raise SystemExit('Ubuntu supplemental Canvas CTest evidence changed')
followup['productionBinariesMatchFullCTest'] = all(
followup['binaries'][n] == guest['binaries'][n] for n in record['executables'])
followup['productionSourceMatchesCurrent'] = all(
followup['sourceSha256'].get(name) == value for name, value in sources.items()
if compiled_source(name))
followup['canvasTestSourcesMatchCurrent'] = all(
followup['sourceSha256'].get(name) == sha(root / name)
for name in ['tests/test_pdf_canvas.cpp', 'tests/run_wayland_validation.py', 'CMakeLists.txt'])
frozen_runner = ubuntu / 'canvas-wayland/runner-at-start.py'
followup['canvasTestSourcesMatchRecordedRun'] = all(
recorded_runner_matches(name, followup['sourceSha256'].get(name), frozen_runner)
for name in ['tests/test_pdf_canvas.cpp', 'tests/run_wayland_validation.py', 'CMakeLists.txt'])
followup['frozenWaylandRunner'] = str(frozen_runner.relative_to(root))
wayland_followup = json.loads((ubuntu / 'canvas-wayland/report.json').read_text())
followup['wayland'] = wayland_followup
followup['waylandMatchesFollowupBuild'] = all(
followup['binaries'].get(name) == digest for name, digest in wayland_followup['executables'].items())
if not all(followup[name] for name in ['productionBinariesMatchFullCTest',
'productionSourceMatchesCurrent', 'canvasTestSourcesMatchRecordedRun', 'waylandMatchesFollowupBuild']):
raise SystemExit('Ubuntu Canvas follow-up does not match the current sources or baseline production binaries')
guest['canvasFollowup'] = followup
guest['wayland'] = {}
for mode in ('gui', 'smoke'):
value = json.loads((ubuntu / 'wayland' / mode / 'report.json').read_text())
value['matchesGuestBuild'] = all(guest['binaries'].get(n) == digest
for n, digest in value['executables'].items())
correction = ubuntu / 'wayland' / mode / 'scope-correction.json'
if correction.is_file():
value['scopeCorrection'] = json.loads(correction.read_text())
guest['wayland'][mode] = value
installed = json.loads((ubuntu / 'installed-smoke/results.json').read_text())
guest['installedSmoke'] = {'cases': len(installed),
'allReady': all(x['state'] == 'Ready' for x in installed),
'matchesInstalledBuild': all(x['binarySha256'] == guest['installedBinaries']['docview'] for x in installed)}
inventory = json.loads((ubuntu / 'installed-runtime-final/runtime.json').read_text())
indexed = {x['path']: x['sha256'] for x in inventory['files']}
guest['installedRuntime'] = {
'runtimeValidationSuccess': inventory['runtimeValidationSuccess'],
'files': len(inventory['files']), 'systemPackages': len(inventory['systemPackages']),
'noticesAndMetadata': len(inventory['notices']), 'noticeGaps': inventory['noticeGaps'],
'matchesInstalledBuild': all(indexed.get('install:bin/' + n) == digest
for n, digest in guest['installedBinaries'].items()),
'completeChromiumNotices': inventory['completeChromiumNotices'],
'completeCorrespondingSources': inventory['completeCorrespondingSources'],
'runtimeBinariesCopied': inventory['runtimeBinariesCopied']}
record['ubuntuValidation'] = guest
heavy_root = results / 'heavy-pdf'
for environment in ['final-arch', 'ubuntu']:
report_path = heavy_root / environment / 'report.json'
if not report_path.is_file():
continue
value = json.loads(report_path.read_text())
measurement = json.loads((report_path.parent / 'measurement.json').read_text())
if (not value['success'] or not measurement['success']
or sha(report_path.parent / 'measurement.json') != value['measurementSha256']
or sha(report_path.parent / 'heavy.pdf') != value['fixtureSha256']
or not all(sha(root / name) == digest for name, digest in value['sourceSha256'].items())):
raise SystemExit('Heavy-PDF supplemental source or evidence changed: ' + environment)
value['recordedSourcesMatchCurrent'] = True
if environment == 'final-arch':
value['binariesMatchCurrent'] = all(sha(root / name) == digest for name, digest in value['binaries'].items())
if not value['binariesMatchCurrent']:
raise SystemExit('Heavy-PDF Arch libraries/workers/test executable changed')
else:
guest_binaries = record.get('ubuntuValidation', {}).get('binaries')
value['productionBinariesMatchUbuntuBaseline'] = (all(
value['binaries'].get('../docview-build/' + name) == guest_binaries[name]
for name in record['executables']) if guest_binaries else None)
if value['productionBinariesMatchUbuntuBaseline'] is False:
raise SystemExit('Heavy-PDF Ubuntu production binaries differ from the selected baseline')
value['measurement'] = measurement
record.setdefault('heavyPdfFollowup', {})[environment] = value
ime_root = results / 'ime'
def record_ime(relative, summary, current):
ime_path = ime_root / relative / 'report.json'
value = json.loads(ime_path.read_text())
if (not value['success'] or not value['inputsUnchanged'] or value['remainingOwnedProcesses']
or sha(ime_path) != summary['reportSha256']
or not all(sha(ime_path.parent / name) == digest for name, digest in summary['evidenceSha256'].items())):
raise SystemExit('IME supplemental evidence is incomplete or changed')
value['recordedSourcesMatchCurrent'] = all(sha(root / name) == digest
for name, digest in value['inputs'].items() if not name.startswith('../'))
value['productionSourcesMatchCurrent'] = all(sha(root / name) == digest
for name, digest in value['inputs'].items() if name.startswith(('src/', 'qml/', 'resources/')) or name == 'resources.qrc')
if not current:
for name, digest in value['inputs'].items():
if name.startswith('tests/ime/') and sha(ime_path.parent / 'harness-snapshot' / Path(name).name) != digest:
raise SystemExit('Historical IME harness snapshot changed: ' + name)
guest_binaries = record.get('ubuntuValidation', {}).get('binaries')
value['productionBinariesMatchUbuntuBaseline'] = (all(
value['inputs'].get('../docview-build/' + name) == guest_binaries[name]
for name in record['executables']) if guest_binaries else None)
if ((current and not value['recordedSourcesMatchCurrent']) or not value['productionSourcesMatchCurrent']
or value['productionBinariesMatchUbuntuBaseline'] is False):
raise SystemExit('IME supplemental sources or Ubuntu production binaries changed')
platform = value.get('requestedPlatform', 'xcb')
expected = ['us', 'jp'] if platform == 'xcb' else ['virtual-keysyms']
if set(value['cases']) != set(expected):
raise SystemExit('IME case set does not match the recorded platform')
for layout in expected:
case = json.loads((ime_path.parent / (layout + '.json')).read_text())
if (not case['success'] or case != value['cases'][layout] or len(case['phases']) != 5
or (current and case['qtPlatform'] != platform)):
raise SystemExit('IME case does not match the successful final report: ' + layout)
value['summary'] = summary
return value
if (ime_root / 'summary.json').is_file():
record['imePriorX11'] = record_ime('ubuntu/final', json.loads((ime_root / 'summary.json').read_text()), False)
if (ime_root / 'current-summary.json').is_file():
current = json.loads((ime_root / 'current-summary.json').read_text())
if set(current) != {'xcb', 'wayland'}:
raise SystemExit('Current IME evidence must contain both tested platforms')
runtime_fingerprint = json.loads((ime_root / 'ubuntu/wayland-runtime-record.json').read_text())
if runtime_fingerprint['remainingValidationProcesses']:
raise SystemExit('IME validation processes were not reclaimed')
record['imeFollowup'] = {platform: record_ime(value['directory'], value, True) for platform, value in current.items()}
record['imeRuntimeFingerprintAfterRun'] = runtime_fingerprint
source_archives = results / 'source-archives'
if (source_archives / 'qt-inspection/report.json').is_file():
downloads = {name: json.loads((source_archives / 'download' / (name + '.json')).read_text())
for name in ['qt-everywhere', 'tomlplusplus']}
for name, value in downloads.items():
archive = root / value['archive']
if not value['success'] or archive.stat().st_size != value['bytes'] or sha(archive) != value['hashes']['sha256']:
raise SystemExit('Collected source archive changed: ' + name)
qt_source = json.loads((source_archives / 'qt-inspection/report.json').read_text())
toml_source = json.loads((source_archives / 'tomlplusplus/verification.json').read_text())
patches = json.loads((source_archives / 'arch-patch-recipe-mode/report.json').read_text())
if (not qt_source['success'] or not patches['success'] or not patches['pristineFilesUnchanged']
or not toml_source['allInstalledHeadersMatch'] or not toml_source['licenseMatchesInstalled']
or sha(source_archives / 'qt-inspection/files.jsonl') != qt_source['inventorySha256']
or sha(source_archives / 'qt-inspection/notices-index.json') != qt_source['noticesIndexSha256']
or qt_source['productionBinaries'] != record['executables']):
raise SystemExit('Source archive inspection evidence is incomplete or changed')
for step in patches['steps']:
if sha(root / step['patch']) != step['patchSha256']:
raise SystemExit('Recorded Arch source patch changed')
record['sourceArchiveCollection'] = {'status': 'partial', 'downloads': downloads,
'qtInspection': qt_source, 'tomlVerification': toml_source, 'archPatchCheck': patches,
'completeCorrespondingSources': False, 'completeChromiumNotices': False}
pdfium_collection_path = source_archives / 'pdfium-git/report.json'
if pdfium_collection_path.is_file():
pdfium_collection = json.loads(pdfium_collection_path.read_text())
pdfium_check_path = source_archives / 'pdfium-check/report.json'
pdfium_check = json.loads(pdfium_check_path.read_text())
pdfium_gitlink = json.loads((source_archives / 'pdfium-gitlink/report.json').read_text())
pdfium_plan = json.loads((source_archives / 'pdfium-git/plan.json').read_text())
if (not pdfium_collection['success'] or not pdfium_check['success'] or not pdfium_gitlink['success']
or pdfium_collection['pinSha256'] != sha(results / 'source-correspondence/pdfium/dependency-pins.json')
or pdfium_collection['planSha256'] != sha(source_archives / 'pdfium-git/plan.json')
or pdfium_collection['collectorSha256'] != sha(root / 'tests/source_archives/collect_pdfium.py')
or pdfium_check['checkerSha256'] != sha(root / 'tests/source_archives/check_pdfium.py')
or pdfium_gitlink['collectorSha256'] != sha(root / 'tests/source_archives/collect_pdfium_gitlink.py')
or pdfium_check['collectionReportSha256'] != sha(pdfium_collection_path)
or pdfium_gitlink['parentCollectionSha256'] != sha(pdfium_collection_path)
or pdfium_gitlink['gitmodulesSha256'] != sha(source_archives / 'pdfium-gitlink/freetype.gitmodules')
or pdfium_check['productionBinaries'] != record['executables']
or not pdfium_check['productionBinariesUnchanged']):
raise SystemExit('PDFium source collection evidence is incomplete or changed')
expected_pins = {(item['path'], item['revision']) for item in pdfium_plan['selectedGit']}
actual_pins = {(item['path'], item['revision']) for item in pdfium_collection['repositories']}
if expected_pins != actual_pins or len(actual_pins) != 28:
raise SystemExit('Selected PDFium Git source inventory differs')
nested_pins = {(item['path'] + '/' + link['path'], link['gitObject'])
for item in pdfium_collection['repositories'] for link in item['gitlinks']}
if nested_pins != {(item['path'], item['revision']) for item in pdfium_gitlink['repositories']}:
raise SystemExit('Nested PDFium Git source inventory differs')
for item in pdfium_collection['repositories'] + pdfium_gitlink['repositories']:
if (not item['success'] or not item['gitFsckPassed'] or not item['archiveVerifiedAgainstGitBlobs']
or sha(root / item['archive']) != item['archiveSha256']
or (root / item['archive']).stat().st_size != item['archiveBytes']
or sha(root / item['inventory']) != item['inventorySha256']):
raise SystemExit('Collected PDFium source archive changed: ' + item['path'])
for platform, checked in pdfium_check['platforms'].items():
for step in checked['steps']:
if (sha(root / step['patch']) != step['sha256'] or step['apply']['exitCode']
or step['dryRun']['exitCode']):
raise SystemExit('PDFium provider patch evidence changed')
if len(checked['linuxPackagedHeaderComparisons']) != 24:
raise SystemExit('PDFium public header inventory differs')
for item in checked['linuxPackagedHeaderComparisons']:
if (not item['match'] or item['sourceSha256'] != item['packagedSha256']
or sha(root / '.deps/pdfium/include' / item['path']) != item['packagedSha256']):
raise SystemExit('PDFium packaged public header changed')
if len(pdfium_check['noticeComparisons']) != 15 or len(pdfium_check['gitilesSourceComparisons']) != 31:
raise SystemExit('PDFium source comparison inventory differs')
for item in pdfium_check['gitilesSourceComparisons']:
if sha(results / 'source-correspondence/pdfium' / item['saved']) != item['sha256']:
raise SystemExit('Previously retrieved PDFium source changed')
for item in pdfium_check['noticeComparisons']:
if not item['match'] or sha(root / '.deps/pdfium' / item['path']) != item['sha256']:
raise SystemExit('PDFium packaged notice changed')
record['sourceArchiveCollection']['pdfiumGit'] = {
'collection': pdfium_collection, 'nestedGitlink': pdfium_gitlink, 'sourceCheck': pdfium_check,
'completeCorrespondingSources': False, 'buildReproduced': False}
sdk_notices_path = source_archives / 'qt-sdk-notices-final/report.json'
if sdk_notices_path.is_file():
sdk_notices = json.loads(sdk_notices_path.read_text())
sdk_evidence = sdk_notices_path.parent
archive = root / sdk_notices['sdkArchive']['path']
if (not sdk_notices['success']
or sdk_notices['collectorSha256'] != sha(root / 'tests/source_archives/collect_qt_sdk_notices.py')
or sdk_notices['runtimeRecordSha256'] != sha(results / 'ui-final/ubuntu/installed-runtime-final/runtime.json')
or sdk_notices['sdkArchive']['sha256'] != sha(archive)
or sdk_notices['sdkArchive']['bytes'] != archive.stat().st_size
or sdk_notices['sdkArchiveInventorySha256'] != sha(sdk_evidence / 'sdk-archive-inventory.json')
or sdk_notices['noticeBundleSha256'] != sha(sdk_evidence / 'THIRD_PARTY_NOTICES.sdk-extract.txt')
or sdk_notices['completeChromiumNotices'] or sdk_notices['completeCorrespondingSources']):
raise SystemExit('Qt SDK notice evidence is incomplete or changed')
if (len(sdk_notices['notices']) != 129 or len(sdk_notices['sbomFileComparisons']) != 67
or len(sdk_notices['testedRuntimeComparisons']) != 83
or sdk_notices['externalPackageReferencesValid']
or len(sdk_notices['packagesWithoutEmbeddedNoticeText']) != 2):
raise SystemExit('Qt SDK notice scope changed; reassess the recorded limitations')
for entry in sdk_notices['metadata'].values():
if sha(root / entry['localPath']) != entry['sha256']:
raise SystemExit('Qt SDK SBOM input changed')
qt_tree = root / qt_source['sourceTree']
for entry in sdk_notices['notices']:
if (sha(sdk_evidence / entry['file']) != entry['sha256']
or sha(qt_tree / entry['source']) != entry['sha256']):
raise SystemExit('Qt SDK notice or corresponding source changed')
for name, digest in sdk_notices['generatorSources'].items():
if sha(qt_tree / name) != digest:
raise SystemExit('Qt SDK SBOM generator source changed')
record['sourceArchiveCollection']['qtSdkNotices'] = sdk_notices
package_root = results / 'ubuntu-package'
if (package_root / 'lifecycle/report.json').is_file():
package_build = json.loads((package_root / 'build/report.json').read_text())
package_repeat = json.loads((package_root / 'repeat-report.json').read_text())
package_verify = json.loads((package_root / 'build/verification.json').read_text())
package_smoke = json.loads((package_root / 'smoke/report.json').read_text())
package_lifecycle = json.loads((package_root / 'lifecycle/report.json').read_text())
archive = root / 'build-ubuntu-vm/packages' / package_build['archive']
if (not all(value['success'] for value in [package_build, package_repeat, package_verify, package_smoke, package_lifecycle])
or package_build['archiveSha256'] != sha(archive)
or package_build['archiveSha256'] != package_repeat['archiveSha256']
or package_build['archiveSha256'] != package_verify['archiveSha256']
or package_build['archiveBytes'] != archive.stat().st_size
or package_build['manifestSha256'] != sha(package_root / 'build/package-manifest.json')
or package_verify['packageManifestSha256'] != package_build['manifestSha256']
or package_build['runtimeRecordSha256'] != sha(package_root / 'build/runtime/runtime.json')
or package_build['packagerSha256'] != sha(root / 'tools/package_ubuntu.py')
or package_build['collectorSha256'] != sha(root / 'tools/collect_ubuntu_validation_runtime.py')
or package_build['executableSha256'] != record['ubuntuValidation']['installedBinaries']
or package_build['executableSha256'] != package_smoke['executables']
or package_smoke['runnerSha256'] != sha(root / 'tests/ubuntu_vm/package_smoke.py')
or package_smoke['smokeSourceSha256'] != sha(root / 'tests/smoke.py')
or package_smoke['waylandRunnerSha256'] != sha(root / 'tests/run_wayland_validation.py')
or package_smoke['launcherSha256'] != sha(root / 'resources/linux/docview-launcher')
or package_lifecycle['runnerSha256'] != sha(root / 'tests/ubuntu_vm/package_lifecycle.py')
or package_lifecycle['smokeReportSha256'] != sha(package_root / 'smoke/report.json')
or package_build['completeChromiumNotices'] or package_build['completeCorrespondingSources']
or package_build['publicReleaseApproved']):
raise SystemExit('Ubuntu package evidence changed or does not match the tested installation')
package_manifest = json.loads((package_root / 'build/package-manifest.json').read_text())
packaged_hashes = {row['path']: row['sha256'] for row in package_manifest['files']}
for source, destination in [('docview-launcher', 'usr/bin/docview'),
('docview.desktop', 'usr/share/applications/docview.desktop'),
('docview.apparmor', 'etc/apparmor.d/docview'),
('deb-postinst', 'DEBIAN/postinst'), ('deb-prerm', 'DEBIAN/prerm')]:
if packaged_hashes[destination] != sha(root / 'resources/linux' / source):
raise SystemExit('Ubuntu package launcher or lifecycle resources changed')
for location in ['x11/results.json', 'wayland/smoke/results.json']:
smoke_cases = json.loads((package_root / 'smoke' / location).read_text())
if len(smoke_cases) != 6 or any(row['state'] != 'Ready' or row['binarySha256'] != package_smoke['launcherSha256'] for row in smoke_cases):
raise SystemExit('Ubuntu installed launcher smoke failed or changed')
package_wayland = json.loads((package_root / 'smoke/wayland/report.json').read_text())
if (not package_wayland['success'] or package_wayland['executables'] != package_build['executableSha256']
or not all(package_smoke['hiddenOriginalPrefixes'].values())
or not package_smoke['callerRuntimeVariablesAbsent'] or not package_smoke['executableBytesUnchanged']
or package_smoke['smokeConditions'] != 12
or not all(package_lifecycle['remove'].values()) or not all(package_lifecycle['purge'].values())
or not package_lifecycle['kernelUserNamespaceRestrictionUnchanged']):
raise SystemExit('Ubuntu package isolation or remove/purge checks are incomplete')
record['ubuntuPackage'] = {'scope': 'Local validation deb in existing dedicated VM; clean OS/public release incomplete',
'build': package_build, 'verification': package_verify, 'repeatedArchiveIdentical': True,
'smoke': package_smoke, 'lifecycle': package_lifecycle, 'archivePath': str(archive.relative_to(root))}
clean_root = package_root / 'clean-vm'
if (clean_root / 'remove/report.json').is_file():
phases = {name: json.loads((clean_root / name / 'report.json').read_text()) for name in ['install', 'smoke', 'remove']}
for name, phase in phases.items():
if (not phase['success'] or phase['archiveSha256'] != package_build['archiveSha256']
or phase['runnerSha256'] != sha(root / 'tests/ubuntu_vm/clean_package.py')
or phase['originalSdkOrBuildPresent'] or phase['uid'] == 0):
raise SystemExit('Fresh Ubuntu package phase failed or changed')
for command in phase['commands']:
if command['exitCode'] or sha(clean_root / name / (command['name'] + '.log')) != command['logSha256']:
raise SystemExit('Fresh Ubuntu command evidence changed')
for name in ['install', 'smoke']:
if phases[name]['executables'] != package_build['executableSha256']:
raise SystemExit('Fresh Ubuntu package uses different production binaries')
if (phases['install']['installedFilesVerified'] != 2004
or len(phases['install']['elfDependenciesBeforeTestHelpers']) != 131
or phases['smoke']['smokeConditions'] != 12 or not phases['smoke']['executableBytesUnchanged']
or not phases['smoke']['callerRuntimeVariablesAbsent']
or phases['smoke']['launcherSha256'] != package_smoke['launcherSha256']
or phases['smoke']['smokeSourceSha256'] != sha(root / 'tests/smoke.py')
or phases['smoke']['waylandRunnerSha256'] != sha(root / 'tests/run_wayland_validation.py')
or not all(phases['remove'][key] for key in ['payloadRemoved', 'profileUnloaded', 'conffilePurged', 'kernelRestrictionUnchanged'])
or len(phases['remove']['userProbesPreserved']) != 4):
raise SystemExit('Fresh Ubuntu package tests are incomplete')
for location in ['x11/results.json', 'wayland/smoke/results.json']:
smoke_cases = json.loads((clean_root / 'smoke' / location).read_text())
if len(smoke_cases) != 6 or any(row['state'] != 'Ready' or row['binarySha256'] != package_smoke['launcherSha256'] for row in smoke_cases):
raise SystemExit('Fresh Ubuntu smoke evidence changed')
fresh_wayland = json.loads((clean_root / 'smoke/wayland/report.json').read_text())
if not fresh_wayland['success'] or fresh_wayland['executables'] != package_build['executableSha256']:
raise SystemExit('Fresh Ubuntu Wayland build changed')
transferred = json.loads((clean_root / 'transferred-inputs.json').read_text())
for item in transferred['files']:
if sha(root / item['path']) != item['sha256']:
raise SystemExit('Fresh Ubuntu transferred test input changed')
base_provenance = json.loads((clean_root / 'base-provenance.json').read_text())
original_image = root / 'build-ubuntu-vm/downloads/noble-server-cloudimg-amd64.img'
if (base_provenance['baseImageSha256'] != sha(original_image) or not base_provenance['newOverlay']
or not base_provenance['existingVerification']['ubuntuImageSignatureValid']
or base_provenance['runtimeSdkCopied']):
raise SystemExit('Fresh Ubuntu base image provenance changed')
record['ubuntuPackage']['scope'] = 'Local validation deb on existing and fresh Ubuntu 24.04 cloud-image VMs; Xvfb/headless Wayland/software; public release and physical desktop incomplete'
record['ubuntuPackage']['freshOs'] = {'base': base_provenance, 'phases': phases,
'transferredTestInputs': len(transferred['files']), 'guiTestHelpersAddedAfterElfDependencyCheck': True}
prior_stress = results / 'stress-final/report.json'
if args.runtime_results and prior_stress.is_file():
record['stressPdfPrior'] = json.loads(prior_stress.read_text())
record['performance'] = {}
for corpus in ['performance', 'performance-standard', 'performance-fonts']:
record['performance'][corpus] = {}
for path in sorted((runtime / corpus).glob('*.json')):
values = json.loads(path.read_text())
record['performance'][corpus][path.stem] = {key: values.get(key) for key in
['success', 'summary', 'documentSha256', 'applicationRssPeakBytes', 'processGroupRssPeakBytes',
'tileCachePeakChargedBytes', 'tileCacheBudgetBytes', 'staleRenderResponses', 'corpus', 'method',
'executableSha256', 'schemaVersion', 'memoryStart', 'memoryAfterFinalClose',
'operationSeries', 'pendingRenderPeak', 'queuedRenderPeak', 'activeRenderPeak',
'discardedQueuedRequests', 'navigationCoverage', 'measurementChecks', 'proposedBudgetObservations',
'coldProcessOpen', 'qtPlatform', 'sceneGraphBackend', 'timingSemantics', 'runnerSha256',
'display', 'maximumMemoryPressureLevel']}
record['performance'][corpus][path.stem]['matchesRecordedBuild'] = (
values.get('executableSha256') == record['executables'])
record['evidenceSha256'][str(path.relative_to(root))] = sha(path)
for path in sorted((runtime / corpus / 'cold').rglob('*.json')):
record['evidenceSha256'][str(path.relative_to(root))] = sha(path)
run_log = runtime / (corpus + '-canonical.log')
if run_log.is_file():
record['evidenceSha256'][str(run_log.relative_to(root))] = sha(run_log)
for name, relative in [('installedSmoke', 'smoke/results.json'),
('stressPdf', 'stress/report.json' if args.runtime_results else 'stress-final/report.json'),
('workerDeadlines', 'worker-deadlines/report.json'),
('installedDependencies', 'installed-dependencies.json')]:
path = runtime / relative
if path.is_file():
record[name] = json.loads(path.read_text())
record['evidenceSha256'][str(path.relative_to(root))] = sha(path)
for relative in ['worker-deadlines/junit.xml', 'worker-deadlines/test.txt']:
path = runtime / relative
if path.is_file():
record['evidenceSha256'][str(path.relative_to(root))] = sha(path)
for folder in ['recent-documents', 'web-epub-boundaries', 'resource-warnings', 'zip-entry-choice', 'epub-spreads',
'link-borders', 'pdf-annotation-flags', 'pdf-comments', 'resource-classification', 'pdf-structure',
'pdf', 'pdf-extended', 'pdf-fonts', 'font-collection', 'pdf-icc', 'smoke',
'renderer-response', 'navigation-reflow', 'archive-ratio', 'linux-development-package',
'performance-interaction', 'performance-before-xhtml-fix', 'xhtml-headings', 'benchmark-xhtml',
'stress', 'stress-final', 'worker-deadlines', 'render-review', 'dependency-provenance',
'completion-regressions', 'canvas-scene-graph', 'heavy-pdf', 'ime', 'source-archives', 'ubuntu-package']:
for path in sorted((root / 'tests/results' / folder).rglob('*')):
if path.is_file() and (path.suffix in {'.png', '.txt', '.log', '.md', '.xml', '.json', '.html', '.py'}
or (folder == 'heavy-pdf' and path.suffix in {'.pdf', '.cpp'})
or (folder == 'ime' and path.suffix in {'.cpp', '.xkb'}) or path.name.endswith('.tar.gz')):
record['evidenceSha256'][str(path.relative_to(root))] = sha(path)
if args.runtime_results:
for path in sorted(runtime.rglob('*')):
if path.is_file() and '__pycache__' not in path.parts:
record['evidenceSha256'][str(path.relative_to(root))] = sha(path)
interaction = runtime / ('performance-interaction/record.json' if args.runtime_results
else 'performance-interaction/canonical-xhtml/record.json')
for path in sorted((root / 'tests/results/source-correspondence').rglob('*')):
if path.is_file() and '__pycache__' not in path.parts:
record['evidenceSha256'][str(path.relative_to(root))] = sha(path)
if interaction.is_file():
record['interactionMeasurement'] = json.loads(interaction.read_text())
sampled = {entry['path']: entry['sha256'] for entry in record['interactionMeasurement'].get('binaries', [])}
record['interactionMeasurementBuildMatches'] = all(
sampled.get(name) == record['executables'][name]
for name in ['docview-pdf-worker', 'docview-archive-worker']) and sampled.get('test_app') == sha(build / 'test_app')
record['interactionMeasurementScope'] = 'test_app with production Controller/QML and worker executables; docview main() is exercised separately by the application benchmarks and installed smoke tests.'
package_folder = runtime / 'linux-development-package'
package_name = 'docview-0.1.0-arch-x86_64-development.json'
package = package_folder / ('package' if args.runtime_results else 'source-notices') / package_name
prior_package = results / 'linux-development-package/source-notices' / package_name
if prior_package.is_file():
record['linuxDevelopmentPackagePrior'] = json.loads(prior_package.read_text())
if package.is_file():
record['linuxDevelopmentPackage'] = json.loads(package.read_text())
packaged = record['linuxDevelopmentPackage'].get('executableSha256', {})
record['linuxDevelopmentPackageInstalledBuildMatches'] = packaged == {
'bin/' + name: digest for name, digest in record['installedExecutableSha256'].items()}
record['linuxDevelopmentPackageUninstalledBuildMatches'] = {
name: packaged.get('bin/' + name) == digest for name, digest in record['executables'].items()}
record['linuxDevelopmentPackageMatchScope'] = ('Compared to cmake-installed executables. The PDF worker RUNPATH '
'changes from its development PDFium path to $ORIGIN/../lib during installation, so its installed '
'file hash differs from build/docview-pdf-worker. Both identities are retained.')
package_smoke = package_folder / ('smoke' if args.runtime_results else 'source-notices-smoke') / 'package-smoke.json'
if package_smoke.is_file():
record['linuxDevelopmentPackageSmoke'] = json.loads(package_smoke.read_text())
prior_smoke = results / 'linux-development-package/source-notices-smoke/package-smoke.json'
if prior_smoke.is_file():
record['linuxDevelopmentPackageSmokePrior'] = json.loads(prior_smoke.read_text())
notice_record = results / 'linux-development-package/source-notices-record.json'
if notice_record.is_file():
record['linuxDevelopmentPackageNoticeUpdate'] = json.loads(notice_record.read_text())
record['pdfComparisons'] = {}
record['pdfComparisonBuildMatches'] = {}
for name in ['pdf', 'pdf-extended', 'pdf-fonts', 'font-collection', 'link-borders', 'pdf-icc']:
path = runtime / name / 'comparison.json'
if path.is_file():
record['pdfComparisons'][name] = json.loads(path.read_text())
record['pdfComparisonBuildMatches'][name] = (
record['pdfComparisons'][name].get('worker_sha256') == record['executables']['docview-pdf-worker'])
record['evidenceSha256'][str(path.relative_to(root))] = sha(path)
if 'workerDeadlines' in record:
record['workerDeadlinesBuildMatches'] = {
'testExecutable': record['workerDeadlines'].get('testExecutableSha256') == sha(build / 'test_worker_deadlines'),
'commonLibrary': record['workerDeadlines'].get('commonLibrarySha256') == sha(build / 'libdocview_common.a')}
if 'stressPdf' in record:
record['stressPdfBuildMatches'] = record['stressPdf'].get('executableSha256') == record['executables']
record['buildScope'] = ('Executable hashes identify the current production build; source hashes record the current tree. '
'The frozen full CTest report is bound to its canonical build record. Later test-only Canvas/HiDPI changes '
'and their focused results are identified separately; the full report is not relabeled as a rerun of those changes. '
'Runtime results are selected explicitly; missing new evidence is not filled with an older success. '
'Each performance record identifies its measured executables separately. '
'Build-match flags distinguish earlier performance and PDF-comparison evidence. '
'The stress record identifies all three measured executables. Historical reports and packages retain their own identities. '
'The standalone real-time watchdog record identifies its own test executable and common library.')
args.output.write_text(json.dumps(record, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
print(f'{len(cases)} passing CTest suites recorded in {args.output}')