v3.25.4: the per-operation Contains match for the profile access rules

The access-rules check was exact-only: every listed decoded line had to be
on the card and every card rule listed (missing + extra both fail), so
"READ/WRITE must be always, the RESIZE condition is irrelevant" could not
be expressed.

A rule may now carry accessRules.mode: 'contains' (the editor's Match
selector: Exact default / Contains; the collapsed header badges
"· per operation"): every listed rule is matched per operation - each AM
bit / command entry (RESIZE/INCREASE) must be covered by a card rule with
the same SC list, whatever AM grouping the card uses
(fcpArrRuleOps/fcpArrRuleOpCovered/fcpArrRuleSatisfied) - and the rules not
listed are not checked (no extra side).  A form outside the supported
subset (a raw template form, a labelled pairs-form line) compares by exact
string; the proprietary (b8) form stays whole.  The check carries mode and
the report notes "compared per operation; other card rules are ignored".
Validation accepts mode in {exact, contains}; the capture keeps writing no
mode (exact).

Docs: help EN/RU.  Tests: the per-op matcher, the contains/exact verdicts
(the combined-AM grouping case, a condition mismatch, the RESIZE condition,
not-listed ignored), the editor selector/badge, the validation, the report
note.  886 frontend + 873 Python; sw cache simple-v428.
This commit is contained in:
2026-10-11 16:18:15 +03:00
parent 1aa24507fc
commit 2a58cfb235
7 changed files with 197 additions and 13 deletions
+86 -7
View File
@@ -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 = '<div class="flex items-center justify-between mb-1 mt-1">';
if (on) {
html += '<button onclick="profilerArrToggleOpen(' + i + ')" class="text-xs text-gray-600 dark:text-slate-400 hover:text-gray-800 dark:hover:text-slate-200">' +
esc((open ? '\u25be ' : '\u25b8 ') + t('Check access rules') + (lines.length ? ' (' + lines.length + ')' : '')) + '</button>';
esc((open ? '\u25be ' : '\u25b8 ') + t('Check access rules') + (lines.length ? ' (' + lines.length + ')' : '') +
(contains ? ' \u00b7 ' + t('per operation') : '')) + '</button>';
} else {
html += '<span class="text-xs text-gray-600 dark:text-slate-400">' + esc(t('Check access rules')) + '</span>';
}
html += '<div class="flex items-center gap-3">';
if (on && lines.length) {
html += '<label class="text-xs flex items-center gap-1" title="' + esc(t('Exact: the card rules must equal the listed rules. Contains: each listed rule must be granted per operation; other card rules are ignored.')) + '">' +
esc(t('Match')) + ' <select onchange="profilerSetAccessRulesMode(' + i + ',this.value)" class="text-xs border border-gray-300 dark:border-slate-600 rounded px-1 py-0.5 dark:bg-slate-800">' +
'<option value="exact"' + (contains ? '' : ' selected') + '>' + esc(t('Exact')) + '</option>' +
'<option value="contains"' + (contains ? ' selected' : '') + '>' + esc(t('Contains (per operation)')) + '</option></select></label>';
}
html += '<label class="text-xs flex items-center gap-1"><input type="radio" name="profiler-ar-' + i + '" value="none"' + (!on ? ' checked' : '') + ' onchange="profilerSetAccessRules(' + i + ',false)"> ' + esc(t('None')) + '</label>';
html += '<label class="text-xs flex items-center gap-1"><input type="radio" name="profiler-ar-' + i + '" value="check"' + (on ? ' checked' : '') + ' onchange="profilerSetAccessRules(' + i + ',true)"> ' + esc(t('Check')) + '</label>';
html += '</div></div>';
@@ -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 = '<div class="mt-1 pl-2">';
html += '<div class="text-xs text-red-600 font-mono">' + esc(t('Access rules')) + '</div>';
if (c.mode === 'contains') {
html += '<div class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">' +
esc(t('Compared per operation; other card rules are ignored')) + '</div>';
}
if (c.unresolved) {
html += '<div class="text-xs text-yellow-600 mt-0.5">' +
esc(t('the EF.ARR reference could not be resolved (no cached EF.ARR or the record is out of range)')) + '</div>';
@@ -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 или запись вне диапазона)',