diff --git a/frontend/help-ru.html b/frontend/help-ru.html index d261f94..d840ca5 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -466,7 +466,7 @@ diff --git a/frontend/help.html b/frontend/help.html index 0c03b6b..fae53d9 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -465,7 +465,7 @@ diff --git a/frontend/index.html b/frontend/index.html index 11294fd..4d2326c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -19807,33 +19807,166 @@ function profilerRenderRule(i, r) { return html; } -// Access-rules check editor (v3.25.0): the captured rule lines are read-only -// (capture fills them from the card/snapshot); the radio toggles the check. +// Access-rules check editor (v3.25.0): captured rules open as structured rows +// (operation bits / command + security conditions) and hand-made rules can be +// built the same way; every change re-encodes the canonical decoded line the +// check compares. Forms outside the supported subset stay raw (read-only, +// removable). Capture fills the lines when a profile is created from a card +// or a snapshot; the radio toggles the check. function profilerRenderAccessRulesEditor(i, r) { const ar = r.accessRules || null; const lines = (ar && ar.expected) || []; - const on = !!(ar && ar.enabled !== false && lines.length); + const on = !!(ar && ar.enabled !== false); let html = '
'; html += '' + esc(t('Check access rules')) + ''; html += '
'; html += ''; - html += ''; + html += ''; html += '
'; if (r.fciMode === 'exact') { html += ''; } - if (lines.length) { - html += '
'; - for (const x of lines) { - html += '
' + esc('\u21b3 ' + x) + '
'; - } - html += '
'; - } else { - html += '
' + esc(t('Captured when the profile is created from a card or a snapshot')) + '
'; + for (let ri = 0; ri < lines.length; ri++) { + html += profilerArrRuleHtml(i, ri, lines[ri]); } + if (!lines.length) { + html += '
' + esc(t('Captured when the profile is created from a card or a snapshot')) + '
'; + } + html += ''; return html; } +// One expected rule row: the operation form + bit checkboxes (or the RESIZE / +// INCREASE command), the condition rows and the canonical preview line. +function profilerArrRuleHtml(i, ri, line) { + const m = fcpArrRuleParse(line); + if (m.raw !== undefined) { + return '
' + + '' + esc(t('raw') + ': ' + m.raw) + '' + + '
'; + } + let html = '
'; + html += '
'; + html += ''; + if (m.kind === 'bits') { + for (const bit of FCP_ARR_AM_BITS.slice().reverse()) { + html += ''; + } + } + html += ''; + html += '
'; + for (let si = 0; si < m.scs.length; si++) { + html += '
'; + html += profilerArrScSelect(i, ri, si, m.scs[si]); + if (m.scs[si].kind === 'key') html += profilerArrUqSelect(i, ri, si, m.scs[si].uq); + html += ''; + html += '
'; + } + html += ''; + html += '
' + esc(fcpArrRuleEncode(m)) + '
'; + html += '
'; + return html; +} + +// The condition select: always / never / the named key references. +function profilerArrScSelect(i, ri, si, sc) { + let opts = ''; + opts += ''; + for (const k of fcpArrKeyRefOptions()) { + opts += ''; + } + return ''; +} + +// The usage-qualifier select (keys only): verify / verification not used, plus +// the current odd value when a captured rule carried one. +function profilerArrUqSelect(i, ri, si, uq) { + const verify = uq !== 0x00 && (uq & 0x08) === 0x08; + const custom = uq !== 0x00 && (uq & 0x08) === 0; + let opts = ''; + opts += ''; + if (custom) { + opts += ''; + } + return ''; +} + +// The stored line of one expected rule (undefined when the row is gone). +function profilerArrLine(i, ri) { + if (!profilerDraft) return undefined; + const r = profilerDraft.rules[i]; + return (((r && r.accessRules) || {}).expected || [])[ri]; +} + +// Parse the stored line, mutate the model, re-encode into the draft and +// re-render (the check compares the stored strings). +function profilerArrUpdate(i, ri, model) { + if (!profilerDraft) return; + const r = profilerDraft.rules[i]; + if (!r.accessRules) r.accessRules = { expected: [] }; + r.accessRules.expected[ri] = fcpArrRuleEncode(model); + profilerRenderEditor(); +} + +function profilerArrToggleBit(i, ri, bit, on) { + const line = profilerArrLine(i, ri); + if (line === undefined) return; + profilerArrUpdate(i, ri, fcpArrRuleToggleBit(fcpArrRuleParse(line), bit, on)); +} + +function profilerArrSetKind(i, ri, kind) { + const line = profilerArrLine(i, ri); + if (line === undefined) return; + profilerArrUpdate(i, ri, fcpArrRuleSetKind(fcpArrRuleParse(line), kind)); +} + +function profilerArrSetSc(i, ri, si, value) { + const line = profilerArrLine(i, ri); + if (line === undefined) return; + profilerArrUpdate(i, ri, fcpArrRuleSetSc(fcpArrRuleParse(line), si, value)); +} + +function profilerArrSetUq(i, ri, si, value) { + const line = profilerArrLine(i, ri); + if (line === undefined) return; + const uq = value === 'verify' ? 0x08 + : (value === 'not-used' ? 0x00 : parseInt(String(value).replace('usage:', ''), 10)); + profilerArrUpdate(i, ri, fcpArrRuleSetUq(fcpArrRuleParse(line), si, uq)); +} + +function profilerArrAddSc(i, ri) { + const line = profilerArrLine(i, ri); + if (line === undefined) return; + profilerArrUpdate(i, ri, fcpArrRuleAddSc(fcpArrRuleParse(line))); +} + +function profilerArrRemoveSc(i, ri, si) { + const line = profilerArrLine(i, ri); + if (line === undefined) return; + profilerArrUpdate(i, ri, fcpArrRuleRemoveSc(fcpArrRuleParse(line), si)); +} + +// A new rule starts from READ always (the common baseline). +function profilerArrAddRule(i) { + if (!profilerDraft) return; + const r = profilerDraft.rules[i]; + if (!r.accessRules) r.accessRules = { expected: [] }; + r.accessRules.expected.push(fcpArrRuleEncode({ kind: 'bits', am: 0x01, scs: [{ kind: 'alw' }] })); + profilerRenderEditor(); +} + +function profilerArrRemoveRule(i, ri) { + if (!profilerDraft) return; + const r = profilerDraft.rules[i]; + if (!r.accessRules) return; + r.accessRules.expected.splice(ri, 1); + profilerRenderEditor(); +} + function profilerSetAccessRules(i, on) { if (!profilerDraft) return; const r = profilerDraft.rules[i]; @@ -20600,6 +20733,160 @@ function fcpArrRules(ref, arrs, path) { return { found: found, rules: out }; } +// ---- structured access-rule editing (v3.25.0 review) ----------------------- +// The editor edits a decoded rule's structure (AM byte / command + SC +// conditions) and stores the canonical decoded strings the check compares. +// fcpArrRuleParse returns {raw} for the forms outside the supported subset +// (OR/AND/NOT templates, SM CRTs, command descriptions) - shown read-only. + +// Split on a separator at parenthesis depth 0 (the templates nest). +function fcpArrSplitTop(s, sep) { + const out = []; + let depth = 0, start = 0; + for (let i = 0; i < s.length; i++) { + if (s[i] === '(') depth++; + else if (s[i] === ')') depth = Math.max(0, depth - 1); + else if (depth === 0 && s.startsWith(sep, i)) { + out.push(s.slice(start, i)); + i += sep.length - 1; + start = i + 1; + } + } + out.push(s.slice(start)); + return out; +} + +// The key reference whose pinKeyRefName matches a decoded name (null for the +// 'key ref 0xNN' default and unknown names). +function fcpArrKeyRefFromName(name) { + for (let ref = 0; ref <= 0xFF; ref++) { + const n = pinKeyRefName(ref); + if (n === name && !/^key ref /.test(n)) return ref; + } + return null; +} + +// {ref, name} options for the condition select (the named key references). +function fcpArrKeyRefOptions() { + const out = []; + for (let ref = 0; ref <= 0xFF; ref++) { + const n = pinKeyRefName(ref); + if (!/^key ref /.test(n)) out.push({ ref: ref, name: n }); + } + return out; +} + +// One SC (security condition) part of a decoded rule -> model, or null. +function fcpArrScParse(text) { + if (text === 'always') return { kind: 'alw' }; + if (text === 'never') return { kind: 'nev' }; + const m = /^(.*) \((verify|verification not used|usage 0x([0-9A-F]{2}))\)$/.exec(text); + if (m) { + const ref = fcpArrKeyRefFromName(m[1]); + if (ref !== null) { + const uq = m[2] === 'verify' ? 0x08 + : (m[2] === 'verification not used' ? 0x00 : parseInt(m[3], 16)); + return { kind: 'key', ref: ref, uq: uq }; + } + } + return null; +} + +// Model -> the decoded string efArrSc would print (the check compares these). +function fcpArrScEncode(sc) { + if (sc.kind === 'alw') return 'always'; + if (sc.kind === 'nev') return 'never'; + if (sc.kind === 'key') { + let s = pinKeyRefName(sc.ref); + const uq = sc.uq; + if (uq === null || uq === undefined || (uq & 0x08)) s += ' (verify)'; + else if (uq === 0x00) s += ' (verification not used)'; + else s += ' (usage 0x' + uq.toString(16).toUpperCase().padStart(2, '0') + ')'; + return s; + } + return sc.text || ''; +} + +// A decoded rule line -> editable model, or {raw} when outside the subset. +function fcpArrRuleParse(line) { + const s = String(line || ''); + const idx = s.indexOf(': '); + const amPart = idx < 0 ? s : s.slice(0, idx); + const scPart = idx < 0 ? '' : s.slice(idx + 2); + let kind = null, am = 0; + if (amPart === 'RESIZE FILE (AM_DO 0x84, INS 0xD4)') kind = 'resize'; + else if (amPart === 'INCREASE (AM_DO 0x84, INS 0x32)') kind = 'increase'; + else { + const m = /^AM 0x([0-9A-F]{2}) \(/.exec(amPart); + if (m) { kind = 'bits'; am = parseInt(m[1], 16); } + } + if (!kind) return { raw: s }; + const scs = []; + if (scPart) { + for (const part of fcpArrSplitTop(scPart, ' AND ')) { + const sc = fcpArrScParse(part); + if (!sc) return { raw: s }; + scs.push(sc); + } + } + return { kind: kind, am: am, scs: scs }; +} + +// Model -> the canonical decoded line. +function fcpArrRuleEncode(rule) { + if (rule.raw !== undefined) return rule.raw; + let am; + if (rule.kind === 'resize') am = 'RESIZE FILE (AM_DO 0x84, INS 0xD4)'; + else if (rule.kind === 'increase') am = 'INCREASE (AM_DO 0x84, INS 0x32)'; + else am = 'AM 0x' + (rule.am & 0xFF).toString(16).toUpperCase().padStart(2, '0') + + ' (' + efArrAmLabel(rule.am) + ')'; + const scs = (rule.scs || []).map(fcpArrScEncode); + return am + (scs.length ? ': ' + scs.join(' AND ') : ''); +} + +// 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) { + if (rule.raw !== undefined) return rule; + const am = on ? (rule.am | bit) : (rule.am & ~bit); + return { kind: rule.kind, am: am & 0xFF, scs: rule.scs.slice() }; +} + +function fcpArrRuleSetKind(rule, kind) { + if (rule.raw !== undefined) return rule; + return { kind: kind, am: kind === 'bits' ? (rule.am || 0x01) : rule.am, scs: rule.scs.slice() }; +} + +function fcpArrRuleSetSc(rule, si, value) { + if (rule.raw !== undefined) return rule; + const scs = rule.scs.slice(); + if (value === 'always') scs[si] = { kind: 'alw' }; + else if (value === 'never') scs[si] = { kind: 'nev' }; + else if (String(value).startsWith('key:')) { + const prev = scs[si]; + scs[si] = { kind: 'key', ref: parseInt(String(value).slice(4), 10), + uq: (prev && prev.kind === 'key') ? prev.uq : 0x08 }; + } + return { kind: rule.kind, am: rule.am, scs: scs }; +} + +function fcpArrRuleSetUq(rule, si, uq) { + if (rule.raw !== undefined) return rule; + const scs = rule.scs.slice(); + if (scs[si] && scs[si].kind === 'key') scs[si] = { kind: 'key', ref: scs[si].ref, uq: uq }; + return { kind: rule.kind, am: rule.am, scs: scs }; +} + +function fcpArrRuleAddSc(rule) { + if (rule.raw !== undefined) return rule; + return { kind: rule.kind, am: rule.am, scs: rule.scs.concat([{ kind: 'alw' }]) }; +} + +function fcpArrRuleRemoveSc(rule, si) { + if (rule.raw !== undefined) return rule; + return { kind: rule.kind, am: rule.am, scs: rule.scs.filter((_, i) => i !== si) }; +} + function fcpDo(key, value) { if (/^[0-9A-F]+$/.test(value) === false) value = value || ''; switch (key) { @@ -21556,23 +21843,26 @@ function pinKeyRefName(v) { return 'key ref 0x' + v.toString(16).toUpperCase().padStart(2, '0'); } -// ISO 7816-4 Tables 16/17: AM byte bit meanings. A record does not store -// whether it protects an EF or a DF, so both mappings are shown. +// ISO 7816-4 Tables 16/17: AM byte bit meanings, highest bit first (the +// decoder's label order). A record does not store whether it protects an EF +// or a DF, so both mappings are shown; the structured access-rule editor +// renders the same table low-to-high as operation checkboxes (v3.25.0 review). +const FCP_ARR_AM_BITS = [ + [0x40, 'DELETE (self)'], + [0x20, 'TERMINATE EF / TERMINATE DF'], + [0x10, 'ACTIVATE'], + [0x08, 'DEACTIVATE'], + [0x04, 'WRITE/APPEND (EF) / CREATE FILE DF (DF)'], + [0x02, 'UPDATE/ERASE (EF) / CREATE FILE EF (DF)'], + [0x01, 'READ/SEARCH (EF) / DELETE FILE child (DF)'], +]; + function efArrAmLabel(am) { - const bits = [ - [0x40, 'DELETE (self)'], - [0x20, 'TERMINATE EF / TERMINATE DF'], - [0x10, 'ACTIVATE'], - [0x08, 'DEACTIVATE'], - [0x04, 'WRITE/APPEND (EF) / CREATE FILE DF (DF)'], - [0x02, 'UPDATE/ERASE (EF) / CREATE FILE EF (DF)'], - [0x01, 'READ/SEARCH (EF) / DELETE FILE child (DF)'], - ]; if (am & 0x80) { - const low = bits.filter(b => (b[0] & 0x07) && (am & b[0])).map(b => b[1]); + const low = FCP_ARR_AM_BITS.filter(b => (b[0] & 0x07) && (am & b[0])).map(b => b[1]); return 'bits 7-4 proprietary' + (low.length ? ' + ' + low.join(' + ') : ''); } - const ops = bits.filter(b => am & b[0]).map(b => b[1]); + const ops = FCP_ARR_AM_BITS.filter(b => am & b[0]).map(b => b[1]); return ops.length ? ops.join(' + ') : 'no operations'; } @@ -23121,6 +23411,10 @@ const LANG_RU = { 'the EF.ARR reference could not be resolved (no cached EF.ARR or the record is out of range)': 'ссылку на EF.ARR не удалось разрешить (нет кэша EF.ARR или запись вне диапазона)', 'Captured when the profile is created from a card or a snapshot': 'Заполняется при создании профиля с карты или из снимка', 'EF.ARR reference ignored (verified by the access-rules check)': 'Ссылка на EF.ARR игнорируется (проверяется по правилам доступа)', + 'rule': 'правило', + 'condition': 'условие', + 'raw': 'как есть', + 'bit mask': 'битовая маска', 'Mask': 'Маска', 'Checking': 'Проверка', 'passed': 'пройдено', diff --git a/frontend/tests/ef_decode.test.js b/frontend/tests/ef_decode.test.js index bac7571..0d6bf8e 100644 --- a/frontend/tests/ef_decode.test.js +++ b/frontend/tests/ef_decode.test.js @@ -39,7 +39,7 @@ const FNS = [ let code = ''; for (const f of FNS) code += extractFunc(html, f) + '\n'; -for (const c of ['GSM7_ALPHABET', 'GSM7_EXT_MAP']) { +for (const c of ['GSM7_ALPHABET', 'GSM7_EXT_MAP', 'FCP_ARR_AM_BITS']) { code += html.match(new RegExp('const ' + c + ' = [\\s\\S]*?\\n\\];'))[0].replace('const ', 'var ') + '\n'; } for (const c of ['EF_UST_SERVICES', 'EF_SST_SERVICES', 'EF_IST_SERVICES', 'EF_PHASE_NAMES']) { diff --git a/frontend/tests/profiler.test.js b/frontend/tests/profiler.test.js index 682e145..02999bd 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', 'profilerAccessRulesDiffHtml', 'profilerSnapshotArr', 'efBytes', 'efHex', 'efTlv', 'efDecArr', 'efArrAmLabel', 'efArrSc', 'efArrAmDo', 'efFidFromPath', + 'fcpArrRef', 'fcpSeidName', 'fcpArrRules', 'fcpArrRefFromFci', 'fcpWithoutArrRef', 'fcpArrSplitTop', 'fcpArrKeyRefFromName', 'fcpArrKeyRefOptions', 'fcpArrScParse', 'fcpArrScEncode', 'fcpArrRuleParse', 'fcpArrRuleEncode', 'fcpArrRuleToggleBit', 'fcpArrRuleSetKind', 'fcpArrRuleSetSc', 'fcpArrRuleSetUq', 'fcpArrRuleAddSc', 'fcpArrRuleRemoveSc', 'profilerRenderAccessRulesEditor', 'profilerArrRuleHtml', 'profilerArrScSelect', 'profilerArrUqSelect', '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'; @@ -40,6 +40,7 @@ code += "var _pysimArrCache = null;\nvar _pysimCardSession = null;\n"; // the view-history hooks (the router lives in the page; this pure test stubs it) code += 'globalThis.navRecord = function() {};\nglobalThis.navReplace = function() {};\n'; code += html.match(/const PROFILER_MASK_PREFIX4_FIDS = \{[\s\S]*?\n\};/)[0] + '\n'; +code += html.match(/const FCP_ARR_AM_BITS = \[[\s\S]*?\n\];/)[0] + '\n'; eval(code); test('profilerNormHex uppercases and strips non-hex', () => { @@ -1058,6 +1059,81 @@ test('the implicit form prefers the file DF\'s own ARR copy', () => { delete global.t; }); +test('fcpArrRuleParse/Encode round-trips the decoded rule forms', () => { + const lines = [ + 'AM 0x01 (READ/SEARCH (EF) / DELETE FILE child (DF)): always', + 'AM 0x1A (ACTIVATE + DEACTIVATE + UPDATE/ERASE (EF) / CREATE FILE EF (DF)): ADM1 (verify)', + 'AM 0x02 (UPDATE/ERASE (EF) / CREATE FILE EF (DF)): PIN app1 (verify) AND ADM1 (verify)', + 'RESIZE FILE (AM_DO 0x84, INS 0xD4): never', + 'INCREASE (AM_DO 0x84, INS 0x32): ADM1 (verify)', + 'AM 0x01 (READ/SEARCH (EF) / DELETE FILE child (DF))', + ]; + for (const line of lines) { + const m = fcpArrRuleParse(line); + assert.strictEqual(m.raw, undefined, line); + assert.strictEqual(fcpArrRuleEncode(m), line, line); + } + // template forms and unknown conditions stay raw and unchanged + for (const raw of [ + 'AM 0x02 (UPDATE/ERASE (EF) / CREATE FILE EF (DF)): (PIN app1 (verify) OR PIN app2 (verify))', + 'AM 0x01 (READ/SEARCH (EF) / DELETE FILE child (DF)): SM CRT 0xB4', + 'command description 0x84 (D4)', + ]) { + const rm = fcpArrRuleParse(raw); + assert.strictEqual(rm.raw, raw, raw); + assert.strictEqual(fcpArrRuleEncode(rm), raw, raw); + } +}); + +test('fcpArrRule mutators re-encode the canonical line', () => { + let m = fcpArrRuleParse('AM 0x01 (READ/SEARCH (EF) / DELETE FILE child (DF)): always'); + m = fcpArrRuleToggleBit(m, 0x02, true); + assert.strictEqual(fcpArrRuleEncode(m), + 'AM 0x03 (UPDATE/ERASE (EF) / CREATE FILE EF (DF) + READ/SEARCH (EF) / DELETE FILE child (DF)): always'); + m = fcpArrRuleSetSc(m, 0, 'key:10'); // 10 = 0x0A = ADM1 + assert.ok(fcpArrRuleEncode(m).endsWith(': ADM1 (verify)'), fcpArrRuleEncode(m)); + m = fcpArrRuleSetUq(m, 0, 0x00); + assert.ok(fcpArrRuleEncode(m).endsWith(': ADM1 (verification not used)'), fcpArrRuleEncode(m)); + m = fcpArrRuleAddSc(m); + assert.ok(fcpArrRuleEncode(m).endsWith(': ADM1 (verification not used) AND always'), fcpArrRuleEncode(m)); + m = fcpArrRuleRemoveSc(m, 1); + assert.ok(fcpArrRuleEncode(m).endsWith(': ADM1 (verification not used)'), fcpArrRuleEncode(m)); + m = fcpArrRuleSetKind(m, 'resize'); + assert.strictEqual(fcpArrRuleEncode(m), 'RESIZE FILE (AM_DO 0x84, INS 0xD4): ADM1 (verification not used)'); + m = fcpArrRuleSetSc(m, 0, 'never'); + assert.strictEqual(fcpArrRuleEncode(m), 'RESIZE FILE (AM_DO 0x84, INS 0xD4): never'); + // a raw model is never modified + const raw = fcpArrRuleParse('command description 0x84 (D4)'); + assert.strictEqual(fcpArrRuleEncode(fcpArrRuleToggleBit(raw, 0x01, true)), 'command description 0x84 (D4)'); +}); + +test('the access-rules editor renders structured rows', () => { + global.t = s => s; + const r = { fciMode: 'exact', accessRules: { expected: [ + 'AM 0x01 (READ/SEARCH (EF) / DELETE FILE child (DF)): always', + 'RESIZE FILE (AM_DO 0x84, INS 0xD4): never', + 'AM 0x02 (UPDATE/ERASE (EF) / CREATE FILE EF (DF)): (PIN app1 (verify) OR PIN app2 (verify))', + ] } }; + const html = profilerRenderAccessRulesEditor(3, r); + // the READ bit is checked; the condition select shows always + assert.ok(html.includes('type="checkbox" checked onchange="profilerArrToggleBit(3,0,1,this.checked)"'), html); + assert.ok(html.includes(''), html); + assert.ok(html.includes(''), html); + // the RESIZE row uses the command form, not bits + assert.ok(html.includes(''), html); + // the template rule is raw and not editable + assert.ok(html.includes('raw: AM 0x02'), html); + assert.ok(!html.includes('profilerArrToggleBit(3,2'), html); + // the preview lines and the + rule button are present + assert.ok(html.includes('RESIZE FILE (AM_DO 0x84, INS 0xD4): never'), html); + assert.ok(html.includes('profilerArrAddRule(3)'), html); + // an empty rule shows the capture hint with no disabled controls + const empty = profilerRenderAccessRulesEditor(4, { fciMode: 'type_size', accessRules: null }); + assert.ok(empty.includes('profilerArrAddRule(4)'), empty); + assert.ok(!empty.includes(' disabled'), empty); + delete global.t; +}); + test('profilerResultAspects and the summary include Access rules', () => { const res = { checks: [ { label: 'exists', ok: true },