OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / scripts / resolve-public-hive-gateway-release.mjs

302 lines · javascript ref:
#!/usr/bin/env node

import { createHash } from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import { spawnSync } from 'node:child_process'
import {
  normalizePublicHiveGatewayReleaseManifest,
  PUBLIC_HIVE_GATEWAY_RELEASE_MANIFEST_PATH,
  PUBLIC_HIVE_GATEWAY_RELEASE_SCHEMA
} from './lib/public-hive-gateway-release-manifest.mjs'

const MAX_MANIFEST_BYTES = 2 * 1024 * 1024
const RELEASE_TAG_PATTERN = /^v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/
const CHANNELS = new Set(['canary', 'stable', 'both', 'none'])
const FORMATS = new Set(['json', 'github'])
const GIT_PROGRAM = '/usr/bin/git'
const UNSAFE_GIT_ENV = new Set([
  'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_COMMON_DIR', 'GIT_CONFIG',
  'GIT_CONFIG_COUNT', 'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_PARAMETERS',
  'GIT_CONFIG_SYSTEM', 'GIT_DIR', 'GIT_EXEC_PATH', 'GIT_GRAFT_FILE',
  'GIT_INDEX_FILE', 'GIT_NAMESPACE', 'GIT_OBJECT_DIRECTORY', 'GIT_PREFIX',
  'GIT_QUARANTINE_PATH', 'GIT_REPLACE_REF_BASE', 'GIT_SHALLOW_FILE',
  'GIT_WORK_TREE'
])

const usage = `
Usage:
  node scripts/resolve-public-hive-gateway-release.mjs \\
    --repo <git-repository> --ref <tag-or-commit> \\
    --release-target <vX.Y.Z> \\
    --requested-channel <canary|stable|both|none> \\
    [--format <json|github>]

The canonical manifest path is fixed at
${PUBLIC_HIVE_GATEWAY_RELEASE_MANIFEST_PATH}. Missing and explicitly disabled
manifests preserve the requested channel. A fully valid enabled manifest forces
the effective release channel to none.
`

try {
  const args = parseArgs(process.argv.slice(2))
  if (args.help) {
    console.log(usage.trim())
    process.exit(0)
  }
  const resolution = resolvePublicHiveGatewayRelease(args)
  if (args.format === 'github') writeGitHubOutput(resolution)
  else process.stdout.write(JSON.stringify(resolution, null, 2) + '\n')
} catch (err) {
  const message = boundedError(err)
  console.error(`Public gateway release resolution failed: ${message}`)
  process.exit(1)
}

export function resolvePublicHiveGatewayRelease ({ repo, ref, releaseTarget, requestedChannel }) {
  const repoRoot = resolveRepo(repo)
  requireSafeRef(ref)
  if (!RELEASE_TAG_PATTERN.test(releaseTarget || '')) {
    throw new Error('--release-target must be a v-prefixed semver tag')
  }
  if (!CHANNELS.has(requestedChannel)) {
    throw new Error('--requested-channel must be canary, stable, both, or none')
  }

  const commitSha = resolveCommit(repoRoot, ref)
  const blob = readCanonicalManifestBlob(repoRoot, commitSha)
  if (!blob) {
    return resolution({
      status: 'missing',
      enabled: false,
      releaseTarget,
      requestedChannel,
      effectiveChannel: requestedChannel,
      ref,
      commitSha,
      manifestSha256: '',
      admissionProfile: '',
      cohortSize: 0
    })
  }

  let manifest
  try {
    manifest = JSON.parse(blob.bytes.toString('utf8'))
  } catch (err) {
    throw new Error(`canonical manifest must be valid JSON: ${err.message}`)
  }
  if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
    throw new Error('canonical manifest must be a JSON object')
  }
  if (manifest.schema !== PUBLIC_HIVE_GATEWAY_RELEASE_SCHEMA) {
    throw new Error(`canonical manifest schema must equal ${JSON.stringify(PUBLIC_HIVE_GATEWAY_RELEASE_SCHEMA)}`)
  }
  if (manifest.enabled !== true && manifest.enabled !== false) {
    throw new Error('canonical manifest enabled must be true or false')
  }

  const manifestSha256 = createHash('sha256').update(blob.bytes).digest('hex')
  if (manifest.enabled === false) {
    return resolution({
      status: 'disabled',
      enabled: false,
      releaseTarget,
      requestedChannel,
      effectiveChannel: requestedChannel,
      ref,
      commitSha,
      manifestSha256,
      admissionProfile: '',
      cohortSize: 0
    })
  }

  const normalized = normalizePublicHiveGatewayReleaseManifest(manifest, {
    releaseTarget,
    requirePublicT1: true
  })
  return resolution({
    status: 'enabled',
    enabled: true,
    releaseTarget,
    requestedChannel,
    effectiveChannel: 'none',
    ref,
    commitSha,
    manifestSha256,
    admissionProfile: normalized.admissionProfile,
    cohortSize: normalized.cohort.length
  })
}

