mtk-wifi-fw/tools/fw_diff.py

144 lines
5.3 KiB
Python
Raw Normal View History

#!/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()