301 lines
19 KiB
Python
301 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""Bounded non-graphical audit and single Meson DESTDIR stage of trusted artifacts.
|
|
|
|
WHY: source, ELF visibility and actual binding evidence answer different questions.
|
|
CONTRACT: historical LAB is read-only; only new external LAB receives results.
|
|
INVARIANT: never run the compositor; never rebuild, install live, or change symbols.
|
|
"""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import signal
|
|
import stat
|
|
import subprocess
|
|
|
|
p = argparse.ArgumentParser(description=__doc__)
|
|
p.add_argument('lab', type=Path)
|
|
p.add_argument('--historical', type=Path, required=True)
|
|
p.add_argument('--historical-readme', type=Path, required=True)
|
|
p.add_argument('--stage', action='store_true')
|
|
p.add_argument('--verify', action='store_true')
|
|
a = p.parse_args()
|
|
lab, old = a.lab.resolve(), a.historical.resolve()
|
|
repo = Path(__file__).resolve().parents[3]
|
|
assert not old.is_relative_to(lab) and not lab.is_relative_to(old)
|
|
assert lab.is_dir() and old.is_dir() and lab != old
|
|
assert lab.is_relative_to(Path.home()) and not lab.is_relative_to(repo)
|
|
if a.verify:
|
|
assert not a.stage
|
|
for kind in ['source', 'evidence']:
|
|
manifest = lab/'results'/(kind+'-sha256.txt')
|
|
lines = manifest.read_text().splitlines()
|
|
for line in lines:
|
|
digest, filename = line.split(' ', 1)
|
|
assert hashlib.sha256(Path(filename).read_bytes()).hexdigest() == digest, filename
|
|
print(kind, len(lines), hashlib.sha256(manifest.read_bytes()).hexdigest(), 'PASS')
|
|
raise SystemExit(0)
|
|
assert not (lab/'results/evidence-sha256.txt').exists(), 'sealed: verify only'
|
|
raw, results = lab/'raw', lab/'results'
|
|
raw.mkdir(exist_ok=True); results.mkdir(exist_ok=True)
|
|
env = {k: v for k, v in os.environ.items() if k not in (
|
|
'SWAYSOCK', 'I3SOCK', 'WAYLAND_DISPLAY', 'DISPLAY', 'XDG_RUNTIME_DIR',
|
|
'LD_LIBRARY_PATH', 'LD_PRELOAD', 'PKG_CONFIG_PATH')}
|
|
os.environ["PYTHONDONTWRITEBYTECODE"] = "1"
|
|
env.update(LC_ALL='C', PYTHONDONTWRITEBYTECODE='1', MESON_ROOT_CMD='/bin/false')
|
|
commands = json.loads((results/'commands.json').read_text()) if (results/'commands.json').exists() else []
|
|
|
|
def sha(path):
|
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
|
|
def save(name, value):
|
|
(results/(name+'.json')).write_text(json.dumps(value, indent=2)+'\n')
|
|
|
|
def read(name):
|
|
return json.loads((old/'results'/(name+'.json')).read_text())
|
|
|
|
def run(label, args, extra=None):
|
|
# Only explicitly requested diagnostics, Meson setup/install and objcopy.
|
|
# Command output is file-backed, with bounded execution and owned group.
|
|
label = f'{len(commands):03}-'+label
|
|
entry = {'args': list(map(str, args)), 'env_override': extra or {}, 'label': label}
|
|
with (raw/(label+'.out')).open('wb') as out, (raw/(label+'.err')).open('wb') as err:
|
|
proc = subprocess.Popen(entry['args'], env={**env, **(extra or {})},
|
|
stdout=out, stderr=err, start_new_session=True)
|
|
try:
|
|
proc.wait(timeout=120)
|
|
except subprocess.TimeoutExpired:
|
|
os.killpg(proc.pid, signal.SIGTERM)
|
|
try:
|
|
proc.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
os.killpg(proc.pid, signal.SIGKILL); proc.wait()
|
|
try:
|
|
os.killpg(proc.pid, signal.SIGKILL)
|
|
except ProcessLookupError:
|
|
pass
|
|
entry['timed_out'] = True
|
|
entry['returncode'] = proc.returncode
|
|
commands.append(entry); save('commands', commands)
|
|
assert proc.returncode == 0, (label, entry)
|
|
assert (raw/(label+'.out')).stat().st_size < 32*1024*1024
|
|
output = (raw/(label+'.out')).read_text()
|
|
(raw/(label.split('-', 1)[1]+'.out')).write_text(output)
|
|
return output
|
|
|
|
integrity = {}
|
|
for kind, count, digest in [
|
|
('source', 1463, 'a27fa33d57d3423e09f02430f04cfed643762d876099b7f88489229115c4a1e5'),
|
|
('evidence', 204, '86f7654c8ad962053644ce2c4c13eb9f026d6618b171e0cc3a8fc3034eb380d5')]:
|
|
manifest = old/'results'/(kind+'-sha256.txt')
|
|
assert sha(manifest) == digest
|
|
lines = manifest.read_text().splitlines(); assert len(lines) == count
|
|
for line in lines:
|
|
expected, filename = line.split(' ', 1)
|
|
path = Path(filename)
|
|
if path == repo/'tooling/probes/step20c-scenefx-abi-package/README.md':
|
|
path = a.historical_readme
|
|
assert sha(path) == expected, filename
|
|
integrity[kind] = {'count': count, 'sha256': digest, 'verified': True}
|
|
save('historical-integrity', integrity)
|
|
elf = read('static-binary-elf'); binary = Path(elf['path'])
|
|
assert sha(binary) == elf['sha256'] and binary.stat().st_size < 128*1024*1024
|
|
for name in ['static-binary-elf', 'experimental-binary-elf', 'system-wlroots-elf',
|
|
'pristine-scenefx-symbols']:
|
|
item = read(name); assert sha(Path(item['path'])) == item['sha256'], name
|
|
for tag, args in [('header', ['readelf', '-hW']), ('dynamic', ['readelf', '-dW']),
|
|
('symbols', ['readelf', '-Ws']), ('relocations', ['readelf', '-rW']),
|
|
('versions', ['readelf', '-VW']), ('nm', ['nm', '--format=posix'])]:
|
|
run('static-'+tag, [*args, binary])
|
|
# Copy retained diagnostic records, do not reexecute the historical ABI campaign.
|
|
for name in ['static-bindings', 'static-relocations', 'loader-static-default']:
|
|
shutil.copyfile(old/'raw'/(name+'.json'), raw/('historical-'+name+'.json'))
|
|
load = read('loader-resolution')
|
|
assert load['static-relocations']['returncode'] == 0
|
|
assert 'undefined symbol:' not in load['static-relocations']['stdout']
|
|
assert 'libscenefx' not in load['static-default']['stdout']
|
|
save('static-elf', {**elf, 'relocations': 'PASS historical trusted loader diagnostics',
|
|
'graphics': 'NOT TESTED', 'loader_records_reused': True})
|
|
|
|
# Full ELF tables distinguish local/global/default visibility from dynsym membership.
|
|
def symbols(path):
|
|
out = {}
|
|
for line in path.read_text().splitlines():
|
|
f = line.split()
|
|
if len(f) >= 8 and f[0].rstrip(':').isdigit():
|
|
name = f[7].split('@')[0]
|
|
rec = dict(type=f[3], bind=f[4], visibility=f[5], index=f[6],
|
|
version=f[7].split('@', 1)[1] if '@' in f[7] else None)
|
|
out.setdefault(name, []).append(rec)
|
|
return out
|
|
sym = symbols(raw/'static-symbols.out')
|
|
scene = set(read('pristine-scenefx-symbols')['defined'])
|
|
wl = set(read('system-wlroots-elf')['defined'])
|
|
shared = read('experimental-binary-elf')
|
|
clip = {'wlr_scene_tree_set_clip', 'wlr_scene_tree_get_clip'}
|
|
local_names = {n for n, records in sym.items() if any(x['bind']=='LOCAL' and x['index']!='UND' for x in records)}
|
|
scan_names = set(sym) | scene | clip
|
|
source_refs = {}
|
|
for path in sorted((old/'mosaic-private/sway').rglob('*.c')):
|
|
for lineno, line in enumerate(path.read_text().splitlines(), 1):
|
|
for name in set(re.findall(r'\b[A-Za-z_][A-Za-z0-9_]*\b', line)) & scan_names:
|
|
source_refs.setdefault(name, []).append({'file': str(path.relative_to(old)),
|
|
'line': lineno, 'text': line.strip()})
|
|
definition_refs = {}
|
|
actual_definitions = {}
|
|
for tree in ['scene-private', 'wlroots-source', 'mosaic-private']:
|
|
for path in sorted((old/tree).rglob('*.c')):
|
|
content = path.read_text()
|
|
for match in re.finditer(r'(?m)^[ \t]*(?:[A-Za-z_]\w*[\s*]+)+([A-Za-z_]\w*)\s*\([^;{}]*\)\s*\{', content):
|
|
name = match[1]
|
|
if name in scan_names:
|
|
actual_definitions.setdefault(name, []).append({'file': str(path.relative_to(old)), 'line': content[:match.start()].count('\n')+1})
|
|
for lineno, line in enumerate(path.read_text().splitlines(), 1):
|
|
for name in set(re.findall(r'\b[A-Za-z_][A-Za-z0-9_]*\b', line)) & scan_names:
|
|
definition_refs.setdefault(name, []).append({'file': str(path.relative_to(old)),
|
|
'line': lineno, 'text': line.strip()})
|
|
bindings = {}
|
|
for mode in ['stock-bindings', 'private-bindings', 'static-bindings']:
|
|
data = read('loader-resolution')[mode]
|
|
for line in (data['stdout']+'\n'+data['stderr']).splitlines():
|
|
match = re.search(r"binding file (.*?) \[0\] to (.*?) \[0\].*symbol `([^']+)'", line)
|
|
if not match:
|
|
continue
|
|
rec = {'consumer': match[1], 'provider': match[2]}
|
|
rows = bindings.setdefault(match[3], {}).setdefault(mode, [])
|
|
if rec not in rows:
|
|
rows.append(rec)
|
|
public = sorted((set(shared['undefined']) & scene) - clip)
|
|
common = sorted(scene & wl)
|
|
assert len(common) == 85
|
|
ownership = []
|
|
local_names = {n for n, records in sym.items() if any(x['bind']=='LOCAL' and x['index']!='UND' for x in records)}
|
|
for name in sorted(set(public) | set(common) | clip | set(elf['undefined']) | local_names):
|
|
actual = bindings.get(name, {})
|
|
ownership.append({'name': name, 'groups': [g for g, yes in [
|
|
('A', name in public), ('B', name in clip), ('C', name in common),
|
|
('D', name in elf['undefined']), ('E', name in local_names)] if yes],
|
|
'definitions': actual_definitions.get(name, []),
|
|
'definition_reference_source_candidates': definition_refs.get(name, []),
|
|
'compositor_source_references': source_refs.get(name, []),
|
|
'elf': sym.get(name, []), 'dynamic_export': name in elf['defined'],
|
|
'expected_owner': 'SceneFX private clip' if name in clip else 'SceneFX scene/renderer' if name in scene else 'shared provider from retained loader graph' if name in elf['undefined'] else 'executable internal implementation',
|
|
'observed_providers': actual,
|
|
'selection': 'direct executable definition (non-dynsym)' if name in clip else ('MEASURED retained bindings (includes callers other than wlroots; see scene counts)' if actual else 'NOT OBSERVED at runtime; ELF definition/reference only'),
|
|
'abi_convention': 'unversioned WLR_USE_UNSTABLE C ABI and shared scene/renderer types' if name in scene or name in clip or name.startswith('wlr_') else 'ELF table version recorded; no new ABI assertion',
|
|
'classification': 'private prototype only; ABI not maintained' if name in clip else 'provider records retained; scene helper necessity inferred from source, not isolation experiment' if actual and name in common else 'current definition/reference characterized; full semantic ABI NOT ESTABLISHED',
|
|
'upgrade_risk': 'any new wlroots version/build NOT QUALIFIED'})
|
|
save('scene-symbol-ownership', {'group_A_scope': 'link-required public SceneFX functions across compiled SwayFX/Mosaic; per-source references distinguish Mosaic-specific paths', 'groups': {'A': public, 'B': sorted(clip), 'C': common,
|
|
'D': elf['undefined'], 'E': sorted(n for n, records in sym.items() if any(x['bind']=='LOCAL' and x['index']!='UND' for x in records))}, 'symbols': ownership})
|
|
save('exported-symbols', {'dynamic_definitions': elf['defined'], 'dynamic_undefined': elf['undefined'], 'full_table': sym})
|
|
save('interposition-matrix', {'observed': bindings, 'scene_wlroots_counts': read('scene-symbol-bindings'),
|
|
'overlap': common, 'visibility_changes': 'NONE', 'new_version': 'NOT TESTED',
|
|
'warning': 'matching names are not defects; hiding them may break wlroots helper delegation'})
|
|
transitive = read('transitive-dependencies')
|
|
stock = read('stock-binary-elf')
|
|
save('transitive-dependencies', {'static_DT_NEEDED': elf['DT_NEEDED'],
|
|
'stock_DT_NEEDED': stock['DT_NEEDED'], 'new_direct': sorted(set(elf['DT_NEEDED'])-set(stock['DT_NEEDED'])),
|
|
'removed_direct': sorted(set(stock['DT_NEEDED'])-set(elf['DT_NEEDED'])),
|
|
'historical_recursive_package_owners': transitive,
|
|
'note': 'historical union includes SceneFX for shared cases; static graph comes from historical loader-static-default.json',
|
|
'proposal_only': 'keep existing runtime dependencies except scenefx conditionally; add libglvnd, mesa, lcms2; promote libdrm/pixman to runtime; review glib2, glibc, xcb-util-wm direct owners; no PKGBUILD change'})
|
|
|
|
# Exact root licenses plus source-level notice candidates remain external.
|
|
notices = []
|
|
for tree in ['stock', 'scene-private', 'wlroots-source']:
|
|
for path in sorted((old/tree).rglob('*')):
|
|
if not path.is_file() or 'subprojects' in path.relative_to(old/tree).parts:
|
|
continue
|
|
if path.name.upper().startswith(('LICENSE', 'COPYING', 'NOTICE')):
|
|
notices.append({'path': str(path.relative_to(old)), 'sha256': sha(path), 'text': path.read_text(errors='replace'), 'kind': 'license_file'})
|
|
elif path.suffix in ['.c', '.h', '.frag', '.vert', '.xml']:
|
|
text = path.read_text(errors='replace')
|
|
if re.search(r'copyright|SPDX-License|permission is hereby|redistribution and use', text, re.I):
|
|
notices.append({'path': str(path.relative_to(old)), 'sha256': sha(path), 'text': text, 'kind': 'source_notice_candidate; inclusion needs link-object provenance review'})
|
|
# Link-input provenance also includes generated protocol code with its own notices.
|
|
# Root MIT files alone are insufficient for a complete distribution notice bundle.
|
|
incorporated = []
|
|
for build_name in ['static-build', 'scene-static-build']:
|
|
build_path = old/build_name
|
|
compile_commands = json.loads((build_path/'compile_commands.json').read_text())
|
|
shutil.copyfile(build_path/'compile_commands.json', raw/(build_name+'-compile-commands.json'))
|
|
shutil.copyfile(build_path/'build.ninja', raw/(build_name+'-build.ninja'))
|
|
for command in compile_commands:
|
|
path = (build_path/command['file']).resolve()
|
|
if not path.is_file():
|
|
continue
|
|
incorporated.append({'path': str(path), 'sha256': sha(path),
|
|
'object': command.get('output'), 'build': build_name,
|
|
'note': 'compile input, final archive extraction not proven by this alone'})
|
|
if path.is_relative_to(build_path) and path.suffix == '.c':
|
|
text = path.read_text()
|
|
comments = re.findall(r'/\*.*?\*/', text, re.S)
|
|
payload = '\n'.join(c for c in comments if re.search(r'copyright|permission|redistribution', c, re.I))
|
|
if payload:
|
|
notices.append({'path': str(path.relative_to(old)), 'sha256': sha(path),
|
|
'text': payload, 'kind': 'generated_protocol_notice'})
|
|
save('incorporated-source-inventory', incorporated)
|
|
run('archive-symbols', ['nm', '-A', '--defined-only', old/'scene-static-build/libscenefx-0.5.a'])
|
|
save('license-notices', {'root_licenses': read('licenses')['source_licenses'],
|
|
'source_notice_candidates': notices, 'system_packages': read('licenses'),
|
|
'incorporation': 'only SceneFX archive embedded; wlroots shared; SwayFX/common/protocol code included; no static system dependency claimed',
|
|
'compliance': 'NOT APPROVED; exact source notices retained, final object/notice closure still required'})
|
|
# Probe N1 solely on a disposable executable; never staged or executed.
|
|
notice_file = lab/'notice-candidate.txt'
|
|
notice_file.write_text('\n\n'.join(x['path']+'\n'+x['text'] for x in notices if x['kind'] in ['license_file', 'generated_protocol_notice']))
|
|
notice_bin = lab/'notice-copy'
|
|
run('notice-add', ['objcopy', '--add-section', '.mosaic_notices='+str(notice_file),
|
|
'--set-section-flags', '.mosaic_notices=readonly,contents', binary, notice_bin])
|
|
extracted = lab/'notice-extracted.txt'
|
|
run('notice-extract', ['objcopy', '--dump-section', '.mosaic_notices='+str(extracted), notice_bin, lab/'notice-extract-copy'])
|
|
assert extracted.read_bytes() == notice_file.read_bytes()
|
|
save('notice-distribution-options', {'N1': {'mechanics': 'PASS objcopy/extraction roundtrip on disposable copy', 'sha256': sha(extracted), 'staged': False, 'strip_retention': 'NOT TESTED', 'legal': 'NOT APPROVED', 'one_file': True, 'remaining': 'complete notice inventory, accessible extraction without executing compositor, retention through strip/sign/package, legal review'},
|
|
'N2': {'mechanics': 'license metadata identifies license; does not contain complete notices automatically', 'one_file': True, 'risk': 'package/source separation or raw executable transfer loses notices', 'legal': 'NOT APPROVED'},
|
|
'N3': {'mechanics': 'conventional installed notice file', 'one_file': False, 'decision': 'USER packaging contract change required', 'legal': 'complete inventory/review still required'}})
|
|
|
|
if a.stage:
|
|
# One-shot marker persists even on failure. Historical build remains untouched.
|
|
marker = lab/'stage-started.json'
|
|
assert not (lab/'stage-install-started.json').exists(), 'staging attempt already consumed'
|
|
if not marker.exists():
|
|
with marker.open('x') as out:
|
|
json.dump({'budget': 1, 'binary': str(binary), 'sha256': sha(binary)}, out)
|
|
source, build = lab/'stage-source', lab/'stage-plan'
|
|
if not source.exists():
|
|
shutil.copytree(old/'mosaic-private', source)
|
|
# Git archives need not contain a subprojects directory; never assume one.
|
|
original = old/'mosaic-private'
|
|
for path in original.rglob('*'):
|
|
if path.is_file() and 'subprojects' not in path.relative_to(original).parts:
|
|
assert sha(path) == sha(source/path.relative_to(original))
|
|
flags = ['--prefix=/usr', '--bindir=bin', '-Dwerror=false', '-Dsd-bus-provider=libsystemd',
|
|
'-Dmosaic-package=true', '-Dswaybar=false', '-Dswaynag=false', '-Dman-pages=disabled', '-Ddefault-wallpaper=false']
|
|
run('stage-plan-setup', ['meson', 'setup', build, source, *flags],
|
|
{'PKG_CONFIG_PATH': str(old/'scene-static-build/meson-uninstalled')})
|
|
plan = json.loads((build/'meson-info/intro-installed.json').read_text())
|
|
assert plan == {str(build/'sway/swayfx-mosaic'): '/usr/bin/swayfx-mosaic'}, plan
|
|
target = build/'sway/swayfx-mosaic'; shutil.copy2(binary, target)
|
|
assert sha(target) == elf['sha256']
|
|
with (lab/'stage-install-started.json').open('x') as out:
|
|
json.dump({'budget': 1}, out)
|
|
run('stage-static', ['meson', 'install', '-C', build, '--destdir', lab/'stage-static', '--no-rebuild'])
|
|
entries = []
|
|
for path in sorted((lab/'stage-static').rglob('*')):
|
|
s = path.lstat(); kind = 'symlink' if stat.S_ISLNK(s.st_mode) else 'directory' if stat.S_ISDIR(s.st_mode) else 'regular' if stat.S_ISREG(s.st_mode) else 'other'
|
|
entries.append({'path': '/'+str(path.relative_to(lab/'stage-static')), 'kind': kind,
|
|
'mode': oct(stat.S_IMODE(s.st_mode)), 'uid': s.st_uid, 'gid': s.st_gid,
|
|
'bytes': s.st_size, 'sha256': sha(path) if kind=='regular' else None,
|
|
'target': os.readlink(path) if kind=='symlink' else None})
|
|
files = [x for x in entries if x['kind']!='directory']
|
|
passed = len(files)==1 and files[0]['path']=='/usr/bin/swayfx-mosaic' and files[0]['kind']=='regular' and files[0]['sha256']==elf['sha256']
|
|
save('static-stage-manifest', {'status': 'STATIC_STAGE_FILE_SHAPE_PASS' if passed else 'STATIC_STAGE_FILE_SHAPE_FAIL',
|
|
'entries': entries, 'complete_non_directory_manifest': files, 'plan': plan,
|
|
'build_options': json.loads((build/'meson-info/intro-buildoptions.json').read_text()),
|
|
'metadata_adaptation_only': True, 'compositor_compiles': 0, 'intended_package_owner': 'swayfx-mosaic',
|
|
'intended_install_uid_gid': 'root:root under future packaging; actual staging remains user-owned',
|
|
'notices_present': False, 'libraries_installed': False})
|
|
assert passed
|
|
print('PASS static analysis, retained binding verification and notice mechanics; stage executed' if a.stage else 'PASS static analysis only; no new staging')
|