diff --git a/frontend/help-ru.html b/frontend/help-ru.html index c83835c..6bbf9d0 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -469,7 +469,7 @@ diff --git a/frontend/help.html b/frontend/help.html index 5360dec..a1a1ade 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -468,7 +468,7 @@ diff --git a/frontend/index.html b/frontend/index.html index d53ca60..070cf92 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1905,7 +1905,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.25.3'; +const SIMPLE_VERSION = '3.25.4'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== view history (the browser Back/Forward buttons) ===== @@ -18943,6 +18943,7 @@ function profilerValidateProfile(obj) { const ar = r.accessRules; if (typeof ar !== 'object' || !Array.isArray(ar.expected) || ar.expected.some(x => typeof x !== 'string')) return 'Invalid access rules: ' + r.path; + if (ar.mode !== undefined && !['exact', 'contains'].includes(ar.mode)) return 'Invalid access rules mode: ' + r.path; } } return null; @@ -19827,14 +19828,22 @@ function profilerRenderAccessRulesEditor(i, r) { const lines = (ar && ar.expected) || []; const on = !!(ar && ar.enabled !== false); const open = on && !!_profilerArrOpen[i]; + const contains = !!(ar && ar.mode === 'contains'); let html = '
'; if (on) { html += ''; + esc((open ? '\u25be ' : '\u25b8 ') + t('Check access rules') + (lines.length ? ' (' + lines.length + ')' : '') + + (contains ? ' \u00b7 ' + t('per operation') : '')) + ''; } else { html += '' + esc(t('Check access rules')) + ''; } html += '
'; + if (on && lines.length) { + html += ''; + } html += ''; html += ''; html += '
'; @@ -20017,6 +20026,17 @@ function profilerSetAccessRulesIgnore(i, on) { r.accessRules.ignoreFciRef = on; } +// Exact (default) compares the canonical lines as stored; 'contains' checks +// every listed rule per operation and ignores the rules not listed (v3.25.4). +function profilerSetAccessRulesMode(i, v) { + if (!profilerDraft) return; + const r = profilerDraft.rules[i]; + if (!r.accessRules) r.accessRules = { expected: [] }; + if (v === 'contains') r.accessRules.mode = 'contains'; + else delete r.accessRules.mode; + profilerRenderEditor(); +} + function profilerRenderContentEditor(i, r) { const c = r.content; const mode = c ? c.mode : 'exact'; @@ -20423,10 +20443,14 @@ async function profilerRunRule(r, source) { // FCI's '8B' reference points at - record-number independent, so cards // with a different EF.ARR layout still verify. if (r.accessRules && r.accessRules.enabled !== false && (r.accessRules.expected || []).length) { + // 'contains' (per operation, v3.25.4): every listed rule must be granted + // by the card - matched per AM bit / command entry, whatever AM grouping + // the card uses - and a rule not listed is not checked. + const arMode = r.accessRules.mode === 'contains' ? 'contains' : 'exact'; const ref = fcpArrRefFromFci(sel.fci_hex); if (!ref) { res.status = 'fail'; - res.checks.push({ label: 'accessRules', expected: r.accessRules.expected, actual: null, ok: false, + res.checks.push({ label: 'accessRules', mode: arMode, expected: r.accessRules.expected, actual: null, ok: false, missing: r.accessRules.expected.slice(), extra: [] }); } else { let arrs = []; @@ -20436,15 +20460,17 @@ async function profilerRunRule(r, source) { const got = fcpArrRules(ref, arrs, r.path); if (!got.found) { if (res.status === 'pass') res.status = 'error'; - res.checks.push({ label: 'accessRules', expected: r.accessRules.expected, actual: null, ok: false, + res.checks.push({ label: 'accessRules', mode: arMode, expected: r.accessRules.expected, actual: null, ok: false, unresolved: true, missing: [], extra: [] }); } else { const exp = r.accessRules.expected; - const missing = exp.filter(x => !got.rules.includes(x)); - const extra = got.rules.filter(x => !exp.includes(x)); + const missing = arMode === 'contains' + ? exp.filter(x => !fcpArrRuleSatisfied(x, got.rules)) + : exp.filter(x => !got.rules.includes(x)); + const extra = arMode === 'contains' ? [] : got.rules.filter(x => !exp.includes(x)); const ok = !missing.length && !extra.length; if (!ok) res.status = 'fail'; - res.checks.push({ label: 'accessRules', expected: exp, actual: got.rules, ok: ok, + res.checks.push({ label: 'accessRules', mode: arMode, expected: exp, actual: got.rules, ok: ok, missing: missing, extra: extra }); } } @@ -20879,6 +20905,50 @@ function fcpArrRuleEncode(rule) { return am + (scs.length ? ': ' + scs.join(' AND ') : ''); } +// The AM entries a rule asserts, for the per-operation 'contains' match +// (v3.25.4): one per operation bit (bit form) or the command form +// (RESIZE/INCREASE); the proprietary (b8) form stays whole - its high nibble +// is opaque. Null for the forms outside the supported subset (raw). +function fcpArrRuleOps(m) { + if (!m || m.raw !== undefined) return null; + if (m.kind === 'bits') { + if (m.am & 0x80) return [{ am: m.am & 0xFF }]; + const ops = []; + for (const b of FCP_ARR_AM_BITS) if (m.am & b[0]) ops.push({ bit: b[0] }); + return ops; + } + return [{ kind: m.kind }]; +} + +// Does one card rule cover an operation of the expected rule? +function fcpArrRuleOpCovered(op, cm) { + if (!cm || cm.raw !== undefined) return false; + if (op.bit !== undefined) return cm.kind === 'bits' && (cm.am & op.bit) !== 0; + if (op.am !== undefined) return cm.kind === 'bits' && (cm.am & 0xFF) === op.am; + return cm.kind === op.kind; +} + +// 'Contains' match (v3.25.4): every operation of the expected rule must be +// covered by a card rule with the same SC list, whatever AM grouping the card +// uses; a rule not listed is not checked. A form outside the supported +// subset (raw, or a card line with a label prefix) compares by exact string. +function fcpArrRuleSatisfied(line, cardRules) { + const rules = cardRules || []; + const m = fcpArrRuleParse(line); + if (m.raw !== undefined) return rules.includes(m.raw); + const ops = fcpArrRuleOps(m); + if (!ops || !ops.length) return rules.includes(line); + const want = (m.scs || []).map(fcpArrScEncode).join(' AND '); + for (const op of ops) { + if (!rules.some(x => { + const cm = fcpArrRuleParse(x); + return cm.raw === undefined && fcpArrRuleOpCovered(op, cm) && + (cm.scs || []).map(fcpArrScEncode).join(' AND ') === want; + })) return false; + } + return true; +} + // The editor mutates the parsed model and re-encodes; a raw model is left as // it is (its row is read-only). function fcpArrRuleToggleBit(rule, bit, on) { @@ -21209,6 +21279,10 @@ function profilerFciInput(i, value) { function profilerAccessRulesDiffHtml(c) { let html = '
'; html += '
' + esc(t('Access rules')) + '
'; + if (c.mode === 'contains') { + html += '
' + + esc(t('Compared per operation; other card rules are ignored')) + '
'; + } if (c.unresolved) { html += '
' + esc(t('the EF.ARR reference could not be resolved (no cached EF.ARR or the record is out of range)')) + '
'; @@ -23441,6 +23515,11 @@ const LANG_RU = { 'Check': 'Проверять', 'Access rules': 'Правила доступа', 'Check access rules': 'Проверять правила доступа', + 'Match': 'Сопоставление', + 'Contains (per operation)': 'Содержит (по операциям)', + 'per operation': 'по операциям', + 'Exact: the card rules must equal the listed rules. Contains: each listed rule must be granted per operation; other card rules are ignored.': 'Точное: правила карты должны совпадать со всеми перечисленными. Содержит: каждое перечисленное правило должно быть разрешено — сравнение по операциям; остальные правила карты игнорируются.', + 'Compared per operation; other card rules are ignored': 'Сравнение по операциям; остальные правила карты игнорируются', 'Ignore the EF.ARR reference in the exact FCI': 'Игнорировать ссылку на EF.ARR в точном FCI', 'the FCI has no EF.ARR reference': 'в FCI нет ссылки на EF.ARR', 'the EF.ARR reference could not be resolved (no cached EF.ARR or the record is out of range)': 'ссылку на EF.ARR не удалось разрешить (нет кэша EF.ARR или запись вне диапазона)', diff --git a/frontend/sw.js b/frontend/sw.js index d91656a..6c68cc2 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v427'; +const CACHE = 'simple-v428'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/profiler.test.js b/frontend/tests/profiler.test.js index 64b29cd..9887d0b 100644 --- a/frontend/tests/profiler.test.js +++ b/frontend/tests/profiler.test.js @@ -22,7 +22,7 @@ function extractFunc(src, name, asyncFn) { } const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'pysimCustomNormPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'profilerMatchedRecordsText', 'profilerCloneName', 'profilerClone', 'profilerNewId', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport', 'parseBerLen', 'parseTlvList', 'fcpInt', 'fcpParseTlvs', 'fcpFileDescriptor', 'fcpLifeCycle', 'fcpSfi', 'fcpDo', 'fcpDecode', - 'fcpArrRef', 'fcpSeidName', 'fcpArrRules', 'fcpArrRefFromFci', 'fcpWithoutArrRef', 'fcpArrSplitTop', 'fcpArrKeyRefFromName', 'fcpArrKeyRefOptions', 'fcpArrScParse', 'fcpArrScEncode', 'fcpArrRuleParse', 'fcpArrRuleEncode', 'fcpArrRuleToggleBit', 'fcpArrRuleSetKind', 'fcpArrRuleSetSc', 'fcpArrRuleSetUq', 'fcpArrRuleAddSc', 'fcpArrRuleRemoveSc', 'profilerRenderAccessRulesEditor', 'profilerArrRuleHtml', 'profilerArrScSelect', 'profilerArrUqSelect', 'profilerArrToggleOpen', 'profilerArrShiftOpen', 'profilerAccessRulesDiffHtml', 'profilerSnapshotArr', 'efBytes', 'efHex', 'efTlv', 'efDecArr', 'efArrAmLabel', 'efArrSc', 'efArrAmDo', 'efFidFromPath', + 'fcpArrRef', 'fcpSeidName', 'fcpArrRules', 'fcpArrRefFromFci', 'fcpWithoutArrRef', 'fcpArrSplitTop', 'fcpArrKeyRefFromName', 'fcpArrKeyRefOptions', 'fcpArrScParse', 'fcpArrScEncode', 'fcpArrRuleParse', 'fcpArrRuleEncode', 'fcpArrRuleOps', 'fcpArrRuleOpCovered', 'fcpArrRuleSatisfied', 'fcpArrRuleToggleBit', 'fcpArrRuleSetKind', 'fcpArrRuleSetSc', 'fcpArrRuleSetUq', 'fcpArrRuleAddSc', 'fcpArrRuleRemoveSc', 'profilerRenderAccessRulesEditor', 'profilerArrRuleHtml', 'profilerArrScSelect', 'profilerArrUqSelect', 'profilerArrToggleOpen', 'profilerArrShiftOpen', 'profilerAccessRulesDiffHtml', 'profilerSnapshotArr', 'efBytes', 'efHex', 'efTlv', 'efDecArr', 'efArrAmLabel', 'efArrSc', 'efArrAmDo', 'efFidFromPath', 'pinKeyRefName', 'fcpPsTemplate', 'fcpDiffHtml', 'profilerFciPreviewItems', 'profilerUpdateFciPreview', 'profilerUpdateRule', 'profilerFciInput', 'profilerScanToggleAll', 'profilerScanIgnoreAllState', 'swapNibbles', 'decIccid', 'profilerSnapshotIccid', 'profilerValidateSnapshot', 'profilerListSwitch', 'profilerScanRefreshOptions', 'profilerLiveSource', 'profilerSnapshotSource', 'profilerVisibleResults', 'profilerRulesFromSnapshot', 'profilerExtraFileResults', 'profilerScanNameKeydown', 'profilerTimingStats', 'profilerTimingAccumulator', 'profilerFormatMs', 'profilerRenderSnapshotSummary', 'profilerSnapshotCountLabel', 'pysimFsInfoHtml', 'profilerLabelText', 'profilerResultsHeaderText', 'profilerRenderResultsView', 'profilerBuildFileRuleFromSnapshot', 'profilerSnapshotPickListHtml', 'profilerScanSetTarget']; let code = ''; for (const f of FNS) code += extractFunc(html, f) + '\n'; @@ -1000,6 +1000,45 @@ test('profilerRunRule checks the access rules against the ARR copies', async () assert.ok(unres.checks.find(c => c.label === 'accessRules').unresolved); }); +test('the access-rules Contains mode checks the listed rules per operation', async () => { + const FCI2 = '621A82054221000F0583026F4F8A01058B032F06028802004B8801B0'; + const line = (am, scs) => fcpArrRuleEncode({ kind: 'bits', am: am, scs: scs }); + const ALW = { kind: 'alw' }, ADM1 = { kind: 'key', ref: 0x0A, uq: 0x08 }; + // the card's record: READ+WRITE combined always, the other bits ADM1, RESIZE never + const arrs = [{ fid: '2F06', path: 'MF/2F06', + records: ['FFFF', '800105900080011AA40683010A9501088401D49700'] }]; + const src = { + select: async () => ({ name: 'EF.SAMPLE', file_type: 'linear_fixed', file_size: null, + record_len: 6, num_of_rec: 2, fci_hex: FCI2, exists: true }), + read: async () => ({ success: false }), + arrs: () => arrs, + }; + const rule = ar => ({ type: 'file', path: 'MF/7F20/6F07', name: 'EF.SAMPLE', + fileType: 'linear_fixed', fciMode: 'type_size', fciHex: FCI2, accessRules: ar }); + // READ/WRITE always, RESIZE not listed: the combined 0x05 satisfies both + // lines; the ADM1 and RESIZE rules are not checked + const ok = await profilerRunRule(rule({ mode: 'contains', expected: [line(0x01, [ALW]), line(0x04, [ALW])] }), src); + assert.strictEqual(ok.status, 'pass', JSON.stringify(ok.checks)); + assert.strictEqual(ok.checks.find(c => c.label === 'accessRules').mode, 'contains'); + // the same lines in the default exact mode fail on the AM grouping + const ex = await profilerRunRule(rule({ expected: [line(0x01, [ALW]), line(0x04, [ALW])] }), src); + assert.strictEqual(ex.status, 'fail'); + assert.strictEqual(ex.checks.find(c => c.label === 'accessRules').missing.length, 2); + // a listed operation the card grants with another condition fails + const miss = await profilerRunRule(rule({ mode: 'contains', expected: [line(0x02, [ALW])] }), src); + assert.strictEqual(miss.status, 'fail'); + const mc = miss.checks.find(c => c.label === 'accessRules'); + assert.deepStrictEqual(mc.missing, [line(0x02, [ALW])]); + assert.deepStrictEqual(mc.extra, []); + // the RESIZE condition is checked when listed + const resize = fcpArrRuleEncode({ kind: 'resize', scs: [{ kind: 'nev' }] }); + const rOk = await profilerRunRule(rule({ mode: 'contains', expected: [resize] }), src); + assert.strictEqual(rOk.status, 'pass', JSON.stringify(rOk.checks)); + const rBad = await profilerRunRule(rule({ mode: 'contains', + expected: [fcpArrRuleEncode({ kind: 'resize', scs: [ADM1] })] }), src); + assert.strictEqual(rBad.status, 'fail'); +}); + test('ignoreFciRef compares the FCI without the EF.ARR reference', async () => { const FCI2 = '621A82054221000F0583026F4F8A01058B032F06028802004B8801B0'; const FCI9 = '621A82054221000F0583026F4F8A01058B032F06098802004B8801B0'; @@ -1108,6 +1147,30 @@ test('fcpArrRule mutators re-encode the canonical line', () => { assert.strictEqual(fcpArrRuleEncode(fcpArrRuleToggleBit(raw, 0x01, true)), 'command description 0x85 (D4)'); }); +test('fcpArrRuleSatisfied matches per operation, whatever the AM grouping', () => { + const line = (am, scs) => fcpArrRuleEncode({ kind: 'bits', am: am, scs: scs }); + const ALW = { kind: 'alw' }, ADM1 = { kind: 'key', ref: 0x0A, uq: 0x08 }; + // the card combines READ+WRITE in one AM byte and grants the other bits ADM1 + const card = [line(0x05, [ALW]), line(0x1A, [ADM1])]; + assert.ok(fcpArrRuleSatisfied(line(0x01, [ALW]), card)); // READ covered by the combined 0x05 + assert.ok(fcpArrRuleSatisfied(line(0x04, [ALW]), card)); // WRITE too + assert.ok(fcpArrRuleSatisfied(line(0x1A, [ADM1]), card)); // a multi-bit rule, same grouping + assert.ok(!fcpArrRuleSatisfied(line(0x02, [ALW]), card)); // UPDATE is ADM1, not always + assert.ok(!fcpArrRuleSatisfied(line(0x08, [ALW]), card)); // DEACTIVATE is ADM1 + // a command entry keys by command, not by bits + const resize = fcpArrRuleEncode({ kind: 'resize', scs: [{ kind: 'nev' }] }); + assert.ok(fcpArrRuleSatisfied(resize, card.concat([resize]))); + assert.ok(!fcpArrRuleSatisfied(fcpArrRuleEncode({ kind: 'resize', scs: [ADM1] }), card.concat([resize]))); + assert.ok(!fcpArrRuleSatisfied(resize, card)); + // a form outside the subset compares by exact string + assert.ok(fcpArrRuleSatisfied('command description 0x85 (D4)', ['command description 0x85 (D4)'])); + assert.ok(!fcpArrRuleSatisfied('command description 0x85 (D4)', card)); + // the proprietary (b8) form stays whole + const prop = fcpArrRuleEncode({ kind: 'bits', am: 0x81, scs: [ALW] }); + assert.ok(fcpArrRuleSatisfied(prop, [prop])); + assert.ok(!fcpArrRuleSatisfied(prop, [line(0x01, [ALW])])); +}); + test('the access-rules editor renders structured rows', () => { global.t = s => s; const r = { fciMode: 'exact', accessRules: { expected: [ @@ -1159,6 +1222,36 @@ test('the access-rules editor renders structured rows', () => { delete global.t; }); +test('the access-rules Match selector switches to per-operation Contains', () => { + global.t = s => s; + const r = { fciMode: 'type_size', accessRules: { expected: [ + 'AM 0x01 (READ/SEARCH (EF) / DELETE FILE child (DF)): always'] } }; + _profilerArrOpen = { 3: true }; + const exact = profilerRenderAccessRulesEditor(3, r); + assert.ok(exact.includes('profilerSetAccessRulesMode(3,this.value)'), exact); + assert.ok(exact.includes(''), exact); + assert.ok(exact.includes(''), exact); + assert.ok(!exact.includes('\u00b7 per operation'), exact); + const contains = profilerRenderAccessRulesEditor(3, { ...r, + accessRules: { ...r.accessRules, mode: 'contains' } }); + assert.ok(contains.includes(''), contains); + assert.ok(contains.includes('Check access rules (1) \u00b7 per operation'), contains); + // the collapsed header carries the mode badge too + _profilerArrOpen = {}; + const closed = profilerRenderAccessRulesEditor(3, { ...r, + accessRules: { ...r.accessRules, mode: 'contains' } }); + assert.ok(closed.includes('\u00b7 per operation'), closed); + // None hides the selector, an empty block has none + const none = profilerRenderAccessRulesEditor(3, { ...r, + accessRules: { ...r.accessRules, enabled: false } }); + assert.ok(!none.includes('profilerSetAccessRulesMode'), none); + _profilerArrOpen = { 4: true }; + const empty = profilerRenderAccessRulesEditor(4, { fciMode: 'type_size', accessRules: { expected: [] } }); + assert.ok(!empty.includes('profilerSetAccessRulesMode'), empty); + _profilerArrOpen = {}; + delete global.t; +}); + test('the access-rule open state survives rule deletion', () => { assert.deepStrictEqual(profilerArrShiftOpen({ 0: true, 2: true, 3: true }, 1), { 0: true, 1: true, 2: true }); assert.deepStrictEqual(profilerArrShiftOpen({ 0: true, 2: true, 3: true }, 2), { 0: true, 2: true }); @@ -1186,6 +1279,12 @@ test('profile validation accepts the access-rules field', () => { accessRules: { expected: 'nope' } }] })); assert.ok(profilerValidateProfile({ ...base, rules: [{ type: 'file', path: 'MF/7F20/6F07', accessRules: { expected: [1] } }] })); + assert.strictEqual(profilerValidateProfile({ ...base, rules: [{ type: 'file', path: 'MF/7F20/6F07', + accessRules: { expected: ['AM 0x01: always'], mode: 'exact' } }] }), null); + assert.strictEqual(profilerValidateProfile({ ...base, rules: [{ type: 'file', path: 'MF/7F20/6F07', + accessRules: { expected: ['AM 0x01: always'], mode: 'contains' } }] }), null); + assert.ok(profilerValidateProfile({ ...base, rules: [{ type: 'file', path: 'MF/7F20/6F07', + accessRules: { expected: ['AM 0x01: always'], mode: 'partial' } }] })); }); test('the access-rules report block marks missing and extra lines', () => { @@ -1201,6 +1300,12 @@ test('the access-rules report block marks missing and extra lines', () => { const noref = profilerAccessRulesDiffHtml({ label: 'accessRules', ok: false, expected: ['A'], actual: null, missing: ['A'], extra: [] }); assert.ok(noref.includes('no EF.ARR reference'), noref); + // contains: the mode note, no extra lines + const cont = profilerAccessRulesDiffHtml({ label: 'accessRules', mode: 'contains', ok: false, + expected: ['A'], actual: ['B'], missing: ['A'], extra: [] }); + assert.ok(cont.includes('Compared per operation; other card rules are ignored'), cont); + assert.ok(cont.includes('\u2212 A'), cont); + assert.ok(!cont.includes('+ B'), cont); delete global.t; }); diff --git a/pyproject.toml b/pyproject.toml index 3407bd9..e7ff9c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.25.3" +version = "3.25.4" 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 819b821..2f72860 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -36,7 +36,7 @@ from osmocom.tlv import BER_TLV_IE from osmocom.utils import rpad -VERSION = '3.25.3' +VERSION = '3.25.4' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE