Files view: per-file tracking (counters, image, device/card changes)

- 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
This commit is contained in:
catarrh committed 2026-10-17 20:51:33 +03:00
1 parent b8d9c4cc63
commit c494bf4111
14 files changed
+1302 -36

No files matched your search

+36 -9
View File
@@ -522,26 +522,53 @@ def _is_df_fid(fid):
return fid == '3f00' or fid.startswith('7f') or fid.startswith('5f')
def selected_df_fid(d):
"""Return the current DF after a SELECT, or None if the SELECT does not
(re)enter a DF. Handles SELECT by FID (P1 00/01), by path (P1 08/09),
and by DF name/AID (P1 04)."""
def selected_df_fid(d, current_df=None):
"""Return the current DF after a SELECT, or None if it is unchanged.
TS 102 221 §8.4.1: a DF/MF/ADF selection makes that file the current
directory; selecting an EF leaves the *parent of that EF* as the current
directory. So a path selection that ends in an EF also (re)enters the
DF the EF lives in (Table 8.2: ``'EF1'`` from MF, ``'7FFF||DF3||EF4'``,
…). ``current_df`` is used for "path from the current DF" paths.
Handles SELECT by FID (P1 00/01), by path (P1 08/09), and by DF name/AID
(P1 04; the special FID '7fff' always means the ADF of the current active
application, §8.3).
"""
if d.get('ins_hex') != 'a4':
return None
p1 = (d.get('p1') or {}).get('raw')
h = (d.get('body') or {}).get('hex') or ''
def resolve(fid):
# '7FFF' is the running application's ADF: when that application was
# selected by an unknown AID, keep the AID as the DF identity.
if fid == '7fff' and (current_df or '').startswith('aid:'):
return current_df
return fid
if p1 in ('00', '01'):
return h if len(h) == 4 and _is_df_fid(h) else None
return resolve(h) if len(h) == 4 and _is_df_fid(h) else None
if p1 in ('08', '09'):
comps = [h[i:i + 4] for i in range(0, len(h) - 3, 4)]
for comp in reversed(comps):
if not comps:
return None
target = comps[-1]
if _is_df_fid(target):
return resolve(target)
# The selected file is an EF: the current directory is its parent.
for comp in reversed(comps[:-1]):
if _is_df_fid(comp):
return comp
return None
return resolve(comp)
if p1 == '08':
return '3f00' # path from MF: directly under MF
return current_df or None # path from the current DF
if p1 == '04': # select by DF name (AID) → map known application AIDs
if h.startswith('a0000000871002'): # USIM AID
return '7fff'
return None
# Unknown application: keep the AID as the DF identity so files under
# it stay distinguishable from the previous DF's (TS 102 221 §11.1.1).
return f'aid:{h.lower()}' if h else None
return None