#!/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 ] [--supravietuitori ] [--json ] // 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=, 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=); 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;