#!/usr/bin/env python3 """Inspect a trusted Ubuntu install with an explicitly selected Qt SDK. This writes a bounded runtime inventory and partial notice ledger, never a tar. Only use on DocView/SDK binaries from trusted builds: ldd executes loader code. No Arch notice pins or claims of complete source/license fulfillment are reused. """ import argparse import hashlib import io import json import os from pathlib import Path import platform import re import stat import subprocess import tarfile import tempfile from verify_pdfium_candidate import verify_installed as verify_pdfium_installed MAX_FILES = 10000 MAX_FILE_BYTES = 512 * 1024 * 1024 MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024 MAX_NOTICES = 2048 MAX_NOTICE_BYTES = 32 * 1024 * 1024 MAX_SDK_METADATA_BYTES = 64 * 1024 * 1024 MAX_SDK_METADATA_FILE_BYTES = 16 * 1024 * 1024 MAX_COMMAND_BYTES = 4 * 1024 * 1024 MAX_COMPONENTS = 256 EXECUTABLES = ('docview', 'docview-pdf-worker', 'docview-archive-worker') QML_MODULES = ('QtQml', 'QtQuick', 'QtQuick/Controls', 'QtQuick/Dialogs', 'QtQuick/Layouts', 'QtQuick/Templates', 'QtQuick/Window', 'QtWebEngine') LICENSE_NAMES = ('LICENSE', 'LICENSE.txt', 'LICENSE.md', 'LICENSE-MIT', 'COPYING', 'COPYING.txt', 'COPYRIGHT', 'copyright', 'NOTICE', 'NOTICE.txt', 'NOTICE.md') # The Ubuntu validation dependency was built from this archived release. These # reviewed identities are code-owned, never taken from a caller's manifest. QPDF_NOTICE_PIN = { 'version': '12.4.1', 'archive': {'name': 'qpdf-12.4.1.tar.gz', 'size': 19713921, 'sha256': 'f045aa277be2356ff53a89a8622945958291177d2483afc20ede7c8a8cd3873c'}, 'archiveRoot': 'qpdf-12.4.1', 'sourceFiles': 2900, 'sourceBytes': 65617659, 'sourceInventorySha256': 'b0c7f66f3a3ea35afb5db36716b2f7d6adfc25d54d408af088c3e3b60f321423', 'library': {'sha256': '215d435d9636075495df489058ef3ed5ce2a00c7f39164d62cebc3ba5b4cfe9d', 'size': 4648400}, 'notices': { 'LICENSE.txt': {'size': 11358, 'sha256': 'cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30'}, 'NOTICE.md': {'size': 2729, 'sha256': 'b207f65a9e5491195ded63b2941199b19a4d30148871f2742c88eae7bfc513a6'}, }, } def digest(data): return hashlib.sha256(data).hexdigest() def beneath(path, roots): return any(path.is_relative_to(root) for root in roots) def checked_file(path, roots, maximum=MAX_FILE_BYTES): path = Path(path).absolute() if not beneath(path, roots): raise ValueError('File request is outside allowed roots') target = path.resolve(strict=True) if not beneath(target, roots): raise ValueError('Symlink target is outside allowed roots') before = target.stat() if not stat.S_ISREG(before.st_mode) or not 0 <= before.st_size <= maximum: raise ValueError('File is special or exceeds size limit') with target.open('rb') as source: actual = hashlib.file_digest(source, 'sha256').hexdigest() after = target.stat() if (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns) != ( after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns): raise ValueError('File changed during inventory') return {'path': str(path), 'resolvedPath': str(target), 'size': before.st_size, 'sha256': actual, 'symlinkResolution': str(path) != str(target)} def bounded_walk(directory, roots, limit=MAX_FILES): """Follow file symlinks with identity checks; never recurse through dir links.""" directory = Path(directory) if not beneath(directory.absolute(), roots) or not beneath(directory.resolve(), roots): raise ValueError('Directory is outside allowed roots') if directory.is_symlink(): raise ValueError('Directory symlink is not traversed') pending, result, visited = [directory], [], 0 while pending: current = pending.pop() with os.scandir(current) as entries: for entry in entries: visited += 1 if visited > limit: raise ValueError('Directory entry limit exceeded') path = Path(entry.path) if entry.is_symlink(): if path.is_dir(): raise ValueError('Directory symlink is not traversed') # Validate now, before any later command/file read. target = path.resolve(strict=True) if not beneath(target, roots) or not target.is_file(): raise ValueError('Symlink target is outside allowed roots or special') result.append(path) elif entry.is_dir(follow_symlinks=False): pending.append(path) elif entry.is_file(follow_symlinks=False): result.append(path) else: raise ValueError('Special file in selected directory') return sorted(result) def run_command(arguments, environment): with tempfile.TemporaryFile() as stdout, tempfile.TemporaryFile() as stderr: p = subprocess.run(arguments, env=environment, stdout=stdout, stderr=stderr, timeout=30) if stdout.tell() > MAX_COMMAND_BYTES or stderr.tell() > MAX_COMMAND_BYTES: raise ValueError('Command output exceeds limit') stdout.seek(0); stderr.seek(0) return p.returncode, stdout.read().decode('utf-8', 'strict'), stderr.read().decode('utf-8', 'replace') def parse_ldd(text): paths, missing = set(), [] for line in text.splitlines(): value = line.strip() if not value or value.startswith(('linux-vdso.', 'linux-gate.')): continue if re.fullmatch(r'\S+ => not found', value): missing.append(value.split()[0]); continue match = re.fullmatch(r'(?:\S+ => )?(/.+?) \(0x[0-9a-fA-F]+\)', value) if not match: raise ValueError('Unrecognized ldd result') paths.add(Path(match[1])) return sorted(paths), sorted(missing) def validate_query(text, sdk): result = {} for line in text.splitlines(): if ':' not in line: raise ValueError('Invalid qtpaths output') key, value = line.split(':', 1) if key in result: raise ValueError('Duplicate qtpaths key') result[key] = value if result.get('QT_VERSION') != '6.11.2': raise ValueError('Expected Qt SDK 6.11.2') for key in ('QT_INSTALL_PREFIX', 'QT_INSTALL_LIBS', 'QT_INSTALL_LIBEXECS', 'QT_INSTALL_QML', 'QT_INSTALL_PLUGINS', 'QT_INSTALL_DATA', 'QT_INSTALL_TRANSLATIONS'): path = Path(result.get(key, '')) if not path.is_absolute() or '..' in path.parts or not path.is_dir(): raise ValueError('Missing or invalid Qt runtime directory: ' + key) if not path.resolve().is_relative_to(sdk): raise ValueError('Qt runtime directory escaped selected SDK') result[key] = str(path) if Path(result['QT_INSTALL_PREFIX']).resolve() != sdk: raise ValueError('qtpaths prefix differs from selected SDK') return result def runtime_candidates(prefix, query): required = {prefix / 'bin' / name for name in EXECUTABLES} | {prefix / 'lib/libpdfium.so'} qml, plugins = Path(query['QT_INSTALL_QML']), Path(query['QT_INSTALL_PLUGINS']) required.add(Path(query['QT_INSTALL_LIBEXECS']) / 'QtWebEngineProcess') required.add(plugins / 'platforms/libqxcb.so') required.update(qml / module / 'qmldir' for module in QML_MODULES) resource = Path(query['QT_INSTALL_DATA']) / 'resources' required.update(resource / name for name in ('qtwebengine_resources.pak', 'qtwebengine_resources_100p.pak', 'qtwebengine_resources_200p.pak', 'icudtl.dat')) translation = Path(query['QT_INSTALL_TRANSLATIONS']) required.update(translation / name for name in ('qtbase_ja.qm', 'qtdeclarative_ja.qm')) required.update(translation / 'qtwebengine_locales' / name for name in ('en-US.pak', 'ja.pak')) missing = [str(path) for path in sorted(required) if not path.is_file()] wayland = [plugins / 'platforms' / name for name in ('libqwayland-generic.so', 'libqwayland.so')] if not any(p.is_file() for p in wayland): missing.append(str(plugins / 'platforms') + '/{libqwayland-generic.so|libqwayland.so}') roots = (Path(query['QT_INSTALL_PREFIX']).resolve(), prefix) files = {p for p in required if p.is_file()} | {p for p in wayland if p.is_file()} for relative in ('QtQml', 'QtQuick', 'QtWebEngine'): path = qml / relative if path.is_dir(): files.update(bounded_walk(path, roots)) for relative in ('platforms', 'imageformats', 'xcbglintegrations', 'wayland-graphics-integration-client', 'platforminputcontexts', 'platformthemes', 'wayland-shell-integration', 'wayland-decoration-client', 'iconengines', 'tls', 'networkinformation'): path = plugins / relative if path.is_dir(): files.update(bounded_walk(path, roots)) for directory in (resource, translation / 'qtwebengine_locales'): if directory.is_dir(): files.update(bounded_walk(directory, roots)) return sorted(files), missing def manifest_record(path): path = Path(path) if not path.is_file() or path.stat().st_size > 1024 * 1024: raise ValueError('Input manifest missing or oversized') raw = path.read_bytes(); items = json.loads(raw) if not isinstance(items, list) or len(items) > 256: raise ValueError('Expected bounded SDK/source input manifest list') result = [] for row in items: if not isinstance(row, dict): raise ValueError('Invalid manifest row') name, sha, size, url = (row.get(k) for k in ('name', 'sha256', 'size', 'url')) if (not isinstance(name, str) or Path(name).name != name or len(name) > 256 or not isinstance(sha, str) or not re.fullmatch('[0-9a-f]{64}', sha) or type(size) is not int or size < 0 or not isinstance(url, str) or not url.startswith('https://') or len(url) > 4096): raise ValueError('Invalid input manifest identity') result.append({'name': name, 'sha256': sha, 'size': size, 'url': url, 'kind': row.get('kind') if row.get('kind') in ('qt', 'source') else 'unspecified'}) return {'manifestSha256': digest(raw), 'inputs': result, 'scope': 'Provided download identities; archives and extracted tree correspondence not reverified here'} class Collector: def __init__(self, prefix, qtpaths, dependency_prefix, output, sources=(), runner=run_command, system_roots=None, system_doc=Path('/usr/share/doc'), qpdf_source_archive=None): self.prefix = Path(prefix).resolve(strict=True) self.qtpaths = Path(qtpaths).absolute() self.sdk = self.qtpaths.parent.parent.resolve(strict=True) self.deps = Path(dependency_prefix).resolve(strict=True) if self.qtpaths.parent.name != 'bin' or self.qtpaths.name not in ('qtpaths', 'qtpaths6'): raise ValueError('Select SDK/bin/qtpaths explicitly') self.output, self.sources, self.runner = Path(output), [Path(p).resolve(strict=True) for p in sources], runner if len(self.sources) > 8: raise ValueError('At most eight selected source roots are allowed') self.system_roots = tuple(Path(p).absolute() for p in (system_roots or ('/lib', '/lib64', '/usr/lib', '/usr/lib64'))) self.allowed = (self.prefix, self.sdk, self.deps) + self.system_roots for root in (self.prefix, self.sdk, self.deps, *self.sources): if not root.is_dir() or root in (Path('/'), Path('/usr'), Path('/opt'), Path('/home'), Path('/tmp'), Path.home()): raise ValueError('A specific installed/source root is required') if any(self.output.resolve().is_relative_to(p) for p in (self.prefix, self.sdk, self.deps, *self.sources)): raise ValueError('Output must be outside inspected roots') self.system_doc = Path(system_doc) self.environment = {**os.environ, 'LC_ALL': 'C', 'LD_LIBRARY_PATH': str(self.deps / 'lib') + ':' + str(self.sdk / 'lib')} for key in ('LD_PRELOAD', 'LD_AUDIT'): self.environment.pop(key, None) self.rows, self.notices, self.missing_notices, self.total, self.notice_total = {}, [], [], 0, 0 self.sdk_metadata_total = 0 self.qpdf_source_archive = Path(qpdf_source_archive).absolute() if qpdf_source_archive else None def qpdf_correspondence(self, input_manifest): # Detect the dependency by its actual path, regardless of its digest; # a modified library must not evade verification by becoming unknown. libraries = {} for row in self.rows.values(): paths = (Path(row['path']), Path(row['resolvedPath'])) if any(re.fullmatch(r'libqpdf\.so(?:\.[0-9]+)*', p.name) for p in paths): libraries[row['resolvedPath']] = row if not libraries: if self.qpdf_source_archive: raise ValueError('qpdf source archive supplied without a qpdf runtime dependency') return {'status': 'not-applicable'} if len(libraries) != 1 or self.qpdf_source_archive is None: raise ValueError('Exactly one qpdf runtime and its fixed source archive are required') pin = QPDF_NOTICE_PIN library = next(iter(libraries.values())) real = Path(library['resolvedPath']) if not real.is_relative_to(self.deps / 'lib'): raise ValueError('qpdf runtime must belong to the selected dependency prefix') current = checked_file(real, (self.deps,)) if any(current[k] != pin['library'][k] or library[k] != current[k] for k in ('sha256', 'size')): raise ValueError('qpdf runtime differs from the fixed notice correspondence') archive = self.qpdf_source_archive if archive.is_symlink(): raise ValueError('qpdf source archive may not be a symlink') identity = checked_file(archive, (archive.parent,), 32 * 1024 * 1024) if archive.name != pin['archive']['name'] or any(identity[k] != pin['archive'][k] for k in ('sha256', 'size')): raise ValueError('qpdf source archive differs from the fixed release') if input_manifest.get('inputs') is not None: rows = [r for r in input_manifest['inputs'] if r['name'] == pin['archive']['name']] if len(rows) != 1 or rows[0]['kind'] != 'source' or any(rows[0][k] != pin['archive'][k] for k in ('sha256', 'size')): raise ValueError('qpdf declared input manifest differs from the fixed release') raw = archive.read_bytes() if digest(raw) != identity['sha256']: raise ValueError('qpdf source archive changed while reading') inventory, seen, total = [], set(), 0 with tarfile.open(fileobj=io.BytesIO(raw), mode='r:gz') as source: for member in source: name = member.name.rstrip('/') parts = name.split('/') if (name in seen or len(seen) >= 10000 or parts[0] != pin['archiveRoot'] or any(p in ('', '.', '..') for p in parts) or '\\' in name): raise ValueError('Invalid qpdf archive path or entry count') seen.add(name) if member.isdir(): continue if not member.isfile() or len(parts) < 2 or not 0 <= member.size <= 32 * 1024 * 1024: raise ValueError('Invalid qpdf archive member') total += member.size if total > 128 * 1024 * 1024: raise ValueError('qpdf expanded source exceeds limit') data = source.extractfile(member).read() inventory.append({'path': '/'.join(parts[1:]), 'size': len(data), 'sha256': digest(data)}) inventory.sort(key=lambda row: row['path']) inventory_sha = digest(json.dumps(inventory, sort_keys=True, separators=(',', ':')).encode()) if (len(inventory) != pin['sourceFiles'] or total != pin['sourceBytes'] or inventory_sha != pin['sourceInventorySha256']): raise ValueError('qpdf source inventory differs from the fixed release') candidates = [p for p in self.sources if all((p / name).is_file() for name in pin['notices'])] candidates = [p for p in candidates if all( checked_file(p / name, (p,), 64 * 1024)['sha256'] == expected['sha256'] for name, expected in pin['notices'].items())] if len(candidates) != 1: raise ValueError('Exactly one matching qpdf source root with LICENSE and NOTICE is required') root = candidates[0] actual = bounded_walk(root, (root,)) if any(p.is_symlink() for p in actual) or {p.relative_to(root).as_posix() for p in actual} != {r['path'] for r in inventory}: raise ValueError('qpdf selected source root differs from archive entries') for entry in inventory: row = checked_file(root / entry['path'], (root,), 32 * 1024 * 1024) if any(row[k] != entry[k] for k in ('sha256', 'size')): raise ValueError('qpdf selected source file differs from archive: ' + entry['path']) return {'status': 'verified', 'version': pin['version'], 'archive': dict(pin['archive']), 'sourceRoot': 'source-' + str(self.sources.index(root)) + ':', 'sourceFiles': len(inventory), 'sourceBytes': total, 'sourceInventorySha256': inventory_sha, 'library': {'path': self.label(Path(library['path'])), 'sha256': current['sha256'], 'size': current['size']}, 'notices': [{'source': name, **expected, 'copiedPath': 'notices/' + expected['sha256'] + '.txt'} for name, expected in pin['notices'].items()]} def label(self, path): path = Path(path) for name, root in [('install', self.prefix), ('qt-sdk', self.sdk), ('dependencies', self.deps), *[(f'source-{i}', p) for i, p in enumerate(self.sources)]]: if path.is_relative_to(root): return name + ':' + path.relative_to(root).as_posix() home = str(Path.home()) return str(path).replace(home + '/', '$HOME/', 1) if str(path).startswith(home + '/') else str(path) def command(self, args): return self.runner([str(a) for a in args], self.environment) def file(self, path, role): key = str(Path(path).absolute()) if key not in self.rows: row = checked_file(Path(key), self.allowed) self.total += row['size'] if len(self.rows) >= MAX_FILES or self.total > MAX_TOTAL_BYTES: raise ValueError('Runtime inventory limit exceeded') row['roles'] = []; self.rows[key] = row row = self.rows[key] if role not in row['roles']: row['roles'].append(role) return row def notice(self, path, roots, origin, category='notice'): sdk_metadata = category == 'sdk-sbom-metadata-not-license-text' row = checked_file(path, roots, MAX_SDK_METADATA_FILE_BYTES if sdk_metadata else 8 * 1024 * 1024) if sdk_metadata: self.sdk_metadata_total += row['size'] else: self.notice_total += row['size'] if (len(self.notices) >= MAX_NOTICES or self.notice_total > MAX_NOTICE_BYTES or self.sdk_metadata_total > MAX_SDK_METADATA_BYTES): raise ValueError('Notice collection limit exceeded') relative = Path('notices' if category == 'notice' else 'sdk-metadata') / (row['sha256'] + '.txt') target = self.output / relative target.parent.mkdir(parents=True, exist_ok=True) raw = Path(row['resolvedPath']).read_bytes() if digest(raw) != row['sha256']: raise ValueError('Notice changed while copying') target.write_bytes(raw) row.update({'path': self.label(path), 'resolvedPath': self.label(Path(row['resolvedPath'])), 'copiedPath': str(relative), 'origin': origin, 'category': category}) self.notices.append(row) def source_notices(self, directory, origin, sdk=False): found = set() for name in LICENSE_NAMES: p = directory / name if p.exists(): found.add(p) for name in ('LICENSES', 'licenses', 'Licenses'): p = directory / name if p.is_dir(): found.update(bounded_walk(p, (directory,), MAX_NOTICES)) if not found: self.missing_notices.append({'origin': origin, 'status': 'no-text-found-in-selected-locations'}) for path in sorted(found): self.notice(path, (directory,), origin) sbom = directory / 'sbom' if sdk and sbom.is_dir(): for path in bounded_walk(sbom, (directory,), MAX_NOTICES): self.notice(path, (directory,), origin, 'sdk-sbom-metadata-not-license-text') def collect(self, os_release, input_manifest=None): if os_release.get('ID') != 'ubuntu' or os_release.get('VERSION_ID') != '24.04': raise ValueError('This collector requires an Ubuntu 24.04 guest') # A candidate must carry its reviewed build/source/notice correspondence; # removing that metadata cannot fall back to an arbitrary provider binary. pdfium_correspondence = verify_pdfium_installed(self.prefix) input_record = manifest_record(input_manifest) if input_manifest else {'status': 'not-provided'} self.output.mkdir(parents=True, exist_ok=False) checked_file(self.qtpaths, (self.sdk,)) if not os.access(self.qtpaths, os.X_OK): raise ValueError('Selected qtpaths is not executable') code, text, _ = self.command([self.qtpaths, '--query']) if code: raise ValueError('qtpaths query failed') query = validate_query(text, self.sdk) candidates, missing = runtime_candidates(self.prefix, query) edges, runtime_failures = [], list(missing) for path in candidates: self.file(path, 'required-or-selected-runtime') for path in ([self.prefix / 'bin' / name for name in EXECUTABLES] + [self.prefix / 'lib/libpdfium.so', Path(query['QT_INSTALL_LIBEXECS']) / 'QtWebEngineProcess']): if path.is_file(): with path.open('rb') as f: if f.read(4) != b'\x7fELF': runtime_failures.append(self.label(path) + ': required ELF header missing') if path.name != 'libpdfium.so' and not os.access(path, os.X_OK): runtime_failures.append(self.label(path) + ': executable permission missing') # Every selected ELF (including QML/plugins/helper) receives ldd; ldd already # reports its transitive ELF dependencies, which are additionally hashed. for path in candidates: with path.open('rb') as f: is_elf = f.read(4) == b'\x7fELF' if not is_elf: continue code, text, _ = self.command(['ldd', path]) dependencies, unresolved = parse_ldd(text) if code: runtime_failures.append(self.label(path) + ': ldd returned ' + str(code)) runtime_failures.extend(self.label(path) + ': missing ' + item for item in unresolved) for dependency in dependencies: self.file(dependency, 'elf-dependency') edges.append({'elf': self.label(path), 'dependencies': [self.label(p) for p in dependencies], 'unresolved': unresolved, 'exitCode': code}) rpaths = [] for path in [self.prefix / 'bin' / name for name in EXECUTABLES]: if not path.is_file(): continue code, text, _ = self.command(['readelf', '-d', path]) if code: runtime_failures.append(self.label(path) + ': readelf failed') rpaths.append({'elf': self.label(path), 'exitCode': code, 'dynamicPathEntries': [line.strip() for line in text.splitlines() if re.search(r'\((?:RUNPATH|RPATH)\)', line)]}) qpdf_correspondence = self.qpdf_correspondence(input_record) packages, unowned = {}, [] for key, row in sorted(self.rows.items()): real = Path(row['resolvedPath']) if not beneath(real, self.system_roots): continue owners = set() paths = {key, str(real)} # dpkg can retain the pre-usrmerge pathname while ldd resolves the # canonical /usr/lib object. Check only these equivalent aliases. for value in list(paths): for short, long in (('/lib/', '/usr/lib/'), ('/lib64/', '/usr/lib64/')): if value.startswith(long): paths.add(short + value[len(long):]) if value.startswith(short): paths.add(long + value[len(short):]) for candidate in sorted(paths): if not Path(candidate).exists() or Path(candidate).resolve() != real: continue code, text, _ = self.command(['dpkg-query', '--search', candidate]) for line in text.splitlines() if code == 0 else []: if ': ' not in line: continue owner, filename = line.rsplit(': ', 1) if filename != candidate: continue for name in owner.split(', '): if re.fullmatch(r'[a-z0-9][a-z0-9+.-]*(?::[a-z0-9][a-z0-9-]*)?', name): owners.add(name) if owners: break row['systemOwners'] = sorted(owners) if not owners: unowned.append(self.label(real)) for owner in owners: if owner in packages: continue if len(packages) >= MAX_COMPONENTS: raise ValueError('System package limit exceeded') code, text, _ = self.command(['dpkg-query', '--show', '--showformat=${binary:Package}\t${Version}\t${Architecture}\n', owner]) fields = text.strip().split('\t') if code or len(fields) != 3 or fields[0] != owner: raise ValueError('Invalid dpkg package identity') packages[owner] = {'version': fields[1], 'architecture': fields[2]} copyright_path = self.system_doc / owner.split(':')[0] / 'copyright' if copyright_path.is_file(): self.notice(copyright_path, (self.system_doc,), 'dpkg:' + owner) else: self.missing_notices.append({'origin': 'dpkg:' + owner, 'status': 'copyright-file-missing'}) self.source_notices(self.sdk, 'selected-Qt-SDK', sdk=True) for name in ('qpdf', 'libzip'): path = self.deps / 'share/doc' / name if path.is_dir(): self.source_notices(path, 'dependency-prefix:' + name) else: self.missing_notices.append({'origin': 'dependency-prefix:' + name, 'status': 'notice-directory-missing'}) for i, source in enumerate(self.sources): self.source_notices(source, 'selected-source-' + str(i)) if qpdf_correspondence['status'] == 'verified': for expected in qpdf_correspondence['notices']: source = qpdf_correspondence['sourceRoot'] + expected['source'] matching = [n for n in self.notices if n['path'] == source] if len(matching) != 1 or any(matching[0][k] != expected[k] for k in ('sha256', 'size', 'copiedPath')): raise ValueError('qpdf notice changed after source verification') pdfium = self.prefix / 'share/doc/docview/pdfium' if pdfium.is_dir(): for path in bounded_walk(pdfium, (self.prefix,), MAX_NOTICES): category = 'source-metadata' if path.name == 'sources.json' or 'candidate' in path.relative_to(pdfium).parts else 'notice' self.notice(path, (self.prefix,), 'installed-PDFium', category) else: self.missing_notices.append({'origin': 'installed-PDFium', 'status': 'notice-directory-missing'}) rows = [] for row in self.rows.values(): rows.append({**row, 'path': self.label(Path(row['path'])), 'resolvedPath': self.label(Path(row['resolvedPath'])), 'roles': sorted(row['roles'])}) return {'schemaVersion': 1, 'scope': 'Ubuntu guest installed-runtime validation and partial notices only; no distribution package', 'runtimeValidationSuccess': not runtime_failures, 'runtimeFailures': runtime_failures, 'osRelease': {k: os_release[k] for k in ('ID', 'VERSION_ID', 'PRETTY_NAME') if k in os_release}, 'architecture': platform.machine(), 'qtVersion': query['QT_VERSION'], 'qtPaths': {k: self.label(Path(v)) for k, v in query.items() if k.startswith('QT_INSTALL_') and k in ( 'QT_INSTALL_PREFIX','QT_INSTALL_LIBS','QT_INSTALL_LIBEXECS','QT_INSTALL_QML','QT_INSTALL_PLUGINS','QT_INSTALL_DATA','QT_INSTALL_TRANSLATIONS')}, 'loaderEnvironment': {'LD_LIBRARY_PATHEntries': ['dependencies:lib', 'qt-sdk:lib'], 'LD_PRELOAD': 'removed', 'LD_AUDIT': 'removed'}, 'files': sorted(rows, key=lambda r:r['path']), 'elfResolution': edges, 'installedRpaths': rpaths, 'pdfiumCorrespondence': pdfium_correspondence, 'qpdfNoticeCorrespondence': qpdf_correspondence, 'systemPackages': packages, 'systemOwnershipUnresolved': sorted(set(unowned)), 'notices': self.notices, 'noticeGaps': self.missing_notices, 'inputManifest': input_record, 'completeChromiumNotices': False, 'completeCorrespondingSources': False, 'legalComplianceVerdict': 'not-assessed', 'archNoticePinReused': False, 'runtimeBinariesCopied': False, 'limitations': ['Successful ldd is not GUI rendering or sandbox execution evidence.', 'Selected Qt plugins/QML are candidates; this is not a trace of every runtime-loaded file.', 'Explicit /opt SDK and dependency loader environment is required for this validation.', 'No whole source-tree, package signature, complete license, or reproducible-build verification.', 'Missing notices are reported separately; runtime success does not mean notice completeness.']} def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--prefix', required=True, type=Path) parser.add_argument('--qtpaths', required=True, type=Path) parser.add_argument('--dependency-prefix', required=True, type=Path) parser.add_argument('--output', required=True, type=Path, help='New output directory; never overwrite earlier evidence') parser.add_argument('--input-manifest', type=Path) parser.add_argument('--source-root', action='append', default=[], type=Path) parser.add_argument('--qpdf-source-archive', type=Path, help='Fixed qpdf release archive required when libqpdf is included') args = parser.parse_args() try: collector = Collector(args.prefix, args.qtpaths, args.dependency_prefix, args.output, args.source_root, qpdf_source_archive=args.qpdf_source_archive) result = collector.collect(platform.freedesktop_os_release(), args.input_manifest) (args.output / 'runtime.json').write_text(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True) + '\n') print(json.dumps({'runtimeValidationSuccess': result['runtimeValidationSuccess'], 'files': len(result['files']), 'systemPackages': len(result['systemPackages']), 'notices': len(result['notices']), 'noticeGaps': len(result['noticeGaps'])})) return 0 if result['runtimeValidationSuccess'] else 1 except (ValueError, OSError, tarfile.TarError, subprocess.TimeoutExpired) as error: # Never preserve an earlier success: --output is exclusive and exceptions # produce no runtime.json. Partial copied notices alone prove no success. print('Validation failed: ' + str(error)) return 1 if __name__ == '__main__': raise SystemExit(main())