Files
docview/tools/package_linux_development.py
2026-09-21 13:41:40 +09:00

224 lines
11 KiB
Python

#!/usr/bin/env python3
"""Build a deterministic, local Arch development tar.gz from a trusted install.
No system runtime binaries or user configuration/history are copied. This is not
a self-contained Linux release. The same install, host inventory, packaging code,
Python/zlib and SOURCE_DATE_EPOCH produce the same archive bytes.
"""
import argparse
import gzip
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import re
import shutil
import stat
import subprocess
import tarfile
import tempfile
import zlib
from collect_linux_dependencies import EXECUTABLES, ROOT, inventory, sha256
PACKAGE_NAME = 'docview-0.1.0-arch-x86_64-development'
MAX_PAYLOAD_BYTES = 512 * 1024 * 1024
MANIFEST = 'share/doc/docview/package-manifest.json'
def payload_files(directory):
result, size = [], 0
for path in sorted(directory.rglob('*')):
info = path.lstat()
if stat.S_ISLNK(info.st_mode) or not (stat.S_ISREG(info.st_mode) or stat.S_ISDIR(info.st_mode)):
raise ValueError('Package contains a link or special file: ' + str(path))
if stat.S_ISDIR(info.st_mode):
continue
if info.st_nlink != 1:
raise ValueError('Package contains a multiply-linked file: ' + str(path))
size += info.st_size
if size > MAX_PAYLOAD_BYTES or len(result) >= 10000:
raise ValueError('Package payload exceeds its finite limit')
result.append(path)
return result
def copy_install(prefix, destination):
allowed = {Path('bin') / name for name in EXECUTABLES} | {Path('lib/libpdfium.so')}
files = payload_files(prefix)
present = {path.relative_to(prefix) for path in files}
if not allowed <= present:
raise ValueError('Install is missing an application executable or PDFium')
initial_hashes = {str(path): sha256(prefix / path) for path in allowed}
for path in files:
relative = path.relative_to(prefix)
if relative not in allowed and not relative.is_relative_to('share/doc/docview'):
raise ValueError('Unexpected file in install payload: ' + str(relative))
target = destination / relative
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(path, target)
target.chmod(0o755 if relative in allowed and relative.parts[0] == 'bin' else 0o644)
if initial_hashes != {str(path): sha256(prefix / path) for path in allowed} or initial_hashes != {
str(path): sha256(destination / path) for path in allowed}:
raise ValueError('Installed binaries changed while copying package input')
def describe_payload(directory, epoch):
files = []
for path in payload_files(directory):
relative = path.relative_to(directory).as_posix()
if relative == MANIFEST:
continue
files.append({'path': relative, 'size': path.stat().st_size, 'sha256': sha256(path),
'mode': '0755' if os.access(path, os.X_OK) else '0644'})
return {'schemaVersion': 1, 'name': PACKAGE_NAME, 'sourceDateEpoch': epoch,
'scope': 'Local Arch Linux development payload; not a clean Ubuntu/Windows package',
'docviewLicense': 'unspecified', 'dependencySourceFulfillment': 'not-complete',
'excludedFromOwnDigest': [MANIFEST], 'files': files}
def write_archive(directory, archive, epoch):
if not 0 <= epoch <= 0xffffffff:
raise ValueError('SOURCE_DATE_EPOCH must fit a gzip timestamp')
files = payload_files(directory)
with archive.open('xb') as raw:
with gzip.GzipFile(filename='', mode='wb', fileobj=raw, compresslevel=9, mtime=epoch) as zipped:
with tarfile.open(fileobj=zipped, mode='w', format=tarfile.PAX_FORMAT) as tar:
for path in files:
relative = path.relative_to(directory).as_posix()
info = tarfile.TarInfo(PACKAGE_NAME + '/' + relative)
info.size = path.stat().st_size
info.mode = 0o755 if os.access(path, os.X_OK) else 0o644
info.uid = info.gid = 0
info.uname = info.gname = ''
info.mtime = epoch
with path.open('rb') as source:
tar.addfile(info, source)
def verify_and_extract(archive, destination):
"""Reject links, traversal, duplicate members and hash mismatches before use."""
if destination.exists() and any(destination.iterdir()):
raise ValueError('Extraction destination must be empty')
destination.mkdir(parents=True, exist_ok=True)
seen, total = set(), 0
with tarfile.open(archive, 'r:gz') as tar:
members = tar.getmembers()
if not members or len(members) > 10000:
raise ValueError('Invalid archive member count')
for member in members:
path = PurePosixPath(member.name)
if (not member.isfile() or path.is_absolute() or '..' in path.parts or
len(path.parts) < 2 or path.parts[0] != PACKAGE_NAME or
str(path) != member.name or member.name in seen or member.mode not in (0o644, 0o755)):
raise ValueError('Unsafe or duplicate archive member: ' + member.name)
seen.add(member.name)
total += member.size
if total > MAX_PAYLOAD_BYTES:
raise ValueError('Archive exceeds unpacked size limit')
manifest_member = tar.getmember(PACKAGE_NAME + '/' + MANIFEST)
if manifest_member.size > 8 * 1024 * 1024:
raise ValueError('Oversized payload manifest')
with tar.extractfile(manifest_member) as source:
manifest = json.load(source)
if manifest.get('schemaVersion') != 1 or manifest.get('name') != PACKAGE_NAME:
raise ValueError('Invalid payload manifest')
expected = {}
for row in manifest['files']:
key = PACKAGE_NAME + '/' + row['path']
if key in expected or key not in seen or not re.fullmatch('[0-9a-f]{64}', row['sha256']):
raise ValueError('Invalid manifest file entry')
expected[key] = row
if set(expected) | {manifest_member.name} != seen:
raise ValueError('Manifest and archive entries differ')
for member in members:
data = tar.extractfile(member)
target = destination / member.name
target.parent.mkdir(parents=True, exist_ok=True)
digest, written = hashlib.sha256(), 0
with data, target.open('xb') as output:
for chunk in iter(lambda: data.read(1024 * 1024), b''):
written += len(chunk)
digest.update(chunk)
output.write(chunk)
if member.name in expected:
row = expected[member.name]
if (row['size'] != written or row['sha256'] != digest.hexdigest() or
int(row['mode'], 8) != member.mode):
raise ValueError('Payload integrity failure: ' + member.name)
target.chmod(member.mode)
return destination / PACKAGE_NAME
def create_package(prefix, output, epoch=0, qtpaths='/usr/lib/qt6/bin/qtpaths'):
prefix, output = prefix.resolve(strict=True), output.resolve()
output.mkdir(parents=True, exist_ok=True)
archive = output / (PACKAGE_NAME + '.tar.gz')
report_path = output / (PACKAGE_NAME + '.json')
if archive.exists() or report_path.exists():
raise ValueError('Package output already exists; select a new output directory')
with tempfile.TemporaryDirectory(prefix='docview-package-') as temporary:
staging = Path(temporary) / 'payload'
staging.mkdir()
copy_install(prefix, staging)
documentation = staging / 'share/doc/docview'
for source, name in [(ROOT / 'docs/LINUX-DEVELOPMENT-PACKAGE.md', 'LINUX-DEVELOPMENT-PACKAGE.md'),
(ROOT / 'resources/licenses/README.md', 'THIRD-PARTY-SCOPE.md')]:
shutil.copyfile(source, documentation / name)
notices = documentation / 'third-party'
if notices.exists():
raise ValueError('Install already contains generated third-party inventory; use a fresh install')
dependencies = inventory(staging, notices, qtpaths)
manifest = describe_payload(staging, epoch)
(staging / MANIFEST).write_text(json.dumps(manifest, ensure_ascii=False, sort_keys=True, indent=2) + '\n')
temporary_archive = Path(temporary) / 'package.tar.gz'
write_archive(staging, temporary_archive, epoch)
extracted = verify_and_extract(temporary_archive, Path(temporary) / 'verified')
# A second archive from the verified extraction establishes deterministic
# metadata/order/compression without relying on filesystem mtimes.
repeated = Path(temporary) / 'repeated.tar.gz'
write_archive(extracted, repeated, epoch)
if sha256(temporary_archive) != sha256(repeated):
raise ValueError('Package is not byte reproducible after extraction')
report = {'schemaVersion': 1, 'name': PACKAGE_NAME, 'archive': archive.name,
'archiveSha256': sha256(temporary_archive), 'archiveBytes': temporary_archive.stat().st_size,
'unpackedFileBytes': sum(p.stat().st_size for p in payload_files(staging)),
'payloadFileCount': len(manifest['files']) + 1, 'sourceDateEpoch': epoch,
'pythonVersion': platform_version(), 'zlibVersion': zlib.ZLIB_RUNTIME_VERSION,
'executableSha256': dependencies['executableSha256'],
'systemRuntimeCandidateBytesNotBundled': dependencies['systemRuntimeCandidateBytes'],
'systemComponentCount': len(dependencies['components']) - 1,
'archiveRoundTripSha256Matches': True, 'guiExtractionSmoke': 'not-run-by-packager',
'scope': dependencies['scope'], 'dependencySources': 'not-collected',
'completeChromiumNotices': False, 'cleanOsAcceptance': False}
shutil.copyfile(temporary_archive, archive)
report_path.write_text(json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + '\n')
return report
def platform_version():
import platform
return platform.python_version()
def main():
parser = argparse.ArgumentParser(description=__doc__)
source = parser.add_mutually_exclusive_group(required=True)
source.add_argument('--prefix', type=Path, help='Trusted, fresh cmake install tree')
source.add_argument('--build-dir', type=Path, help='Run cmake --install into a private temporary tree; no build')
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--epoch', type=int, default=int(os.environ.get('SOURCE_DATE_EPOCH', '0')))
parser.add_argument('--qtpaths', default='/usr/lib/qt6/bin/qtpaths')
args = parser.parse_args()
if args.build_dir:
with tempfile.TemporaryDirectory(prefix='docview-install-') as directory:
subprocess.run(['cmake', '--install', str(args.build_dir.resolve()), '--prefix', directory], check=True)
result = create_package(Path(directory), args.output, args.epoch, args.qtpaths)
else:
result = create_package(args.prefix, args.output, args.epoch, args.qtpaths)
print(json.dumps(result, indent=2))
if __name__ == '__main__':
main()