- new `simtrace_analyser/filetrack.py`: a single-pass tracker over a session's decoded messages, keyed by (DF, FID) and trace-wide (resets only wipe the per-channel selection): counters (`select`/`read`/`update`, successful operations only), a sparse image of the observed bytes (transparent files by offset, record files per record number) and the change log - changes: **device** = an UPDATE seen in the trace (applied to the image, `no_change` flags a provable no-op); **card** = a READ whose bytes differ from the image on bytes already known (logged at the surfacing read with the changed ranges and the before/after hex; never-seen bytes just fill the image, they are an observation, not a change) - SFI-addressed accesses join the same entry as path/FID ones: the SFI ↔ FID relations come from the normative tables and from every surfaced FCP `88` DO (including the spec's "SFI = 5 LSBs of the FID" rule when the DO is absent); an SFI used before its relation is learned stays provisional and merges into the resolved entry - the DF identity follows TS 102 221 §8.4/Table 8.2: `selected_df_fid()` now resolves an EF path selection to its parent DF and returns `aid:<hex>` for applications selected by an unknown AID (named from EF.DIR when the trace surfaced it); `Database._decode_rows` gained an `on_message` hook and `track_files()` - `GET /api/sessions/<id>/files`: the operation timeline, the per-file counters/coverage/change log, the SFI relations and the AID labels - PWA: the side panel's Map/Stats tabs became a view selector (Map / Stats / Files); the Files view is a timeline of Read / Update / Changed (card) rows (colour-coded, the same file names as the Map view), a row click jumps to the APDU (loading the pages it needs) and a card-change row's Δ opens the changed ranges with the previous and the new bytes; EN/RU - bounds: the image freezes past 64 KB of observed bytes per file, the change log keeps the last 1000 events per file (counters keep counting) - tests: tests/test_filetrack.py (counters, resets, channels, image/diff semantics, record files, SFI resolution and the provisional merge, EF.DIR labels, caps, the endpoint) and Node tests for the Files-view helpers and wiring - v2.3.0; MIN_SERVER_VERSION 2.3.0; SW cache simtrace-analyser-v63
313 lines
12 KiB
Python
313 lines
12 KiB
Python
"""Tests for PCAP/PCAPNG import parsing and the import endpoint."""
|
|
|
|
import os
|
|
import struct
|
|
import tempfile
|
|
import unittest
|
|
|
|
from simtrace2_pysniff.pcap import (
|
|
build_pcap, parse_pcap, parse_pcapng, wrap_gsmtap, extract_gsmtap,
|
|
LINKTYPE_ETHERNET,
|
|
)
|
|
from simtrace2_pysniff.gsmtap import (build_gsmtap_packet, GSMTAP_SIM_ATR,
|
|
GSMTAP_SIM_APDU, GSMTAP_SIM_RST_EVENT,
|
|
GSMTAP_SIM_VCC_EVENT)
|
|
|
|
|
|
def _gsmtap_hdr(sub_type):
|
|
return build_gsmtap_packet(sub_type, b'')[:16]
|
|
|
|
|
|
def _pcapng_block(block_type, body):
|
|
body = body + b'\x00' * ((-len(body)) % 4)
|
|
total = 8 + len(body) + 4
|
|
return struct.pack('>II', block_type, total) + body + struct.pack('>I', total)
|
|
|
|
|
|
def _build_pcapng(packets):
|
|
"""Build a minimal big-endian PCAPNG with one Ethernet interface.
|
|
|
|
*packets* is a list of (gsmtap_hdr, data, ts_hi, ts_lo) tuples.
|
|
"""
|
|
shb = _pcapng_block(0x0A0D0D0A, struct.pack('>IHHq', 0x1A2B3C4D, 1, 0, -1))
|
|
idb = _pcapng_block(0x00000001,
|
|
struct.pack('>HHI', LINKTYPE_ETHERNET, 0, 65535) +
|
|
struct.pack('>HH', 0, 0))
|
|
out = shb + idb
|
|
for gsmtap_hdr, data, ts_hi, ts_lo in packets:
|
|
frame = wrap_gsmtap(gsmtap_hdr + data)
|
|
epb = _pcapng_block(
|
|
0x00000006,
|
|
struct.pack('>IIIII', 0, ts_hi, ts_lo, len(frame), len(frame)) + frame)
|
|
out += epb
|
|
return out
|
|
|
|
|
|
class TestParsePcap(unittest.TestCase):
|
|
def test_roundtrip(self):
|
|
packets = [
|
|
(_gsmtap_hdr(GSMTAP_SIM_ATR), b'\x3b\x00', 1.0),
|
|
(_gsmtap_hdr(GSMTAP_SIM_APDU), b'\x80\xf2\x00\x00\x00', 2.5),
|
|
]
|
|
data = build_pcap(packets)
|
|
parsed = [(round(ts, 1), t, payload) for ts, t, payload, _f in parse_pcap(data)]
|
|
self.assertEqual(parsed, [
|
|
(1.0, 'atr', b'\x3b\x00'),
|
|
(2.5, 'tpdu', b'\x80\xf2\x00\x00\x00'),
|
|
])
|
|
|
|
def test_roundtrip_line_events(self):
|
|
# sigrok-iso7816-stream custom sub-types survive a PCAP round-trip.
|
|
packets = [
|
|
(_gsmtap_hdr(GSMTAP_SIM_RST_EVENT), b'\x01\x00\x00', 1.0),
|
|
(_gsmtap_hdr(GSMTAP_SIM_VCC_EVENT), b'\x00\x00\x00', 1.5),
|
|
]
|
|
data = build_pcap(packets)
|
|
parsed = [(round(ts, 1), t, payload) for ts, t, payload, _f in parse_pcap(data)]
|
|
self.assertEqual(parsed, [
|
|
(1.0, 'rst', b'\x01\x00\x00'),
|
|
(1.5, 'vcc', b'\x00\x00\x00'),
|
|
])
|
|
|
|
def test_no_gsmtap(self):
|
|
# A packet that is not GSMTAP-SIM yields nothing.
|
|
frame = wrap_gsmtap(b'\x03' + b'\x00' * 15) # type=3, not SIM
|
|
# build a fake pcap with an Ethernet frame that has no GSMTAP
|
|
packets = [(b'', b'\x00' * 64, 1.0)]
|
|
data = build_pcap(packets)
|
|
self.assertEqual(list(parse_pcap(data)), [])
|
|
|
|
def test_bad_fcs_flag_roundtrip(self):
|
|
# GSMTAP_FLAG_BAD_FCS (res byte 0x01) survives a PCAP round-trip.
|
|
from simtrace2_pysniff.gsmtap import GSMTAP_FLAG_BAD_FCS
|
|
hdr = bytearray(_gsmtap_hdr(GSMTAP_SIM_APDU))
|
|
hdr[15] |= GSMTAP_FLAG_BAD_FCS
|
|
packets = [(bytes(hdr), b'\x80\xf2\x00\x00', 1.0),
|
|
(_gsmtap_hdr(GSMTAP_SIM_APDU), b'\x80\xf2\x00\x00', 2.0)]
|
|
data = build_pcap(packets)
|
|
parsed = list(parse_pcap(data))
|
|
self.assertEqual(parsed[0], (1.0, 'tpdu', b'\x80\xf2\x00\x00', GSMTAP_FLAG_BAD_FCS))
|
|
self.assertEqual(parsed[1], (2.0, 'tpdu', b'\x80\xf2\x00\x00', 0))
|
|
|
|
|
|
class TestParsePcapng(unittest.TestCase):
|
|
def test_parse(self):
|
|
data = _build_pcapng([
|
|
(_gsmtap_hdr(GSMTAP_SIM_ATR), b'\x3b\x04', 0, 0),
|
|
(_gsmtap_hdr(GSMTAP_SIM_APDU), b'\x80\xf2\x00\x00\x00', 0, 1),
|
|
])
|
|
parsed = [(t, payload) for _ts, t, payload, _f in parse_pcapng(data)]
|
|
self.assertEqual(parsed, [
|
|
('atr', b'\x3b\x04'),
|
|
('tpdu', b'\x80\xf2\x00\x00\x00'),
|
|
])
|
|
|
|
def test_no_gsmtap(self):
|
|
# IDB + EPB carrying a GSMTAP header whose type != SIM → no messages.
|
|
shb = _pcapng_block(0x0A0D0D0A, struct.pack('>IHHq', 0x1A2B3C4D, 1, 0, -1))
|
|
idb = _pcapng_block(0x00000001,
|
|
struct.pack('>HHI', LINKTYPE_ETHERNET, 0, 65535) +
|
|
struct.pack('>HH', 0, 0))
|
|
non_sim = struct.pack('!BBBBHBBIBBBB', 2, 4, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0) + b'\x00'
|
|
epb = _pcapng_block(0x00000006,
|
|
struct.pack('>IIIII', 0, 0, 0, len(non_sim), len(non_sim)) + non_sim)
|
|
self.assertEqual(list(parse_pcapng(shb + idb + epb)), [])
|
|
|
|
|
|
class TestTsresol(unittest.TestCase):
|
|
def test_nanoseconds_after_padded_option(self):
|
|
from simtrace2_pysniff.pcap import _parse_tsresol
|
|
# if_name (code 2, len 2, "lo") then if_tsresol (code 9, len 1, 9), LE.
|
|
opts = (struct.pack('<HH', 2, 2) + b'lo' + b'\x00\x00' +
|
|
struct.pack('<HH', 9, 1) + b'\x09' + b'\x00\x00\x00' +
|
|
struct.pack('<HH', 0, 0))
|
|
self.assertEqual(_parse_tsresol(opts, '<'), 1e-9)
|
|
|
|
def test_default_microseconds(self):
|
|
from simtrace2_pysniff.pcap import _parse_tsresol
|
|
self.assertEqual(_parse_tsresol(struct.pack('<HH', 0, 0), '<'), 1e-6)
|
|
|
|
def test_parse_pcapng_nanoseconds(self):
|
|
# IDB with if_tsresol=9 → EPB timestamps in nanoseconds → seconds.
|
|
shb = _pcapng_block(0x0A0D0D0A, struct.pack('>IHHq', 0x1A2B3C4D, 1, 0, -1))
|
|
idb = _pcapng_block(0x00000001,
|
|
struct.pack('>HHI', LINKTYPE_ETHERNET, 0, 65535) +
|
|
struct.pack('>HH', 9, 1) + b'\x09' + b'\x00\x00\x00' +
|
|
struct.pack('>HH', 0, 0))
|
|
# epoch 1600000000.5 s → 1600000000500000000 ns
|
|
raw = 1600000000500000000
|
|
ts_hi, ts_lo = raw >> 32, raw & 0xFFFFFFFF
|
|
frame = wrap_gsmtap(_gsmtap_hdr(GSMTAP_SIM_ATR) + b'\x3b\x04')
|
|
epb = _pcapng_block(0x00000006,
|
|
struct.pack('>IIIII', 0, ts_hi, ts_lo, len(frame), len(frame)) + frame)
|
|
parsed = list(parse_pcapng(shb + idb + epb))
|
|
self.assertEqual(len(parsed), 1)
|
|
self.assertAlmostEqual(parsed[0][0], 1600000000.5, places=6)
|
|
|
|
|
|
class TestImportEndpoint(unittest.TestCase):
|
|
def _handler(self, body):
|
|
from simtrace_analyser.server import RequestHandler
|
|
from simtrace_analyser.database import Database
|
|
|
|
class Fake(RequestHandler):
|
|
def __init__(self, body, db):
|
|
self.db = db
|
|
self._body = body
|
|
self._status = None
|
|
self._json = None
|
|
|
|
def _read_body(self):
|
|
return self._body
|
|
|
|
def _send_json(self, data, status=200):
|
|
self._status = status
|
|
self._json = data
|
|
|
|
def _send_error(self, status, message):
|
|
self._status = status
|
|
self._json = {'error': message}
|
|
|
|
tmp = tempfile.NamedTemporaryFile(suffix='.db', delete=False)
|
|
tmp.close()
|
|
db = Database(tmp.name)
|
|
self.addCleanup(lambda: (db._conn.close(), os.remove(tmp.name)))
|
|
return Fake(body, db), tmp.name
|
|
|
|
def test_import_pcap_creates_session(self):
|
|
packets = [
|
|
(_gsmtap_hdr(GSMTAP_SIM_ATR), b'\x3b\x00', 1.0),
|
|
(_gsmtap_hdr(GSMTAP_SIM_APDU), b'\x80\xf2\x00\x00\x00', 2.0),
|
|
]
|
|
h, _ = self._handler(build_pcap(packets))
|
|
h._handle_import_pcap({'name': ['mytrace']})
|
|
self.assertEqual(h._status, 200)
|
|
self.assertEqual(h._json['name'], 'mytrace')
|
|
self.assertEqual(h._json['message_count'], 2)
|
|
self.assertEqual(h.db.count_messages(h._json['session_id']), 2)
|
|
|
|
def test_import_no_gsmtap_400(self):
|
|
# Ethernet frame with no GSMTAP → no usable data.
|
|
h, _ = self._handler(build_pcap([(b'', b'\x00' * 64, 1.0)]))
|
|
h._handle_import_pcap({'name': ['empty']})
|
|
self.assertEqual(h._status, 400)
|
|
self.assertIn('No GSMTAP data', h._json['error'])
|
|
self.assertEqual(h.db.count_sessions(), 0)
|
|
|
|
def test_import_session_window_from_timestamps(self):
|
|
from datetime import datetime
|
|
# Two packets one hour apart in epoch time.
|
|
t0 = 1600000000.0
|
|
packets = [
|
|
(_gsmtap_hdr(GSMTAP_SIM_ATR), b'\x3b\x00', t0),
|
|
(_gsmtap_hdr(GSMTAP_SIM_APDU), b'\x80\xf2\x00\x00\x00', t0 + 3600.0),
|
|
]
|
|
h, _ = self._handler(build_pcap(packets))
|
|
h._handle_import_pcap({'name': ['window']})
|
|
session = h.db.get_session(h._json['session_id'])
|
|
started = datetime.fromisoformat(session['started'])
|
|
ended = datetime.fromisoformat(session['ended'])
|
|
self.assertEqual((ended - started).total_seconds(), 3600.0)
|
|
|
|
def test_import_synthetic_ts_falls_back(self):
|
|
# SPB-style synthetic timestamps (0, 1, …) → keep wall-clock fallback.
|
|
packets = [
|
|
(_gsmtap_hdr(GSMTAP_SIM_ATR), b'\x3b\x00', 0.0),
|
|
(_gsmtap_hdr(GSMTAP_SIM_APDU), b'\x80\xf2\x00\x00\x00', 1.0),
|
|
]
|
|
h, _ = self._handler(build_pcap(packets))
|
|
h._handle_import_pcap({'name': ['synth']})
|
|
session = h.db.get_session(h._json['session_id'])
|
|
self.assertTrue(session['ended'] is not None)
|
|
|
|
|
|
class TestVersion(unittest.TestCase):
|
|
def test_package_version(self):
|
|
from simtrace2_pysniff import __version__
|
|
self.assertEqual(__version__, '2.3.0')
|
|
|
|
def test_status_includes_version(self):
|
|
import os
|
|
from simtrace_analyser.server import RequestHandler
|
|
from simtrace_analyser.database import Database
|
|
from simtrace2_pysniff import __version__
|
|
|
|
class FakeCapture:
|
|
active = False
|
|
session_id = None
|
|
device_connected = None
|
|
|
|
class Fake(RequestHandler):
|
|
def __init__(self, db):
|
|
self.db = db
|
|
self.capture = FakeCapture()
|
|
self._json = None
|
|
|
|
def _send_json(self, data, status=200):
|
|
self._json = data
|
|
|
|
tmp = tempfile.NamedTemporaryFile(suffix='.db', delete=False)
|
|
tmp.close()
|
|
db = Database(tmp.name)
|
|
self.addCleanup(lambda: (db._conn.close(), os.remove(tmp.name)))
|
|
|
|
h = Fake(db)
|
|
h._handle_status()
|
|
self.assertEqual(h._json['version'], __version__)
|
|
|
|
|
|
class TestCaptureDisabled(unittest.TestCase):
|
|
def _handler(self, capture_mode):
|
|
import os
|
|
from simtrace_analyser.server import RequestHandler
|
|
from simtrace_analyser.database import Database
|
|
|
|
class Fake(RequestHandler):
|
|
def __init__(self, db, capture_mode):
|
|
self.db = db
|
|
self.capture = None
|
|
self.capture_mode = capture_mode
|
|
self._status = None
|
|
self._json = None
|
|
|
|
def _send_json(self, data, status=200):
|
|
self._status = status
|
|
self._json = data
|
|
|
|
def _send_error(self, status, message):
|
|
self._status = status
|
|
self._json = {'error': message}
|
|
|
|
tmp = tempfile.NamedTemporaryFile(suffix='.db', delete=False)
|
|
tmp.close()
|
|
db = Database(tmp.name)
|
|
self.addCleanup(lambda: (db._conn.close(), os.remove(tmp.name)))
|
|
return Fake(db, capture_mode)
|
|
|
|
def test_status_reports_disabled(self):
|
|
h = self._handler('disabled')
|
|
h._handle_status()
|
|
self.assertEqual(h._json['capture_mode'], 'disabled')
|
|
self.assertFalse(h._json['capture_active'])
|
|
self.assertIsNone(h._json['session_id'])
|
|
|
|
def test_capture_start_403(self):
|
|
h = self._handler('disabled')
|
|
h._handle_capture_start()
|
|
self.assertEqual(h._status, 403)
|
|
self.assertIn('Capture disabled', h._json['error'])
|
|
|
|
def test_capture_stop_403(self):
|
|
h = self._handler('disabled')
|
|
h._handle_capture_stop()
|
|
self.assertEqual(h._status, 403)
|
|
|
|
def test_status_reports_gsmtap(self):
|
|
h = self._handler('gsmtap')
|
|
h._handle_status()
|
|
self.assertEqual(h._json['capture_mode'], 'gsmtap')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|