Change details: no filler past a short file, no offsets for a single row (v2.7.3)

- the hex views padded every line to 16 bytes with '--', so a 2-byte file
  looked like it was missing data; the row now ends at the known file/record
  length (from the FCP — hexImageRows gained the size parameter), while '--'
  still marks bytes *inside* the file that were never observed (an unknown
  length keeps the old row padding, nothing better is available)
- the offset column is shown only when a view spans more than one row — a
  four-hex-digit 0000 above two bytes was noise
- the legend lists only the marks actually rendered: 'never observed' is gone
  from a short/fully-observed view, and 'newly observed' when nothing was
  first observed (the summary line still counts both)
- tests: pure hexImageRows cases (the 2-byte file, a 9-byte record with an
  unobserved tail, the unknown-size fallback) plus two renderFileDiff cases
  pinning the missing filler/offset and the trimmed legend; 527 Python +
  75 Node tests
- README/README_RUS describe the new hex-view behaviour;
  PWA_VERSION/pyproject/version.py 2.7.3, SW cache simtrace-analyser-v83
This commit is contained in:
catarrh committed 2026-10-18 00:37:12 +03:00
1 parent bc6fcc02c2
commit 71a94f5f50
8 files changed
+121 -25

No files matched your search

+6 -4
View File
@@ -219,10 +219,12 @@ card-side change, or an update that wrote or first observed bytes) also offer
`ADF/EF_IMSI (6F07)`, `DF_GSM_ACCESS/EF_KcGPRS (4F52)`, `AID …/4F09` — the DF `ADF/EF_IMSI (6F07)`, `DF_GSM_ACCESS/EF_KcGPRS (4F52)`, `AID …/4F09` — the DF
named from the EF.DIR labels when known) and the affected unit — the whole named from the EF.DIR labels when known) and the affected unit — the whole
file, or the single record that changed — **before and after** the change, as file, or the single record that changed — **before and after** the change, as
hex views (16 bytes per row, never-observed bytes drawn as `--`) with the bytes hex views (up to 16 bytes per row — a shorter file/record ends its row there
that changed highlighted in one colour and the bytes the operation first instead of padding with `--`, and the offset column only appears when a view
observed in another, the changed/new byte counts, the APDU reference and the spans more than one row) with the bytes that changed highlighted in one colour
colour legend below the views. and the bytes the operation first observed in another, the changed/new byte
counts, the APDU reference and a colour legend below the views that lists only
the marks actually shown.
The images come from `GET /api/sessions/<id>/files/image?key=…&change=…` and The images come from `GET /api/sessions/<id>/files/image?key=…&change=…` and
are reconstructed from the current image by undoing the later changes are reconstructed from the current image by undoing the later changes
+4 -2
View File
@@ -224,10 +224,12 @@ Files — это таймлайн файловых операций по все
`ADF/EF_IMSI (6F07)`, `DF_GSM_ACCESS/EF_KcGPRS (4F52)`, `AID …/4F09` — DF `ADF/EF_IMSI (6F07)`, `DF_GSM_ACCESS/EF_KcGPRS (4F52)`, `AID …/4F09` — DF
называется по меткам из EF.DIR, если они есть) и затронутую единицу — файл называется по меткам из EF.DIR, если они есть) и затронутую единицу — файл
целиком или изменившуюся запись — **до и после** изменения в виде hex-вида целиком или изменившуюся запись — **до и после** изменения в виде hex-вида
(16 байт в строке, никогда не наблюдавшиеся байты — `--`), причём изменённые (до 16 байт в строке: короткий файл/запись заканчивает строку, а не
дополняется `--`, и столбец смещений появляется, только когда представление
занимает больше одной строки), причём изменённые
байты подсвечены одним цветом, а впервые наблюдаемые — другим; ниже — байты подсвечены одним цветом, а впервые наблюдаемые — другим; ниже —
счётчики изменённых/новых байт, ссылка на APDU и легенда цветов под счётчики изменённых/новых байт, ссылка на APDU и легенда цветов под
представлениями. представлениями, перечисляющая только реально показанные пометки.
Изображения отдаёт `GET /api/sessions/<id>/files/image?key=…&change=…`; они Изображения отдаёт `GET /api/sessions/<id>/files/image?key=…&change=…`; они
восстанавливаются из текущего образа отменой более поздних изменений восстанавливаются из текущего образа отменой более поздних изменений
+47 -15
View File
@@ -2171,7 +2171,7 @@ function fileOpLabel(op) {
// Hex view of an image: 16 bytes per row across the observed segments, with // Hex view of an image: 16 bytes per row across the observed segments, with
// gaps drawn as unknown cells. *ranges* (bytes that changed) and *observed* // gaps drawn as unknown cells. *ranges* (bytes that changed) and *observed*
// (bytes first observed by this change) mark the cells. // (bytes first observed by this change) mark the cells.
function hexImageRows(segments, ranges, observed, limit) { function hexImageRows(segments, ranges, observed, limit, size) {
const maxBytes = limit || 2048; const maxBytes = limit || 2048;
const bytes = new Map(); const bytes = new Map();
let end = 0; let end = 0;
@@ -2184,6 +2184,7 @@ function hexImageRows(segments, ranges, observed, limit) {
} }
const covered = (list, pos) => (list || []).some(([p, n]) => pos >= p && pos < p + n); const covered = (list, pos) => (list || []).some(([p, n]) => pos >= p && pos < p + n);
const rows = []; const rows = [];
const used = {changed: false, fresh: false, unknown: false};
let truncated = false; let truncated = false;
for (let off = 0; off < end; off += 16) { for (let off = 0; off < end; off += 16) {
if (off >= maxBytes) { if (off >= maxBytes) {
@@ -2193,30 +2194,51 @@ function hexImageRows(segments, ranges, observed, limit) {
const cells = []; const cells = [];
for (let i = 0; i < 16; i++) { for (let i = 0; i < 16; i++) {
const pos = off + i; const pos = off + i;
// Never pad past the known file/record length: the filler would
// read as missing data when the file is simply short. '--'
// still marks bytes inside the file that were never observed.
if (size && pos >= size) break;
const hex = bytes.get(pos); const hex = bytes.get(pos);
if (hex === undefined) { if (hex === undefined) {
cells.push({hex: '--', cls: 'hex-unknown'}); cells.push({hex: '--', cls: 'hex-unknown'});
used.unknown = true;
continue; continue;
} }
const cls = covered(ranges, pos) ? 'hex-changed' const cls = covered(ranges, pos) ? 'hex-changed'
: covered(observed, pos) ? 'hex-new' : ''; : covered(observed, pos) ? 'hex-new' : '';
if (cls === 'hex-changed') used.changed = true;
else if (cls === 'hex-new') used.fresh = true;
cells.push({hex: hex, cls: cls}); cells.push({hex: hex, cls: cls});
} }
rows.push({offset: off, cells: cells}); rows.push({offset: off, cells: cells});
} }
return {rows: rows, truncated: truncated, end: end}; return {rows: rows, truncated: truncated, end: end, used: used};
} }
// One side of the change view: the hex rows plus the marks actually used, so
// the caller can show only the legend entries that apply.
function hexImageHtml(image, key) { function hexImageHtml(image, key) {
const segments = image[key] || []; const segments = image[key] || [];
const view = hexImageRows(segments, image.ranges, image.observed_ranges); const asRecord = image.record !== null && image.record !== undefined;
const body = view.rows.map(r => const size = asRecord ? image.record_len : image.size;
`<div class="hex-row"><span class="hex-addr">${r.offset.toString(16).toUpperCase().padStart(4, '0')}</span>` + const view = hexImageRows(segments, image.ranges, image.observed_ranges,
undefined, size);
// The offset column only helps when the view spans more than one row.
const addr = view.rows.length > 1;
const used = view.used;
let body = view.rows.map(r =>
`<div class="hex-row">` +
(addr ? `<span class="hex-addr">${r.offset.toString(16).toUpperCase().padStart(4, '0')}</span>` : '') +
r.cells.map(c => `<span class="hex-cell ${c.cls}">${c.hex}</span>`).join('') + r.cells.map(c => `<span class="hex-cell ${c.cls}">${c.hex}</span>`).join('') +
'</div>' '</div>'
).join('') || `<div class="hex-row hex-unknown">${t('never observed')}</div>`; ).join('');
return `<div class="hex-view">${body}</div>` + if (!view.rows.length) {
body = `<div class="hex-row hex-unknown">${t('never observed')}</div>`;
used.unknown = true;
}
const html = `<div class="hex-view">${body}</div>` +
(view.truncated ? `<div class="hex-note">${t('Showing the first {n} bytes of the image').replace('{n}', 2048)}</div>` : ''); (view.truncated ? `<div class="hex-note">${t('Showing the first {n} bytes of the image').replace('{n}', 2048)}</div>` : '');
return {html: html, used: used};
} }
async function renderFileView() { async function renderFileView() {
@@ -2301,18 +2323,28 @@ function renderFileDiff(image) {
if (image.index !== null && image.index !== undefined) meta.push(`#${image.index}`); if (image.index !== null && image.index !== undefined) meta.push(`#${image.index}`);
if (image.elapsed !== null && image.elapsed !== undefined) meta.push(fmt_elapsed(image.elapsed)); if (image.elapsed !== null && image.elapsed !== undefined) meta.push(fmt_elapsed(image.elapsed));
el('file-diff-sub').textContent = `${image.path || image.name || ''} — ${meta.join(' · ')}`; el('file-diff-sub').textContent = `${image.path || image.name || ''} — ${meta.join(' · ')}`;
const before = hexImageHtml(image, 'before');
const after = hexImageHtml(image, 'after');
const used = {
changed: before.used.changed || after.used.changed,
fresh: before.used.fresh || after.used.fresh,
unknown: before.used.unknown || after.used.unknown,
};
// Only the marks the views actually show get a legend entry (a short
// file has no '--' filler, so 'never observed' would be misleading).
const legend = [
used.changed ? `<span class="hex-legend-item"><span class="hex-cell hex-changed">aa</span> ${t('changed')}</span>` : '',
used.fresh ? `<span class="hex-legend-item"><span class="hex-cell hex-new">aa</span> ${t('newly observed')}</span>` : '',
used.unknown ? `<span class="hex-legend-item"><span class="hex-cell hex-unknown">--</span> ${t('never observed')}</span>` : '',
].join('');
el('file-diff-body').innerHTML = el('file-diff-body').innerHTML =
`<div class="hex-summary">${t('{c} bytes changed, {n} bytes newly observed') `<div class="hex-summary">${t('{c} bytes changed, {n} bytes newly observed')
.replace('{c}', changed).replace('{n}', fresh)}</div> .replace('{c}', changed).replace('{n}', fresh)}</div>
<div class="hex-pair"> <div class="hex-pair">
<div><h4>${t('Before')}</h4>${hexImageHtml(image, 'before')}</div> <div><h4>${t('Before')}</h4>${before.html}</div>
<div><h4>${t('After')}</h4>${hexImageHtml(image, 'after')}</div> <div><h4>${t('After')}</h4>${after.html}</div>
</div>
<div class="hex-legend">
<span class="hex-legend-item"><span class="hex-cell hex-changed">aa</span> ${t('changed')}</span>
<span class="hex-legend-item"><span class="hex-cell hex-new">aa</span> ${t('newly observed')}</span>
<span class="hex-legend-item"><span class="hex-cell hex-unknown">--</span> ${t('never observed')}</span>
</div>` + </div>` +
(legend ? `<div class="hex-legend">${legend}</div>` : '') +
(image.image_truncated ? `<p class="hex-note">${t('The image is frozen (size cap reached) — later changes are not tracked.')}</p>` : ''); (image.image_truncated ? `<p class="hex-note">${t('The image is frozen (size cap reached) — later changes are not tracked.')}</p>` : '');
} }
@@ -3341,7 +3373,7 @@ function onSearchChange() {
} }
// ──────────────────── Versioning ──────────────────── // ──────────────────── Versioning ────────────────────
const PWA_VERSION = '2.7.2'; const PWA_VERSION = '2.7.3';
const MIN_SERVER_VERSION = '2.3.0'; const MIN_SERVER_VERSION = '2.3.0';
function parseVersion(v) { function parseVersion(v) {
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simtrace-analyser-v82'; const CACHE = 'simtrace-analyser-v83';
const PRECACHE = [ const PRECACHE = [
'index.html', 'index.html',
+60
View File
@@ -319,6 +319,24 @@ test('Files view: the hex image rows mark changed, new and unknown bytes', () =>
assert.equal(view.rows[0].ascii, undefined); assert.equal(view.rows[0].ascii, undefined);
}); });
test('Files view: the hex rows stop at the known length (no filler)', () => {
// A 2-byte file: two cells, no '--' filler that would look like data loss.
const short = hexImageRows([{offset: 0, hex: 'aabb'}], [], [], undefined, 2);
assert.equal(short.rows.length, 1);
assert.equal(short.rows[0].cells.length, 2);
assert.deepEqual(short.used, {changed: false, fresh: false, unknown: false});
// A 9-byte record with 4 bytes seen: the row ends at 9, and '--' marks the
// bytes inside the record that were never observed.
const rec = hexImageRows([{offset: 0, hex: '01020304'}], [[0, 2]], [],
undefined, 9);
assert.equal(rec.rows[0].cells.length, 9);
assert.equal(rec.rows[0].cells.filter(c => c.cls === 'hex-unknown').length, 5);
assert.deepEqual(rec.used, {changed: true, fresh: false, unknown: true});
// Without a known length the old row padding stays (nothing better to do).
const noSize = hexImageRows([{offset: 0, hex: 'aabb'}], [], [], undefined, null);
assert.equal(noSize.rows[0].cells.length, 16);
});
test('Files view: the hex image respects the row limit', () => { test('Files view: the hex image respects the row limit', () => {
const view = hexImageRows([{offset: 0, hex: 'aa'.repeat(64)}], [], [], 32); const view = hexImageRows([{offset: 0, hex: 'aa'.repeat(64)}], [], [], 32);
assert.equal(view.truncated, true); assert.equal(view.truncated, true);
@@ -513,6 +531,48 @@ test('Change details modal: header, summary, legend and both views', () => {
assert.match(el('file-diff-body').innerHTML, /image is frozen/); assert.match(el('file-diff-body').innerHTML, /image is frozen/);
}); });
test('Change details: a short file shows no filler, no offset, no stray legend', () => {
renderFileDiff({
name: 'EF_Kc', path: 'DF_GSM/EF_Kc (6F20)', op: 'UPDATE BINARY',
record: null, size: 2, record_len: null, n_records: null,
index: 3, elapsed: 1.25, ranges: [[1, 1]], observed_ranges: [],
before: [{offset: 0, hex: 'aabb'}], after: [{offset: 0, hex: 'aacc'}],
image_truncated: false,
});
const body = el('file-diff-body').innerHTML;
assert.doesNotMatch(body, /hex-unknown/); // no '--' cells at all
assert.doesNotMatch(body, /hex-addr/); // a single row: no offset column
assert.match(body, /hex-cell hex-changed/);
// The legend lists only what the views show: 'changed' (the summary line
// above still counts the zero "newly observed" bytes).
const legend = body.slice(body.indexOf('hex-legend'));
assert.equal((legend.match(/class="hex-legend-item"/g) || []).length, 1);
assert.match(legend, /changed</);
assert.doesNotMatch(legend, /newly observed|never observed/);
});
test('Change details: a short record keeps its never-observed tail', () => {
// A 9-byte record with 4 bytes observed: the row ends at 9 (no filler
// past it) and '--' marks the bytes that were never observed.
renderFileDiff({
name: 'EF_IMSI', path: 'ADF/EF_IMSI (6F07)', op: 'READ RECORD',
record: 1, record_len: 9, n_records: 2, size: 18,
index: 5, elapsed: 2, ranges: [[0, 2]], observed_ranges: [],
before: [{offset: 0, hex: 'ffffffff'}],
after: [{offset: 0, hex: 'ffffffff'}],
image_truncated: false,
});
const body = el('file-diff-body').innerHTML;
assert.match(body, /hex-cell hex-changed/);
assert.match(body, /hex-cell hex-unknown">--<\/span>\s*<\/div>\s*<\/div>/);
assert.doesNotMatch(body, /hex-addr/); // one row: no offset column
const legend = body.slice(body.indexOf('hex-legend'));
assert.equal((legend.match(/class="hex-legend-item"/g) || []).length, 2);
assert.match(legend, /changed</);
assert.match(legend, /never observed/);
assert.doesNotMatch(legend, /newly observed/);
});
// ── Session loading must survive "no active capture" ─────────────────── // ── Session loading must survive "no active capture" ───────────────────
eval(extractFunc(src, 'stopCaptureIfRunning')); eval(extractFunc(src, 'stopCaptureIfRunning'));
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend"
[project] [project]
name = "simtrace-analyser" name = "simtrace-analyser"
version = "2.7.2" version = "2.7.3"
description = "SIMtrace Analyser — APDU trace analyser for GSMTAP/PCAP captures (bundles the simtrace2-pysniff utility)" description = "SIMtrace Analyser — APDU trace analyser for GSMTAP/PCAP captures (bundles the simtrace2-pysniff utility)"
readme = {file = "README.md", content-type = "text/markdown"} readme = {file = "README.md", content-type = "text/markdown"}
license = {text = "GPL-2.0-or-later"} license = {text = "GPL-2.0-or-later"}
+1 -1
View File
@@ -1,3 +1,3 @@
"""Single source of truth for the SIMtrace Analyser version.""" """Single source of truth for the SIMtrace Analyser version."""
__version__ = '2.7.2' __version__ = '2.7.3'
+1 -1
View File
@@ -224,7 +224,7 @@ class TestImportEndpoint(unittest.TestCase):
class TestVersion(unittest.TestCase): class TestVersion(unittest.TestCase):
def test_package_version(self): def test_package_version(self):
from simtrace2_pysniff import __version__ from simtrace2_pysniff import __version__
self.assertEqual(__version__, '2.7.2') self.assertEqual(__version__, '2.7.3')
def test_status_includes_version(self): def test_status_includes_version(self):
import os import os