import { createHash } from 'node:crypto'; import { createReadStream } from 'node:fs'; import { chmod, mkdir, open, readFile, rename, rm, stat } from 'node:fs/promises'; import path from 'node:path'; import { Readable } from 'node:stream'; import { fileURLToPath } from 'node:url'; const scriptPath = fileURLToPath(import.meta.url); const frontendRoot = path.resolve(path.dirname(scriptPath), '..'); function parseArguments(argv) { const options = { metadataPath: path.join(frontendRoot, 'desktop-release.json'), destinationDirectory: path.join(frontendRoot, 'downloads'), checkOnly: false }; for (let index = 0; index < argv.length; index += 1) { const argument = argv[index]; if (argument === '--check-only') { options.checkOnly = true; continue; } if (argument === '--metadata' || argument === '--destination-dir') { const value = argv[index + 1]; if (!value) throw new Error(`${argument} requires a path`); index += 1; if (argument === '--metadata') options.metadataPath = path.resolve(value); if (argument === '--destination-dir') options.destinationDirectory = path.resolve(value); continue; } throw new Error(`Unknown argument: ${argument}`); } return options; } export function validateMetadata(value) { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error('Desktop release metadata must be an object'); } const metadata = { version: String(value.version || '').trim(), tag: String(value.tag || '').trim(), asset: String(value.asset || '').trim(), downloadUrl: String(value.downloadUrl || '').trim(), publicPath: String(value.publicPath || '').trim(), sha256: String(value.sha256 || '').trim().toLowerCase(), size: Number(value.size) }; if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(metadata.version)) { throw new Error('Desktop release version is invalid'); } if (metadata.tag !== `v${metadata.version}`) { throw new Error('Desktop release tag must match the version'); } if (!metadata.asset || path.basename(metadata.asset) !== metadata.asset) { throw new Error('Desktop release asset must be a file name'); } if (!/\.exe$/i.test(metadata.asset)) { throw new Error('Desktop release asset must be an EXE file'); } if (!/^[a-f0-9]{64}$/.test(metadata.sha256)) { throw new Error('Desktop release SHA256 is invalid'); } if (!Number.isSafeInteger(metadata.size) || metadata.size <= 0) { throw new Error('Desktop release size is invalid'); } let releaseUrl; try { releaseUrl = new URL(metadata.downloadUrl); } catch { throw new Error('Desktop release download URL is invalid'); } if (releaseUrl.protocol !== 'https:') { throw new Error('Desktop release download URL must use HTTPS'); } if (decodeURIComponent(path.basename(releaseUrl.pathname)) !== metadata.asset) { throw new Error('Desktop release URL does not match the asset name'); } if (metadata.publicPath !== `/downloads/${metadata.asset}`) { throw new Error('Desktop release public path does not match the asset name'); } return metadata; } export async function computeFileSha256(filePath) { const hash = createHash('sha256'); for await (const chunk of createReadStream(filePath)) { hash.update(chunk); } return hash.digest('hex'); } async function inspectInstaller(filePath, metadata) { try { const fileStats = await stat(filePath); if (!fileStats.isFile() || fileStats.size !== metadata.size) { return { valid: false, reason: 'size_mismatch' }; } const digest = await computeFileSha256(filePath); return { valid: digest === metadata.sha256, reason: digest === metadata.sha256 ? 'verified' : 'sha256_mismatch' }; } catch (error) { if (error?.code === 'ENOENT') return { valid: false, reason: 'missing' }; throw error; } } async function downloadInstaller(metadata, temporaryPath) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 120_000); try { const response = await fetch(metadata.downloadUrl, { headers: { 'user-agent': 'wanwanyun-release-sync/1.0' }, redirect: 'follow', signal: controller.signal }); if (!response.ok || !response.body) { throw new Error(`Desktop release download failed with HTTP ${response.status}`); } if (new URL(response.url).protocol !== 'https:') { throw new Error('Desktop release redirected to an insecure URL'); } const hash = createHash('sha256'); const output = await open(temporaryPath, 'wx', 0o600); let downloadedBytes = 0; try { for await (const chunk of Readable.fromWeb(response.body)) { const buffer = Buffer.from(chunk); downloadedBytes += buffer.length; hash.update(buffer); let offset = 0; while (offset < buffer.length) { const { bytesWritten } = await output.write(buffer, offset); if (bytesWritten <= 0) throw new Error('Desktop release write made no progress'); offset += bytesWritten; } } await output.sync(); } finally { await output.close(); } const digest = hash.digest('hex'); if (downloadedBytes !== metadata.size) { throw new Error(`Desktop release size mismatch: expected ${metadata.size}, received ${downloadedBytes}`); } if (digest !== metadata.sha256) { throw new Error('Desktop release SHA256 verification failed'); } } finally { clearTimeout(timeout); } } async function replaceInstaller(temporaryPath, destinationPath) { try { await rename(temporaryPath, destinationPath); } catch (error) { if (!['EEXIST', 'EPERM'].includes(error?.code)) throw error; await rm(destinationPath, { force: true }); await rename(temporaryPath, destinationPath); } await chmod(destinationPath, 0o644); } export async function syncDesktopRelease(options) { const rawMetadata = JSON.parse(await readFile(options.metadataPath, 'utf8')); const metadata = validateMetadata(rawMetadata); const destinationDirectory = path.resolve(options.destinationDirectory); const destinationPath = path.join(destinationDirectory, metadata.asset); await mkdir(destinationDirectory, { recursive: true }); const existing = await inspectInstaller(destinationPath, metadata); if (existing.valid) { return { status: 'verified', destinationPath, metadata }; } if (options.checkOnly) { throw new Error(`Desktop release check failed: ${existing.reason}`); } const temporaryPath = path.join( destinationDirectory, `.${metadata.asset}.${process.pid}.${Date.now()}.tmp` ); try { await downloadInstaller(metadata, temporaryPath); await replaceInstaller(temporaryPath, destinationPath); } catch (error) { await rm(temporaryPath, { force: true }); throw error; } return { status: 'downloaded', destinationPath, metadata }; } async function main() { const options = parseArguments(process.argv.slice(2)); const result = await syncDesktopRelease(options); console.log( `Desktop release ${result.status}: ${result.metadata.asset} ` + `(${result.metadata.sha256})` ); } if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) { main().catch((error) => { console.error(`Desktop release sync failed: ${error.message}`); process.exitCode = 1; }); }