initial commit
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user