#!/usr/bin/env python3 """ 构建期 dylib 白名单生成脚本 优先扫描最终 .app Bundle 内实际嵌入并会被 dyld 加载的镜像,生成: 1. RDKnownImages.plist — 白名单 + HMAC-SHA256 摘要 2. Generated/RDWhitelistSecret.swift — 仅在缺失时补生成 在 Xcode Build Phase 中调用时,会复用现有密钥并更新最终 app bundle 内的 plist; 手动执行时若无法定位 app bundle,则退回扫描仓库中的 framework/dylib 来源。 """ import os import sys import hmac import hashlib import plistlib import secrets import re from pathlib import Path # 所有 framework 来源目录(相对于项目根目录) FRAMEWORK_SEARCH_PATHS = [ 'Pods', 'readoor/BookShelf/Classes/Factorys', 'readoor/3.0/Factorys', 'readoor/3.0/Features/ThirdPartyService', ] # 输出路径 PLIST_OUTPUT = 'readoor/3.0/Base/Security/RDKnownImages.plist' SECRET_OUTPUT = 'readoor/3.0/Base/Security/Generated/RDWhitelistSecret.swift' def scan_source_frameworks(search_paths, app_name): """扫描仓库中的 framework 和 dylib 来源,作为无法定位 app bundle 时的兜底方案。""" known = [] seen = set() for search_dir in search_paths: if not os.path.isdir(search_dir): continue for root, dirs, files in os.walk(search_dir): # .framework 在 macOS 上是目录,出现在 dirs 中 for d in dirs: if d.endswith('.framework'): basename = d.replace('.framework', '') rel_path = os.path.relpath(os.path.join(root, d), '.') key = (basename, rel_path) if key not in seen: seen.add(key) known.append({'name': basename, 'path': rel_path}) for f in files: if f.endswith('.dylib'): basename = f.replace('.dylib', '') rel_path = os.path.relpath(os.path.join(root, f), '.') key = (basename, rel_path) if key not in seen: seen.add(key) known.append({'name': basename, 'path': rel_path}) # App 自身 app_key = (app_name, app_name) if app_key not in seen: known.append({'name': app_name, 'path': app_name}) # 按 name 排序保证稳定性 known.sort(key=lambda x: x['name']) return known def scan_bundled_images(app_bundle_path, executable_path): """扫描最终 app bundle 中会实际被加载的镜像,路径使用 bundle 相对路径。""" known = [] seen = set() executable_name = Path(executable_path).name if executable_path else Path(app_bundle_path).stem main_key = (executable_name, executable_path or executable_name) seen.add(main_key) known.append({'name': executable_name, 'path': executable_path or executable_name}) for root, dirs, files in os.walk(app_bundle_path): dirs.sort() files.sort() for d in dirs: if not d.endswith('.framework'): continue framework_dir = os.path.join(root, d) basename = d.replace('.framework', '') # if basename == 'MJExtension': # 故意排除 # continue binary_path = os.path.join(framework_dir, basename) if not os.path.isfile(binary_path): continue rel_path = os.path.relpath(binary_path, app_bundle_path) key = (basename, rel_path) if key not in seen: seen.add(key) known.append({'name': basename, 'path': rel_path}) for f in files: if not f.endswith('.dylib'): continue dylib_path = os.path.join(root, f) basename = f.replace('.dylib', '') rel_path = os.path.relpath(dylib_path, app_bundle_path) key = (basename, rel_path) if key not in seen: seen.add(key) known.append({'name': basename, 'path': rel_path}) known.sort(key=lambda x: (x['name'], x['path'])) return known def compute_digest(entries, secret): """对排序后的白名单内容计算 HMAC-SHA256 摘要""" parts = [f"{e['name']}|{e['path']}" for e in entries] message = '\n'.join(parts).encode('utf-8') return hmac.new(secret, message, hashlib.sha256).hexdigest() def generate_swift_secret(secret_bytes): """将构建期密钥写入 Swift 源码""" hex_bytes = ', '.join(f'0x{b:02x}' for b in secret_bytes) source = f"""// Auto-generated by generate_image_whitelist.py — do not edit manually import Foundation enum RDWhitelistSecret {{ static let key: [UInt8] = [{hex_bytes}] }} """ os.makedirs(os.path.dirname(SECRET_OUTPUT), exist_ok=True) with open(SECRET_OUTPUT, 'w') as f: f.write(source) def load_existing_secret(): if not os.path.isfile(SECRET_OUTPUT): return None with open(SECRET_OUTPUT, 'r', encoding='utf-8') as f: content = f.read() hex_values = re.findall(r'0x([0-9a-fA-F]{2})', content) if not hex_values: return None return bytes(int(value, 16) for value in hex_values) def resolve_app_bundle_path(): target_build_dir = os.environ.get('TARGET_BUILD_DIR') wrapper_name = os.environ.get('WRAPPER_NAME') if target_build_dir and wrapper_name: app_bundle_path = os.path.join(target_build_dir, wrapper_name) if os.path.isdir(app_bundle_path): return app_bundle_path return None def main(): root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) os.chdir(root) build_secret = load_existing_secret() if build_secret is None: build_secret = secrets.token_bytes(32) generate_swift_secret(build_secret) print(f'[whitelist] Generated {SECRET_OUTPUT}') app_bundle_path = resolve_app_bundle_path() executable_path = os.environ.get('EXECUTABLE_PATH', 'readoor') if app_bundle_path: frameworks = scan_bundled_images(app_bundle_path, executable_path) print(f'[whitelist] Scanned app bundle images from {app_bundle_path}') else: frameworks = scan_source_frameworks(FRAMEWORK_SEARCH_PATHS, 'readoor') print('[whitelist] App bundle not found, fallback to source framework scan') digest = compute_digest(frameworks, build_secret) output = { 'known_images': frameworks, 'digest': digest } if app_bundle_path: destination = os.path.join(app_bundle_path, os.path.basename(PLIST_OUTPUT)) os.makedirs(os.path.dirname(destination), exist_ok=True) with open(destination, 'wb') as f: plistlib.dump(output, f) print(f'[whitelist] Generated app bundle plist {destination} with {len(frameworks)} dynamic entries') else: os.makedirs(os.path.dirname(PLIST_OUTPUT), exist_ok=True) with open(PLIST_OUTPUT, 'wb') as f: plistlib.dump(output, f) print(f'[whitelist] Generated {PLIST_OUTPUT} with {len(frameworks)} dynamic entries') print(f'[whitelist] digest={digest[:16]}...') return 0 if __name__ == '__main__': sys.exit(main())