#!/usr/bin/env python3 """verneed-closure — third release gate: the ABI versions a bundle needs vs the ones it ships. Why: `check-appimage-closure` (tiers 1-2) proves every DT_NEEDED soname and every dlopen-by-name soname resolves. It does NOT prove the bundle carries the *symbol version* the executable was linked against. A bundle can ship libfoo.so.1 and still break at load time because the binary's .gnu.version_r asks for FOO_2.0 while the bundled lib only defines FOO_1.0. That failure is static — no runtime needed. Hard failures (exit 1): a) a required soname IS bundled but does not define the required version (ABI drift inside the bundle) b) a required soname is neither bundled nor resolvable on the host Diagnostics (never fail the build): needs satisfied by host system libraries, because a release that delegates glibc to the host is legitimate. Usage: verneed-closure.py Exit: 0 = no hard ABI failures, 1 = at least one, 2 = usage/parse error """ import os, re, subprocess, sys, collections def elf_files(root): for dp, _, fs in os.walk(root): for f in fs: p = os.path.join(dp, f) try: with open(p, 'rb') as fh: if fh.read(4) != b'\x7fELF': continue except OSError: continue yield p def readelf(p, flag): try: return subprocess.run(['readelf', flag, p], capture_output=True, text=True, timeout=90).stdout except Exception: return '' def soname(p): m = re.search(r'\(SONAME\)\s+Library soname: \[([^\]]+)\]', readelf(p, '-d')) if m: return m.group(1) m = re.search(r"\[([^\]]+\.so[^\]]*)\]", readelf(p, '-d')) return m.group(1) if m else os.path.basename(p) def provided(p): """versions DEFINED by this object: soname -> {'BASE', }""" out = readelf(p, '-V') m = re.search(r"Version definition section '\.gnu\.version_d'.*?(?=\nVersion |\Z)", out, re.S) if not m: return {} name, prov = None, set() for line in m.group(0).splitlines(): d = re.match(r'\s*(?:0x)?[0-9a-f]+:\s+Rev:\s+\d+\s+Flags:\s+(\S+)\s+Index:\s+\d+\s+Cnt:\s+\d+\s+Name:\s+(\S+)', line) if d: flags, n = d.group(1), d.group(2) if flags.upper() == 'BASE': name = n else: prov.add(n) continue d = re.match(r'\s*(?:0x)?[0-9a-f]+:\s+Rev:\s+\d+\s+Flags:\s+\S+\s+Index:\s+\d+\s+Cnt:\s+\d+\s+Name:\s+(\S+)', line) if d: prov.add(d.group(1)) if name is None: return {} return {name: prov | {'BASE'}} def needed(p): """versions REQUIRED by this object: {(soname, version)}""" out = readelf(p, '-V') m = re.search(r"Version needs section '\.gnu\.version_r'.*?(?=\Z)", out, re.S) if not m: return set() req, cur = set(), None for line in m.group(0).splitlines(): f = re.match(r'\s*(?:0x)?[0-9a-f]+:\s+Version:\s+\d+\s+File:\s+(\S+)\s+Cnt:\s+\d+', line) if f: cur = f.group(1) continue n = re.match(r'\s*(?:0x)?[0-9a-f]+:\s+Name:\s+(\S+)\s+Flags:', line) if n and cur: req.add((cur, n.group(1))) return req def main(): if len(sys.argv) < 2: print(__doc__) return 2 root = sys.argv[1] if os.path.isfile(root): subprocess.run([root, '--appimage-extract'], cwd=os.path.dirname(os.path.abspath(root)), capture_output=True, timeout=600) root = os.path.join(os.path.dirname(os.path.abspath(root)), 'squashfs-root') if not os.path.isdir(root): print('no such AppDir:', root) return 2 elves = list(elf_files(root)) index = collections.defaultdict(set) for p in elves: for name, vers in provided(p).items(): index[name] |= vers print('bundle: %s\nelf objects: %d\nsonames bundled: %d' % (root, len(elves), len(index))) host = {} def host_versions(name): if name in host: return host[name] vers = set() out = '' for exe in ('ldconfig', '/sbin/ldconfig', '/usr/sbin/ldconfig'): try: r = subprocess.run([exe, '-p'], capture_output=True, text=True, timeout=60) if r.stdout: out = r.stdout break except Exception: continue for line in out.splitlines(): toks = line.split() if toks and toks[0] == name and '=>' in line: path = line.split('=>')[-1].strip() if os.path.exists(path): for _, v in provided(path).items(): vers |= v host[name] = vers return vers hard, diag, checked = [], [], 0 for p in elves: for (name, ver) in sorted(needed(p)): checked += 1 if name in index: if ver in index[name] or ver == 'BASE': continue hard.append((p, name, ver, 'bundled but version missing')) else: hv = host_versions(name) if ver in hv or ver == 'BASE': diag.append((p, name, ver)) else: hard.append((p, name, ver, 'not bundled and not on host')) print('version requirements checked: %d' % checked) print('host-satisfied (diagnostic, non-blocking): %d' % len(diag)) print('HARD failures: %d' % len(hard)) for p, name, ver, why in hard[:40]: print(' FAIL %s needs %s(%s) - %s' % (os.path.relpath(p, root), name, ver, why)) return 1 if hard else 0 if __name__ == '__main__': sys.exit(main())