import { createHash } from "node:crypto"; import { access, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { unzipSync } from "fflate"; const projectRoot = fileURLToPath(new URL("../../../", import.meta.url)); const desktopRoot = path.join(projectRoot, "apps", "desktop"); const manifestPath = path.join( projectRoot, "packages", "docx-engine", "src", "pandoc-runtime.json" ); const maximumArchiveBytes = 64 * 1024 * 1024; const maximumExtractedBytes = 256 * 1024 * 1024; const downloadTimeoutMs = 120_000; function sha256(content) { return createHash("sha256").update(content).digest("hex").toUpperCase(); } function normalizedBaseName(fileName) { return fileName.replaceAll("\\", "/").split("/").filter(Boolean).at(-1); } async function fileExists(filePath) { try { await access(filePath); return true; } catch { return false; } } async function readRuntimeManifest() { const manifest = JSON.parse(await readFile(manifestPath, "utf8")); const artifact = manifest.artifacts?.find( (candidate) => candidate.platform === "win32" && candidate.architecture === "x64" ); if (!artifact || artifact.archiveType !== "zip") { throw new Error("Pandoc 清单缺少 Windows x64 ZIP 发行项"); } const requiredNames = [ normalizedBaseName(artifact.executableRelativePath), ...artifact.licenseFiles.map(normalizedBaseName) ]; if ( requiredNames.some( (fileName) => !fileName || !Number.isInteger(artifact.files?.[fileName]?.bytes) || !/^[A-F0-9]{64}$/u.test(artifact.files?.[fileName]?.sha256 ?? "") ) ) { throw new Error("Pandoc 清单缺少最小运行时文件哈希或长度"); } return { manifest, artifact }; } async function downloadArchive(downloadUrl, destination) { const response = await fetch(downloadUrl, { redirect: "follow", signal: AbortSignal.timeout(downloadTimeoutMs) }); if (!response.ok) { throw new Error(`Pandoc 下载失败:HTTP ${response.status}`); } const declaredLength = Number(response.headers.get("content-length")); if ( Number.isFinite(declaredLength) && declaredLength > maximumArchiveBytes ) { throw new Error("Pandoc 归档超过允许的下载大小"); } const content = new Uint8Array(await response.arrayBuffer()); if (content.byteLength > maximumArchiveBytes) { throw new Error("Pandoc 归档超过允许的下载大小"); } await writeFile(destination, content); return content; } async function loadVerifiedArchive(artifact, archivePath) { if (await fileExists(archivePath)) { const cached = new Uint8Array(await readFile(archivePath)); if (sha256(cached) === artifact.sha256) { return cached; } await rm(archivePath, { force: true }); } const partialPath = `${archivePath}.${process.pid}.partial`; await rm(partialPath, { force: true }); try { const downloaded = await downloadArchive( artifact.downloadUrl, partialPath ); const actualHash = sha256(downloaded); if (actualHash !== artifact.sha256) { throw new Error( `Pandoc 归档 SHA-256 不匹配:期望 ${artifact.sha256},实际 ${actualHash}` ); } await rename(partialPath, archivePath); return downloaded; } finally { await rm(partialPath, { force: true }); } } function extractRequiredFiles(archive, artifact) { const requiredNames = new Set([ normalizedBaseName(artifact.executableRelativePath), ...artifact.licenseFiles.map(normalizedBaseName) ]); let selectedBytes = 0; const entries = unzipSync(archive, { filter(file) { const selected = requiredNames.has(normalizedBaseName(file.name)); if (selected) { selectedBytes += file.originalSize; if (selectedBytes > maximumExtractedBytes) { throw new Error("Pandoc 最小运行时超过允许的解压大小"); } } return selected; } }); const files = new Map(); for (const [entryName, content] of Object.entries(entries)) { const baseName = normalizedBaseName(entryName); if (!baseName || !requiredNames.has(baseName)) { continue; } if (files.has(baseName)) { throw new Error(`Pandoc 归档包含重复文件:${baseName}`); } files.set(baseName, content); } for (const requiredName of requiredNames) { if (!files.has(requiredName)) { throw new Error(`Pandoc 归档缺少必须文件:${requiredName}`); } } return files; } function verifyPandocVersion(executablePath, expectedVersion) { const result = spawnSync(executablePath, ["--version"], { encoding: "utf8", maxBuffer: 64 * 1024, windowsHide: true }); if (result.error || result.status !== 0) { throw new Error(`内置 Pandoc 无法执行:${result.error?.message ?? result.stderr}`); } const firstLine = result.stdout.split(/\r?\n/u)[0]?.trim(); if (firstLine !== `pandoc ${expectedVersion}`) { throw new Error( `内置 Pandoc 版本不匹配:期望 pandoc ${expectedVersion},实际 ${firstLine ?? "未知"}` ); } } async function smokeTestPandoc(executablePath) { const smokeRoot = path.join( tmpdir(), `morphdoc-pandoc-smoke-${process.pid}-${Date.now()}` ); const dataDirectory = path.join(smokeRoot, "data"); const outputPath = path.join(smokeRoot, "smoke.docx"); await mkdir(dataDirectory, { recursive: true }); try { const result = spawnSync( executablePath, [ "--from=gfm", "--to=docx", `--data-dir=${dataDirectory}`, `--output=${outputPath}` ], { encoding: "utf8", input: "# 墨呈\n\nPandoc 运行时冒烟测试。\n", maxBuffer: 1024 * 1024, windowsHide: true } ); if (result.error || result.status !== 0) { throw new Error(`内置 Pandoc DOCX 冒烟失败:${result.error?.message ?? result.stderr}`); } const output = await readFile(outputPath); if (output.byteLength < 1024 || output[0] !== 0x50 || output[1] !== 0x4b) { throw new Error("内置 Pandoc 生成的 DOCX 不是有效 ZIP 包"); } } finally { await rm(smokeRoot, { recursive: true, force: true }); } } async function verifyPreparedRuntime(targetDirectory, manifest, artifact) { const auditPath = path.join(targetDirectory, "runtime-manifest.json"); if (!(await fileExists(auditPath))) { return false; } try { const audit = JSON.parse(await readFile(auditPath, "utf8")); if ( audit.version !== manifest.version || audit.archiveSha256 !== artifact.sha256 || audit.downloadUrl !== artifact.downloadUrl ) { return false; } const expectedFiles = [ normalizedBaseName(artifact.executableRelativePath), ...artifact.licenseFiles.map(normalizedBaseName) ]; for (const fileName of expectedFiles) { const content = await readFile(path.join(targetDirectory, fileName)); const expected = artifact.files[fileName]; if ( !expected || content.byteLength !== expected.bytes || sha256(content) !== expected.sha256 || audit.files?.[fileName]?.sha256 !== expected.sha256 ) { return false; } } const executablePath = path.join( targetDirectory, normalizedBaseName(artifact.executableRelativePath) ); verifyPandocVersion(executablePath, manifest.version); await smokeTestPandoc(executablePath); return true; } catch { return false; } } async function preparePandocRuntime({ checkOnly = false } = {}) { const { manifest, artifact } = await readRuntimeManifest(); const platformDirectory = "windows-x86_64"; const runtimeRoot = path.join(desktopRoot, ".runtime", "pandoc"); const targetDirectory = path.join( runtimeRoot, manifest.version, platformDirectory ); if (await verifyPreparedRuntime(targetDirectory, manifest, artifact)) { console.log(`Pandoc ${manifest.version} 最小运行时已通过校验`); return targetDirectory; } if (checkOnly) { throw new Error("Pandoc 最小运行时缺失或未通过完整性校验"); } const downloadDirectory = path.join(runtimeRoot, "downloads"); await mkdir(downloadDirectory, { recursive: true }); const archiveName = new URL(artifact.downloadUrl).pathname.split("/").at(-1); if (!archiveName) { throw new Error("Pandoc 下载地址缺少归档文件名"); } const archivePath = path.join(downloadDirectory, archiveName); const archive = await loadVerifiedArchive(artifact, archivePath); const files = extractRequiredFiles(archive, artifact); const temporaryDirectory = path.join( runtimeRoot, manifest.version, `.${platformDirectory}-${process.pid}-${Date.now()}` ); await rm(temporaryDirectory, { recursive: true, force: true }); await mkdir(temporaryDirectory, { recursive: true }); try { const auditFiles = {}; for (const [fileName, content] of files) { const expected = artifact.files[fileName]; const actualHash = sha256(content); if ( !expected || content.byteLength !== expected.bytes || actualHash !== expected.sha256 ) { throw new Error(`Pandoc 文件完整性校验失败:${fileName}`); } await writeFile(path.join(temporaryDirectory, fileName), content); auditFiles[fileName] = { bytes: content.byteLength, sha256: actualHash }; } const executablePath = path.join( temporaryDirectory, normalizedBaseName(artifact.executableRelativePath) ); verifyPandocVersion(executablePath, manifest.version); await smokeTestPandoc(executablePath); await writeFile( path.join(temporaryDirectory, "runtime-manifest.json"), `${JSON.stringify( { version: manifest.version, platform: "win32", architecture: "x64", license: manifest.license, projectUrl: manifest.projectUrl, sourceArchiveUrl: manifest.sourceArchiveUrl, downloadUrl: artifact.downloadUrl, archiveSha256: artifact.sha256, files: auditFiles }, null, 2 )}\n`, "utf8" ); await rm(targetDirectory, { recursive: true, force: true }); await rename(temporaryDirectory, targetDirectory); } finally { await rm(temporaryDirectory, { recursive: true, force: true }); } console.log(`Pandoc ${manifest.version} 最小运行时已准备:${targetDirectory}`); return targetDirectory; } const isDirectInvocation = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); if (isDirectInvocation) { preparePandocRuntime({ checkOnly: process.argv.includes("--check") }).catch( (error) => { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; } ); } export { extractRequiredFiles, preparePandocRuntime, sha256, verifyPandocVersion };