function resolution (values) {
  return Object.freeze({
    schema: 'hiverelay-public-gateway-release-resolution-v1',
    manifestPath: PUBLIC_HIVE_GATEWAY_RELEASE_MANIFEST_PATH,
    ...values
  })
}

function parseArgs (argv) {
  const out = { format: 'json' }
  const seen = new Set()
  for (let i = 0; i < argv.length; i++) {
    const arg = argv[i]
    if (arg === '--help' || arg === '-h') {
      if (seen.has('help')) throw new Error(`duplicate argument: ${arg}`)
      seen.add('help')
      out.help = true
      continue
    }
    const names = {
      '--repo': 'repo',
      '--ref': 'ref',
      '--release-target': 'releaseTarget',
      '--requested-channel': 'requestedChannel',
      '--format': 'format'
    }
    const name = names[arg]
    if (!name) throw new Error(`unknown argument: ${arg}`)
    if (seen.has(name)) throw new Error(`duplicate argument: ${arg}`)
    seen.add(name)
    const value = argv[++i]
    if (!value || value.startsWith('--')) throw new Error(`missing value for ${arg}`)
    out[name] = value
  }
  if (out.help) return out
  for (const name of ['repo', 'ref', 'releaseTarget', 'requestedChannel']) {
    if (!out[name]) throw new Error(`missing required argument: ${argumentName(name)}`)
  }
  if (!FORMATS.has(out.format)) throw new Error('--format must be json or github')
  return out
}

function argumentName (name) {
  return ({
    repo: '--repo',
    ref: '--ref',
    releaseTarget: '--release-target',
    requestedChannel: '--requested-channel'
  })[name]
}

function resolveRepo (value) {
  if (typeof value !== 'string' || value.length === 0 || value.length > 4096 || hasControlChars(value)) {
    throw new Error('--repo is invalid')
  }
  let resolved
  try {
    resolved = fs.realpathSync(path.resolve(value))
  } catch {
    throw new Error(`repository does not exist: ${value}`)
  }
  const result = git(resolved, ['rev-parse', '--is-inside-work-tree'])
  if (result.stdout.toString('utf8').trim() !== 'true') throw new Error('--repo must name a Git work tree')
  return resolved
}

function requireSafeRef (value) {
  if (typeof value !== 'string' || value.length === 0 || value.length > 256 || value.startsWith('-') ||
      hasControlChars(value) || !/^[A-Za-z0-9][A-Za-z0-9._/@{}^~:+-]*$/.test(value)) {
    throw new Error('--ref is invalid')
  }
}

