#!/usr/bin/env python3 """Diff two Connac2 firmware container versions (same file, different builds). Compares: metadata (hidden build strings, sizes), region tables aligned by address, per-region byte-equality ratio, and printable-string set changes (added/removed — the human-readable changelog signal). Usage: fw_diff.py A.bin B.bin [--md out.md] """ import argparse import re import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from mtk_fw_extract import REGION, TRAILER # noqa: E402 def parse(d: bytes): t = TRAILER.unpack_from(d, len(d) - TRAILER.size) n = t[2] table = len(d) - TRAILER.size - n * REGION.size off, regions = 0, {} for i in range(n): r = REGION.unpack_from(d, table + i * REGION.size) addr, ln, feat = r[4], r[5], r[6] regions[addr] = {'len': ln, 'feat': feat, 'data': d[off:off + ln]} off += ln hidden = None gap = table - off if gap > 0: runs = [x.strip(b'#') for x in re.findall(rb'[\x20-\x7e]{8,}', d[off:table])] runs = [x for x in runs if len(x) >= 8] hidden = runs[0].decode() if runs else None return {'trailer_date': t[7].decode(errors='replace'), 'n_region': n, 'hidden': hidden, 'regions': regions, 'size': len(d)} def equal_ratio(a: bytes, b: bytes) -> float: if not a and not b: return 1.0 n = min(len(a), len(b)) eq = sum(x == y for x, y in zip(a[:n], b[:n])) return eq / max(len(a), len(b)) JUNK = re.compile(r'^[A-Za-z0-9 _%:.\-(){}\[\],;+*/=<>!\'"?&|$@#~`^\\]*$') def strings(data: bytes, minlen=6): """Human-plausible strings only: leading identifier character, a real alphabetic run, and letter-density floor. Filters random binary that happens to decode printable.""" out = set() for m in re.findall(rb'[\x20-\x7e]{%d,}' % minlen, data): s = m.strip(b'#').decode(errors='replace') if len(s) < minlen: continue if not re.match(r'^[A-Za-z%_]', s): continue if not re.search(r'[A-Za-z]{3,}', s): continue letters = sum(c.isalpha() or c == '_' for c in s) if letters / len(s) < 0.5: continue out.add(s) return out def diff(a_path: Path, b_path: Path) -> dict: A, B = parse(a_path.read_bytes()), parse(b_path.read_bytes()) addrs = sorted(set(A['regions']) | set(B['regions'])) regions = [] for a in addrs: ra, rb = A['regions'].get(a), B['regions'].get(a) if ra and rb: regions.append({'addr': f'0x{a:08x}', 'in': 'both', 'len_a': ra['len'], 'len_b': rb['len'], 'feat_a': f"0x{ra['feat']:02x}", 'feat_b': f"0x{rb['feat']:02x}", 'equal': round(equal_ratio(ra['data'], rb['data']), 4)}) else: r = ra or rb regions.append({'addr': f'0x{a:08x}', 'in': 'a' if ra else 'b', 'len': r['len'], 'feat': f"0x{r['feat']:02x}"}) sa = set().union(*[strings(r['data']) for r in A['regions'].values()]) sb = set().union(*[strings(r['data']) for r in B['regions'].values()]) return { 'a': {'file': str(a_path), 'build': A['hidden'], 'trailer_date': A['trailer_date'], 'size': A['size']}, 'b': {'file': str(b_path), 'build': B['hidden'], 'trailer_date': B['trailer_date'], 'size': B['size']}, 'regions': regions, 'strings_added': sorted(sb - sa), 'strings_removed': sorted(sa - sb), } def to_markdown(r: dict, cap=60) -> str: L = [f"# {Path(r['a']['file']).name} vs {Path(r['b']['file']).name}", '', f"- A: `{r['a']['build']}` (trailer {r['a']['trailer_date']}, {r['a']['size']}B)", f"- B: `{r['b']['build']}` (trailer {r['b']['trailer_date']}, {r['b']['size']}B)", '', '## Regions', '', '| addr | in | len A | len B | feat A→B | equal bytes |', '|---|---|---|---|---|---|'] for g in r['regions']: if g['in'] == 'both': L.append(f"| {g['addr']} | both | {g['len_a']} | {g['len_b']} " f"| {g['feat_a']}→{g['feat_b']} | {g['equal']:.1%} |") else: L.append(f"| {g['addr']} | {g['in']} only | {g.get('len','')} | " f" | {g.get('feat','')} | — |") L += ['', f"## Strings added in B ({len(r['strings_added'])}, capped {cap})", ''] L += [f'- `{s}`' for s in r['strings_added'][:cap]] L += ['', f"## Strings removed in B ({len(r['strings_removed'])}, capped {cap})", ''] L += [f'- `{s}`' for s in r['strings_removed'][:cap]] return '\n'.join(L) + '\n' def main(): ap = argparse.ArgumentParser() ap.add_argument('a') ap.add_argument('b') ap.add_argument('--md') ap.add_argument('--json') args = ap.parse_args() r = diff(Path(args.a), Path(args.b)) md = to_markdown(r) if args.md: Path(args.md).parent.mkdir(parents=True, exist_ok=True) Path(args.md).write_text(md) if args.json: Path(args.json).write_text(__import__('json').dumps(r, indent=2)) print(md[:2500]) print(f"... [{len(r['strings_added'])} added / " f"{len(r['strings_removed'])} removed strings]") if __name__ == '__main__': main()