166 lines
7.3 KiB
JavaScript
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
|
|
})
|