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
+531
View File
@@ -0,0 +1,531 @@
#!/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())