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.
103 lines
8.0 KiB
JavaScript
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;
|