92 lines
4.2 KiB
JavaScript
92 lines
4.2 KiB
JavaScript
import { createHash } from 'node:crypto'
|
||
import { execFileSync } from 'node:child_process'
|
||
import { lstat, mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises'
|
||
import path from 'node:path'
|
||
import { fileURLToPath } from 'node:url'
|
||
import { runtimeRoot, sourceRoot } from './runtime-config.mjs'
|
||
|
||
const receiptPath = path.join(sourceRoot, 'out', 'build-state.json')
|
||
const skipped = new Set(['node_modules', 'dist', 'dist-ssr', 'output', 'bundled', '__pycache__', '.git'])
|
||
const sourceDirectories = ['backend', 'frontend', 'plugins/pmail_telegram_push', 'deploy']
|
||
|
||
export async function sourceFingerprint(root = sourceRoot) {
|
||
const hash = createHash('sha256')
|
||
async function visit(relative) {
|
||
const entries = await readdir(path.join(root, relative), { withFileTypes: true })
|
||
entries.sort((left, right) => left.name.localeCompare(right.name, 'en'))
|
||
for (const entry of entries) {
|
||
if (skipped.has(entry.name) || entry.name.startsWith('.tmp-') || entry.name === 'auto-imports.d.ts' || entry.name === 'components.d.ts') continue
|
||
const name = `${relative}/${entry.name}`
|
||
if (entry.isDirectory()) {
|
||
await visit(name)
|
||
} else if (!/\.(exe|log|pid|db|tsbuildinfo)$/.test(entry.name)) {
|
||
if (!entry.isFile()) throw new Error(`源码包含链接或特殊文件:${name}`)
|
||
hash.update(name).update('\0').update(await readFile(path.join(root, name))).update('\0')
|
||
}
|
||
}
|
||
}
|
||
for (const directory of sourceDirectories) await visit(directory)
|
||
return hash.digest('hex')
|
||
}
|
||
|
||
export async function fileDigest(filename) {
|
||
const info = await lstat(filename)
|
||
if (!info.isFile() || info.isSymbolicLink() || info.size === 0) throw new Error('编译产物为空或不是普通文件')
|
||
return createHash('sha256').update(await readFile(filename)).digest('hex')
|
||
}
|
||
|
||
const artifacts = [
|
||
['out/backend/smail.exe', 'smail.exe'],
|
||
['out/backend/smail-admin.exe', 'smail-admin.exe'],
|
||
['out/plugins/spam_block.exe', null],
|
||
['out/plugins/pmail_telegram_push.exe', null],
|
||
['out/plugins/wechat_push.exe', null]
|
||
]
|
||
|
||
export async function sealBuild(expectedFingerprint) {
|
||
const actual = await sourceFingerprint()
|
||
if (!expectedFingerprint || actual !== expectedFingerprint) throw new Error('编译期间源码发生变化,请重新编译,拒绝部署不一致产物')
|
||
const files = await Promise.all(artifacts.map(async ([source, target]) => ({
|
||
source, target, sha256: await fileDigest(path.join(sourceRoot, source))
|
||
})))
|
||
const receipt = {
|
||
sourceFingerprint: actual,
|
||
gitHash: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: sourceRoot, encoding: 'utf8', windowsHide: true }).trim(),
|
||
builtAt: new Date().toISOString(),
|
||
files
|
||
}
|
||
await mkdir(path.dirname(receiptPath), { recursive: true })
|
||
await writeFile(`${receiptPath}.tmp`, JSON.stringify(receipt, null, 2) + '\n')
|
||
await rename(`${receiptPath}.tmp`, receiptPath)
|
||
return receipt
|
||
}
|
||
|
||
export async function verifyBuild({ deployed = false } = {}) {
|
||
const receipt = JSON.parse(await readFile(receiptPath, 'utf8'))
|
||
if (receipt.sourceFingerprint !== await sourceFingerprint()) throw new Error('源码已变化,必须重新编译后才能启动')
|
||
if (JSON.stringify(receipt.files.map(item => [item.source, item.target])) !== JSON.stringify(artifacts)) {
|
||
throw new Error('编译清单不完整,请重新编译')
|
||
}
|
||
for (const item of receipt.files) {
|
||
if (await fileDigest(path.join(sourceRoot, item.source)) !== item.sha256) throw new Error(`编译产物校验失败:${item.source}`)
|
||
if (deployed && item.target && await fileDigest(path.join(runtimeRoot, item.target)) !== item.sha256) {
|
||
throw new Error(`部署产物不是最新编译结果:${item.target}`)
|
||
}
|
||
}
|
||
return receipt
|
||
}
|
||
|
||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||
try {
|
||
switch (process.argv[2]) {
|
||
case 'fingerprint': console.log(await sourceFingerprint()); break
|
||
case 'seal': await sealBuild(process.argv[3]); break
|
||
case 'verify': await verifyBuild({ deployed: process.argv.includes('--deployed') }); break
|
||
default: throw new Error('用法:build-state.mjs fingerprint|seal|verify')
|
||
}
|
||
} catch (error) {
|
||
console.error(error.message)
|
||
process.exitCode = 1
|
||
}
|
||
}
|