aere-proof-of-software/tools/proof-of-software/test/model.test.mjs
Aere Network 065f84e34a Aere Proof of Software 1.4.0: an attestation of what was built, from what, by whom and when, verifiable without trusting Aere Network
Every artifact's SHA-256 and size, the SBOM's digest, the git commit and tree of the source, a hybrid signature (classical + ML-DSA,
both required) and, when notarized, a first-seen time on Aere Network covered by the validators' post-quantum certificate. verify
recomputes everything from the files; --rebuild-from repeats an npm pack build from a clone the verifier chose; the SBOM is
re-derived from the committed package-lock.json or, new in 1.4.0, from the committed go.mod and go.sum; --signer requires the
signing keys you expect (1.4.0); a developer credential binds the keys to a person, judged against a trust root you choose. The
builder's declared date can only accuse, never acquit (1.4.0). Includes the GitHub Action and the hybrid signature library it uses.

Laid out as in the development repository (tools/proof-of-software/, sdk-pq-sign/, sdk/) so that nothing is rewritten for
publication. Tests and negative controls measured on 2026-09-29 are listed in README.md.
2026-09-29 22:46:57 +03:00

103 lines
8.0 KiB
JavaScript

// Proof of Software 1.2.0, proveninta modelelor AI: ML-BOM CycloneDX 1.6, nume relative la --base, hash in flux. Fiecare afirmatie cu
// perechea ei negativa. Offline.
// node aerenew/tools/proof-of-software/test/model.test.mjs
import assert from 'node:assert';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
let treceri = 0; const esecuri = [];
async function test(nume, fn) { try { await fn(); treceri++; console.log(' OK ' + nume); } catch (e) { esecuri.push(nume); console.log(' ESEC ' + nume + ' -> ' + String(e.message || e).slice(0, 240)); } }
// pragul fluxului coborat la 1 KiB INAINTE de import, ca un fisier de 3 MiB sa treaca prin ramura in flux
process.env.AERE_POS_FLUX_PESTE = '1024';
const POS = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'pos.mjs');
const { modelBom, sha256File, attest, verify, buildStatement } = await import('../pos.mjs');
const sha = (b) => '0x' + crypto.createHash('sha256').update(b).digest('hex');
const D = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-model-'));
const M = path.join(D, 'tiny-lm');
fs.mkdirSync(path.join(M, 'tokenizer'), { recursive: true });
fs.mkdirSync(path.join(M, '.cache'));
const shard1 = crypto.randomBytes(3 * 1024 * 1024 + 17), shard2 = crypto.randomBytes(200 * 1024);
fs.writeFileSync(path.join(M, 'config.json'), JSON.stringify({ architectures: ['TinyLMForCausalLM'], model_type: 'tiny_lm', hidden_size: 64 }));
fs.writeFileSync(path.join(M, 'model-00001-of-00002.safetensors'), shard1);
fs.writeFileSync(path.join(M, 'model-00002-of-00002.safetensors'), shard2);
fs.writeFileSync(path.join(M, 'tokenizer', 'config.json'), JSON.stringify({ vocab_size: 512 }));
fs.writeFileSync(path.join(M, 'README.md'), '# tiny-lm\n');
fs.writeFileSync(path.join(M, '.cache', 'ignored.bin'), 'not part of the model');
const DATE = path.join(D, 'train.jsonl'); fs.writeFileSync(DATE, '{"text":"hello"}\n{"text":"world"}\n');
await test('hash in flux (fisier de 3 MiB peste pragul de 1 KiB) = hash-ul intregului fisier, calculat independent', () => {
assert.strictEqual(sha256File(path.join(M, 'model-00001-of-00002.safetensors')), sha(shard1));
assert.strictEqual(sha256File(path.join(M, 'model-00002-of-00002.safetensors')), sha(shard2));
});
const { bom, files, manifestSha256 } = modelBom({ dir: M, name: 'tiny-lm', version: '0.1', task: 'text-generation', datasets: ['train=' + DATE] });
await test('ML-BOM CycloneDX 1.6: componenta machine-learning-model, arhitectura din config.json, fiecare fisier cu SHA-256 corect, dosarul ascuns sarit', () => {
assert.deepStrictEqual([bom.bomFormat, bom.specVersion, bom.version], ['CycloneDX', '1.6', 1]);
assert.match(bom.serialNumber, /^urn:uuid:[0-9a-f-]{36}$/);
const m = bom.metadata.component;
assert.deepStrictEqual([m.type, m['bom-ref'], m.name, m.version], ['machine-learning-model', 'model', 'tiny-lm', '0.1']);
assert.deepStrictEqual(m.modelCard.modelParameters, { task: 'text-generation', architectureFamily: 'tiny_lm', modelArchitecture: 'TinyLMForCausalLM', datasets: [{ ref: 'data:train' }] });
const fisiere = bom.components.filter((c) => c.type === 'file');
assert.deepStrictEqual(fisiere.map((c) => c.name), ['README.md', 'config.json', 'model-00001-of-00002.safetensors', 'model-00002-of-00002.safetensors', 'tokenizer/config.json']);
assert.strictEqual(fisiere.find((c) => c.name === 'model-00001-of-00002.safetensors').hashes[0].content, sha(shard1).slice(2));
assert.ok(!bom.components.some((c) => /ignored/.test(c.name)), 'dosarul ascuns .cache a intrat in model');
const refs = bom.components.map((c) => c['bom-ref']); assert.strictEqual(new Set(refs).size, refs.length, 'bom-ref duplicat');
assert.deepStrictEqual(bom.dependencies[0].dependsOn.sort(), refs.sort());
const d = bom.components.find((c) => c.type === 'data'); assert.strictEqual(d.hashes[0].content, sha(fs.readFileSync(DATE)).slice(2));
assert.strictEqual(files.length, 5);
});
await test('digestul manifestului se reface din regula scrisa in BOM, si se schimba la un octet atins (pereche negativa)', () => {
const linii = bom.components.filter((c) => c.type === 'file').map((c) => `${c.name}\t0x${c.hashes[0].content}\t${c.properties[0].value}\n`).join('');
assert.strictEqual(sha(Buffer.from(linii, 'utf8')), manifestSha256);
assert.strictEqual(bom.metadata.component.properties.find((p) => p.name === 'aere:manifestSha256').value, manifestSha256);
const b = Buffer.from(shard2); b[7] ^= 1; const alt = path.join(D, 'alt'); fs.cpSync(M, alt, { recursive: true });
fs.writeFileSync(path.join(alt, 'model-00002-of-00002.safetensors'), b);
assert.notStrictEqual(modelBom({ dir: alt }).manifestSha256, manifestSha256);
});
await test('fara --base doua config.json se ciocnesc (refuz cu indicatia --base); cu --base au nume relative distincte', () => {
assert.throws(() => buildStatement({ artifacts: files, cwd: D }), /share the name .*use --base/);
const s = buildStatement({ artifacts: files, base: M, cwd: D });
assert.strictEqual(s.artifactNames, 'relative-path');
assert.deepStrictEqual(s.artifacts.map((a) => a.name).sort(), ['README.md', 'config.json', 'model-00001-of-00002.safetensors', 'model-00002-of-00002.safetensors', 'tokenizer/config.json']);
assert.throws(() => buildStatement({ artifacts: [DATE], base: M, cwd: D }), /not inside --base/);
});
const BOMF = path.join(D, 'mlbom.json'); fs.writeFileSync(BOMF, JSON.stringify(bom));
const att = await attest({ artifacts: files, base: M, sbom: BOMF, name: 'tiny-lm', version: '0.1', cwd: D });
await test('atestare cu --base + ML-BOM, verificare cu --base: VALID', async () => {
const r = await verify(att, [...files, BOMF], { base: M });
assert.ok(r.valid, JSON.stringify(r.checks.filter((c) => c.pass === false)));
assert.ok(r.checks.some((c) => c.name === 'sbom mlbom.json: sha256 matches' && c.pass === true));
});
await test('un octet schimbat intr-o greutate: INVALID, cu numele fisierului; o greutate lipsa: INVALID (lipsa nu e valida)', async () => {
const cop = path.join(D, 'copie'); fs.cpSync(M, cop, { recursive: true });
const b = Buffer.from(shard1); b[b.length - 1] ^= 1; fs.writeFileSync(path.join(cop, 'model-00001-of-00002.safetensors'), b);
const fis = files.map((p) => path.join(cop, path.relative(M, p)));
const r = await verify(att, [...fis, BOMF], { base: cop });
assert.strictEqual(r.valid, false);
assert.ok(r.checks.some((c) => c.pass === false && c.name === 'artifact model-00001-of-00002.safetensors: sha256 and size match'), JSON.stringify(r.checks.filter((c) => c.pass === false)));
const r2 = await verify(att, [...files.filter((p) => !/00002/.test(p)), BOMF], { base: M });
assert.strictEqual(r2.valid, false); assert.ok(r2.checks.some((c) => c.pass === false && /00002.*present/.test(c.name)));
});
await test('o atestare cu nume relative verificata FARA --base: INVALID cu motivul numit (nu potrivire pe bazenume)', async () => {
const r = await verify(att, [...files, BOMF], {});
assert.strictEqual(r.valid, false); assert.ok(r.checks.some((c) => c.pass === false && /pass --base/.test(c.detail)), JSON.stringify(r.checks.filter((c) => c.pass === false)).slice(0, 300));
});
await test('CLI: model-bom scrie fisierul si numara fisierele; un --dataset fara NUME=FISIER e refuzat', () => {
const out = path.join(D, 'cli-mlbom.json');
const t = execFileSync(process.execPath, [POS, 'model-bom', '--model-dir', M, '--out', out, '--name', 'tiny-lm'], { encoding: 'utf8' });
assert.match(t, /ML-BOM written to .*: 5 file\(s\), 0 dataset\(s\)/);
assert.strictEqual(JSON.parse(fs.readFileSync(out, 'utf8')).metadata.component.type, 'machine-learning-model');
let refuz = ''; try { execFileSync(process.execPath, [POS, 'model-bom', '--model-dir', M, '--out', out, '--dataset', 'fara-egal'], { encoding: 'utf8', stdio: 'pipe' }); } catch (e) { refuz = String(e.stderr); }
assert.match(refuz, /--dataset takes NAME=FILE/);
});
fs.rmSync(D, { recursive: true, force: true });
console.log(`\n${treceri} treceri, ${esecuri.length} esecuri`);
process.exitCode = esecuri.length ? 1 : 0;