143 lines
5.3 KiB
Python
Executable file
143 lines
5.3 KiB
Python
Executable file
#!/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()
|