142 lines
5.5 KiB
Python
142 lines
5.5 KiB
Python
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
"""Tests for the patcher: python -m pytest test_patcher.py
|
|
|
|
Offline: S-records, the boot loader's LZSS against our packer, the version
|
|
label guard. With the official update (CDJ900NXS_UPD, or C900NXS.UPD in this
|
|
folder): the stock file rebuilt byte for byte, the relabelled stock differing
|
|
in three bytes only, and every tampering with the boot area caught."""
|
|
import os
|
|
import random
|
|
import struct
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, HERE)
|
|
import upd # noqa: E402
|
|
import cdj900nxs_patcher as cli # noqa: E402
|
|
|
|
STOCK = os.environ.get('CDJ900NXS_UPD') or os.path.join(HERE, 'C900NXS.UPD')
|
|
need_stock = pytest.mark.skipif(not os.path.isfile(STOCK), reason='needs the official C900NXS.UPD')
|
|
|
|
|
|
def test_record_round_trip():
|
|
rec = upd.Record(2, 0x040020, bytes(range(32)))
|
|
line = rec.encode()
|
|
assert line.startswith(b'S2') and line.endswith(b'\r\n')
|
|
raw = bytes.fromhex(line[2:-2].decode())
|
|
assert raw[0] == len(raw) - 1 and sum(raw) & 0xFF == 0xFF
|
|
|
|
|
|
def test_lzss_matches_the_boot_loader():
|
|
_, lzss = cli.load_repo(cli.DEFAULT_REPO)
|
|
rnd = random.Random(7)
|
|
data = bytes(rnd.choice(b' \0ABAB\xff') for _ in range(20000)) + b' ' * 5000 + bytes(4000)
|
|
assert upd.unpack_lzss(lzss.pack(data)) == data
|
|
|
|
|
|
def test_region_checksum_and_padding():
|
|
reg = upd.pack_region(b'abc')
|
|
assert len(reg) % 32 == 0
|
|
size = struct.unpack_from('<I', reg)[0]
|
|
assert struct.unpack_from('<H', reg, 4 + size)[0] == sum(reg[:4 + size]) & 0xFFFF
|
|
|
|
|
|
def test_relabel_touches_the_label_only():
|
|
h = b'CDJ-900NXS MAINVer1.31\x00 0'
|
|
assert upd.relabel(h, '1.32') == b'CDJ-900NXS MAINVer1.32\x00 0'
|
|
with pytest.raises(upd.UpdError):
|
|
upd.relabel(h, '1.3')
|
|
|
|
|
|
@need_stock
|
|
def test_stock_rebuilt_byte_for_byte():
|
|
raw = open(STOCK, 'rb').read()
|
|
stock = upd.Stock(raw)
|
|
size = struct.unpack_from('<I', stock.flash, upd.APP_START)[0]
|
|
stream = stock.flash[upd.APP_START + 4:upd.APP_START + 4 + size]
|
|
assert upd.build(stock, stock.application, stream, None) == raw
|
|
|
|
|
|
@need_stock
|
|
def test_relabelled_stock_differs_in_three_bytes():
|
|
raw = open(STOCK, 'rb').read()
|
|
stock = upd.Stock(raw)
|
|
patcher, lzss = cli.load_repo(cli.DEFAULT_REPO)
|
|
out, app, checks = cli.build(stock, [], patcher, lzss)
|
|
assert len(out) == len(raw) and sum(a != b for a, b in zip(out, raw)) == 3
|
|
assert app == stock.application and any('boot loader' in c for c in checks)
|
|
|
|
|
|
@need_stock
|
|
def test_other_files_refused():
|
|
raw = bytearray(open(STOCK, 'rb').read())
|
|
raw[-3] ^= 1
|
|
with pytest.raises(upd.UpdError):
|
|
upd.Stock(bytes(raw))
|
|
|
|
|
|
@need_stock
|
|
def test_boot_area_tampering_caught():
|
|
raw = open(STOCK, 'rb').read()
|
|
stock = upd.Stock(raw)
|
|
size = struct.unpack_from('<I', stock.flash, upd.APP_START)[0]
|
|
stream = stock.flash[upd.APP_START + 4:upd.APP_START + 4 + size]
|
|
good = upd.build(stock, stock.application, stream)
|
|
# rebuild MAIN with one boot loader byte changed, CRCs made right again
|
|
sections = upd.parse(good)
|
|
main = sections[1]
|
|
recs = list(main.records)
|
|
i = next(n for n, r in enumerate(recs) if r.kind == 2 and r.address < upd.APP_START)
|
|
recs[i] = upd.Record(2, recs[i].address, bytes([recs[i].data[0] ^ 1]) + recs[i].data[1:])
|
|
bad = upd.join([sections[0].blob, upd.encode_section(main.header, recs), sections[2].blob])
|
|
with pytest.raises(upd.UpdError, match='boot loader'):
|
|
upd.verify(bad, stock, stock.application)
|
|
|
|
|
|
MODS = [('code', '', True, ''), ('utility', '', True, ''), ('overview', '', True, ''),
|
|
('beatjump', '', True, ''), ('cuecolours', '', True, ''), ('hotcuekeys', '', True, '')]
|
|
|
|
|
|
def test_every_mod_can_be_unticked():
|
|
chosen = {m[0] for m in MODS}
|
|
cli._toggle(chosen, 'code', MODS)
|
|
assert 'code' not in chosen and 'utility' not in chosen # what needs it goes with it
|
|
cli._toggle(chosen, 'cuecolours', MODS)
|
|
assert 'cuecolours' not in chosen and 'hotcuekeys' not in chosen
|
|
cli._toggle(chosen, 'hotcuekeys', MODS)
|
|
assert {'hotcuekeys', 'cuecolours'} <= chosen # and ticking brings what it needs
|
|
cli._toggle(chosen, 'overview', MODS)
|
|
assert 'overview' not in chosen
|
|
cli._toggle(chosen, 'cuecolours', MODS)
|
|
assert not {'cuecolours', 'hotcuekeys', 'beatjump'} & chosen # and what needs that
|
|
cli._toggle(chosen, 'beatjump', MODS)
|
|
assert {'beatjump', 'hotcuekeys', 'cuecolours'} <= chosen
|
|
|
|
|
|
def test_disclaimer_must_be_accepted(monkeypatch):
|
|
monkeypatch.setattr(cli.sys.stdin, 'isatty', lambda: True, raising=False)
|
|
assert 'Claude Code' in cli.DISCLAIMER
|
|
cli.confirm(False, ask=lambda _: 'yes')
|
|
cli.confirm(True, ask=lambda _: pytest.fail('asked although accepted'))
|
|
for answer in ('', 'no', 'q'):
|
|
with pytest.raises(SystemExit, match='not accepted'):
|
|
cli.confirm(False, ask=lambda _: answer)
|
|
|
|
|
|
def test_disclaimer_without_a_terminal(monkeypatch):
|
|
monkeypatch.setattr(cli.sys.stdin, 'isatty', lambda: False, raising=False)
|
|
with pytest.raises(SystemExit, match='--accept'):
|
|
cli.confirm(False)
|
|
|
|
|
|
def test_credit_can_be_unticked():
|
|
import types
|
|
mods = cli.available(types.SimpleNamespace(MODS={"code": "c", "credit": "x"}), cli.DEFAULT_REPO)
|
|
assert [m[0] for m in mods] == ['code', 'credit']
|
|
chosen = {m[0] for m in mods if m[0] not in cli.KNOWN_FAULTS}
|
|
assert 'credit' in chosen # ticked at the start
|
|
cli._toggle(chosen, 'credit', mods)
|
|
assert chosen == {'code'}
|