OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / scripts / generate-blind-client-composition-authority.mjs

225 lines · javascript ref:
#!/usr/bin/env node
/* eslint-disable quotes */
import fs from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import b4a from 'b4a'
import {
  CLIENT_COMPOSITION_SCHEMA_NAMES_V1,
  CLIENT_COMPOSITION_VECTOR_EXPECTATIONS_V1,
  encodeClientCompositionFormatAuthorityV1,
  verifyClientCompositionAuthorityV1
} from '../packages/blind-protocol/client-composition-authority.js'
import { buildClientCompositionFixtureVectorsV1 } from '../packages/blind-protocol/client-composition-runtime-vectors.js'
import {
  encodeVectorManifest,
  hashClientCompositionFormat,
  hashClientCompositionVectorSet
} from '../packages/blind-protocol/hashes.js'
import { SCHEMA_CATEGORY } from '../packages/blind-protocol/registry.js'
import {
  compileMasterSchemaCatalogForCategory,
  encodeSchemaCatalog
} from '../packages/blind-protocol/schema-meta.js'

const root = fileURLToPath(new URL('..', import.meta.url))
const check = process.argv.slice(2).includes('--check')
const unknownArguments = process.argv.slice(2).filter(argument => argument !== '--check')
if (unknownArguments.length > 0) throw new Error(`unknown arguments: ${unknownArguments.join(' ')}`)

const relative = Object.freeze({
  specification: 'docs/protocol/HIVERELAY-BLIND-CLIENT-COMPOSITION-V1.md',
  master: 'docs/protocol/BLIND-APP-AGNOSTIC-HIVERELAY-MASTER-SPEC.md',
  formatAuthority: 'packages/blind-protocol/hiverelay-blind-client-composition-format-v1.cenc',
  schemaCatalog: 'packages/blind-protocol/hiverelay-blind-client-composition-schema-catalog-v1.cenc',
  vectorManifest: 'packages/blind-protocol/hiverelay-blind-client-composition-vector-manifest-v1.cenc',
  metadata: 'packages/blind-protocol/hiverelay-blind-client-composition-authority-v1.json',
  generatedModule: 'packages/blind-protocol/client-composition-authority-generated.js',
  vectorRoot: 'packages/blind-protocol/vectors/client-composition'
})

const [specBytesSnapshot, masterBytesSnapshot] = await Promise.all([
  fs.readFile(path.join(root, relative.specification)),
  fs.readFile(path.join(root, relative.master))
])
const masterTextSnapshot = masterBytesSnapshot.toString('utf8')
if (!Buffer.from(masterTextSnapshot, 'utf8').equals(masterBytesSnapshot)) {
  throw new Error('stable master is not strict UTF-8')
}

function jsonBytes (value) {
  return b4a.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8')
}

