diff --git a/frontend/index.html b/frontend/index.html
index 6bf8591..56a57cb 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -555,6 +555,9 @@
+
+
+
@@ -1679,7 +1682,7 @@
// ===== Version =====
// Single source of truth for the PWA version: shown in the header and used
// by the server version check in pysimConnect().
-const SIMPLE_VERSION = '3.6.12';
+const SIMPLE_VERSION = '3.6.13';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching =====
@@ -8331,7 +8334,7 @@ function scriptsCapFileChanged() {
function getRamSpParams() {
return {
spi1: document.getElementById('sp-spi1').value,
- spi2: document.getElementById('sp-spi2').value,
+ spi2: document.getElementById('sp-spi2-hex').value,
kic: document.getElementById('sp-kic-hex').value,
kid: document.getElementById('sp-kid-hex').value,
tar: (document.getElementById('sp-tar').value || '000000').replace(/[^0-9a-fA-F]/g, ''),
@@ -8903,6 +8906,34 @@ function ramDeleteApdu(aid, withCascade) {
return '80E400' + (withCascade ? '80' : '00') + berLenStr(data.length / 2) + data + '00';
}
+// A successful delete drops the object from the Explore result locally (the
+// re-explore is not re-run). The registry has no package-to-applet link, so
+// a cascade package delete also drops the applets whose AID starts with the
+// package AID.
+function ramRemoveFromExplorer(aid, cascade) {
+ const d = _ramExplorerData;
+ if (!d) return false;
+ const a = (aid || '').toUpperCase();
+ const elfs = d.elfs || [];
+ const apps = d.apps || [];
+ const isElf = elfs.some(e => (e.aid || '').toUpperCase() === a);
+ const isApp = apps.some(x => (x.aid || '').toUpperCase() === a);
+ if (!isElf && !isApp) return false;
+ if (isElf) {
+ d.elfs = elfs.filter(e => (e.aid || '').toUpperCase() !== a);
+ if (cascade) {
+ d.apps = apps.filter(x => {
+ const xa = (x.aid || '').toUpperCase();
+ return xa !== a && !xa.startsWith(a);
+ });
+ }
+ } else {
+ d.apps = apps.filter(x => (x.aid || '').toUpperCase() !== a);
+ }
+ ramRenderExplorer();
+ return true;
+}
+
async function ramDeleteFromExplorer(aid, withCascade) {
// The preset is the source of truth (see ramExecute): re-read it before
// the operation so a stale form copy cannot send an already-consumed
@@ -8930,7 +8961,7 @@ async function ramDeleteFromExplorer(aid, withCascade) {
const resultEl = document.getElementById('ram-result');
const stepsEl = document.getElementById('ram-steps');
const sw = res.por && res.por.decoded ? res.por.decoded.last_status_word : '';
- if (!res.success || !res.por || !spPorAccepted(res.por) ||
+ if (!res.success || !spPorAccepted(res.por) ||
(sw && !ramRemoteSwOk(sw))) {
resultEl.textContent = t('Failed') + ': ' +
(res.por ? res.por.response_status : (res.error || res.sw)) +
@@ -8938,14 +8969,13 @@ async function ramDeleteFromExplorer(aid, withCascade) {
resultEl.classList.remove('hidden', 'text-green-600'); resultEl.classList.add('text-red-600');
return;
}
+ const removed = ramRemoveFromExplorer(aid, withCascade);
stepsEl.classList.remove('hidden');
- stepsEl.textContent = label + ' ' + aid + (sw ? ' -> ' + sw : '');
- resultEl.textContent = t('OK');
+ stepsEl.textContent = label + ' ' + aid + ' -> ' + (sw || t('no PoR'));
+ resultEl.textContent = t('OK') + (removed ? '' : ' (' + t('not in the list') + ')');
resultEl.classList.remove('hidden', 'text-red-600'); resultEl.classList.add('text-green-600');
- // Continue from the counter the delete consumed: replaying the old one
- // gets cntr_low on every page and the failed run writes it back over the
- // preset (the counter reset reported after Delete All).
- await ramExplore(sp);
+ // The record is dropped locally (no re-explore); the counter the card
+ // consumed was saved above.
}
// One RAM install step line: the PoR verdict plus the remote command's
@@ -9512,6 +9542,21 @@ function cardsApplyFields(idx) {
const c = cards[parseInt(idx)];
if (!c) return false;
document.getElementById('sp-spi1').value = c.spi1;
+ // Sync the SPI2 selects with the preset byte (low 5 bits = PoR/security,
+ // bit 0x20 = PoR via SMS-SUBMIT): updateSp() recomputes sp-spi2-hex from
+ // them, so without this the preset value was clobbered on every refresh.
+ const spi2Val = parseInt(c.spi2, 16) || 0;
+ const baseHex = (spi2Val & 0x1F).toString(16).padStart(2, '0').toUpperCase();
+ const baseSel = document.getElementById('sp-spi2');
+ if (baseSel) {
+ if (baseSel.options && typeof Option === 'function' &&
+ !Array.prototype.some.call(baseSel.options, o => o.value === baseHex)) {
+ baseSel.appendChild(new Option(baseHex + ' — preset', baseHex));
+ }
+ baseSel.value = baseHex;
+ }
+ const smSel = document.getElementById('sp-spi2-sm');
+ if (smSel) smSel.value = String((spi2Val >> 5) & 1);
document.getElementById('sp-spi2-hex').value = c.spi2;
const kicByte = parseInt(c.kic, 16);
document.getElementById('sp-kic-idx').value = ((kicByte >> 4) & 0x0F).toString(16).toUpperCase();
@@ -16537,6 +16582,8 @@ const LANG_RU = {
'no data': 'нет данных',
'(no data)': '(нет данных)',
'response via SMS not captured': 'ответ по SMS не перехвачен',
+ 'no PoR': 'нет PoR',
+ 'not in the list': 'нет в списке',
'free NV': 'свободно NV',
'Failed': 'Ошибка',
'cascade': 'каскадно',
diff --git a/frontend/sw.js b/frontend/sw.js
index 2f6e73f..d5395b8 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'simple-v285';
+const CACHE = 'simple-v286';
const URLS = [
'index.html',
'help.html',
diff --git a/frontend/tests/ram.test.js b/frontend/tests/ram.test.js
index 820fe13..2f8d055 100644
--- a/frontend/tests/ram.test.js
+++ b/frontend/tests/ram.test.js
@@ -23,7 +23,7 @@ function extractFunc(src, name) {
// Extract chain builder functions and dependencies
const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamBuildRowHex', 'ramFmtLifecycle', 'ramFmtPrivileges', 'ramRenderExploreHtml', 'ramStepLine', 'ramGetStatusApdu', 'ramDeleteApdu',
- 'stkParamsBuild', 'ramRemoteSwOk', 'spPorAccepted', 'ramIncrementCntr', 'ramDeleteFromExplorer', 'ramListingSpi2',
+ 'stkParamsBuild', 'ramRemoteSwOk', 'spPorAccepted', 'ramIncrementCntr', 'ramDeleteFromExplorer', 'ramListingSpi2', 'ramRemoveFromExplorer',
'_parseRawElfEntry', '_parseRawAppEntry', 'ramParseElfStatus', 'ramParseAppStatus', 'parseTLV', '_parseE3Entry',
'ramCardIdxAfterRemove', 'ramClearResults', 'ramHideProgress', 'ramOpChanged', 'ramRender', 'ramApplyCard', 'ramExecute',
'jcAidNorm', 'jcAidName', 'jcAidSuffix', 'jcAidHtml'];
@@ -440,6 +440,33 @@ test('ramRemoteSwOk mirrors the server success set', () => {
}
});
+test('ramRemoveFromExplorer drops the object locally (cascade drops its applets)', () => {
+ let renders = 0;
+ globalThis.ramRenderExplorer = () => { renders++; };
+ _ramExplorerData = {
+ apps: [{ aid: 'F0414C4641610101' }, { aid: 'F0414C4641610199' },
+ { aid: 'A1130001180001FFFFFFFF89A1003908' }],
+ elfs: [{ aid: 'F0414C46416101' }, { aid: 'A1130001180001FFFFFFFF89A1003900' }],
+ };
+ assert.strictEqual(ramRemoveFromExplorer('f0414c4641610101', false), true);
+ assert.deepStrictEqual(_ramExplorerData.apps.map(a => a.aid),
+ ['F0414C4641610199', 'A1130001180001FFFFFFFF89A1003908']);
+ assert.strictEqual(renders, 1);
+ assert.strictEqual(ramRemoveFromExplorer('F0414C46416101', true), true);
+ assert.deepStrictEqual(_ramExplorerData.elfs.map(e => e.aid),
+ ['A1130001180001FFFFFFFF89A1003900']);
+ assert.deepStrictEqual(_ramExplorerData.apps.map(a => a.aid),
+ ['A1130001180001FFFFFFFF89A1003908'], 'an unrelated applet stays');
+ assert.strictEqual(renders, 2);
+ assert.strictEqual(ramRemoveFromExplorer('DEADBEEF', false), false);
+ delete globalThis.ramRenderExplorer;
+});
+
+test('getRamSpParams reads the computed SPI2 byte, not the base select', () => {
+ const fn = extractFunc(html, 'getRamSpParams');
+ assert.ok(/spi2: document\.getElementById\('sp-spi2-hex'\)/.test(fn), fn);
+});
+
test('ramListingSpi2 requests the SMS-submit PoR for the listing queries', () => {
// Apps (40) and ELF (20/10) listings can exceed the ENVELOPE response;
// with SPI2=0x01 the card answers actual_response_sms_submit and the data
@@ -451,7 +478,7 @@ test('ramListingSpi2 requests the SMS-submit PoR for the listing queries', () =>
function stubDeleteEnv(sendResult) {
// ramShowProgress/ramHideProgress are the real extracted helpers
const els = fakeRamDocument(['ram-result', 'ram-steps', 'ram-progress', 'ram-progress-text']);
- const calls = { refresh: [], saved: [], sent: [], explored: null };
+ const calls = { refresh: [], saved: [], sent: [], explored: null, removed: null };
globalThis.t = s => s;
globalThis.spRefreshFromPreset = sel => { calls.refresh.push(sel); };
globalThis.getRamSpParams = () => ({ cntr: '0000000005', kicKey: 'AA', kidKey: 'BB' });
@@ -475,10 +502,13 @@ function unstubDeleteEnv() {
globalThis.spRefreshFromPreset = () => ''; // the top-level stub
}
-test('ramDeleteFromExplorer refreshes the preset and continues from the consumed counter', async () => {
+test('ramDeleteFromExplorer refreshes the preset and drops the record locally', async () => {
const { els, calls } = stubDeleteEnv({ success: true,
por: { response_status: 'por_ok', decoded: { last_status_word: '9000' } } });
+ _ramExplorerData = { apps: [{ aid: 'F0414C4641610101' }], elfs: [{ aid: 'F0414C46416101' }] };
+ globalThis.ramRenderExplorer = () => {};
await ramDeleteFromExplorer('F0414C46416101', true);
+ delete globalThis.ramRenderExplorer;
assert.deepStrictEqual(calls.refresh, ['ram-card-sel'],
'the preset must be re-read before the operation');
assert.strictEqual(calls.sent.length, 1);
@@ -486,8 +516,11 @@ test('ramDeleteFromExplorer refreshes the preset and continues from the consumed
assert.strictEqual(calls.sent[0].cntr, '0000000005');
assert.deepStrictEqual(calls.saved, ['0000000006'],
'the accepted packet advances the saved counter');
- assert.strictEqual(calls.explored, '0000000006',
- 'the re-explore must start from the consumed counter, never replay it');
+ assert.deepStrictEqual(_ramExplorerData.elfs, [],
+ 'a successful cascade delete drops the package from the Explore result');
+ assert.deepStrictEqual(_ramExplorerData.apps, [],
+ 'and the package applets (AID prefix) with it');
+ assert.strictEqual(calls.explored, null, 'no re-explore is run');
assert.strictEqual(els['ram-result'].textContent, 'OK');
unstubDeleteEnv();
});
diff --git a/frontend/tests/ram_counter_flow.test.js b/frontend/tests/ram_counter_flow.test.js
index 2153b47..2852017 100644
--- a/frontend/tests/ram_counter_flow.test.js
+++ b/frontend/tests/ram_counter_flow.test.js
@@ -22,8 +22,10 @@ function extractFunc(src, name) {
}
// The real functions behind the Explore Delete flow: the counter the card
-// consumed must be persisted into the preset, or the next operation starts
-// one behind and is rejected with cntr_low.
+// consumed must be persisted into the preset, a successful delete drops the
+// record locally (no re-explore), and a delete with no PoR (the SPI may
+// request none) is not a failure - live 2026-09-28 showed "Failed: 9000"
+// although the delete executed.
let code = '';
for (const fn of ['berLenStr', 'ramDeleteApdu', 'ramIncrementCntr', 'ramSaveCntr',
'getRamSpParams', 'spPorAccepted', 'ramRemoteSwOk', 'ramShowProgress',
@@ -36,7 +38,7 @@ function fakeEnv(por, success) {
const els = {};
const mk = () => ({ value: '', textContent: '',
classList: { add() {}, remove() {}, toggle() {} } });
- for (const id of ['sp-spi1', 'sp-spi2', 'sp-kic-hex', 'sp-kid-hex', 'sp-tar',
+ for (const id of ['sp-spi1', 'sp-spi2-hex', 'sp-kic-hex', 'sp-kid-hex', 'sp-tar',
'sp-cntr', 'sp-kic-key', 'sp-kid-key', 'ram-result', 'ram-steps',
'ram-progress', 'ram-progress-text']) els[id] = mk();
els['ram-card-sel'] = { value: '0' };
@@ -44,74 +46,83 @@ function fakeEnv(por, success) {
// the form (the delete flow aborts without them)
els['sp-kic-key'].value = 'AA';
els['sp-kid-key'].value = 'BB';
+ els['sp-spi2-hex'].value = '01';
globalThis.document = { getElementById: id => els[id] || null };
globalThis.cards = [{ name: 'C', cntr: '0000000005', kicKey: 'AA', kidKey: 'BB' }];
- const calls = { saved: 0, explored: null, sent: null };
+ const calls = { saved: 0, explored: null, removed: null, sent: null };
globalThis.cardsSave = () => { calls.saved++; };
globalThis.cardsRender = () => {};
globalThis.ramRender = () => {};
globalThis.t = s => s;
globalThis.alert = () => {};
globalThis.confirm = () => true;
- // the preset re-read (cardsApply -> the sp-* form fields); the real
- // spRefreshFromPreset is covered by cards_counter.test.js
globalThis.spRefreshFromPreset = () => {
els['sp-cntr'].value = cards[0].cntr;
return cards[0].cntr;
};
globalThis.ramSendOta = async (apdu, sp) => {
- calls.sent = { apdu: apdu, cntr: sp.cntr };
+ calls.sent = { apdu: apdu, cntr: sp.cntr, spi2: sp.spi2 };
return { success: success !== false, por: por };
};
globalThis.ramExplore = async sp => { calls.explored = sp.cntr; };
+ globalThis.ramRemoveFromExplorer = (aid, cascade) => {
+ calls.removed = { aid: aid, cascade: cascade };
+ return true;
+ };
return { els, calls };
}
-test('accepted delete persists the consumed counter and re-explores from it', async () => {
+test('accepted delete persists the consumed counter and drops the record', async () => {
const { els, calls } = fakeEnv({ response_status: 'por_ok',
decoded: { last_status_word: '9000' } });
await ramDeleteFromExplorer('F0414C46416101', false);
assert.strictEqual(calls.sent.cntr, '0000000005');
assert.strictEqual(calls.sent.apdu, '80E40000094F07F0414C4641610100');
+ assert.strictEqual(calls.sent.spi2, '01', 'the computed SPI2 byte must be used');
assert.strictEqual(cards[0].cntr, '0000000006',
'the preset must carry the counter the card consumed');
assert.strictEqual(els['sp-cntr'].value, '0000000006');
assert.ok(calls.saved > 0, 'cardsSave() must persist it');
- assert.strictEqual(calls.explored, '0000000006',
- 'the re-explore must start at N+1, never replay N');
+ assert.deepStrictEqual(calls.removed, { aid: 'F0414C46416101', cascade: false });
+ assert.strictEqual(calls.explored, null, 'no re-explore: the record is dropped locally');
assert.strictEqual(els['ram-result'].textContent, 'OK');
});
+test('a delete with no PoR is accepted (the envelope 9000 is the result)', async () => {
+ const { els, calls } = fakeEnv(undefined);
+ await ramDeleteFromExplorer('F0414C46416101', false);
+ assert.strictEqual(cards[0].cntr, '0000000006');
+ assert.deepStrictEqual(calls.removed, { aid: 'F0414C46416101', cascade: false });
+ assert.strictEqual(els['ram-result'].textContent, 'OK');
+ assert.ok(els['ram-steps'].textContent.includes('no PoR'), els['ram-steps'].textContent);
+});
+
test('cntr_low leaves the preset untouched (the card did not consume the packet)', async () => {
const { calls } = fakeEnv({ response_status: 'cntr_low' });
await ramDeleteFromExplorer('F0414C46416101', false);
assert.strictEqual(cards[0].cntr, '0000000005');
assert.strictEqual(calls.saved, 0);
- assert.strictEqual(calls.explored, null);
+ assert.strictEqual(calls.removed, null);
});
-test('a refused DELETE still advances the counter but does not re-explore', async () => {
+test('a refused DELETE still advances the counter but drops nothing', async () => {
const { calls } = fakeEnv({ response_status: 'por_ok',
decoded: { last_status_word: '6A88' } });
await ramDeleteFromExplorer('F0414C46416101', true);
assert.strictEqual(cards[0].cntr, '0000000006');
- assert.strictEqual(calls.explored, null);
+ assert.strictEqual(calls.removed, null);
});
-test('a delete accepted via actual_response_sms_submit advances and re-explores', async () => {
- // The card consumed the packet and will deliver the remote result as an
- // SMS-SUBMIT; the counter must advance and the re-explore must run (the
- // remote SW is unknown, so no SW is shown).
- const { els, calls } = fakeEnv({ response_status: 'actual_response_sms_submit' });
+test('a delete accepted via actual_response_sms_submit advances and drops the record', async () => {
+ const { calls } = fakeEnv({ response_status: 'actual_response_sms_submit' });
await ramDeleteFromExplorer('F0414C46416101', false);
assert.strictEqual(cards[0].cntr, '0000000006');
- assert.strictEqual(calls.explored, '0000000006');
- assert.strictEqual(els['ram-result'].textContent, 'OK');
+ assert.deepStrictEqual(calls.removed, { aid: 'F0414C46416101', cascade: false });
});
test('a send failure leaves the preset untouched', async () => {
const { calls } = fakeEnv({ response_status: 'por_ok' }, false);
await ramDeleteFromExplorer('F0414C46416101', false);
assert.strictEqual(cards[0].cntr, '0000000005');
- assert.strictEqual(calls.explored, null);
+ assert.strictEqual(calls.removed, null);
});
diff --git a/pyproject.toml b/pyproject.toml
index 817c7a2..8de9e97 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
-version = "3.6.12"
+version = "3.6.13"
description = "HTTP REST server wrapping pysim for the SIMple PWA"
requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py
index 356f66e..f3baa22 100644
--- a/pysim_simple_server/server.py
+++ b/pysim_simple_server/server.py
@@ -31,7 +31,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
-VERSION = '3.6.12'
+VERSION = '3.6.13'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE