#!/usr/bin/env python3 """Inventory a trusted DocView install and its Arch Linux system dependencies. This does not download sources, infer a license choice, or certify redistribution. Only use ldd on executables built by this project, never on an input document. """ import argparse import hashlib import json import os from pathlib import Path import platform import re import shutil import subprocess import sys from urllib.parse import quote ROOT = Path(__file__).resolve().parents[1] EXECUTABLES = ('docview', 'docview-pdf-worker', 'docview-archive-worker') MAX_FILES = 10000 MAX_COMPONENTS = 256 MAX_LICENSE_BYTES = 32 * 1024 * 1024 QT_EMBEDDED_NOTICE_METADATA_SHA256 = '8d90e93aa487eddef4b81722a53b89953cbb07b45d02488e8ea0413f300fd723' def sha256(path): with Path(path).open('rb') as stream: return hashlib.file_digest(stream, 'sha256').hexdigest() def run(args): environment = {**os.environ, 'LC_ALL': 'C'} for key in ('LD_PRELOAD', 'LD_LIBRARY_PATH', 'LD_AUDIT'): environment.pop(key, None) result = subprocess.run(args, check=True, text=True, capture_output=True, timeout=30, env=environment) return result.stdout def pacman_fields(text): result, key = {}, None for line in text.splitlines(): if line.startswith('%') and line.endswith('%'): key = line[1:-1] result[key] = [] elif line and key: result[key].append(line) return result class PackageDatabase: def __init__(self, root=Path('/var/lib/pacman/local')): self.packages, self.owners = {}, {} for directory in sorted(root.iterdir()): if not directory.is_dir() or not (directory / 'desc').is_file(): continue desc = pacman_fields((directory / 'desc').read_text()) name = desc['NAME'][0] self.packages[name] = desc for item in pacman_fields((directory / 'files').read_text()).get('FILES', []): if not item.endswith('/'): self.owners['/' + item] = name def owner(self, path): value = self.owners.get(str(path)) or self.owners.get(str(path.resolve())) if not value: raise ValueError(f'No installed Arch package owns runtime file: {path}') return value def ldd_paths(output): paths = set() for line in output.splitlines(): if 'not found' in line: raise ValueError('Unresolved ELF dependency: ' + line.strip()) value = line.strip() if not value or value.startswith('linux-vdso'): continue match = re.fullmatch(r'(?:\S+ => )?(/.+?) \(0x[0-9a-fA-F]+\)', value) if not match: raise ValueError('Unrecognized ldd dependency: ' + value) paths.add(Path(match[1])) return paths def elf(path): with path.open('rb') as stream: return stream.read(4) == b'\x7fELF' def qt_runtime_files(query): """Finite candidate modules; not a claim that every style/plugin was loaded.""" files = {Path(query['QT_INSTALL_LIBEXECS']) / 'QtWebEngineProcess'} qml = Path(query['QT_INSTALL_QML']) for relative in ('QtQml', 'QtQuick/Controls', 'QtQuick/Dialogs', 'QtQuick/Layouts', 'QtQuick/Templates', 'QtQuick/Window', 'QtQuick/NativeStyle', 'QtQuick/Effects', 'QtWebEngine'): directory = qml / relative if directory.is_dir(): files.update(p for p in directory.rglob('*') if p.is_file()) files.update(p for p in (qml / 'QtQuick').glob('*') if p.is_file()) plugins = Path(query['QT_INSTALL_PLUGINS']) for relative in ('platforms/libqxcb.so', 'platforms/libqwayland-generic.so', 'platforms/libqwayland-egl.so'): path = plugins / relative if path.is_file(): files.add(path) for filename in ('libqjpeg.so', 'libqgif.so', 'libqico.so', 'libqsvg.so', 'libqwebp.so'): path = plugins / 'imageformats' / filename if path.is_file(): files.add(path) for relative in ('xcbglintegrations', 'wayland-graphics-integration-client'): directory = plugins / relative if directory.is_dir(): files.update(p for p in directory.rglob('*') if p.is_file()) resources = Path(query['QT_INSTALL_DATA']) / 'resources' files.update(p for p in resources.glob('*') if p.is_file() and (p.name.startswith('qtwebengine') or p.name in ('icudtl.dat', 'v8_context_snapshot.bin'))) translations = Path(query['QT_INSTALL_TRANSLATIONS']) for name in ('qtbase_ja.qm', 'qtdeclarative_ja.qm'): path = translations / name if not path.is_file(): raise ValueError('Required Japanese Qt translation is missing: ' + name) files.add(path) files.update(p for p in (translations / 'qtwebengine_locales').glob('*') if p.is_file()) for pattern in ('qtbase_*.qm', 'qtdeclarative_*.qm', 'qtwebengine_*.qm'): files.update(p for p in translations.glob(pattern) if p.is_file()) if not files or len(files) > MAX_FILES or not all(p.is_file() for p in files): raise ValueError('Missing or oversized Qt runtime inventory') return files def license_candidates(name, desc, base=Path('/usr/share/licenses')): result = set() directory = base / name if directory.is_dir(): result.update(p for p in directory.rglob('*') if p.is_file()) # Include the generic text when Arch uses a shared SPDX license directory. for label in desc.get('LICENSE', []): for token in re.findall(r'[A-Za-z0-9][A-Za-z0-9.+-]*', label): path = base / 'spdx' / (token + '.txt') if path.is_file(): result.add(path) return sorted(result) def license_supplements(name, version, base=ROOT / 'resources/licenses/linux-supplemental'): result = [] upstream_version = version.rsplit('-', 1)[0].split(':')[-1] for row in json.loads((base / 'sources.json').read_text()): if row['package'] != name: continue if row['version'] != upstream_version: raise ValueError('Supplemental license version needs review: ' + name) for filename, expected in sorted(row['files'].items()): if Path(filename).name != filename: raise ValueError('Unsafe supplemental license filename') path = base / name / filename if sha256(path) != expected: raise ValueError('Supplemental license digest mismatch: ' + str(path)) result.append((path, row)) return result def qt_embedded_notices(qt_version, package_version, system_files, base=ROOT / 'resources/licenses/qt-embedded-notices'): """Bind the reviewed partial notice set to the exact system DataPack variant.""" source = base / 'sources.json' if not source.is_file() or source.stat().st_size > 65536: raise ValueError('Qt embedded notice metadata missing or oversized') metadata_bytes = source.read_bytes() if hashlib.sha256(metadata_bytes).hexdigest() != QT_EMBEDDED_NOTICE_METADATA_SHA256: raise ValueError('Qt embedded notice metadata changed; review required') value = json.loads(metadata_bytes) if (value['schemaVersion'] != 1 or value['qtVersion'] != qt_version or value['packageVersion'] != package_version or value['package'] != 'qt6-webengine' or value['completeChromiumNotices'] is not False or value['runtimeDistribution'] != 'system-not-bundled'): raise ValueError('Qt embedded notice target version or scope needs review') for expected in value['dataPacks']: matches = [row for row in system_files if row['path'] == expected['path']] if (len(matches) != 1 or any(matches[0].get(key) != expected[key] for key in ('package', 'size', 'sha256')) or expected['packageVersion'] != package_version): raise ValueError('Qt embedded notice DataPack inventory mismatch; review required') for row in value['files']: if Path(row['name']).name != row['name']: raise ValueError('Unsafe Qt embedded notice filename') path = base / row['name'] if (not path.is_file() or path.stat().st_size != row['size'] or sha256(path) != row['sha256']): raise ValueError('Qt embedded notice text missing or digest/size mismatch') return source, value def pdfium_supplemental_notices(prefix, locked): relative = Path('share/doc/docview/pdfium/supplemental') source = prefix / relative / 'sources.json' if not source.is_file() or source.stat().st_size > 65536: raise ValueError('PDFium supplemental notice metadata missing or oversized') value = json.loads(source.read_text()) if (value.get('schemaVersion') != 1 or value.get('pdfiumVersion') != locked['version'] or value.get('pdfiumUpstreamCommit') != locked['upstreamCommit'] or value.get('pdfiumLibrarySha256') != sha256(prefix / 'lib/libpdfium.so')): raise ValueError('PDFium supplemental notice source or binary needs review') rows = value.get('files') if (not isinstance(rows, list) or len(rows) != 2 or any(not isinstance(r, dict) for r in rows) or {r.get('name') for r in rows} != { 'libcxx-LICENSE.txt', 'libcxxabi-LICENSE.txt'}): raise ValueError('Unexpected PDFium supplemental notice set') files = [] for row in rows: path = prefix / relative / row['name'] if (not path.is_file() or path.stat().st_size > 1024 * 1024 or path.stat().st_size != row['size'] or sha256(path) != row['sha256']): raise ValueError('PDFium supplemental notice digest or size mismatch') files.append({**row, 'path': (relative / row['name']).as_posix()}) return {'metadataPath': (relative / 'sources.json').as_posix(), 'metadataSha256': sha256(source), 'scope': value['scope'], 'providerRecipeCommit': value['providerRecipeCommit'], 'pdfiumLibrarySha256': value['pdfiumLibrarySha256'], 'files': files} def inventory(prefix, output, qtpaths='/usr/lib/qt6/bin/qtpaths'): prefix, output = prefix.resolve(strict=True), output.resolve() if sys.platform != 'linux' or platform.machine() != 'x86_64': raise ValueError('This inventory targets Arch Linux x86_64 only') os_release = platform.freedesktop_os_release() if os_release.get('ID') != 'arch': raise ValueError('An Arch package database is required; this is not an Ubuntu package') if output.exists() and any(output.iterdir()): raise ValueError('Inventory output directory must be empty') output.mkdir(parents=True, exist_ok=True) query = dict(line.split(':', 1) for line in run([qtpaths, '--query']).splitlines() if ':' in line) if query.get('QT_VERSION') != '6.11.2': raise ValueError('Expected the verified Qt 6.11.2 runtime') database = PackageDatabase() inputs = [prefix / 'bin' / name for name in EXECUTABLES] before = {str(p.relative_to(prefix)): sha256(p) for p in inputs} bundled = prefix / 'lib/libpdfium.so' expected_pdfium = ROOT / '.deps/pdfium/lib/libpdfium.so' if not bundled.is_file() or sha256(bundled) != sha256(expected_pdfium): raise ValueError('Installed PDFium differs from the pinned local archive') candidates = qt_runtime_files(query) system = {p.resolve(): {'qt-runtime-candidate'} for p in candidates} edges = [] for path in inputs + sorted(candidates): if not elf(path): continue label = str(path.relative_to(prefix)) if path.is_relative_to(prefix) else str(path) resolved = [] for dependency in sorted(ldd_paths(run(['ldd', str(path)]))): real = dependency.resolve(strict=True) if real.is_relative_to(prefix): if real != bundled: raise ValueError('Unexpected bundled shared library: ' + str(real)) resolved.append('bundle:lib/libpdfium.so') else: system.setdefault(real, set()).add('elf-dependency') resolved.append(str(real)) edges.append({'elf': label, 'resolved': sorted(resolved)}) if len(system) > MAX_FILES: raise ValueError('Runtime inventory exceeds file limit') components, system_files = {}, [] # toml++ is compiled into DocView as well as potentially dynamically linked. names = {'tomlplusplus', 'qt6-base', 'qt6-declarative', 'qt6-webengine'} for path, roles in sorted(system.items()): owner = database.owner(path) names.add(owner) system_files.append({'path': str(path), 'package': owner, 'size': path.stat().st_size, 'sha256': sha256(path), 'roles': sorted(roles)}) if len(names) > MAX_COMPONENTS: raise ValueError('Runtime inventory exceeds package limit') total_license_bytes = 0 for name in sorted(names): desc = database.packages[name] version = desc['VERSION'][0] base = desc.get('BASE', [name])[0] licenses = [] embedded_notices = None for source in license_candidates(name, desc): if source.stat().st_size > 8 * 1024 * 1024: raise ValueError('License file exceeds limit: ' + str(source)) total_license_bytes += source.stat().st_size if total_license_bytes > MAX_LICENSE_BYTES: raise ValueError('License collection exceeds limit') relative = Path('licenses') / name / source.relative_to('/usr/share/licenses') target = output / relative target.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(source, target) licenses.append({'path': relative.as_posix(), 'sourcePath': str(source), 'sha256': sha256(target), 'size': target.stat().st_size}) for source, provenance in license_supplements(name, version): total_license_bytes += source.stat().st_size if total_license_bytes > MAX_LICENSE_BYTES: raise ValueError('License collection exceeds limit') relative = Path('licenses') / name / 'upstream' / source.name target = output / relative target.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(source, target) licenses.append({'path': relative.as_posix(), 'sourceUrl': provenance['url'], 'upstreamVersion': provenance['version'], 'downloadSha256': provenance['downloadSha256'], 'collectionScope': 'Upstream notice text only; not a complete source collection', 'sha256': sha256(target), 'size': target.stat().st_size}) if name == 'qt6-webengine': metadata_path, reviewed = qt_embedded_notices(query['QT_VERSION'], version, system_files) folder = Path('licenses') / name / 'embedded-resource-notices' metadata_relative = folder / 'sources.json' (output / folder).mkdir(parents=True, exist_ok=True) shutil.copyfile(metadata_path, output / metadata_relative) if sha256(output / metadata_relative) != QT_EMBEDDED_NOTICE_METADATA_SHA256: raise ValueError('Qt embedded notice metadata changed while copying') total_license_bytes += (output / metadata_relative).stat().st_size for row in reviewed['files']: relative = folder / row['name'] target = output / relative shutil.copyfile(metadata_path.parent / row['name'], target) if target.stat().st_size != row['size'] or sha256(target) != row['sha256']: raise ValueError('Qt embedded notice text changed while copying') total_license_bytes += row['size'] licenses.append({'path': relative.as_posix(), 'size': row['size'], 'sha256': row['sha256'], 'origin': 'reviewed-installed-DataPack-resource-comment', 'sourceResources': row['sourceResources'], 'collectionScope': reviewed['scope']}) if total_license_bytes > MAX_LICENSE_BYTES: raise ValueError('License collection exceeds limit') embedded_notices = {'status': 'collected-reviewed-resource-subset', 'metadataPath': metadata_relative.as_posix(), 'metadataSha256': QT_EMBEDDED_NOTICE_METADATA_SHA256, 'noticeCount': len(reviewed['files']), 'sourceResourceCount': sum(len(row['sourceResources']) for row in reviewed['files']), 'runtimeDistribution': reviewed['runtimeDistribution'], 'completeChromiumNotices': False, 'scope': reviewed['scope']} components[name] = { 'version': version, 'architecture': desc.get('ARCH', ['unknown'])[0], 'distribution': 'system-not-bundled', 'upstreamHome': desc.get('URL', [''])[0], 'licenseLabelsFromPackage': desc.get('LICENSE', []), 'licenseTexts': licenses, 'noticeStatus': 'collected-package-or-upstream-texts-not-a-compliance-verdict' if licenses else 'text-not-found', 'source': {'status': 'not-collected', 'packageBase': base, 'packageVersion': version, 'recipeRepository': 'https://gitlab.archlinux.org/archlinux/packaging/packages/' + quote(base), 'recipeCommit': None, 'note': 'This package contains source pointers, not complete corresponding source. Selected exact recipes and patches are recorded separately in the repository source-correspondence evidence.'}} if name == 'tomlplusplus': components[name]['usage'] = ['system runtime library', 'headers compiled into DocView'] if embedded_notices: components[name]['embeddedResourceNotices'] = embedded_notices locked = json.loads((ROOT / 'cmake/pdfium.lock.json').read_text()) pdfium_supplements = pdfium_supplemental_notices(prefix, locked) pdfium_licenses = [] for path in sorted((prefix / 'share/doc/docview/pdfium').rglob('*')): if path.is_file() and path.name != 'sources.json': pdfium_licenses.append({'path': path.relative_to(prefix).as_posix(), 'sha256': sha256(path), 'size': path.stat().st_size}) if not pdfium_licenses: raise ValueError('PDFium archive license texts missing from install') components['PDFium-independent-worker'] = { 'version': locked['version'], 'distribution': 'bundled', 'path': 'lib/libpdfium.so', 'sha256': sha256(bundled), 'size': bundled.stat().st_size, 'licenseTexts': pdfium_licenses, 'upstreamHome': locked['upstream'], 'supplementalNotices': pdfium_supplements, 'source': {'status': 'not-collected', 'upstreamCommit': locked['upstreamCommit'], 'sourceUrl': locked['upstream'], 'binaryProvider': locked['binaryProvider'], 'binaryArchive': locked['linuxX64Archive'], 'archiveSha256': locked['linuxX64Sha256'], 'buildOptions': locked['buildOptions'], 'note': 'Upstream revision and provider archive are pinned; corresponding source tree and all dependency sources are not included.'}} versions = {} for path in inputs + [bundled]: values = sorted(set(re.findall(r'Name: ((?:GLIBC|GLIBCXX|CXXABI)_[A-Za-z0-9_.]+)', run(['readelf', '--version-info', str(path)])))) versions[path.relative_to(prefix).as_posix()] = values if before != {str(p.relative_to(prefix)): sha256(p) for p in inputs}: raise ValueError('Installed executables changed during inventory') result = {'schemaVersion': 1, 'scope': 'Local Arch Linux x86_64 development package; not a clean-OS or redistribution acceptance', 'host': {'id': os_release['ID'], 'prettyName': os_release.get('PRETTY_NAME', ''), 'architecture': platform.machine(), 'qtVersion': query['QT_VERSION'], 'glibcPackage': database.packages['glibc']['VERSION'][0]}, 'executableSha256': before, 'requiredSymbolVersions': versions, 'components': components, 'systemFiles': system_files, 'elfResolution': edges, 'systemRuntimeCandidateBytes': sum(row['size'] for row in system_files), 'scopeLimits': [ 'Qt libraries, selected QML/style/plugin candidates, WebEngine helper/resources/locales and ELF closure are system dependencies, not copied runtime binaries.', 'Candidate styles/plugins are a bounded inventory, not a trace proving every file was used. Other platform, IME, theme, GPU and font providers may load additional files.', 'System fonts and their licenses are not bundled; FontBroker uses locally installed fonts.', 'Arch package license labels can list alternatives. This record does not choose a license for DocView or resolve every component condition.', 'Qt WebEngine includes Chromium third parties. Seven reviewed notice texts from 13 system DataPack resources are included, but neither these nor the top-level package license are a complete build-specific Chromium attribution collection.', 'Complete corresponding dependency sources are not included in this package. Selected source-correspondence evidence is maintained separately; URLs and license texts alone do not establish source fulfillment.', 'Ubuntu 24.04, Windows, clean installation and signed distribution remain unverified.']} (output / 'dependency-manifest.json').write_text(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True) + '\n') lines = ['DocView Linux development package: third-party notices', '', 'Local Arch Linux x86_64 artifact. Not a complete redistribution approval.', 'DocView public license: unspecified. This inventory does not license DocView.', 'Only the independent PDFium binary is bundled; the other runtime libraries use the host system.', 'License texts below are copied verbatim from installed packages, recorded versioned upstream sources, or reviewed system DataPack resource comments. Package labels may be alternatives.', 'Seven notice texts extracted from 13 fixed QtWebEngine DataPack resources are included; the Qt runtime remains system-only and complete Chromium attribution remains uncollected.', 'Complete corresponding dependency sources and build materials are NOT included in this package.', 'Qt WebEngine Chromium build-specific notices remain to be completed before a release.', 'See dependency-manifest.json for exact versions, hashes, source pointers and limitations.', '', 'References:', 'https://doc.qt.io/qt-6/qtwebengine-licensing.html', 'https://doc.qt.io/qt-6/linux-deployment.html', ''] for name, item in sorted(components.items()): lines += [f'{name} {item["version"]} [{item["distribution"]}]', ' Upstream: ' + item['upstreamHome'], ' Package license labels: ' + '; '.join(item.get('licenseLabelsFromPackage', ['See bundled PDFium notices'])), ' Source status: ' + item['source']['status']] lines += [' License text: ' + text['path'] for text in item['licenseTexts']] if not item['licenseTexts']: lines.append(' License text: NOT COLLECTED from the installed package') lines.append('') (output / 'NOTICE.txt').write_text('\n'.join(lines), encoding='utf-8') return result def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--prefix', type=Path, required=True) parser.add_argument('--output', type=Path, required=True) parser.add_argument('--qtpaths', default='/usr/lib/qt6/bin/qtpaths') args = parser.parse_args() result = inventory(args.prefix, args.output, args.qtpaths) print(f'{len(result["components"])} components; {len(result["systemFiles"])} system runtime candidates; dependency sources not collected') if __name__ == '__main__': main()