function generatedModuleBytes (metadata) {
  const schemaNames = JSON.stringify(metadata.schemaNames, null, 2)
  const metadataFields = { ...metadata }
  delete metadataFields.schemaNames
  return b4a.from(`/* eslint-disable */\n// Generated by scripts/generate-blind-client-composition-authority.mjs. Do not edit.\n` +
    `export const CLIENT_COMPOSITION_AUTHORITY_V1 = Object.freeze({\n` +
    `  ...${JSON.stringify(metadataFields, null, 2)},\n` +
    `  schemaNames: Object.freeze(${schemaNames})\n` +
    `})\n\n` +
    `export const CLIENT_COMPOSITION_AUTHORITY_STATUS = Object.freeze({\n` +
    `  profile: 'client-composition-authority-v1',\n` +
    `  authorityArtifactPath: ${JSON.stringify(metadata.formatAuthorityArtifact)},\n` +
    `  schemaCatalogArtifactPath: ${JSON.stringify(metadata.schemaCatalogArtifact)},\n` +
    `  vectorManifestPath: ${JSON.stringify(metadata.vectorManifestArtifact)},\n` +
    `  metadataPath: ${JSON.stringify(relative.metadata)},\n` +
    `  schemaNames: Object.freeze(${schemaNames}),\n` +
    `  schemaCount: ${metadata.schemaCount},\n` +
    `  vectorCount: ${metadata.vectorCount},\n` +
    `  formatAuthorityPublished: true,\n` +
    `  vectorAuthorityPublished: true,\n` +
    `  releaseBlockers: Object.freeze([]),\n` +
    `  releaseReady: true\n` +
    `})\n\n` +
    `export function assertClientCompositionAuthorityReady () {\n` +
    `  if (CLIENT_COMPOSITION_AUTHORITY_STATUS.releaseReady) return CLIENT_COMPOSITION_AUTHORITY_STATUS\n` +
    `  const error = new Error('client-composition binary authority is incomplete')\n` +
    `  error.code = 'BLIND_CLIENT_COMPOSITION_AUTHORITY_INCOMPLETE'\n` +
    `  error.releaseBlockers = [...CLIENT_COMPOSITION_AUTHORITY_STATUS.releaseBlockers]\n` +
    `  throw error\n` +
    `}\n`, 'utf8')
}

function buildOutputs () {
  const compiled = compileMasterSchemaCatalogForCategory(masterTextSnapshot, SCHEMA_CATEGORY.CLIENT_EXAMPLE)
  const names = compiled.entries.map(entry => b4a.toString(entry.schemaName, 'ascii'))
  if (JSON.stringify(names) !== JSON.stringify(CLIENT_COMPOSITION_SCHEMA_NAMES_V1)) {
    throw new Error(`client-composition schema inventory drifted: ${names.join(',')}`)
  }
  const schemaCatalogBytes = encodeSchemaCatalog(compiled.entries, {
    minimum: CLIENT_COMPOSITION_SCHEMA_NAMES_V1.length,
    maximum: CLIENT_COMPOSITION_SCHEMA_NAMES_V1.length,
    name: 'client-composition schema catalog'
  })
  const formatAuthorityBytes = encodeClientCompositionFormatAuthorityV1(specBytesSnapshot, schemaCatalogBytes)
  const vectors = buildClientCompositionFixtureVectorsV1()
  vectors.set('registry/client-composition-schema-catalog-v1.cenc', b4a.from(schemaCatalogBytes))
  const expectedPaths = CLIENT_COMPOSITION_VECTOR_EXPECTATIONS_V1.map(entry => entry.path)
  const actualPaths = [...vectors.keys()].sort()
  if (JSON.stringify(actualPaths) !== JSON.stringify(expectedPaths)) {
    throw new Error(`client-composition vector inventory drifted: ${actualPaths.join(',')}`)
  }
  const vectorManifestBytes = encodeVectorManifest(
    [...vectors].map(([vectorPath, bytes]) => ({ path: vectorPath, bytes }))
  )
  const formatHash = hashClientCompositionFormat(formatAuthorityBytes)
  const vectorSetHash = hashClientCompositionVectorSet(vectorManifestBytes)
  verifyClientCompositionAuthorityV1({
    formatAuthorityBytes,
    specBytes: specBytesSnapshot,
    schemaCatalogBytes,
    vectorManifestBytes,
    vectors,
    expectedFormatHash: formatHash,
    expectedVectorSetHash: vectorSetHash
  })
  const metadata = Object.freeze({
    profile: 'client-composition-authority-v1',
    authorityVersion: 1,
    formatMajor: 1,
    formatMinor: 0,
    specificationArtifact: relative.specification,
    formatAuthorityArtifact: relative.formatAuthority,
    schemaCatalogArtifact: relative.schemaCatalog,
    vectorManifestArtifact: relative.vectorManifest,
    vectorRoot: relative.vectorRoot,
    formatHash: b4a.toString(formatHash, 'hex'),
    vectorSetHash: b4a.toString(vectorSetHash, 'hex'),
    schemaCount: CLIENT_COMPOSITION_SCHEMA_NAMES_V1.length,
    vectorCount: CLIENT_COMPOSITION_VECTOR_EXPECTATIONS_V1.length,
    schemaNames: CLIENT_COMPOSITION_SCHEMA_NAMES_V1
  })
  const outputs = new Map([
    [relative.formatAuthority, formatAuthorityBytes],
    [relative.schemaCatalog, schemaCatalogBytes],
    [relative.vectorManifest, vectorManifestBytes],
    [relative.metadata, jsonBytes(metadata)],
    [relative.generatedModule, generatedModuleBytes(metadata)]
  ])
  for (const [vectorPath, bytes] of vectors) {
    outputs.set(path.posix.join(relative.vectorRoot, vectorPath), bytes)
  }
  return { outputs, metadata }
}

function assertDeterministic (first, second) {
  if (first.outputs.size !== second.outputs.size) throw new Error('client-composition generation is nondeterministic')
  for (const [file, bytes] of first.outputs) {
    if (!second.outputs.has(file) || !b4a.equals(bytes, second.outputs.get(file))) {
      throw new Error(`client-composition generation is nondeterministic for ${file}`)
    }
  }
}

async function listFiles (directory, prefix = '') {
  const output = []
  let entries
  try {
    entries = await fs.readdir(directory, { withFileTypes: true })
  } catch (error) {
    if (error.code === 'ENOENT') return output
    throw error
  }
  for (const entry of entries) {
    const name = prefix === '' ? entry.name : `${prefix}/${entry.name}`
    if (entry.isDirectory()) output.push(...await listFiles(path.join(directory, entry.name), name))
    else if (entry.isFile()) output.push(name)
    else throw new Error(`client-composition vector tree contains a non-file entry: ${name}`)
  }
  return output.sort()
}

async function checkOutputs (outputs) {
  for (const [file, expected] of outputs) {
    let actual
    try {
      actual = await fs.readFile(path.join(root, file))
    } catch (error) {
      if (error.code === 'ENOENT') throw new Error(`missing generated client-composition artifact: ${file}`)
      throw error
    }
    if (!b4a.equals(actual, expected)) throw new Error(`stale generated client-composition artifact: ${file}`)
  }
  const actualVectorFiles = await listFiles(path.join(root, relative.vectorRoot))
  const expectedVectorFiles = [...outputs.keys()]
    .filter(file => file.startsWith(`${relative.vectorRoot}/`))
    .map(file => file.slice(relative.vectorRoot.length + 1))
    .sort()
  if (JSON.stringify(actualVectorFiles) !== JSON.stringify(expectedVectorFiles)) {
    throw new Error('client-composition vector directory has missing or unexpected files')
  }
}

async function writeOutputs (outputs) {
  const temporaryFiles = []
  try {
    let ordinal = 0
    for (const [file, bytes] of outputs) {
      const target = path.join(root, file)
      await fs.mkdir(path.dirname(target), { recursive: true })
      const temporary = `${target}.tmp-${process.pid}-${ordinal++}`
      temporaryFiles.push(temporary)
      await fs.writeFile(temporary, bytes, { flag: 'wx' })
      await fs.rename(temporary, target)
      temporaryFiles.pop()
    }
  } finally {
    await Promise.all(temporaryFiles.map(file => fs.rm(file, { force: true })))
  }
  await checkOutputs(outputs)
}

const first = buildOutputs()
const second = buildOutputs()
assertDeterministic(first, second)
if (check) await checkOutputs(first.outputs)
else await writeOutputs(first.outputs)

process.stdout.write(`${check ? 'verified' : 'generated'} client-composition authority ` +
  `${first.metadata.formatHash}/${first.metadata.vectorSetHash} ` +
  `(${first.metadata.schemaCount} schemas, ${first.metadata.vectorCount} vectors)\n`)