function resolveCommit (repo, ref) {
  const result = git(repo, ['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`])
  const sha = result.stdout.toString('utf8').trim()
  if (!/^[a-f0-9]{40}$/i.test(sha)) throw new Error(`ref did not resolve to a commit: ${ref}`)
  return sha.toLowerCase()
}

function readCanonicalManifestBlob (repo, commitSha) {
  const tree = git(repo, [
    'ls-tree',
    '-z',
    '--full-tree',
    commitSha,
    '--',
    PUBLIC_HIVE_GATEWAY_RELEASE_MANIFEST_PATH
  ]).stdout
  if (tree.length === 0) return null

  const records = tree.toString('utf8').split('\0').filter(Boolean)
  if (records.length !== 1) throw new Error('canonical manifest path resolved ambiguously')
  const match = /^(100644|100755) (blob) ([a-f0-9]{40})\t(.+)$/.exec(records[0])
  if (!match || match[4] !== PUBLIC_HIVE_GATEWAY_RELEASE_MANIFEST_PATH) {
    throw new Error('canonical manifest path must resolve to one regular Git blob')
  }

  const sizeResult = git(repo, ['cat-file', '-s', match[3]])
  const sizeText = sizeResult.stdout.toString('utf8').trim()
  if (!/^(?:0|[1-9][0-9]*)$/.test(sizeText)) throw new Error('canonical manifest blob size is malformed')
  const size = Number(sizeText)
  if (!Number.isSafeInteger(size) || size < 1 || size > MAX_MANIFEST_BYTES) {
    throw new Error(`canonical manifest must be between 1 and ${MAX_MANIFEST_BYTES} bytes`)
  }
  const bytes = git(repo, ['cat-file', 'blob', match[3]], MAX_MANIFEST_BYTES + 1).stdout
  if (bytes.length !== size) throw new Error('canonical manifest blob length changed while reading')
  return { bytes }
}

function git (cwd, args, maxBuffer = 256 * 1024) {
  const result = spawnSync(GIT_PROGRAM, ['--no-replace-objects', ...args], {
    cwd,
    encoding: null,
    env: hardenedGitEnv({
      GIT_GRAFT_FILE: '/dev/null/hiverelay-disabled',
      GIT_NO_REPLACE_OBJECTS: '1'
    }),
    maxBuffer,
    timeout: 10000,
    windowsHide: true
  })
  if (result.error) throw new Error(`git ${args[0]} failed: ${result.error.message}`)
  if (result.status !== 0) {
    const stderr = String(result.stderr || '').trim().replace(/[\r\n]+/g, ' ')
    throw new Error(`git ${args[0]} failed${stderr ? `: ${stderr}` : ''}`)
  }
  return result
}

function writeGitHubOutput (value) {
  const lines = [
    ['effective_channel', value.effectiveChannel],
    ['public_gateway_enabled', value.enabled ? 'true' : 'false'],
    ['public_gateway_manifest_status', value.status],
    ['public_gateway_manifest_path', value.manifestPath],
    ['public_gateway_manifest_sha256', value.manifestSha256],
    ['public_gateway_release_target', value.releaseTarget],
    ['public_gateway_commit_sha', value.commitSha],
    ['public_gateway_admission_profile', value.admissionProfile],
    ['public_gateway_cohort_size', value.cohortSize]
  ]
  for (const [name, rawValue] of lines) {
    const stringValue = String(rawValue)
    if (hasControlChars(stringValue)) throw new Error(`refusing unsafe GitHub output for ${name}`)
    process.stdout.write(`${name}=${stringValue}\n`)
  }
}

function hasControlChars (value) {
  for (const character of String(value)) {
    const code = character.charCodeAt(0)
    if (code <= 31 || code === 127) return true
  }
  return false
}

function hardenedGitEnv (overrides) {
  const env = { ...process.env }
  for (const name of Object.keys(env)) {
    if (UNSAFE_GIT_ENV.has(name) || /^GIT_CONFIG_(?:KEY|VALUE)_\d+$/.test(name)) delete env[name]
  }
  return { ...env, ...overrides }
}

function boundedError (err) {
  const message = String(err?.message || err || 'unknown error').replace(/[\r\n]+/g, ' ')
  return message.length > 1000 ? message.slice(0, 1000) + '…' : message
}