From 71a94f5f500709fa4942a8ff2f983ee86976bade Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Sun, 18 Oct 2026 00:37:12 +0300 Subject: [PATCH] Change details: no filler past a short file, no offsets for a single row (v2.7.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 10 +++--- README_RUS.md | 6 ++-- frontend/index.html | 62 +++++++++++++++++++++++++-------- frontend/sw.js | 2 +- frontend/tests/comprion.test.js | 60 +++++++++++++++++++++++++++++++ pyproject.toml | 2 +- simtrace_analyser/version.py | 2 +- tests/test_pcap_import.py | 2 +- 8 files changed, 121 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 7f339ad..34119ba 100644 --- a/README.md +++ b/README.md @@ -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 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 -hex views (16 bytes per row, never-observed bytes drawn as `--`) with the bytes -that changed highlighted in one colour and the bytes the operation first -observed in another, the changed/new byte counts, the APDU reference and the -colour legend below the views. +hex views (up to 16 bytes per row — a shorter file/record ends its row there +instead of padding with `--`, and the offset column only appears when a view +spans more than one row) with the bytes that changed highlighted in one colour +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//files/image?key=…&change=…` and are reconstructed from the current image by undoing the later changes diff --git a/README_RUS.md b/README_RUS.md index 4532486..51d3a5a 100644 --- a/README_RUS.md +++ b/README_RUS.md @@ -224,10 +224,12 @@ Files — это таймлайн файловых операций по все `ADF/EF_IMSI (6F07)`, `DF_GSM_ACCESS/EF_KcGPRS (4F52)`, `AID …/4F09` — DF называется по меткам из EF.DIR, если они есть) и затронутую единицу — файл целиком или изменившуюся запись — **до и после** изменения в виде hex-вида -(16 байт в строке, никогда не наблюдавшиеся байты — `--`), причём изменённые +(до 16 байт в строке: короткий файл/запись заканчивает строку, а не +дополняется `--`, и столбец смещений появляется, только когда представление +занимает больше одной строки), причём изменённые байты подсвечены одним цветом, а впервые наблюдаемые — другим; ниже — счётчики изменённых/новых байт, ссылка на APDU и легенда цветов под -представлениями. +представлениями, перечисляющая только реально показанные пометки. Изображения отдаёт `GET /api/sessions//files/image?key=…&change=…`; они восстанавливаются из текущего образа отменой более поздних изменений diff --git a/frontend/index.html b/frontend/index.html index f43d277..2829984 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2171,7 +2171,7 @@ function fileOpLabel(op) { // 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* // (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 bytes = new Map(); 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 rows = []; + const used = {changed: false, fresh: false, unknown: false}; let truncated = false; for (let off = 0; off < end; off += 16) { if (off >= maxBytes) { @@ -2193,30 +2194,51 @@ function hexImageRows(segments, ranges, observed, limit) { const cells = []; for (let i = 0; i < 16; 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); if (hex === undefined) { cells.push({hex: '--', cls: 'hex-unknown'}); + used.unknown = true; continue; } const cls = covered(ranges, pos) ? 'hex-changed' : 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}); } 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) { const segments = image[key] || []; - const view = hexImageRows(segments, image.ranges, image.observed_ranges); - const body = view.rows.map(r => - `
${r.offset.toString(16).toUpperCase().padStart(4, '0')}` + + const asRecord = image.record !== null && image.record !== undefined; + const size = asRecord ? image.record_len : image.size; + 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 => + `
` + + (addr ? `${r.offset.toString(16).toUpperCase().padStart(4, '0')}` : '') + r.cells.map(c => `${c.hex}`).join('') + '
' - ).join('') || `
${t('never observed')}
`; - return `
${body}
` + + ).join(''); + if (!view.rows.length) { + body = `
${t('never observed')}
`; + used.unknown = true; + } + const html = `
${body}
` + (view.truncated ? `
${t('Showing the first {n} bytes of the image').replace('{n}', 2048)}
` : ''); + return {html: html, used: used}; } async function renderFileView() { @@ -2301,18 +2323,28 @@ function renderFileDiff(image) { if (image.index !== null && image.index !== undefined) meta.push(`#${image.index}`); if (image.elapsed !== null && image.elapsed !== undefined) meta.push(fmt_elapsed(image.elapsed)); 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 ? `aa ${t('changed')}` : '', + used.fresh ? `aa ${t('newly observed')}` : '', + used.unknown ? `-- ${t('never observed')}` : '', + ].join(''); el('file-diff-body').innerHTML = `
${t('{c} bytes changed, {n} bytes newly observed') .replace('{c}', changed).replace('{n}', fresh)}
-

${t('Before')}

${hexImageHtml(image, 'before')}
-

${t('After')}

${hexImageHtml(image, 'after')}
-
-
- aa ${t('changed')} - aa ${t('newly observed')} - -- ${t('never observed')} +

${t('Before')}

${before.html}
+

${t('After')}

${after.html}
` + + (legend ? `
${legend}
` : '') + (image.image_truncated ? `

${t('The image is frozen (size cap reached) — later changes are not tracked.')}

` : ''); } @@ -3341,7 +3373,7 @@ function onSearchChange() { } // ──────────────────── Versioning ──────────────────── -const PWA_VERSION = '2.7.2'; +const PWA_VERSION = '2.7.3'; const MIN_SERVER_VERSION = '2.3.0'; function parseVersion(v) { diff --git a/frontend/sw.js b/frontend/sw.js index 4ba80a4..1e685ad 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simtrace-analyser-v82'; +const CACHE = 'simtrace-analyser-v83'; const PRECACHE = [ 'index.html', diff --git a/frontend/tests/comprion.test.js b/frontend/tests/comprion.test.js index ca98afa..0d877e2 100644 --- a/frontend/tests/comprion.test.js +++ b/frontend/tests/comprion.test.js @@ -319,6 +319,24 @@ test('Files view: the hex image rows mark changed, new and unknown bytes', () => 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', () => { const view = hexImageRows([{offset: 0, hex: 'aa'.repeat(64)}], [], [], 32); 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/); }); +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 { + // 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