#!/usr/bin/env python3 import os import sys import argparse import subprocess import re import xml.etree.ElementTree as ET from operator import itemgetter whoami = os.path.basename(sys.argv[0]) def warn(*args, **kwargs): print(*args, file=sys.stderr, **kwargs) class Main: def main(self, args=sys.argv[1:], prog=whoami): options = self.parse_args(args, prog) if options.action == 'dump': self.dump(options) elif options.action == 'get-sizes': self.get_sizes(options) elif options.action == 'compare': self.compare(options) else: exit(f'{whoami}: unknown action') def parse_args(self, args, prog): parser = argparse.ArgumentParser( prog=prog, # formatter_class=argparse.RawDescriptionHelpFormatter, description='Check ABI for changes', ) subparsers = parser.add_subparsers( dest='action', help='specify subcommand; run action with --help for details', required=True) lib_arg = ('--lib', {'help': 'library file', 'required': True}) p_dump = subparsers.add_parser( 'dump', help='dump qpdf symbols in a library') p_dump.add_argument(lib_arg[0], **lib_arg[1]) p_get_sizes = subparsers.add_parser( 'get-sizes', help='dump sizes of all public classes') p_get_sizes.add_argument('--include', help='include path', required=True) p_compare = subparsers.add_parser( 'compare', help='compare libraries and sizes') p_compare.add_argument('--new-lib', help='new library file', required=True) p_compare.add_argument('--old-lib', help='old library file', required=True) p_compare.add_argument('--old-sizes', help='output of old sizes', required=True) p_compare.add_argument('--new-sizes', help='output of new sizes', required=True) return parser.parse_args(args) def get_versions(self, path): p = os.path.basename(os.path.realpath(path)) m = re.match(r'^libqpdf.so.(\d+).(\d+).(\d+)$', p) if not m: exit(f'{whoami}: {path} does end with libqpdf.so.x.y.z') major = int(m.group(1)) minor = int(m.group(2)) patch = int(m.group(3)) return (major, minor, patch) def get_symbols(self, path): symbols = set() p = subprocess.run( ['nm', '-D', '--demangle', '--with-symbol-versions', path], stdout=subprocess.PIPE) if p.returncode: exit(f'{whoami}: failed to get symbols from {path}') for line in p.stdout.decode().split('\n'): # The LIBQPDF_\d+ comes from the version tag in # libqpdf.map.in. m = re.match(r'^[0-9a-f]+ (.) (.+)@@LIBQPDF_\d+\s*$', line) if not m: continue symbol = m.group(2) if re.match(r'^((void|int|bool|(.*? for)) )?std::', symbol): # Calling different methods of STL classes causes # different template instantiations to appear. # Standard library methods that sneak into the binary # interface are not considered part of the qpdf ABI. continue symbols.add(symbol) return symbols def dump(self, options): # This is just for manual use to investigate surprises. for i in sorted(self.get_symbols(options.lib)): print(i) @staticmethod def get_header_sizes(include, filename): print(f'{filename}...', file=sys.stderr) p = subprocess.run([ 'castxml', '--castxml-output=1', f'-I{include}', f'{include}/{filename}', '-o', '-', ], stdout=subprocess.PIPE, check=True) tree = ET.fromstring(p.stdout) this_file = tree.find(f'.//File[@name="{include}/{filename}"]') if this_file is None: # This file doesn't define anything, e.g., DLL.h return [] this_file_id = this_file.attrib["id"] wanted = [ 'Namespace', 'Struct', 'Union', 'Class', # records ] by_id = {} for elem in tree: # Reference # https://github.com/CastXML/CastXML/blob/master/doc/manual/castxml.xsd if elem.tag not in wanted: continue is_record = elem.tag != 'Namespace' record = { 'is_record': is_record, 'in_file': (elem.get('file') == this_file_id and elem.get('incomplete') is None), 'name': elem.get('name'), 'access': elem.get('access'), 'context': elem.get('context'), 'size': elem.get('size'), } by_id[elem.attrib['id']] = record classes = [] for id_, record in by_id.items(): cur = record if not cur['in_file']: continue name = '' private = False while cur is not None: if cur.get('access') == 'private': private = True parent = cur.get('context') name = f'{cur["name"]}{name}' if parent is None or parent == '_1': break name = f'::{name}' cur = by_id.get(cur.get('context')) if not private: classes.append([name, record['size']]) return classes def get_sizes(self, options): classes = [] for f in os.listdir(f'{options.include}/qpdf'): if f.startswith('auto_') or f == 'QPDFObject.hh': continue if f.endswith('.h') or f.endswith('.hh'): classes.extend(self.get_header_sizes( options.include, f"qpdf/{f}")) classes.sort(key=itemgetter(0)) for c, s in classes: print(c, s) def read_sizes(self, filename): sizes = {} with open(filename, 'r') as f: for line in f.readlines(): line = line.strip() m = re.match(r'^(.*) (\d+)$', line) if not m: exit(f'{filename}: bad sizes line: {line}') sizes[m.group(1)] = m.group(2) return sizes def compare(self, options): old_version = self.get_versions(options.old_lib) new_version = self.get_versions(options.new_lib) old = self.get_symbols(options.old_lib) new = self.get_symbols(options.new_lib) if old_version > new_version: exit(f'{whoami}: old version is newer than new version') allow_abi_change = new_version[0] > old_version[0] allow_added = allow_abi_change or (new_version[1] > old_version[1]) removed = sorted(old - new) added = sorted(new - old) if removed: print('INTERFACES REMOVED:') for i in removed: print(' ', i) else: print('No interfaces were removed') if added: print('INTERFACES ADDED') for i in added: print(' ', i) else: print('No interfaces were added') if removed and not allow_abi_change: exit(f'{whoami}: **ERROR**: major version must be bumped') elif added and not allow_added: exit(f'{whoami}: **ERROR**: minor version must be bumped') else: print(f'{whoami}: ABI check passed.') old_sizes = self.read_sizes(options.old_sizes) new_sizes = self.read_sizes(options.new_sizes) size_errors = False for k, v in old_sizes.items(): if k in new_sizes and v != new_sizes[k]: size_errors = True print(f'{k} changed size from {v} to {new_sizes[k]}') if size_errors: if not allow_abi_change: exit(f'{whoami}:' 'size changes detected; this is an ABI change.') else: print(f'{whoami}: no size changes detected') if __name__ == '__main__': try: subprocess.run(['castxml', '--version'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except Exception: exit('f{whoami}: castxml must be installed') try: Main().main() except KeyboardInterrupt: exit(130)