|
| 1 | +import argparse |
| 2 | +import glob |
| 3 | +import os |
| 4 | +from pathlib import Path |
| 5 | +import platform |
| 6 | +import shutil |
| 7 | +import subprocess |
| 8 | +import sys |
| 9 | +import tempfile |
| 10 | +from datetime import datetime |
| 11 | + |
| 12 | +parser = argparse.ArgumentParser( |
| 13 | + description='Test building the API repo and plugins out-of-tree (for CI checking of end-user workflow)' |
| 14 | +) |
| 15 | +parser.add_argument('--headless', default=False, action='store_true', help='Only include headless plugins') |
| 16 | +parser.add_argument('-j', '--parallel', default=4, help='Number of parallel jobs to tell cmake to run.') |
| 17 | +parser.add_argument('--bn-install-dir', default=os.environ.get('BN_INSTALL_DIR'), |
| 18 | + help='Path to a Binary Ninja installation. Defaults to BN_INSTALL_DIR.') |
| 19 | +parser.add_argument('--qt-install-dir', default=os.environ.get('QT_INSTALL_DIR'), |
| 20 | + help='Path to a Qt installation. Defaults to QT_INSTALL_DIR.') |
| 21 | +parser.add_argument('--qmake', default=os.environ.get('QMAKE'), help='Path to qmake. Defaults to QMAKE or PATH lookup.') |
| 22 | +parser.add_argument('--cmake', default=os.environ.get('CMAKE'), help='Path to cmake. Defaults to CMAKE or PATH lookup.') |
| 23 | +parser.add_argument('--cc', default=os.environ.get('CC'), help='C compiler to use. Defaults to CC or CMake platform default.') |
| 24 | +parser.add_argument('--cxx', default=os.environ.get('CXX'), help='C++ compiler to use. Defaults to CXX or CMake platform default.') |
| 25 | +parser.add_argument('--extra-project', action='append', default=[], |
| 26 | + help='Additional out-of-tree CMake project directory to build. May be specified multiple times.') |
| 27 | +args = parser.parse_args() |
| 28 | + |
| 29 | +api_base = Path(__file__).parent.parent.absolute() |
| 30 | + |
| 31 | + |
| 32 | +def fail(message): |
| 33 | + print(f'ERROR: {message}') |
| 34 | + sys.exit(1) |
| 35 | + |
| 36 | + |
| 37 | +def prepend_path(env, path): |
| 38 | + env['PATH'] = f'{path}{os.pathsep}{env.get("PATH", "")}' |
| 39 | + |
| 40 | + |
| 41 | +def find_executable(name, explicit_path=None, env=None): |
| 42 | + if explicit_path: |
| 43 | + path = Path(explicit_path).expanduser() |
| 44 | + if path.is_file(): |
| 45 | + return str(path) |
| 46 | + fail(f'{name} was specified as {path}, but that file does not exist.') |
| 47 | + |
| 48 | + return shutil.which(name, path=(env or os.environ).get('PATH')) |
| 49 | + |
| 50 | + |
| 51 | +def run_tool(tool, args_for_tool, env=None): |
| 52 | + try: |
| 53 | + result = subprocess.run([tool] + args_for_tool, check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, |
| 54 | + text=True, env=env) |
| 55 | + return result.stdout.strip() |
| 56 | + except subprocess.CalledProcessError as e: |
| 57 | + output = e.stdout.strip() if e.stdout else '' |
| 58 | + fail(f'{tool} failed with exit code {e.returncode}.\n{output}') |
| 59 | + |
| 60 | + |
| 61 | +def print_tool(tool_name, tool_path, version_args, env=None): |
| 62 | + print(f'{tool_name}: {tool_path}') |
| 63 | + version = run_tool(tool_path, version_args, env=env) |
| 64 | + print(version) |
| 65 | + |
| 66 | + |
| 67 | +def configure_qt_env(env): |
| 68 | + qt_dir = Path(args.qt_install_dir).expanduser().absolute() if args.qt_install_dir else None |
| 69 | + if qt_dir: |
| 70 | + if not qt_dir.exists(): |
| 71 | + fail(f'Qt directory was specified as {qt_dir}, but it does not exist.') |
| 72 | + qt_bin_dir = qt_dir / 'bin' |
| 73 | + if not qt_bin_dir.exists(): |
| 74 | + fail(f'Qt directory {qt_dir} does not contain a bin directory.') |
| 75 | + prepend_path(env, qt_bin_dir) |
| 76 | + env.setdefault('CMAKE_PREFIX_PATH', str(qt_dir / 'lib' / 'cmake')) |
| 77 | + print(f'Qt directory: {qt_dir}') |
| 78 | + print(f'CMAKE_PREFIX_PATH: {env["CMAKE_PREFIX_PATH"]}') |
| 79 | + |
| 80 | + if args.headless: |
| 81 | + return None |
| 82 | + |
| 83 | + qmake = find_executable('qmake', args.qmake, env=env) |
| 84 | + if not qmake: |
| 85 | + fail('qmake not found. Install Qt and add its bin directory to PATH, set QT_INSTALL_DIR, or pass --qt-install-dir/--qmake.') |
| 86 | + |
| 87 | + print_tool('qmake', qmake, ['--version'], env=env) |
| 88 | + qt_lib_path = run_tool(qmake, ['-query', 'QT_INSTALL_LIBS'], env=env) |
| 89 | + qt_cmake_path = str(Path(qt_lib_path) / 'cmake') |
| 90 | + env.setdefault('CMAKE_PREFIX_PATH', qt_cmake_path) |
| 91 | + print(f'Qt libraries: {qt_lib_path}') |
| 92 | + print(f'CMAKE_PREFIX_PATH: {env["CMAKE_PREFIX_PATH"]}') |
| 93 | + return qmake |
| 94 | + |
| 95 | + |
| 96 | +def check_binary_ninja_install(env): |
| 97 | + bn_install_dir = Path(args.bn_install_dir).expanduser().absolute() if args.bn_install_dir else None |
| 98 | + if not bn_install_dir: |
| 99 | + fail('Binary Ninja install directory was not specified. Set BN_INSTALL_DIR or pass --bn-install-dir.') |
| 100 | + if not bn_install_dir.exists(): |
| 101 | + fail(f'Binary Ninja install directory {bn_install_dir} does not exist.') |
| 102 | + |
| 103 | + env['BN_INSTALL_DIR'] = str(bn_install_dir) |
| 104 | + if platform.system() == 'Darwin': |
| 105 | + core_candidates = [bn_install_dir / 'Contents' / 'MacOS' / 'libbinaryninjacore.dylib'] |
| 106 | + ui_candidates = [bn_install_dir / 'Contents' / 'MacOS' / 'libbinaryninjaui.dylib'] |
| 107 | + elif platform.system() == 'Windows': |
| 108 | + core_candidates = [bn_install_dir / 'binaryninjacore.dll'] |
| 109 | + ui_candidates = [bn_install_dir / 'binaryninjaui.dll'] |
| 110 | + else: |
| 111 | + core_candidates = [bn_install_dir / 'libbinaryninjacore.so.1', bn_install_dir / 'libbinaryninjacore.so'] |
| 112 | + ui_candidates = [bn_install_dir / 'libbinaryninjaui.so.1', bn_install_dir / 'libbinaryninjaui.so'] |
| 113 | + |
| 114 | + core = next((p for p in core_candidates if p.exists()), None) |
| 115 | + if not core: |
| 116 | + fail(f'Binary Ninja Core was not found in {bn_install_dir}. Checked: {", ".join(str(p) for p in core_candidates)}') |
| 117 | + print(f'Binary Ninja install: {bn_install_dir}') |
| 118 | + print(f'Binary Ninja Core: {core}') |
| 119 | + |
| 120 | + ui = next((p for p in ui_candidates if p.exists()), None) |
| 121 | + if args.headless: |
| 122 | + print('Binary Ninja UI: skipped because --headless was specified') |
| 123 | + elif ui: |
| 124 | + print(f'Binary Ninja UI: {ui}') |
| 125 | + else: |
| 126 | + fail(f'Binary Ninja UI was not found in {bn_install_dir}. Checked: {", ".join(str(p) for p in ui_candidates)}') |
| 127 | + |
| 128 | + |
| 129 | +def contains_ui_dependency(cmake_lists): |
| 130 | + with open(cmake_lists, 'r', encoding='utf-8') as cmake_file: |
| 131 | + contents = cmake_file.read() |
| 132 | + return 'binaryninjaui' in contents or 'Qt6::' in contents or 'find_package(Qt6' in contents |
| 133 | + |
| 134 | + |
| 135 | +def external_project_cmake_files(): |
| 136 | + cmake_files = [] |
| 137 | + |
| 138 | + # The examples are the supported customer-facing out-of-tree CMake projects |
| 139 | + for f in glob.glob(str(api_base / 'examples' / '**' / 'CMakeLists.txt'), recursive=True): |
| 140 | + cmake_path = Path(f) |
| 141 | + if cmake_path.parent == api_base / 'examples': |
| 142 | + continue |
| 143 | + cmake_files.append(cmake_path) |
| 144 | + |
| 145 | + for project in args.extra_project: |
| 146 | + project_path = Path(project).expanduser() |
| 147 | + if not project_path.is_absolute(): |
| 148 | + project_path = api_base / project_path |
| 149 | + cmake_path = project_path / 'CMakeLists.txt' |
| 150 | + if not cmake_path.exists(): |
| 151 | + fail(f'Extra project {project_path} does not contain a CMakeLists.txt file.') |
| 152 | + cmake_files.append(cmake_path) |
| 153 | + |
| 154 | + return sorted(cmake_files) |
| 155 | + |
| 156 | + |
| 157 | +configure_env = os.environ.copy() |
| 158 | +configure_qt_env(configure_env) |
| 159 | +check_binary_ninja_install(configure_env) |
| 160 | + |
| 161 | +if args.cc: |
| 162 | + configure_env['CC'] = args.cc |
| 163 | +if args.cxx: |
| 164 | + configure_env['CXX'] = args.cxx |
| 165 | + |
| 166 | +cmake = find_executable('cmake', args.cmake, env=configure_env) |
| 167 | +if not cmake: |
| 168 | + fail('cmake not found. Install CMake and add it to PATH, set CMAKE, or pass --cmake.') |
| 169 | +print_tool('cmake', cmake, ['--version'], env=configure_env) |
| 170 | + |
| 171 | +print(f'C compiler: {configure_env.get("CC", "CMake platform default")}') |
| 172 | +print(f'C++ compiler: {configure_env.get("CXX", "CMake platform default")}') |
| 173 | + |
| 174 | +configure_args = [] |
| 175 | +build_args = [] |
| 176 | + |
| 177 | +if platform.system() == "Windows": |
| 178 | + configure_env['CXXFLAGS'] = f'/MP{args.parallel}' |
| 179 | + configure_env['CFLAGS'] = f'/MP{args.parallel}' |
| 180 | +else: |
| 181 | + build_args.extend(['-j', str(args.parallel)]) |
| 182 | + |
| 183 | +if args.headless: |
| 184 | + configure_args.extend(['-DHEADLESS=1']) |
| 185 | + |
| 186 | +# Copy api out of the source tree and build it externally |
| 187 | +with tempfile.TemporaryDirectory() as tempdir: |
| 188 | + temp_api_base = Path(tempdir) / 'binaryninjaapi' |
| 189 | + print(f'Copy {api_base} => {temp_api_base}') |
| 190 | + shutil.copytree(api_base, temp_api_base) |
| 191 | + |
| 192 | + # Now try to build bundled out-of-tree projects the way customers would. |
| 193 | + for f in external_project_cmake_files(): |
| 194 | + example_base = Path(f).parent |
| 195 | + |
| 196 | + # Check for headless |
| 197 | + if args.headless and contains_ui_dependency(f): |
| 198 | + print(f'Skip {example_base} because it requires UI/Qt and --headless was specified') |
| 199 | + continue |
| 200 | + |
| 201 | + with tempfile.TemporaryDirectory() as tempexdir: |
| 202 | + temp_example_base = Path(tempexdir) / example_base.name |
| 203 | + print(f'Copy {example_base} => {temp_example_base} at {datetime.now()}') |
| 204 | + shutil.copytree(example_base, temp_example_base) |
| 205 | + |
| 206 | + if (temp_example_base / 'build').exists(): |
| 207 | + shutil.rmtree(temp_example_base / 'build') |
| 208 | + |
| 209 | + try: |
| 210 | + subprocess.check_call([cmake, '-B', 'build', f'-DBN_API_PATH={temp_api_base}'] + configure_args, |
| 211 | + cwd=temp_example_base, env=configure_env) |
| 212 | + subprocess.check_call([cmake, '--build', 'build'] + build_args, cwd=temp_example_base) |
| 213 | + finally: |
| 214 | + if (temp_example_base / 'build').exists(): |
| 215 | + shutil.rmtree(temp_example_base / 'build') |
0 commit comments