Files view: change details with before/after images (v2.4.0)
- the timeline's Δ glyph became a **Details** button, and it now appears on Update rows too (not just card-side changes); EN "Changed (card)" -> "Changed on card", RU "Изменено (картой)" -> "Изменено картой" - the details modal shows the affected unit — the whole file, or the single record that changed — **before and after** the change as hex views (16 bytes per row, ASCII column, never-observed bytes as `--`), with the bytes that changed and the bytes the operation *first observed* highlighted in two different colours, plus the changed/new byte counts and the APDU reference; the old before/after byte table is gone - `GET /api/sessions/<id>/files/image?key=…&change=…` returns those two views (segments with offsets) plus the change's metadata; the images are reconstructed from the current image by undoing the later changes (`FileTracker.change_images`), so nothing is duplicated per event — every value change is an event, hence the values are exact (a byte only first observed later can appear as known in both views) - tracker: change events carry `observed_ranges` (bytes that were unknown before the operation) next to `ranges`; an update that only observes new bytes now records an event (so it can be inspected); record-file ranges are in-record byte offsets (the record number stays in its own field), which the previous "record number as position" could not express for a hex highlight - tests: change-images reconstruction (before/after per event, later changes undone, records isolated, observation-only events, unknown gaps), the image endpoint (200/404/400), and Node tests for the hex-image helper (highlight classes, unknown gaps, ASCII, row limit) and the modal rendering - v2.4.0; SW cache simtrace-analyser-v65
This commit is contained in:
1 parent
87b6811adf
commit
cad3999307
12 files changed
+616
-119
No files matched your search
@@ -191,9 +191,20 @@ in the stored wire shape and are skipped with a note in the import report.
|
||||
The Trace tab's side panel has a view selector (*Map* / *Stats* / **Files**;
|
||||
a drop-down, since three tab captions no longer fit). The Files view is a
|
||||
trace-wide timeline of file operations — every **Read**, **Update** and
|
||||
**Changed (card)** row shows the same file name as the Map view, and clicking
|
||||
a row jumps to the APDU in the trace. A *Changed (card)* row carries a Δ
|
||||
button that opens the changed ranges with the previous and the new bytes.
|
||||
**Changed on card** row shows the same file name as the Map view, and clicking
|
||||
a row jumps to the APDU in the trace. Rows that carry a recorded change (a
|
||||
card-side change, or an update that wrote or first observed bytes) also offer
|
||||
**Details**: a modal showing the affected unit — the whole file, or the single
|
||||
record that changed — **before and after** the change, as hex views (16 bytes
|
||||
per row with an ASCII column, never-observed bytes drawn as `--`) with the
|
||||
bytes that changed highlighted in one colour and the bytes the operation first
|
||||
observed in another, plus the changed/new byte counts and the APDU reference.
|
||||
|
||||
The images come from `GET /api/sessions/<id>/files/image?key=…&change=…` and
|
||||
are reconstructed from the current image by undoing the later changes
|
||||
(`filetrack.FileTracker.change_images`) — nothing is duplicated per event.
|
||||
Every value change is recorded, so the values are exact; a byte that was only
|
||||
*first observed* after the selected change can appear as known in both views.
|
||||
|
||||
The data comes from `simtrace_analyser/filetrack.py`
|
||||
(`GET /api/sessions/<id>/files`), a single-pass tracker over the decoded
|
||||
|
||||
+15
-3
@@ -196,9 +196,21 @@ Application Layer* — два отдельных поля выбора, имен
|
||||
В боковой панели вкладки «Трассировка» появился выбор вида (*Map* / *Stats* /
|
||||
**Files** — выпадающий список, три подписи-вкладки уже не помещались). Вид
|
||||
Files — это таймлайн файловых операций по всей трассе: строки **Read**,
|
||||
**Update** и **Changed (card)** показывают те же имена файлов, что и вид Map,
|
||||
а щелчок по строке переходит к APDU в трассе. У строки *Changed (card)* есть
|
||||
кнопка Δ, открывающая изменённые диапазоны с прежними и новыми байтами.
|
||||
**Update** и **Changed on card** показывают те же имена файлов, что и вид Map,
|
||||
а щелчок по строке переходит к APDU в трассе. У строк с записанным изменением
|
||||
(изменение со стороны карты либо запись, изменившая или впервые показавшая
|
||||
байты) есть кнопка **Details**: окно показывает затронутую единицу — файл
|
||||
целиком или изменившуюся запись — **до и после** изменения в виде hex-вида
|
||||
(16 байт в строке со столбцом ASCII, никогда не наблюдавшиеся байты — `--`),
|
||||
причём изменённые байты подсвечены одним цветом, а впервые наблюдаемые —
|
||||
другим; ниже — счётчики изменённых/новых байт и ссылка на APDU.
|
||||
|
||||
Изображения отдаёт `GET /api/sessions/<id>/files/image?key=…&change=…`; они
|
||||
восстанавливаются из текущего образа отменой более поздних изменений
|
||||
(`filetrack.FileTracker.change_images`) — поэтому ничего не дублируется по
|
||||
событиям. Каждое изменение значения записывается, так что значения точны;
|
||||
байт, который был лишь *впервые показан* после выбранного изменения, может
|
||||
отображаться известным в обоих видах.
|
||||
|
||||
Данные даёт `simtrace_analyser/filetrack.py` (`GET /api/sessions/<id>/files`) —
|
||||
однопроходный анализатор сессии, который ведёт по ключу (DF, FID) и **через
|
||||
|
||||
+138
-39
@@ -188,13 +188,25 @@ body { font: 14px/1.5 system-ui, sans-serif; background: var(--bg); color: var(-
|
||||
.file-op-read { color: var(--text-muted); }
|
||||
.file-op-update { color: var(--accent); }
|
||||
.file-op-card { color: var(--warn); }
|
||||
.file-op .file-op-diff { margin-left: auto; background: none; border: none; color: var(--warn); cursor: pointer; font-size: 12px; padding: 0 2px; flex: 0 0 auto; }
|
||||
.file-op .file-op-diff:hover { color: var(--accent); }
|
||||
.file-diff-modal { max-width: 640px; }
|
||||
.file-diff-body { max-height: 50vh; overflow-y: auto; margin-bottom: 16px; }
|
||||
.file-diff-body table { font-size: 12px; }
|
||||
.file-diff-body td { padding: 2px 8px 2px 0; vertical-align: top; }
|
||||
.file-diff-body .file-diff-hex { font-family: ui-monospace, monospace; word-break: break-all; }
|
||||
.file-op .file-op-diff { margin-left: auto; background: none; border: none; color: var(--accent); cursor: pointer; font-size: 12px; padding: 0 2px; flex: 0 0 auto; text-decoration: underline; }
|
||||
.file-op .file-op-diff:hover { color: var(--warn); }
|
||||
.file-diff-modal { max-width: 760px; }
|
||||
.file-diff-body { max-height: 60vh; overflow-y: auto; margin-bottom: 16px; }
|
||||
.hex-legend { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-muted); margin-bottom: 6px; flex-wrap: wrap; }
|
||||
.hex-summary { font-size: 12px; color: var(--text-muted); margin-bottom: 10px; }
|
||||
.hex-pair { display: flex; gap: 16px; flex-wrap: wrap; }
|
||||
.hex-pair > div { flex: 1 1 320px; min-width: 0; }
|
||||
.hex-pair h4 { font-size: 12px; text-transform: uppercase; letter-spacing: 1px; color: var(--accent); margin-bottom: 4px; }
|
||||
.hex-view { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; line-height: 1.45; white-space: pre; overflow-x: auto; }
|
||||
.hex-row { display: flex; align-items: baseline; }
|
||||
.hex-addr { color: var(--text-muted); margin-right: 8px; }
|
||||
.hex-cell { padding: 0 1px; border-radius: 2px; }
|
||||
.hex-cell.hex-changed { background: var(--warn); color: #1a1a1a; font-weight: 600; }
|
||||
.hex-cell.hex-new { background: var(--accent); color: #fff; font-weight: 600; }
|
||||
.hex-cell.hex-unknown { color: var(--text-muted); }
|
||||
.hex-ascii { color: var(--text-muted); margin-left: 8px; }
|
||||
.hex-note { font-size: 12px; color: var(--text-muted); margin-top: 6px; }
|
||||
.hex-error { font-size: 12px; color: var(--error); }
|
||||
.map-item { padding: 3px 8px; cursor: pointer; border-radius: 4px; font-size: 13px; color: var(--text); word-break: break-word; }
|
||||
.map-item:hover { background: var(--bg-input); }
|
||||
.map-files-toggle { color: var(--accent); cursor: pointer; }
|
||||
@@ -438,7 +450,7 @@ body { font: 14px/1.5 system-ui, sans-serif; background: var(--bg); color: var(-
|
||||
<!-- Card-side change detail (Files view) -->
|
||||
<div class="modal-overlay" id="file-diff-modal">
|
||||
<div class="modal file-diff-modal">
|
||||
<h3 data-l10n="Card-side change">Card-side change</h3>
|
||||
<h3 data-l10n="Change details">Change details</h3>
|
||||
<p id="file-diff-sub"></p>
|
||||
<div class="file-diff-body" id="file-diff-body"></div>
|
||||
<div class="modal-buttons">
|
||||
@@ -499,6 +511,7 @@ const STATE = {
|
||||
sideView: 'map',
|
||||
filesData: null,
|
||||
filesSessionId: null,
|
||||
filesImages: {},
|
||||
pendingDeleteId: null,
|
||||
serverAvailable: false,
|
||||
serverName: '',
|
||||
@@ -1193,6 +1206,10 @@ const API = {
|
||||
stopCapture() { return this._fetch('POST', '/api/capture/stop'); },
|
||||
getLatest(after) { return this._fetch('GET', `/api/capture/latest?after=${after}`); },
|
||||
getSessionFiles(id) { return this._fetch('GET', `/api/sessions/${id}/files`); },
|
||||
getSessionFileImage(id, key, change) {
|
||||
return this._fetch('GET', `/api/sessions/${id}/files/image?key=` +
|
||||
encodeURIComponent(key) + `&change=${change}`);
|
||||
},
|
||||
searchMessages(sessionId, query) { return this._fetch('GET', `/api/apdu/search?session_id=${sessionId}&q=${encodeURIComponent(query)}`); },
|
||||
filterMessages(sessionId, type) { return this._fetch('GET', `/api/apdu/filter?session_id=${sessionId}&type=${type}`); },
|
||||
async _importPost(params, body, contentType) {
|
||||
@@ -2087,7 +2104,7 @@ function fileOpClass(kind) {
|
||||
|
||||
function fileOpVerb(kind) {
|
||||
if (kind === 'update') return t('Update');
|
||||
if (kind === 'card_change') return t('Changed (card)');
|
||||
if (kind === 'card_change') return t('Changed on card');
|
||||
return t('Read');
|
||||
}
|
||||
|
||||
@@ -2096,21 +2113,59 @@ function fileOpLabel(op) {
|
||||
return `${fileOpVerb(op.kind)} ${name}`;
|
||||
}
|
||||
|
||||
// Split a change's concatenated before/after hex into per-range rows.
|
||||
function fileDiffRows(change) {
|
||||
const rows = [];
|
||||
let at = 0;
|
||||
for (const [pos, len] of (change.ranges || [])) {
|
||||
const take = len * 2;
|
||||
rows.push({
|
||||
pos: pos,
|
||||
len: len,
|
||||
before: (change.before_hex || '').slice(at, at + take),
|
||||
after: (change.after_hex || '').slice(at, at + take),
|
||||
});
|
||||
at += take;
|
||||
// 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) {
|
||||
const maxBytes = limit || 2048;
|
||||
const bytes = new Map();
|
||||
let end = 0;
|
||||
for (const seg of segments || []) {
|
||||
const hex = (seg.hex || '').toLowerCase();
|
||||
for (let i = 0; i < hex.length / 2; i++) {
|
||||
bytes.set(seg.offset + i, hex.substr(i * 2, 2));
|
||||
}
|
||||
return rows;
|
||||
end = Math.max(end, seg.offset + Math.floor(hex.length / 2));
|
||||
}
|
||||
const covered = (list, pos) => (list || []).some(([p, n]) => pos >= p && pos < p + n);
|
||||
const rows = [];
|
||||
let truncated = false;
|
||||
for (let off = 0; off < end; off += 16) {
|
||||
if (off >= maxBytes) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
const cells = [];
|
||||
let ascii = '';
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const pos = off + i;
|
||||
const hex = bytes.get(pos);
|
||||
if (hex === undefined) {
|
||||
cells.push({hex: '--', cls: 'hex-unknown'});
|
||||
ascii += ' ';
|
||||
continue;
|
||||
}
|
||||
const cls = covered(ranges, pos) ? 'hex-changed'
|
||||
: covered(observed, pos) ? 'hex-new' : '';
|
||||
const value = parseInt(hex, 16);
|
||||
cells.push({hex: hex, cls: cls});
|
||||
ascii += (value >= 0x20 && value < 0x7f) ? String.fromCharCode(value) : '.';
|
||||
}
|
||||
rows.push({offset: off, cells: cells, ascii: ascii});
|
||||
}
|
||||
return {rows: rows, truncated: truncated, end: end};
|
||||
}
|
||||
|
||||
function hexImageHtml(image, key) {
|
||||
const segments = image[key] || [];
|
||||
const view = hexImageRows(segments, image.ranges, image.observed_ranges);
|
||||
const body = view.rows.map(r =>
|
||||
`<div class="hex-row"><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('') +
|
||||
`<span class="hex-ascii">${escapeHtml(r.ascii)}</span></div>`
|
||||
).join('') || `<div class="hex-row hex-unknown">${t('never observed')}</div>`;
|
||||
return `<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>` : '');
|
||||
}
|
||||
|
||||
async function renderFileView() {
|
||||
@@ -2149,8 +2204,8 @@ async function renderFileView() {
|
||||
return;
|
||||
}
|
||||
view.innerHTML = timeline.map(op => {
|
||||
const diff = (op.kind === 'card_change' && op.change_ref)
|
||||
? `<button class="file-op-diff" data-diff="${escapeHtml(op.change_ref.file)}|${op.change_ref.idx}" title="${t('Show the change')}">Δ</button>`
|
||||
const diff = op.change_ref
|
||||
? `<button class="file-op-diff" data-diff="${escapeHtml(op.change_ref.file)}|${op.change_ref.idx}" title="${t('Show the change')}">${t('Details')}</button>`
|
||||
: '';
|
||||
return `<div class="file-op ${fileOpClass(op.kind)}" data-idx="${op.index}">
|
||||
<span>${escapeHtml(fileOpLabel(op))}</span>${diff}
|
||||
@@ -2158,20 +2213,56 @@ async function renderFileView() {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function showFileDiff(fileKey, idx) {
|
||||
async function showFileDiff(fileKey, idx) {
|
||||
const data = STATE.filesData;
|
||||
const entry = data && data.files ? data.files[fileKey] : null;
|
||||
const change = entry && entry.changes ? entry.changes[idx] : null;
|
||||
if (!change) return;
|
||||
const rows = fileDiffRows(change);
|
||||
el('file-diff-sub').textContent = `${entry.name} — ${change.op}`;
|
||||
el('file-diff-body').innerHTML = rows.map(r => `<table>
|
||||
<tr><td>${change.record != null ? t('Record') : t('Offset')}:</td><td>${escapeHtml(change.record != null ? String(r.pos) : '0x' + r.pos.toString(16).toUpperCase())} (${r.len} B)</td></tr>
|
||||
<tr><td>${t('Before')}:</td><td class="file-diff-hex">${escapeHtml(r.before.toUpperCase())}</td></tr>
|
||||
<tr><td>${t('After')}:</td><td class="file-diff-hex">${escapeHtml(r.after.toUpperCase())}</td></tr>
|
||||
<tr><td colspan="2" style="height:6px"></td></tr>
|
||||
</table>`).join('');
|
||||
if (!entry) return;
|
||||
el('file-diff-sub').textContent = entry.name;
|
||||
el('file-diff-body').innerHTML = `<p class="hex-note">${t('Loading…')}</p>`;
|
||||
el('file-diff-modal').classList.add('show');
|
||||
const cacheKey = `${STATE.sessionViewId}|${fileKey}|${idx}`;
|
||||
let image = STATE.filesImages[cacheKey];
|
||||
try {
|
||||
if (!image) {
|
||||
image = await API.getSessionFileImage(STATE.sessionViewId, fileKey, idx);
|
||||
if (!image || !image.before) {
|
||||
throw new Error((image && image.error) || t('Failed to load files'));
|
||||
}
|
||||
STATE.filesImages[cacheKey] = image;
|
||||
}
|
||||
} catch (e) {
|
||||
el('file-diff-body').innerHTML =
|
||||
`<p class="hex-error">${t('Failed to load files')}: ${escapeHtml((e && e.message) || '')}</p>`;
|
||||
return;
|
||||
}
|
||||
renderFileDiff(image);
|
||||
}
|
||||
|
||||
function renderFileDiff(image) {
|
||||
const changed = (image.ranges || []).reduce((n, r) => n + r[1], 0);
|
||||
const fresh = (image.observed_ranges || []).reduce((n, r) => n + r[1], 0);
|
||||
const meta = [];
|
||||
if (image.op) meta.push(image.op);
|
||||
if (image.record !== null && image.record !== undefined) {
|
||||
meta.push(`${t('Record')} ${image.record}` +
|
||||
(image.n_records ? ` / ${image.n_records}` : ''));
|
||||
}
|
||||
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.name || ''} — ${meta.join(' · ')}`;
|
||||
el('file-diff-body').innerHTML =
|
||||
`<div class="hex-legend">
|
||||
<span class="hex-cell hex-changed">aa</span> ${t('changed')}
|
||||
<span class="hex-cell hex-new">aa</span> ${t('newly observed')}
|
||||
<span class="hex-cell hex-unknown">--</span> ${t('never observed')}
|
||||
</div>
|
||||
<div class="hex-summary">${t('{c} bytes changed, {n} bytes newly observed')
|
||||
.replace('{c}', changed).replace('{n}', fresh)}</div>
|
||||
<div class="hex-pair">
|
||||
<div><h4>${t('Before')}</h4>${hexImageHtml(image, 'before')}</div>
|
||||
<div><h4>${t('After')}</h4>${hexImageHtml(image, 'after')}</div>
|
||||
</div>` +
|
||||
(image.image_truncated ? `<p class="hex-note">${t('The image is frozen (size cap reached) — later changes are not tracked.')}</p>` : '');
|
||||
}
|
||||
|
||||
// Load the pages up to *idx* (the message list is fetched in pages) and focus
|
||||
@@ -2810,6 +2901,7 @@ async function loadSession(id) {
|
||||
STATE.sessionViewId = id;
|
||||
STATE.filesData = null;
|
||||
STATE.filesSessionId = null;
|
||||
STATE.filesImages = {};
|
||||
STATE.sessionStarted = data.session.started;
|
||||
STATE.captureActive = false;
|
||||
STATE.currentSessionName = data.session.name || t('Session #') + id;
|
||||
@@ -3072,7 +3164,7 @@ function onFilterChange() {
|
||||
}
|
||||
|
||||
// ──────────────────── Versioning ────────────────────
|
||||
const PWA_VERSION = '2.3.1';
|
||||
const PWA_VERSION = '2.4.0';
|
||||
const MIN_SERVER_VERSION = '2.3.0';
|
||||
|
||||
function parseVersion(v) {
|
||||
@@ -3118,8 +3210,15 @@ const LANG_RU = {
|
||||
'Files': 'Файлы',
|
||||
'Read': 'Чтение',
|
||||
'Update': 'Запись',
|
||||
'Changed (card)': 'Изменено (картой)',
|
||||
'Card-side change': 'Изменение на карте',
|
||||
'Changed on card': 'Изменено картой',
|
||||
'Change details': 'Подробности изменения',
|
||||
'Details': 'Подробнее',
|
||||
'changed': 'изменено',
|
||||
'newly observed': 'впервые наблюдается',
|
||||
'never observed': 'не наблюдалось',
|
||||
'{c} bytes changed, {n} bytes newly observed': 'Изменено байт: {c}; впервые наблюдается байт: {n}',
|
||||
'Showing the first {n} bytes of the image': 'Показаны первые {n} байт образа',
|
||||
'The image is frozen (size cap reached) — later changes are not tracked.': 'Образ заморожен (достигнут предел размера) — последующие изменения не отслеживаются.',
|
||||
'Close': 'Закрыть',
|
||||
'Offset': 'Смещение',
|
||||
'Record': 'Запись №',
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'simtrace-analyser-v64';
|
||||
const CACHE = 'simtrace-analyser-v65';
|
||||
|
||||
const PRECACHE = [
|
||||
'index.html',
|
||||
|
||||
@@ -282,7 +282,8 @@ eval(
|
||||
extractFunc(src, 'fileOpClass') + '\n' +
|
||||
extractFunc(src, 'fileOpVerb') + '\n' +
|
||||
extractFunc(src, 'fileOpLabel') + '\n' +
|
||||
extractFunc(src, 'fileDiffRows')
|
||||
extractFunc(src, 'escapeHtml') + '\n' +
|
||||
extractFunc(src, 'hexImageRows')
|
||||
);
|
||||
|
||||
test('Files view: operation kinds map to classes and verbs', () => {
|
||||
@@ -291,24 +292,36 @@ test('Files view: operation kinds map to classes and verbs', () => {
|
||||
assert.equal(fileOpClass('card_change'), 'file-op-card');
|
||||
assert.equal(fileOpVerb('read'), 'Read');
|
||||
assert.equal(fileOpVerb('update'), 'Update');
|
||||
assert.equal(fileOpVerb('card_change'), 'Changed (card)');
|
||||
assert.equal(fileOpVerb('card_change'), 'Changed on card');
|
||||
assert.equal(fileOpLabel({kind: 'read', file: {name: 'EF_ICCID'}}), 'Read EF_ICCID');
|
||||
assert.equal(fileOpLabel({kind: 'card_change', file: {name: 'EF_LOCI'}}),
|
||||
'Changed (card) EF_LOCI');
|
||||
'Changed on card EF_LOCI');
|
||||
assert.equal(fileOpLabel({kind: 'read', file: null}), 'Read ?');
|
||||
});
|
||||
|
||||
test('Files view: the diff rows slice the concatenated hex', () => {
|
||||
const change = {
|
||||
ranges: [[8, 2], [16, 1]],
|
||||
before_hex: '95f9aa',
|
||||
after_hex: '000001',
|
||||
};
|
||||
assert.deepEqual(fileDiffRows(change), [
|
||||
{pos: 8, len: 2, before: '95f9', after: '0000'},
|
||||
{pos: 16, len: 1, before: 'aa', after: '01'},
|
||||
]);
|
||||
assert.deepEqual(fileDiffRows({ranges: [], before_hex: '', after_hex: ''}), []);
|
||||
test('Files view: the hex image rows mark changed, new and unknown bytes', () => {
|
||||
// 'ABCD' then a gap, then 'ef' newly observed.
|
||||
const view = hexImageRows(
|
||||
[{offset: 0, hex: '41424344'}, {offset: 8, hex: '6566'}],
|
||||
[[2, 1]], // changed
|
||||
[[8, 2]]); // newly observed
|
||||
assert.equal(view.truncated, false);
|
||||
assert.equal(view.end, 10);
|
||||
assert.equal(view.rows.length, 1);
|
||||
const cells = view.rows[0].cells;
|
||||
assert.deepEqual(cells[0], {hex: '41', cls: ''});
|
||||
assert.deepEqual(cells[2], {hex: '43', cls: 'hex-changed'});
|
||||
assert.deepEqual(cells[4], {hex: '--', cls: 'hex-unknown'}); // the gap
|
||||
assert.deepEqual(cells[8], {hex: '65', cls: 'hex-new'});
|
||||
// ASCII column: printable bytes stay, gaps and the unobserved tail are blank.
|
||||
assert.equal(view.rows[0].ascii, 'ABCD ef ');
|
||||
});
|
||||
|
||||
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);
|
||||
assert.equal(view.rows.length, 2);
|
||||
assert.equal(view.end, 64);
|
||||
});
|
||||
|
||||
test('Files view: side-panel dropdown and the timeline are wired', () => {
|
||||
@@ -321,6 +334,10 @@ test('Files view: side-panel dropdown and the timeline are wired', () => {
|
||||
assert.match(src, /\$on\('side-view-select', 'change'/);
|
||||
assert.match(src, /\$on\('file-view', 'click'/);
|
||||
assert.match(src, /API\.getSessionFiles\(sid\)/);
|
||||
assert.match(src, />\$\{t\('Details'\)\}<\/button>/);
|
||||
assert.match(src, /API\.getSessionFileImage\(STATE\.sessionViewId, fileKey, idx\)/);
|
||||
assert.match(src, /getSessionFileImage\(id, key, change\)/);
|
||||
assert.match(src, /files\/image\?key=/);
|
||||
assert.match(src, /if \(row\) jumpToMessage\(Number\(row\.dataset\.idx\)\);/);
|
||||
assert.match(src, /getSessionFiles\(id\)\s*\{ return this\._fetch\('GET', `\/api\/sessions\/\$\{id\}\/files`\); \}/);
|
||||
});
|
||||
@@ -331,8 +348,12 @@ test('Files view: strings carry Russian translations', () => {
|
||||
for (const m of lang.matchAll(/'((?:[^'\\]|\\.)*)'\s*:/g)) {
|
||||
keys.add(m[1].replace(/\\(['\\])/g, '$1'));
|
||||
}
|
||||
for (const s of ['Files', 'Read', 'Update', 'Changed (card)', 'Card-side change',
|
||||
'Close', 'Offset', 'Record', 'Before', 'After', 'Show the change',
|
||||
for (const s of ['Files', 'Read', 'Update', 'Changed on card', 'Change details',
|
||||
'Details', 'Close', 'Offset', 'Record', 'Before', 'After', 'Show the change',
|
||||
'changed', 'newly observed', 'never observed',
|
||||
'{c} bytes changed, {n} bytes newly observed',
|
||||
'Showing the first {n} bytes of the image',
|
||||
'The image is frozen (size cap reached) — later changes are not tracked.',
|
||||
'No file operations recorded', 'Failed to load files', 'Loading…']) {
|
||||
assert.ok(keys.has(s), 'no LANG_RU entry for: ' + s);
|
||||
}
|
||||
@@ -431,3 +452,49 @@ test('Files view: a response for a session left meanwhile is ignored', async ()
|
||||
assert.equal(STATE.filesData, null);
|
||||
assert.equal(STATE.filesSessionId, null);
|
||||
});
|
||||
|
||||
// ── Change-details modal rendering (stubbed DOM) ───────────────────────
|
||||
eval(
|
||||
'function el(id) { return dlgEl(id); }\n' +
|
||||
extractFunc(src, 'fmt_elapsed') + '\n' +
|
||||
extractFunc(src, 'escapeHtml') + '\n' +
|
||||
extractFunc(src, 'hexImageRows') + '\n' +
|
||||
extractFunc(src, 'hexImageHtml') + '\n' +
|
||||
extractFunc(src, 'renderFileDiff')
|
||||
);
|
||||
|
||||
test('Change details modal: header, summary, legend and both views', () => {
|
||||
renderFileDiff({
|
||||
name: 'EF_ICCID',
|
||||
op: 'READ BINARY',
|
||||
record: null,
|
||||
n_records: null,
|
||||
index: 12,
|
||||
elapsed: 3.25,
|
||||
ranges: [[8, 2]],
|
||||
observed_ranges: [[0, 4]],
|
||||
before: [{offset: 0, hex: '41424344'}],
|
||||
after: [{offset: 0, hex: '41424344'}, {offset: 8, hex: '9900'}],
|
||||
image_truncated: false,
|
||||
});
|
||||
const sub = el('file-diff-sub').textContent;
|
||||
assert.match(sub, /EF_ICCID — READ BINARY/);
|
||||
assert.match(sub, /#12/);
|
||||
const body = el('file-diff-body').innerHTML;
|
||||
assert.match(body, /2 bytes changed, 4 bytes newly observed/);
|
||||
assert.match(body, /hex-cell hex-changed/); // legend + the changed byte
|
||||
assert.match(body, /hex-cell hex-new/);
|
||||
assert.match(body, /hex-cell hex-unknown/);
|
||||
assert.match(body, /Before/);
|
||||
assert.match(body, /After/);
|
||||
assert.equal((body.match(/class="hex-view"/g) || []).length, 2);
|
||||
// The record header variant and the frozen-image note.
|
||||
renderFileDiff({
|
||||
name: 'EF_IMSI', op: 'READ RECORD', record: 2, n_records: 5, index: 40,
|
||||
elapsed: 9.5, ranges: [[0, 1]], observed_ranges: [],
|
||||
before: [{offset: 0, hex: 'aa'}], after: [{offset: 0, hex: 'bb'}],
|
||||
image_truncated: true,
|
||||
});
|
||||
assert.match(el('file-diff-sub').textContent, /Record 2 \/ 5/);
|
||||
assert.match(el('file-diff-body').innerHTML, /image is frozen/);
|
||||
});
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend"
|
||||
|
||||
[project]
|
||||
name = "simtrace-analyser"
|
||||
version = "2.3.1"
|
||||
version = "2.4.0"
|
||||
description = "SIMtrace Analyser — APDU trace analyser for GSMTAP/PCAP captures (bundles the simtrace2-pysniff utility)"
|
||||
readme = {file = "README.md", content-type = "text/markdown"}
|
||||
license = {text = "GPL-2.0-or-later"}
|
||||
|
||||
@@ -198,13 +198,22 @@ class Database:
|
||||
Returns the tracker's result dict: the operation timeline, per-file
|
||||
counters/images/changes, the SFI relations and the AID labels.
|
||||
"""
|
||||
tracker = self._file_tracker(session_id)
|
||||
return tracker.result()
|
||||
|
||||
def change_images(self, session_id, key, idx):
|
||||
"""Before/after images of the unit affected by change *idx* of *key*
|
||||
(the Files view's Details modal), or None for an unknown file/index."""
|
||||
return self._file_tracker(session_id).change_images(key, idx)
|
||||
|
||||
def _file_tracker(self, session_id):
|
||||
from .filetrack import FileTracker
|
||||
rows = self._conn.execute(
|
||||
'SELECT id, session_id, elapsed, type, data, flags FROM messages '
|
||||
'WHERE session_id=? ORDER BY elapsed, id', (session_id,)).fetchall()
|
||||
tracker = FileTracker()
|
||||
self._decode_rows(rows, on_message=tracker.observe)
|
||||
return tracker.result()
|
||||
return tracker
|
||||
|
||||
def get_messages_raw(self, session_id):
|
||||
rows = self._conn.execute(
|
||||
|
||||
+126
-21
@@ -77,6 +77,37 @@ def _key_str(key):
|
||||
return f'{df}/{fid}'
|
||||
|
||||
|
||||
def _parse_key(key):
|
||||
df, _sep, fid = (key or '').rpartition('/')
|
||||
return df, fid
|
||||
|
||||
|
||||
def _apply_hex(buf, ranges, hex_str):
|
||||
"""Write a concatenated hex string into *buf* at the given ranges."""
|
||||
at = 0
|
||||
for pos, length in ranges or []:
|
||||
chunk = bytes.fromhex(hex_str[at:at + length * 2])
|
||||
at += length * 2
|
||||
if len(buf) < pos + length:
|
||||
buf.extend(b'\x00' * (pos + length - len(buf)))
|
||||
buf[pos:pos + length] = chunk
|
||||
|
||||
|
||||
def _segments(buf, mask):
|
||||
"""Observed runs of the image: ``[{'offset': n, 'hex': '…'}, …]``."""
|
||||
out = []
|
||||
i = 0
|
||||
while i < len(mask):
|
||||
if not mask[i]:
|
||||
i += 1
|
||||
continue
|
||||
start = i
|
||||
while i < len(mask) and mask[i]:
|
||||
i += 1
|
||||
out.append({'offset': start, 'hex': bytes(buf[start:i]).hex()})
|
||||
return out
|
||||
|
||||
|
||||
class _FileEntry:
|
||||
"""One tracked file: counters, sparse image, change log."""
|
||||
|
||||
@@ -111,14 +142,18 @@ class _FileEntry:
|
||||
def apply(self, record, offset, data):
|
||||
"""Apply observed bytes.
|
||||
|
||||
Returns ``(diffs, compared_all)``: *diffs* is a list of
|
||||
``(position, before, after)`` for the positions whose known value
|
||||
differed (position = byte offset for transparent files, record number
|
||||
for record files); *compared_all* tells whether every written byte was
|
||||
already known (so a zero diff proves a no-op).
|
||||
Returns ``(diffs, observed, compared_all)``:
|
||||
|
||||
* *diffs* — ``[(pos, before, after)]`` for positions whose known value
|
||||
differed (pos = the byte offset in the file, or *in the record* for
|
||||
record files);
|
||||
* *observed* — ``[(pos, len)]`` runs whose bytes were not known before
|
||||
this operation (a first observation);
|
||||
* *compared_all* — every written byte was already known (so an empty
|
||||
diff proves a no-op).
|
||||
"""
|
||||
if self.image_truncated:
|
||||
return [], False
|
||||
return [], [], False
|
||||
if record is None:
|
||||
buf, mask, base = self.buf, self.mask, offset
|
||||
needed = base + len(data)
|
||||
@@ -130,20 +165,24 @@ class _FileEntry:
|
||||
if (needed > len(buf)
|
||||
and self._tracked_bytes() + (needed - len(buf)) > self.image_cap):
|
||||
self.image_truncated = True
|
||||
return [], False
|
||||
return [], [], False
|
||||
if len(buf) < needed:
|
||||
buf.extend(b'\x00' * (needed - len(buf)))
|
||||
mask.extend(b'\x00' * (needed - len(mask)))
|
||||
diffs = []
|
||||
observed = []
|
||||
compared_all = True
|
||||
i = 0
|
||||
while i < len(data):
|
||||
pos = base + i
|
||||
if not mask[pos]:
|
||||
compared_all = False
|
||||
buf[pos] = data[i]
|
||||
mask[pos] = 1
|
||||
start = pos
|
||||
while i < len(data) and not mask[base + i]:
|
||||
buf[base + i] = data[i]
|
||||
mask[base + i] = 1
|
||||
i += 1
|
||||
observed.append((start, i - (start - base)))
|
||||
continue
|
||||
if buf[pos] == data[i]:
|
||||
i += 1
|
||||
@@ -156,9 +195,8 @@ class _FileEntry:
|
||||
after.append(data[i])
|
||||
buf[base + i] = data[i]
|
||||
i += 1
|
||||
diffs.append((start if record is None else record,
|
||||
bytes(before), bytes(after)))
|
||||
return diffs, compared_all
|
||||
diffs.append((start, bytes(before), bytes(after)))
|
||||
return diffs, observed, compared_all
|
||||
|
||||
def coverage(self):
|
||||
known = sum(self.mask) + sum(sum(m) for m in self.rec_mask.values())
|
||||
@@ -291,7 +329,8 @@ class FileTracker:
|
||||
self.timeline.append(item)
|
||||
return item
|
||||
|
||||
def _add_change(self, entry, kind, index, msg, op, diffs, record=None):
|
||||
def _add_change(self, entry, kind, index, msg, op, diffs, observed=(),
|
||||
record=None):
|
||||
entry.changes_total += 1
|
||||
if len(entry.changes) >= self._events_cap:
|
||||
return None
|
||||
@@ -299,6 +338,7 @@ class FileTracker:
|
||||
'kind': kind, 'op': op, 'index': index, 'msg_id': msg.get('id'),
|
||||
'elapsed': msg.get('elapsed'), 'record': record,
|
||||
'ranges': [[pos, len(before)] for pos, before, _after in diffs],
|
||||
'observed_ranges': [[pos, length] for pos, length in observed],
|
||||
'before_hex': b''.join(b for _p, b, _a in diffs).hex(),
|
||||
'after_hex': b''.join(a for _p, _b, a in diffs).hex(),
|
||||
}
|
||||
@@ -371,12 +411,13 @@ class FileTracker:
|
||||
entry.sfi = sfi
|
||||
entry.counters['read'] += 1
|
||||
record = self._record_number(d)
|
||||
diffs, _compared = entry.apply(record, self._offset(d, record), data)
|
||||
diffs, observed, _compared = entry.apply(record, self._offset(d, record), data)
|
||||
if entry.image_truncated:
|
||||
return
|
||||
if diffs:
|
||||
idx = self._add_change(entry, 'card', index, msg,
|
||||
d.get('ins_name') or '', diffs, record=record)
|
||||
d.get('ins_name') or '', diffs,
|
||||
observed=observed, record=record)
|
||||
self._push('card_change', entry, index, msg, record=record,
|
||||
change_ref={'file': _key_str(entry.key), 'idx': idx}
|
||||
if idx is not None else None)
|
||||
@@ -396,13 +437,17 @@ class FileTracker:
|
||||
entry.sfi = sfi
|
||||
entry.counters['update'] += 1
|
||||
record = self._record_number(d)
|
||||
diffs, compared_all = entry.apply(record, self._offset(d, record), data)
|
||||
diffs, observed, compared_all = entry.apply(record, self._offset(d, record), data)
|
||||
if entry.image_truncated:
|
||||
return
|
||||
if diffs:
|
||||
self._add_change(entry, 'device', index, msg,
|
||||
d.get('ins_name') or '', diffs, record=record)
|
||||
self._push('update', entry, index, msg, record=record,
|
||||
change_ref = None
|
||||
if diffs or observed:
|
||||
idx = self._add_change(entry, 'device', index, msg,
|
||||
d.get('ins_name') or '', diffs,
|
||||
observed=observed, record=record)
|
||||
if idx is not None:
|
||||
change_ref = {'file': _key_str(entry.key), 'idx': idx}
|
||||
self._push('update', entry, index, msg, record=record, change_ref=change_ref,
|
||||
no_change=(not diffs and compared_all))
|
||||
|
||||
# -- helpers ---------------------------------------------------------
|
||||
@@ -430,7 +475,67 @@ class FileTracker:
|
||||
|
||||
# -- result ----------------------------------------------------------
|
||||
|
||||
def result(self):
|
||||
def change_images(self, key, idx):
|
||||
"""Before/after images of the unit affected by change *idx* of *key*.
|
||||
|
||||
The unit is the whole file for transparent files, the changed record
|
||||
for record files. The images are reconstructed from the current image
|
||||
by undoing the later events' changed bytes (every value change is an
|
||||
event, so the values are exact; a byte that was only *first observed*
|
||||
later can appear as known in both views). The requested event's own
|
||||
``observed_ranges`` are shown as unknown in the *before* view — that is
|
||||
what "newly observed" means.
|
||||
"""
|
||||
entry = self.files.get(_parse_key(key))
|
||||
if entry is None or not 0 <= idx < len(entry.changes):
|
||||
return None
|
||||
event = entry.changes[idx]
|
||||
record = event.get('record')
|
||||
if record is None:
|
||||
buf = bytearray(entry.buf)
|
||||
mask = bytearray(entry.mask)
|
||||
else:
|
||||
buf = bytearray(entry.records.get(record, b''))
|
||||
mask = bytearray(entry.rec_mask.get(record, b''))
|
||||
for later in reversed(entry.changes[idx:]): # the event itself included
|
||||
if later.get('record') != record:
|
||||
continue
|
||||
_apply_hex(buf, later['ranges'], later['before_hex'])
|
||||
# The before view: this event's newly observed bytes were unknown then.
|
||||
for pos, length in event.get('observed_ranges') or []:
|
||||
for i in range(pos, min(pos + length, len(mask))):
|
||||
mask[i] = 0
|
||||
before = _segments(buf, mask)
|
||||
# The after view: this event's changed bytes and its observations.
|
||||
_apply_hex(buf, event['ranges'], event['after_hex'])
|
||||
for pos, length in event.get('observed_ranges') or []:
|
||||
for i in range(pos, min(pos + length, len(mask))):
|
||||
mask[i] = 1
|
||||
after = _segments(buf, mask)
|
||||
return {
|
||||
'key': _key_str(entry.key),
|
||||
'name': entry.name,
|
||||
'df': entry.df,
|
||||
'fid': entry.fid,
|
||||
'structure': entry.structure,
|
||||
'size': entry.size,
|
||||
'record_len': entry.record_len,
|
||||
'n_records': entry.n_records,
|
||||
'record': record,
|
||||
'image_truncated': entry.image_truncated,
|
||||
'coverage': entry.coverage(),
|
||||
'kind': event['kind'],
|
||||
'op': event['op'],
|
||||
'index': event['index'],
|
||||
'msg_id': event.get('msg_id'),
|
||||
'elapsed': event.get('elapsed'),
|
||||
'ranges': event['ranges'],
|
||||
'observed_ranges': event.get('observed_ranges') or [],
|
||||
'before': before,
|
||||
'after': after,
|
||||
}
|
||||
|
||||
def result(self, include_images=False):
|
||||
files = {}
|
||||
for key, entry in self.files.items():
|
||||
files[_key_str(key)] = {
|
||||
|
||||
@@ -141,6 +141,9 @@ class RequestHandler(BaseHTTPRequestHandler):
|
||||
elif path.startswith('/api/sessions/') and path.endswith('/pcap'):
|
||||
session_id = int(path.split('/')[-2])
|
||||
self._handle_get_session_pcap(session_id)
|
||||
elif path.startswith('/api/sessions/') and path.endswith('/files/image'):
|
||||
session_id = int(path.split('/')[-3])
|
||||
self._handle_session_files_image(session_id, params)
|
||||
elif path.startswith('/api/sessions/') and path.endswith('/files'):
|
||||
session_id = int(path.split('/')[-2])
|
||||
self._handle_session_files(session_id)
|
||||
@@ -475,6 +478,29 @@ class RequestHandler(BaseHTTPRequestHandler):
|
||||
f'files={len(data["files"])} operations={data["timeline_total"]}')
|
||||
self._send_json(data)
|
||||
|
||||
def _handle_session_files_image(self, session_id, params):
|
||||
"""Before/after images of one change (the Files view's Details)."""
|
||||
session = self.db.get_session(session_id)
|
||||
if session is None:
|
||||
self._send_error(404, 'Session not found')
|
||||
return
|
||||
key = (params.get('key', [''])[0]).strip()
|
||||
try:
|
||||
idx = int(params.get('change', ['0'])[0])
|
||||
except ValueError:
|
||||
self._send_error(400, 'change must be an index')
|
||||
return
|
||||
if not key or idx < 0:
|
||||
self._send_error(400, 'key and change are required')
|
||||
return
|
||||
data = self.db.change_images(session_id, key, idx)
|
||||
if data is None:
|
||||
self._send_error(404, 'No such file or change')
|
||||
return
|
||||
_log(f'Session file change: session={session_id} key={key} '
|
||||
f'change={idx} op={data["op"]} index={data["index"]}')
|
||||
self._send_json(data)
|
||||
|
||||
def _handle_rename_session(self, session_id, name):
|
||||
self.db.rename_session(session_id, name)
|
||||
self._send_json({'ok': True})
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Single source of truth for the SIMtrace Analyser version."""
|
||||
|
||||
__version__ = '2.3.1'
|
||||
__version__ = '2.4.0'
|
||||
+200
-32
@@ -39,16 +39,16 @@ def fcp(fid, sfi=None, structure=None, rec_len=None, n_rec=None, size=None):
|
||||
return '62' + f'{len(body) // 2:02x}' + body
|
||||
|
||||
|
||||
def read_binary(p1, p2, data_hex, sw='9000'):
|
||||
return f'00b0{p1:02x}{p2:02x}{len(data_hex) // 2:02x}{data_hex}{sw}'
|
||||
def read_binary(offset, data_hex, sw='9000'):
|
||||
return f'00b0{offset >> 8:02x}{offset & 0xff:02x}{len(data_hex) // 2:02x}{data_hex}{sw}'
|
||||
|
||||
|
||||
def read_binary_sfi(sfi, offset, data_hex, sw='9000'):
|
||||
return f'00b0{0x80 | sfi:02x}{offset:02x}{len(data_hex) // 2:02x}{data_hex}{sw}'
|
||||
|
||||
|
||||
def update_binary(p1, p2, data_hex, sw='9000'):
|
||||
return f'00d6{p1:02x}{p2:02x}{len(data_hex) // 2:02x}{data_hex}{sw}'
|
||||
def update_binary(offset, data_hex, sw='9000'):
|
||||
return f'00d6{offset >> 8:02x}{offset & 0xff:02x}{len(data_hex) // 2:02x}{data_hex}{sw}'
|
||||
|
||||
|
||||
def update_binary_sfi(sfi, offset, data_hex, sw='9000'):
|
||||
@@ -89,8 +89,8 @@ class TestCountersAndRegistration(TrackerCase):
|
||||
def test_select_read_update_counters(self):
|
||||
data = self.track(
|
||||
sel_fid('2fe2'), gr(fcp('2fe2', sfi=2, size=10)),
|
||||
read_binary(0, 0, '9807110900106014000000'[:22]),
|
||||
update_binary(0, 0, '980711090010601495F9'),
|
||||
read_binary(0, '9807110900106014000000'[:22]),
|
||||
update_binary(0, '980711090010601495F9'),
|
||||
sel_fid('2fe2'), gr(fcp('2fe2', sfi=2, size=10)),
|
||||
)
|
||||
entry = data['files']['3f00/2fe2']
|
||||
@@ -107,7 +107,7 @@ class TestCountersAndRegistration(TrackerCase):
|
||||
from simtrace2_pysniff.gsmtap import GSMTAP_SIM_ATR
|
||||
data = self.track(
|
||||
sel_fid('2fe2'), gr(fcp('2fe2', sfi=2, size=10)),
|
||||
read_binary(0, 0, '98' + '07' * 5),
|
||||
read_binary(0, '98' + '07' * 5),
|
||||
)
|
||||
gizmo = {'kind': 'read'}
|
||||
# An ATR (card reset) does not clear the per-file registry.
|
||||
@@ -119,11 +119,11 @@ class TestCountersAndRegistration(TrackerCase):
|
||||
db.insert_messages(sid, [
|
||||
(0.0, 'tpdu', bytes.fromhex(sel_fid('2fe2')), 0),
|
||||
(0.5, 'tpdu', bytes.fromhex(gr(fcp('2fe2', sfi=2, size=10))), 0),
|
||||
(1.0, 'tpdu', bytes.fromhex(read_binary(0, 0, '98' + '07' * 5)), 0),
|
||||
(1.0, 'tpdu', bytes.fromhex(read_binary(0, '98' + '07' * 5)), 0),
|
||||
(2.0, 'atr', bytes.fromhex('3b9f96801fc78031e073fe2117574a3382033339001e'), 0),
|
||||
(3.0, 'tpdu', bytes.fromhex(sel_fid('2fe2')), 0),
|
||||
(3.5, 'tpdu', bytes.fromhex(gr(fcp('2fe2', sfi=2, size=10))), 0),
|
||||
(4.0, 'tpdu', bytes.fromhex(read_binary(0, 0, '98' + '07' * 5)), 0),
|
||||
(4.0, 'tpdu', bytes.fromhex(read_binary(0, '98' + '07' * 5)), 0),
|
||||
])
|
||||
data = db.track_files(sid)
|
||||
self.assertEqual(data['files']['3f00/2fe2']['counters'],
|
||||
@@ -134,10 +134,10 @@ class TestCountersAndRegistration(TrackerCase):
|
||||
def test_channels_merge_into_one_file(self):
|
||||
data = self.track(
|
||||
sel_fid('2fe2'), gr(fcp('2fe2', sfi=2, size=10)),
|
||||
read_binary(0, 0, '9807110900'),
|
||||
read_binary(0, '9807110900'),
|
||||
'01' + sel_fid('2fe2')[2:], # same file on channel 1
|
||||
'01' + gr(fcp('2fe2', sfi=2, size=10))[2:],
|
||||
'01' + read_binary(0, 0, '9807110900')[2:],
|
||||
'01' + read_binary(0, '9807110900')[2:],
|
||||
)
|
||||
self.assertEqual(data['files']['3f00/2fe2']['counters']['read'], 2)
|
||||
self.assertEqual(len(data['files']), 1)
|
||||
@@ -147,7 +147,7 @@ class TestImageAndChanges(TrackerCase):
|
||||
def test_read_fills_unknown_bytes_without_a_change(self):
|
||||
data = self.track(
|
||||
sel_fid('2fe2'), gr(fcp('2fe2', sfi=2, size=10)),
|
||||
read_binary(0, 0, '980711090010601495F9'),
|
||||
read_binary(0, '980711090010601495F9'),
|
||||
)
|
||||
entry = data['files']['3f00/2fe2']
|
||||
self.assertEqual(entry['coverage']['known'], 10)
|
||||
@@ -157,8 +157,8 @@ class TestImageAndChanges(TrackerCase):
|
||||
def test_card_change_surfaced_by_a_read(self):
|
||||
data = self.track(
|
||||
sel_fid('2fe2'), gr(fcp('2fe2', sfi=2, size=10)),
|
||||
read_binary(0, 0, '980711090010601495F9'),
|
||||
read_binary(0, 0, '98071109001060140000'),
|
||||
read_binary(0, '980711090010601495F9'),
|
||||
read_binary(0, '98071109001060140000'),
|
||||
)
|
||||
entry = data['files']['3f00/2fe2']
|
||||
self.assertEqual(entry['changes_total'], 1)
|
||||
@@ -178,8 +178,8 @@ class TestImageAndChanges(TrackerCase):
|
||||
def test_repeating_a_read_is_not_a_change(self):
|
||||
data = self.track(
|
||||
sel_fid('2fe2'), gr(fcp('2fe2', sfi=2, size=10)),
|
||||
read_binary(0, 0, '980711090010601495F9'),
|
||||
read_binary(0, 0, '980711090010601495F9'),
|
||||
read_binary(0, '980711090010601495F9'),
|
||||
read_binary(0, '980711090010601495F9'),
|
||||
)
|
||||
self.assertEqual(data['files']['3f00/2fe2']['changes_total'], 0)
|
||||
self.assertEqual([i['kind'] for i in data['timeline']], ['read', 'read'])
|
||||
@@ -187,8 +187,8 @@ class TestImageAndChanges(TrackerCase):
|
||||
def test_device_change_from_an_update(self):
|
||||
data = self.track(
|
||||
sel_fid('2fe2'), gr(fcp('2fe2', sfi=2, size=10)),
|
||||
read_binary(0, 0, '980711090010601495F9'),
|
||||
update_binary(0, 0, '98071109001060140000'),
|
||||
read_binary(0, '980711090010601495F9'),
|
||||
update_binary(0, '98071109001060140000'),
|
||||
)
|
||||
entry = data['files']['3f00/2fe2']
|
||||
self.assertEqual(entry['changes_total'], 1)
|
||||
@@ -201,24 +201,39 @@ class TestImageAndChanges(TrackerCase):
|
||||
def test_identical_update_is_flagged(self):
|
||||
data = self.track(
|
||||
sel_fid('2fe2'), gr(fcp('2fe2', sfi=2, size=10)),
|
||||
read_binary(0, 0, '980711090010601495F9'),
|
||||
update_binary(0, 0, '980711090010601495F9'),
|
||||
read_binary(0, '980711090010601495F9'),
|
||||
update_binary(0, '980711090010601495F9'),
|
||||
)
|
||||
entry = data['files']['3f00/2fe2']
|
||||
self.assertEqual(entry['changes_total'], 0)
|
||||
update_row = [i for i in data['timeline'] if i['kind'] == 'update'][0]
|
||||
self.assertTrue(update_row['no_change'])
|
||||
|
||||
def test_update_of_unknown_bytes_learns_without_an_event(self):
|
||||
def test_update_of_unknown_bytes_is_an_observation_not_a_change(self):
|
||||
data = self.track(
|
||||
sel_fid('2fe2'), gr(fcp('2fe2', sfi=2, size=10)),
|
||||
update_binary(0, 0, '9807110900'),
|
||||
update_binary(0, '9807110900'),
|
||||
)
|
||||
entry = data['files']['3f00/2fe2']
|
||||
self.assertEqual(entry['coverage']['known'], 5)
|
||||
self.assertEqual(entry['changes_total'], 0)
|
||||
self.assertEqual(entry['changes_total'], 1)
|
||||
change = entry['changes'][0]
|
||||
self.assertEqual(change['kind'], 'device')
|
||||
self.assertEqual(change['ranges'], []) # nothing changed
|
||||
self.assertEqual(change['observed_ranges'], [[0, 5]]) # but it was new
|
||||
update_row = [i for i in data['timeline'] if i['kind'] == 'update'][0]
|
||||
self.assertFalse(update_row['no_change']) # cannot prove a no-op
|
||||
self.assertEqual(update_row['change_ref'], {'file': '3f00/2fe2', 'idx': 0})
|
||||
|
||||
def test_read_records_the_newly_observed_bytes(self):
|
||||
data = self.track(
|
||||
sel_fid('2fe2'), gr(fcp('2fe2', sfi=2, size=10)),
|
||||
read_binary(0, '980711090010601495F9'),
|
||||
read_binary(0, '98071109001060140000'),
|
||||
)
|
||||
change = data['files']['3f00/2fe2']['changes'][0]
|
||||
self.assertEqual(change['ranges'], [[8, 2]])
|
||||
self.assertEqual(change['observed_ranges'], []) # nothing was new
|
||||
|
||||
|
||||
class TestRecordFiles(TrackerCase):
|
||||
@@ -235,7 +250,8 @@ class TestRecordFiles(TrackerCase):
|
||||
self.assertEqual(entry['n_records'], 3)
|
||||
self.assertEqual(entry['coverage']['known'], 16)
|
||||
self.assertEqual(entry['changes_total'], 1)
|
||||
self.assertEqual(entry['changes'][0]['ranges'], [[1, 1]])
|
||||
self.assertEqual(entry['changes'][0]['record'], 1)
|
||||
self.assertEqual(entry['changes'][0]['ranges'], [[7, 1]])
|
||||
|
||||
def test_read_data_delivered_by_get_response(self):
|
||||
data = self.track(
|
||||
@@ -258,7 +274,8 @@ class TestRecordFiles(TrackerCase):
|
||||
entry = data['files']['7fff/6f07']
|
||||
self.assertEqual(entry['counters'], {'select': 1, 'read': 1, 'update': 1})
|
||||
self.assertEqual(entry['changes'][0]['kind'], 'device')
|
||||
self.assertEqual(entry['changes'][0]['ranges'], [[2, 1]])
|
||||
self.assertEqual(entry['changes'][0]['record'], 2)
|
||||
self.assertEqual(entry['changes'][0]['ranges'], [[7, 1]])
|
||||
|
||||
|
||||
class TestSfiResolution(TrackerCase):
|
||||
@@ -335,8 +352,8 @@ class TestCapsAndLabels(TrackerCase):
|
||||
tracker = FileTracker(image_cap=8)
|
||||
data = self.track(
|
||||
sel_fid('2fe2'), gr(fcp('2fe2', sfi=2, size=10)),
|
||||
read_binary(0, 0, '9807110900'),
|
||||
read_binary(5, 0, '10601495F9'),
|
||||
read_binary(0, '9807110900'),
|
||||
read_binary(5, '10601495F9'),
|
||||
tracker=tracker,
|
||||
)
|
||||
entry = data['files']['3f00/2fe2']
|
||||
@@ -348,9 +365,9 @@ class TestCapsAndLabels(TrackerCase):
|
||||
tracker = FileTracker(events_cap=1)
|
||||
data = self.track(
|
||||
sel_fid('2fe2'), gr(fcp('2fe2', sfi=2, size=4)),
|
||||
read_binary(0, 0, 'AABBCCDD'),
|
||||
read_binary(0, 0, 'AABBCC00'),
|
||||
read_binary(0, 0, 'AABB0000'),
|
||||
read_binary(0, 'AABBCCDD'),
|
||||
read_binary(0, 'AABBCC00'),
|
||||
read_binary(0, 'AABB0000'),
|
||||
tracker=tracker,
|
||||
)
|
||||
entry = data['files']['3f00/2fe2']
|
||||
@@ -371,11 +388,115 @@ class TestCapsAndLabels(TrackerCase):
|
||||
|
||||
def test_unattributed_operations_are_skipped(self):
|
||||
# A read with no selection and no SFI has no file to attribute.
|
||||
data = self.track(read_binary(0, 0, 'AABBCCDD'))
|
||||
data = self.track(read_binary(0, 'AABBCCDD'))
|
||||
self.assertEqual(data['files'], {})
|
||||
self.assertEqual(data['timeline'], [])
|
||||
|
||||
|
||||
class TestChangeImages(TrackerCase):
|
||||
"""The before/after views a change's Details modal shows."""
|
||||
|
||||
def _images(self, *tpdu, key='3f00/2fe2', idx=0):
|
||||
from simtrace_analyser.filetrack import FileTracker
|
||||
tmp = tempfile.NamedTemporaryFile(suffix='.db', delete=False)
|
||||
tmp.close()
|
||||
db = Database(tmp.name)
|
||||
self.addCleanup(lambda: (db._conn.close(), os.remove(tmp.name)))
|
||||
sid = db.create_session('test')
|
||||
db.insert_messages(sid, [(float(i), 'tpdu', bytes.fromhex(h), 0)
|
||||
for i, h in enumerate(tpdu)])
|
||||
tracker = FileTracker()
|
||||
db._decode_rows(db._conn.execute(
|
||||
'SELECT id, session_id, elapsed, type, data, flags FROM messages '
|
||||
'WHERE session_id=? ORDER BY elapsed, id', (sid,)).fetchall(),
|
||||
on_message=tracker.observe)
|
||||
return tracker.change_images(key, idx)
|
||||
|
||||
def test_device_change_before_and_after(self):
|
||||
result = self._images(
|
||||
sel_fid('2fe2'), gr(fcp('2fe2', sfi=2, size=4)),
|
||||
read_binary(0, 'AABBCCDD'),
|
||||
update_binary(0, 'AABB0000'),
|
||||
)
|
||||
self.assertEqual(result['kind'], 'device')
|
||||
self.assertEqual(result['record'], None)
|
||||
self.assertEqual(result['ranges'], [[2, 2]])
|
||||
self.assertEqual(result['observed_ranges'], [])
|
||||
# the whole file, not only the changed part
|
||||
self.assertEqual(result['before'], [{'offset': 0, 'hex': 'aabbccdd'}])
|
||||
self.assertEqual(result['after'], [{'offset': 0, 'hex': 'aabb0000'}])
|
||||
|
||||
def test_later_changes_are_undone_for_the_before_view(self):
|
||||
# The first change's "after" must not include the second change,
|
||||
# although the current image does.
|
||||
first = self._images(
|
||||
sel_fid('2fe2'), gr(fcp('2fe2', sfi=2, size=4)),
|
||||
read_binary(0, 'AABBCCDD'),
|
||||
update_binary(0, 'AABB0000'),
|
||||
update_binary(2, 'CCDD'),
|
||||
)
|
||||
self.assertEqual(first['before'], [{'offset': 0, 'hex': 'aabbccdd'}])
|
||||
self.assertEqual(first['after'], [{'offset': 0, 'hex': 'aabb0000'}])
|
||||
|
||||
def test_second_change_keeps_the_first_as_the_before_state(self):
|
||||
result = self._images(
|
||||
sel_fid('2fe2'), gr(fcp('2fe2', sfi=2, size=4)),
|
||||
read_binary(0, 'AABBCCDD'),
|
||||
update_binary(0, 'AABB0000'),
|
||||
update_binary(2, 'CCDD'),
|
||||
idx=1,
|
||||
)
|
||||
self.assertEqual(result['before'], [{'offset': 0, 'hex': 'aabb0000'}])
|
||||
self.assertEqual(result['after'], [{'offset': 0, 'hex': 'aabbccdd'}])
|
||||
self.assertEqual(result['ranges'], [[2, 2]])
|
||||
|
||||
def test_observation_only_event_shows_unknown_before(self):
|
||||
result = self._images(
|
||||
sel_fid('2fe2'), gr(fcp('2fe2', sfi=2, size=4)),
|
||||
update_binary(0, 'AABBCCDD'),
|
||||
)
|
||||
self.assertEqual(result['ranges'], [])
|
||||
self.assertEqual(result['observed_ranges'], [[0, 4]])
|
||||
self.assertEqual(result['before'], []) # unknown before the write
|
||||
self.assertEqual(result['after'], [{'offset': 0, 'hex': 'aabbccdd'}])
|
||||
|
||||
def test_record_change_shows_only_that_record(self):
|
||||
result = self._images(
|
||||
sel_fid('7fff'), sel_fid('6f07'),
|
||||
gr(fcp('6f07', rec_len=4, n_rec=3)),
|
||||
read_record(1, '11111111'),
|
||||
read_record(2, '22222222'),
|
||||
read_record(1, '11110011'),
|
||||
key='7fff/6f07', idx=0,
|
||||
)
|
||||
self.assertEqual(result['record'], 1)
|
||||
self.assertEqual(result['ranges'], [[2, 1]])
|
||||
self.assertEqual(result['before'], [{'offset': 0, 'hex': '11111111'}])
|
||||
self.assertEqual(result['after'], [{'offset': 0, 'hex': '11110011'}])
|
||||
|
||||
def test_unknown_key_or_index_returns_none(self):
|
||||
self.assertIsNone(self._images(
|
||||
sel_fid('2fe2'), gr(fcp('2fe2', sfi=2, size=4)),
|
||||
read_binary(0, 'AABBCCDD'), key='3f00/9999'))
|
||||
self.assertIsNone(self._images(
|
||||
sel_fid('2fe2'), gr(fcp('2fe2', sfi=2, size=4)),
|
||||
read_binary(0, 'AABBCCDD'), idx=5))
|
||||
|
||||
def test_segments_keep_unknown_gaps(self):
|
||||
result = self._images(
|
||||
sel_fid('2fe2'), gr(fcp('2fe2', sfi=2, size=10)),
|
||||
read_binary(0, 'AABB'),
|
||||
read_binary(6, 'CCDD'),
|
||||
update_binary(6, 'CC00'),
|
||||
)
|
||||
self.assertEqual(result['before'],
|
||||
[{'offset': 0, 'hex': 'aabb'},
|
||||
{'offset': 6, 'hex': 'ccdd'}])
|
||||
self.assertEqual(result['after'],
|
||||
[{'offset': 0, 'hex': 'aabb'},
|
||||
{'offset': 6, 'hex': 'cc00'}])
|
||||
|
||||
|
||||
class TestEndpoint(unittest.TestCase):
|
||||
def test_session_files_endpoint(self):
|
||||
from simtrace_analyser.server import RequestHandler
|
||||
@@ -402,7 +523,7 @@ class TestEndpoint(unittest.TestCase):
|
||||
db.insert_messages(sid, [
|
||||
(0.0, 'tpdu', bytes.fromhex(sel_fid('2fe2')), 0),
|
||||
(0.5, 'tpdu', bytes.fromhex(gr(fcp('2fe2', sfi=2, size=10))), 0),
|
||||
(1.0, 'tpdu', bytes.fromhex(read_binary(0, 0, '980711090010601495F9')), 0),
|
||||
(1.0, 'tpdu', bytes.fromhex(read_binary(0, '980711090010601495F9')), 0),
|
||||
])
|
||||
h = Fake(db)
|
||||
h._handle_session_files(sid)
|
||||
@@ -414,6 +535,53 @@ class TestEndpoint(unittest.TestCase):
|
||||
h2._handle_session_files(99999)
|
||||
self.assertEqual(h2._status, 404)
|
||||
|
||||
def test_session_files_image_endpoint(self):
|
||||
from simtrace_analyser.server import RequestHandler
|
||||
|
||||
class Fake(RequestHandler):
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
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, code=None):
|
||||
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)))
|
||||
sid = db.create_session('test')
|
||||
db.insert_messages(sid, [
|
||||
(0.0, 'tpdu', bytes.fromhex(sel_fid('2fe2')), 0),
|
||||
(0.5, 'tpdu', bytes.fromhex(gr(fcp('2fe2', sfi=2, size=4))), 0),
|
||||
(1.0, 'tpdu', bytes.fromhex(read_binary(0, 'AABBCCDD')), 0),
|
||||
(2.0, 'tpdu', bytes.fromhex(update_binary(0, 'AABB0000')), 0),
|
||||
])
|
||||
h = Fake(db)
|
||||
h._handle_session_files_image(sid, {'key': ['3f00/2fe2'], 'change': ['0']})
|
||||
self.assertEqual(h._status, 200)
|
||||
self.assertEqual(h._json['before'], [{'offset': 0, 'hex': 'aabbccdd'}])
|
||||
self.assertEqual(h._json['after'], [{'offset': 0, 'hex': 'aabb0000'}])
|
||||
self.assertEqual(h._json['ranges'], [[2, 2]])
|
||||
self.assertEqual(h._json['op'], 'UPDATE BINARY')
|
||||
# Unknown file / bad index → 404, bad parameters → 400.
|
||||
for params in ({'key': ['3f00/9999'], 'change': ['0']},
|
||||
{'key': ['3f00/2fe2'], 'change': ['9']}):
|
||||
h2 = Fake(db)
|
||||
h2._handle_session_files_image(sid, params)
|
||||
self.assertEqual(h2._status, 404)
|
||||
for params in ({'key': [''], 'change': ['0']},
|
||||
{'key': ['3f00/2fe2'], 'change': ['x']}):
|
||||
h3 = Fake(db)
|
||||
h3._handle_session_files_image(sid, params)
|
||||
self.assertEqual(h3._status, 400)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -224,7 +224,7 @@ class TestImportEndpoint(unittest.TestCase):
|
||||
class TestVersion(unittest.TestCase):
|
||||
def test_package_version(self):
|
||||
from simtrace2_pysniff import __version__
|
||||
self.assertEqual(__version__, '2.3.1')
|
||||
self.assertEqual(__version__, '2.4.0')
|
||||
|
||||
def test_status_includes_version(self):
|
||||
import os
|
||||
|
||||
Reference in new issue
Block a user