diff --git a/.github/scripts/build-site.sh b/.github/scripts/build-site.sh index f95e715..be6bd89 100755 --- a/.github/scripts/build-site.sh +++ b/.github/scripts/build-site.sh @@ -9,6 +9,9 @@ set -euo pipefail shopt -s nullglob +echo "== Validation des index de statut (fail-fast) ==" +node .github/scripts/validate-members.mjs + echo "== Émission des credentials (déterministe) ==" count=0 for d in data/members/*/; do diff --git a/.github/scripts/lib/member-indices.mjs b/.github/scripts/lib/member-indices.mjs new file mode 100644 index 0000000..33d6513 --- /dev/null +++ b/.github/scripts/lib/member-indices.mjs @@ -0,0 +1,32 @@ +// Intégrité des index de statut (Bitstring Status List). Fonction pure, testable. +// Invariants tenus par l'intake mais vérifiés à l'émission (défense en profondeur) : +// - chaque status_index est un entier >= 0 ; +// - deux membres actifs ne partagent JAMAIS un index (sinon ils partagent un bit de +// statut : révoquer l'un révoquerait l'autre) ; +// - aucun membre actif n'est sur un index présent dans le registre de révocation +// (un index révoqué est brûlé à vie -> le badge naîtrait « révoqué »). + +/** + * @param {Array<{github?: string, status_index: any}>} members + * @param {Array|Set} revoked index révoqués (registre) + * @returns {{ ok: true } | { ok: false, error: string }} + */ +export function checkStatusIndices(members, revoked = []) { + const revokedSet = revoked instanceof Set ? revoked : new Set([...revoked].map(Number)); + const seen = new Map(); + for (const m of members) { + const who = m.github ? `@${m.github}` : '(membre sans handle)'; + const idx = Number(m.status_index); + if (!Number.isInteger(idx) || idx < 0) { + return { ok: false, error: `status_index invalide pour ${who} : ${m.status_index}` }; + } + if (seen.has(idx)) { + return { ok: false, error: `status_index ${idx} partagé par @${seen.get(idx)} et ${who} — deux membres au même bit de statut` }; + } + seen.set(idx, m.github); + if (revokedSet.has(idx)) { + return { ok: false, error: `${who} est à l'index ${idx}, révoqué à vie — son badge naîtrait « révoqué »` }; + } + } + return { ok: true }; +} diff --git a/.github/scripts/lib/member-indices.test.mjs b/.github/scripts/lib/member-indices.test.mjs new file mode 100644 index 0000000..bb40df9 --- /dev/null +++ b/.github/scripts/lib/member-indices.test.mjs @@ -0,0 +1,37 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { checkStatusIndices } from './member-indices.mjs'; + +describe('checkStatusIndices', () => { + it('accepte des index distincts hors registre', () => { + const r = checkStatusIndices( + [{ github: 'a', status_index: 1 }, { github: 'b', status_index: 2 }], + [0], + ); + assert.deepEqual(r, { ok: true }); + }); + + it('rejette deux membres au même index', () => { + const r = checkStatusIndices( + [{ github: 'a', status_index: 3 }, { github: 'b', status_index: 3 }], + [], + ); + assert.equal(r.ok, false); + assert.match(r.error, /partagé par @a et @b/); + }); + + it('rejette un membre actif sur un index révoqué (le bug blafourcade)', () => { + const r = checkStatusIndices([{ github: 'blafourcade', status_index: 0 }], [0]); + assert.equal(r.ok, false); + assert.match(r.error, /révoqué à vie/); + }); + + it('rejette un status_index non entier ou négatif', () => { + assert.equal(checkStatusIndices([{ github: 'a', status_index: 'x' }]).ok, false); + assert.equal(checkStatusIndices([{ github: 'a', status_index: -1 }]).ok, false); + }); + + it('accepte une liste vide', () => { + assert.deepEqual(checkStatusIndices([], [0, 1, 2]), { ok: true }); + }); +}); diff --git a/.github/scripts/validate-members.mjs b/.github/scripts/validate-members.mjs new file mode 100644 index 0000000..1e5ed64 --- /dev/null +++ b/.github/scripts/validate-members.mjs @@ -0,0 +1,19 @@ +// Couche I/O : garde fail-fast à l'émission. Lit tous les records + le registre de +// révocation, vérifie l'intégrité des index de statut, et échoue bruyamment (exit 1) +// avant toute signature/déploiement. Empêche de publier deux membres au même bit de +// statut, ou un membre neuf sur un index déjà révoqué. Logique pure dans lib/. +import { readFileSync, existsSync } from 'node:fs'; +import { parseMemberYaml } from './lib/credential.mjs'; +import { listMemberHandles, recordPathFor } from './lib/member-paths.mjs'; +import { LEDGER_PATH, parseLedger } from './lib/revocation.mjs'; +import { checkStatusIndices } from './lib/member-indices.mjs'; + +const members = listMemberHandles().map((h) => parseMemberYaml(readFileSync(recordPathFor(h), 'utf8'))); +const revoked = existsSync(LEDGER_PATH) ? parseLedger(readFileSync(LEDGER_PATH, 'utf8')) : []; + +const result = checkStatusIndices(members, revoked); +if (!result.ok) { + console.error(`::error::Intégrité des index de statut violée : ${result.error}`); + process.exit(1); +} +console.log(`Indices de statut OK : ${members.length} membre(s), distincts et hors registre de révocation.`);