aere-quantum/device-identity/mutanti-device.mjs

113 lines
12 KiB
JavaScript

#!/usr/bin/env node
// mutanti-device.mjs: a SEARCH for conditions of the verifier that no row of the probe depends on. The plantings of the negative
// control (plantari-device.mjs) are written by hand, each with the rows it must turn red; a list written by hand stays behind the
// code. This derives its mutants from the code itself, with a parser, and runs the probe on each:
// a && b, a || b -> a ; b (each side of every logical operator taken out)
// a === b, a !== b -> true ; false (the comparison no longer decides)
// a < b, <=, >, >= -> the edge moved (< to <=, <= to <, ...) ; true
// x - sk, x + sk -> x (a tolerance removed)
// a call that answers yes or no (aceeasi, numai, verifica, Number.isInteger, includes, test, some, every, ...) -> true ; false
// A mutant is KILLED when a row of the probe goes red or the probe can no longer build its world; it SURVIVES when every row stays
// green. A survivor is either a condition without a row (a row is missing) or a change that cannot be observed (declared in
// ECHIVALENTI below, with the reason).
//
// WHAT IS SEARCHED: verifyDevice and everything it calls in device.mjs. NOT searched: the producers and the key files (PRODUCATORI
// below: the functions that WRITE links). What a producer refuses to write is a convenience for its caller, not a guarantee to a
// verifier, which must assume links written by other tools; rows D01 and D02 of the probe measure the refusals the README names.
// The probe runs in its fast mode (--repede): the rows that start processes are not run, so the conditions of device-cli.mjs are
// not searched either (the plantings written by hand cover the command line).
//
// node mutanti-device.mjs [--paralel N] [--doar <text in the mutant's description>] [--supravietuitori <json of an earlier run>] [--json <file>]
// exit 0: no survivor outside ECHIVALENTI and every entry of ECHIVALENTI still names a survivor; 1: otherwise; 2: NEMASURAT
// It needs the `acorn` parser, which is NOT a dependency of the verifier: AERE_ACORN=<directory of the acorn package>, or a copy
// Node can resolve from here. Without it nothing is measured (exit 2).
import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { createRequire } from 'node:module';
const AICI = path.dirname(fileURLToPath(import.meta.url)); const NUME = path.basename(AICI); const M = 'device.mjs';
const arg = (n) => { const i = process.argv.indexOf(n); return i >= 0 ? process.argv[i + 1] : null; };
let acorn = null;
for (const c of [process.env.AERE_ACORN, 'acorn'].filter(Boolean)) { try { acorn = createRequire(import.meta.url)(c); break; } catch { /* the next one */ } }
if (!acorn || typeof acorn.parse !== 'function') { console.log('NEMASURAT: the acorn parser was not found (AERE_ACORN=<directory of the acorn package>); it is needed only by this search, not by the verifier'); process.exit(2); }
/** The top-level names of device.mjs that WRITE links or key files: their conditions are not searched (see the header). */
export const PRODUCATORI = ['dinCheie', 'generateDeviceKey', 'exportDeviceKey', 'importDeviceKey', 'semneaza', 'cine', 'signLink', 'verifyLink', 'signAttestation', 'manufacture', 'releaseFirmware', 'firmwarePolicy',
'ownershipHead', 'transferOwnership', 'authorize', 'statusRecord', 'attest', 'bundle', 'proofOfDevice', 'iso'];
/**
* Survivors that are not missing rows: [text that the mutant's description contains (without its line number), why no row can see it].
* Every entry must still name at least one survivor (an entry that names none is stale and fails the run), and a survivor that no
* entry names fails it too.
*/
export const ECHIVALENTI = [
['REGULI.includes(rule) -> true', 'an assertion about the verifier itself: it fires only if the verifier names a rule it does not declare; rows A10 and E01 check from outside that every rule a row names is declared'],
['NEJUDECATE.includes(cauza) -> true', 'the same assertion for the causes of not judging; row S25 checks from outside that every cause listed is declared'],
];
const SRC = fs.readFileSync(path.join(AICI, M), 'utf8'); const AST = acorn.parse(SRC, { ecmaVersion: 'latest', sourceType: 'module' });
const linia = (poz) => SRC.slice(0, poz).split('\n').length; const text = (n) => SRC.slice(n.start, n.end);
const scurt = (s) => (s.length > 70 ? s.slice(0, 67) + '...' : s).replace(/\s+/g, ' ');
const DA_NU = new Set(['aceeasi', 'numai', 'verifica', 'verifyText', 'hibridCanonic', 'b64canonic', 'softwareCovers', 'ob', 'sir', 'statusBit', 'deFel', 'eBun']);
const DA_NU_METODE = new Set(['isInteger', 'isArray', 'includes', 'test', 'some', 'every', 'has', 'isNaN']);
const mutanti = []; let inAfara = 0, cauta = true;
const adauga = (n, cu, ce) => { if (!cauta) { inAfara++; return; } const cheie = `${ce}: ${scurt(text(n))} -> ${scurt(cu)}`; mutanti.push({ start: n.start, end: n.end, cu, cheie, descriere: `${M}:${linia(n.start)} ${cheie}` }); };
function mergi(n, parinte) {
if (!n || typeof n.type !== 'string') return;
if (n.type === 'LogicalExpression' && (n.operator === '&&' || n.operator === '||')) { adauga(n, '(' + text(n.left) + ')', `${n.operator} without its right side`); adauga(n, '(' + text(n.right) + ')', `${n.operator} without its left side`); }
if (n.type === 'BinaryExpression') {
const o = n.operator;
if (o === '===' || o === '!==') { adauga(n, 'true', `${o} always true`); adauga(n, 'false', `${o} always false`); }
const mutat = { '<': '<=', '<=': '<', '>': '>=', '>=': '>' }[o];
if (mutat) { adauga(n, '(' + text(n.left) + ' ' + mutat + ' ' + text(n.right) + ')', `the edge of ${o} moved`); adauga(n, 'true', `${o} always true`); }
if ((o === '+' || o === '-') && n.right.type === 'Identifier' && n.right.name === 'sk') adauga(n, '(' + text(n.left) + ')', 'the tolerance removed');
}
if (n.type === 'CallExpression') {
const c = n.callee; const nume = c.type === 'Identifier' ? c.name : c.type === 'MemberExpression' && c.property.type === 'Identifier' ? '.' + c.property.name : null;
if (nume && (DA_NU.has(nume) || (nume[0] === '.' && DA_NU_METODE.has(nume.slice(1)))) && !(parinte && parinte.type === 'ExpressionStatement')) { adauga(n, 'true', `${nume}() always yes`); adauga(n, 'false', `${nume}() always no`); }
}
for (const k of Object.keys(n)) { const v = n[k]; if (k === 'start' || k === 'end' || k === 'type') continue; if (Array.isArray(v)) v.forEach((x) => mergi(x, n)); else if (v && typeof v === 'object') mergi(v, n); }
}
// each top-level statement: searched unless every name it declares is a producer
const numeDeclarate = (st) => { const d = st.type === 'ExportNamedDeclaration' && st.declaration ? st.declaration : st; return d.type === 'FunctionDeclaration' || d.type === 'ClassDeclaration' ? [d.id.name] : d.type === 'VariableDeclaration' ? d.declarations.map((x) => (x.id.type === 'Identifier' ? x.id.name : '?')) : []; };
const vazute = new Set();
for (const st of AST.body) { const nume = numeDeclarate(st); nume.forEach((x) => vazute.add(x)); cauta = !(nume.length > 0 && nume.every((x) => PRODUCATORI.includes(x))); mergi(st, null); }
const lipsa = PRODUCATORI.filter((x) => !vazute.has(x));
if (lipsa.length) { console.log(`NEMASURAT: PRODUCATORI names what ${M} no longer declares at its top level: ${lipsa.join(', ')}`); process.exit(2); }
const doar = arg('--doar'); const dinRulare = arg('--supravietuitori') ? new Set(JSON.parse(fs.readFileSync(arg('--supravietuitori'), 'utf8')).mutanti.filter((m) => m.ucis === null).map((m) => m.cheie || m.descriere.replace(/^\S+ /, ''))) : null;
const alesi = mutanti.filter((m) => (!doar || m.descriere.includes(doar)) && (!dinRulare || dinRulare.has(m.cheie))); const filtrat = !!doar || !!dinRulare;
console.log(`${mutanti.length} mutants derived from the verifier in ${M}; ${inAfara} more lie in the producers and the key files, which are NOT searched` + (filtrat ? `\nFILTERED (${doar ? '--doar ' + doar : '--supravietuitori'}): ${alesi.length} run; the others are not measured by this run` : ''));
// one copy per worker: this directory and the files it imports from its neighbours (as the negative control does); each mutant is
// written over device.mjs of the worker's copy
const T = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-device-mutanti-'));
function copie(nume) {
const R = path.join(T, nume); const d = path.join(R, NUME); fs.mkdirSync(d, { recursive: true });
for (const e of fs.readdirSync(AICI, { withFileTypes: true })) { if (e.isDirectory()) { if (e.name === 'fixturi' || e.name === 'dovezi-ancora-28001') fs.cpSync(path.join(AICI, e.name), path.join(d, e.name), { recursive: true }); } else if (/\.mjs$/.test(e.name)) fs.copyFileSync(path.join(AICI, e.name), path.join(d, e.name)); }
const vecini = new Set(); for (const f of [M, 'device-cli.mjs', 'proba-device.mjs']) for (const m of fs.readFileSync(path.join(AICI, f), 'utf8').matchAll(/from '\.\.\/([^']+)'/g)) vecini.add(m[1]);
for (const v of vecini) { const q = path.join(R, v); fs.mkdirSync(path.dirname(q), { recursive: true }); fs.copyFileSync(path.join(AICI, '..', v), q); }
return d;
}
const proba = (d) => new Promise((gata) => {
const c = spawn(process.execPath, [path.join(d, 'proba-device.mjs'), '--repede', '--json'], { stdio: ['ignore', 'pipe', 'pipe'] }); let out = '', err = ''; const t = setTimeout(() => c.kill(), 120000);
c.stdout.on('data', (x) => { out += x; }); c.stderr.on('data', (x) => { err += x; });
c.on('close', () => { clearTimeout(t); try { gata(JSON.parse(out.trim().split('\n').slice(-1)[0])); } catch { gata({ cazut: (err || out).trim().split('\n').slice(-1)[0].slice(0, 160) }); } });
});
let stare = 0;
try {
const dMartor = copie('martor'); const martor = await proba(dMartor);
if (martor.cazut || !Array.isArray(martor.rows) || martor.rosii) { console.log('NEMASURAT: the probe on the untouched copy is not green: ' + (martor.cazut || martor.rosii + ' red rows')); process.exit(2); }
console.log(`the untouched copy: ${martor.verzi} rows green (fast mode)`);
const n = Math.max(1, Number(arg('--paralel')) || Math.min(8, os.cpus().length)); const rez = new Array(alesi.length); let k = 0, facuti = 0;
await Promise.all(Array.from({ length: Math.min(n, alesi.length) }, async (_, w) => { const d = copie('w' + w); while (k < alesi.length) { const i = k++; const m = alesi[i];
fs.writeFileSync(path.join(d, M), SRC.slice(0, m.start) + m.cu + SRC.slice(m.end)); const r = await proba(d);
rez[i] = r.cazut ? { ucis: 'the probe could not run: ' + r.cazut } : r.rosii ? { ucis: r.rows.filter((x) => x.state === 'ROSU').map((x) => x.id) } : { ucis: null };
if (++facuti % 100 === 0) console.error(` ... ${facuti} of ${alesi.length}`); } }));
const supravietuitori = alesi.filter((m, i) => rez[i].ucis === null); const declarati = (m) => ECHIVALENTI.find(([t]) => m.cheie.includes(t));
const nedeclarati = supravietuitori.filter((m) => !declarati(m)), invechite = filtrat ? [] : ECHIVALENTI.filter(([t]) => !supravietuitori.some((m) => m.cheie.includes(t)));
const prinCadere = rez.filter((r) => typeof r.ucis === 'string').length;
console.log(`\nKILLED ${alesi.length - supravietuitori.length} (${prinCadere} of them because the probe could no longer build its world), SURVIVED ${supravietuitori.length}: ${supravietuitori.length - nedeclarati.length} declared equivalent, ${nedeclarati.length} NOT declared`);
for (const m of nedeclarati) console.log(' SURVIVES ' + m.descriere);
for (const [t] of invechite) console.log(' STALE an entry of ECHIVALENTI names no survivor any more: ' + t);
if (arg('--json')) fs.writeFileSync(arg('--json'), JSON.stringify({ mutanti: alesi.map((m, i) => ({ descriere: m.descriere, cheie: m.cheie, ucis: rez[i].ucis })) }, null, 1) + '\n');
stare = nedeclarati.length || invechite.length ? 1 : filtrat ? 2 : 0;
} finally { fs.rmSync(T, { recursive: true, force: true }); }
process.exitCode = stare;