Files
ycode/scripts/vendor-omp.mjs
T

166 lines
7.3 KiB
JavaScript

/**
* Vendors the pinned OMP kernel binary and its license files into resources/omp.
*
* The binary is not committed to Git. `npm run omp:vendor` restores it from the
* pinned upstream release (or from a local copy via `--from <file|directory>`)
* and verifies every asset against resources/omp/runtime.lock.json.
*/
import { createHash } from 'node:crypto'
import { createReadStream, createWriteStream, promises as fs } from 'node:fs'
import path from 'node:path'
import { Readable } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import { fileURLToPath } from 'node:url'
const project = fileURLToPath(new URL('../', import.meta.url))
const directory = path.join(project, 'resources', 'omp')
const lockPath = path.join(directory, 'runtime.lock.json')
const hashPattern = /^[a-f0-9]{64}$/
const namePattern = /^[A-Za-z0-9._-]+$/
const releasePrefix = 'https://github.com/can1357/oh-my-pi/releases/download/'
const rawPrefix = 'https://raw.githubusercontent.com/can1357/oh-my-pi/'
const maxAssetBytes = 512 * 1024 * 1024
function asset(value, label) {
if (!value || typeof value !== 'object' || typeof value.name !== 'string' || !namePattern.test(value.name)
|| !Number.isSafeInteger(value.bytes) || value.bytes <= 0 || value.bytes > maxAssetBytes
|| typeof value.sha256 !== 'string' || !hashPattern.test(value.sha256)) {
throw new Error(`Invalid ${label} entry in runtime.lock.json`)
}
return value
}
async function readLock() {
const lock = JSON.parse((await fs.readFile(lockPath, 'utf8')).replace(/^\uFEFF/, ''))
if (lock.format !== 1 || lock.kernel !== 'omp' || lock.upstream !== 'oh-my-pi' || lock.platform !== 'win32-x64'
|| typeof lock.version !== 'string' || !/^\d+\.\d+\.\d+$/.test(lock.version)
|| lock.releaseUrl !== `https://github.com/can1357/oh-my-pi/releases/tag/v${lock.version}`
|| !Number.isFinite(Date.parse(lock.publishedAt))) {
throw new Error('Invalid OMP runtime lock')
}
const binary = asset(lock.binary, 'binary')
const license = asset(lock.license, 'license')
const thirdPartyNotices = asset(lock.thirdPartyNotices, 'thirdPartyNotices')
if (binary.name !== 'omp.exe' || license.name !== 'LICENSE' || thirdPartyNotices.name !== 'THIRD-PARTY-NOTICES.txt') {
throw new Error('OMP runtime lock uses unexpected asset names')
}
return { lock, binary, license, thirdPartyNotices }
}
async function sha256File(file) {
const hash = createHash('sha256')
for await (const chunk of createReadStream(file)) hash.update(chunk)
return hash.digest('hex')
}
async function verifyFile(file, spec) {
const stat = await fs.stat(file).catch(() => null)
if (!stat?.isFile()) throw new Error(`Missing OMP asset: ${path.relative(project, file)}`)
if (stat.size !== spec.bytes) throw new Error(`OMP asset size mismatch: ${spec.name} (${stat.size} != ${spec.bytes})`)
const digest = await sha256File(file)
if (digest !== spec.sha256) throw new Error(`OMP asset digest mismatch: ${spec.name}`)
return digest
}
async function copyVerified(source, target, spec) {
if (path.resolve(source) === path.resolve(target)) return verifyFile(target, spec)
const stat = await fs.stat(source)
const file = stat.isDirectory() ? path.join(source, spec.name) : source
const sourceStat = await fs.stat(file)
if (!sourceStat.isFile() || sourceStat.size !== spec.bytes) throw new Error(`Local OMP asset has an unexpected size: ${file}`)
const digest = await sha256File(file)
if (digest !== spec.sha256) throw new Error(`Local OMP asset does not match the pinned release: ${file}`)
const temporary = `${target}.part`
await fs.copyFile(file, temporary)
try {
await verifyFile(temporary, spec)
await fs.rename(temporary, target)
} catch (error) {
await fs.rm(temporary, { force: true })
throw error
}
return digest
}
async function download(url, target, spec) {
const response = await fetch(url, {
redirect: 'follow',
headers: { 'user-agent': 'YCode OMP vendor' },
signal: AbortSignal.timeout(10 * 60 * 1000)
})
if (!response.ok || !response.body) throw new Error(`OMP download failed (${response.status}): ${url}`)
const declared = response.headers.get('content-length')
if (declared && Number(declared) !== spec.bytes) throw new Error(`OMP download size header mismatch: ${spec.name}`)
const temporary = `${target}.part`
try {
await pipeline(Readable.fromWeb(response.body), createWriteStream(temporary))
const digest = await verifyFile(temporary, spec)
await fs.rename(temporary, target)
return digest
} catch (error) {
await fs.rm(temporary, { force: true })
throw error
}
}
function releaseUrl(version, name) {
const url = `${releasePrefix}v${version}/${name}`
if (!url.startsWith(releasePrefix) || !namePattern.test(name)) throw new Error(`Unsafe OMP release URL: ${url}`)
return url
}
function rawUrl(version, name) {
const url = `${rawPrefix}v${version}/${name}`
if (!url.startsWith(rawPrefix) || !namePattern.test(name)) throw new Error(`Unsafe OMP raw URL: ${url}`)
return url
}
async function ensureAsset({ lock, spec, source, target, verifyOnly, offline }) {
if (await fs.stat(target).catch(() => null)) {
const digest = await verifyFile(target, spec)
console.log(`[omp] ${spec.name} verified (${spec.bytes} bytes, ${digest.slice(0, 12)}…)`)
return
}
if (verifyOnly) throw new Error(`Missing OMP asset: ${path.relative(project, target)}`)
if (source) {
if (spec.name !== 'omp.exe') throw new Error(`--from only applies to omp.exe, not ${spec.name}`)
const digest = await copyVerified(source, target, spec)
console.log(`[omp] ${spec.name} imported from ${source} (${spec.bytes} bytes, ${digest.slice(0, 12)}…)`)
return
}
if (offline) throw new Error(`OMP asset is missing in offline mode: ${path.relative(project, target)}`)
try {
const digest = await download(releaseUrl(lock.version, spec.name), target, spec)
console.log(`[omp] ${spec.name} downloaded (${spec.bytes} bytes, ${digest.slice(0, 12)}…)`)
} catch (releaseError) {
if (spec.name === 'omp.exe') throw releaseError
const digest = await download(rawUrl(lock.version, spec.name), target, spec)
console.log(`[omp] ${spec.name} downloaded from raw source (${spec.bytes} bytes, ${digest.slice(0, 12)}…)`)
}
}
async function main() {
const args = process.argv.slice(2)
const verifyOnly = args.includes('--verify')
const offline = args.includes('--offline')
const fromIndex = args.indexOf('--from')
const source = fromIndex >= 0 ? args[fromIndex + 1] : null
if (fromIndex >= 0 && (!source || source.startsWith('--'))) throw new Error('--from requires a file or directory')
const { lock, binary, license, thirdPartyNotices } = await readLock()
await fs.mkdir(directory, { recursive: true })
console.log(`[omp] pinned ${lock.upstream} v${lock.version} (${lock.platform})`)
await ensureAsset({ lock, spec: binary, source, target: path.join(directory, binary.name), verifyOnly, offline })
await ensureAsset({ lock, spec: license, target: path.join(directory, license.name), verifyOnly, offline })
await ensureAsset({ lock, spec: thirdPartyNotices, target: path.join(directory, thirdPartyNotices.name), verifyOnly, offline })
console.log(`[omp] ready: ${path.relative(project, directory)}`)
}
main().catch((error) => {
console.error(`[omp] ${error.message}`)
process.exitCode = 1
})