import fs from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; import os from "node:os"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { parse } from "yaml"; import { extractFile, uncache } from "@electron/asar"; const EXPECTED_PUBLISH = Object.freeze({ provider: "github", owner: "Sucukdeluxe", repo: "Multi-Debrid-Downloader" }); const EXPECTED_PRODUCT_NAME = "Multi-Debrid-Downloader"; const EXPECTED_NSIS_ARTIFACT_NAME = "${productName}-Setup-${version}.${ext}"; const EXPECTED_PORTABLE_ARTIFACT_NAME = "${productName}-${version}-portable.${ext}"; const EXPECTED_NSIS_INCLUDE = "resources/installer.nsh"; const EXPECTED_JVM_ASAR_UNPACK = "resources/extractor-jvm/**/*"; const REQUIRED_BUILD_FILES = Object.freeze([ "resources/extractor-jvm/**/*", "LICENSE", "THIRD_PARTY_NOTICES.md" ]); const EXPECTED_EXTRA_RESOURCE = Object.freeze({ from: "LICENSE", to: "LICENSE" }); const EXPECTED_ICON_RESOURCE = Object.freeze({ from: "assets/app_icon.ico", to: "assets/app_icon.ico" }); const REDISTRIBUTION_FILES = Object.freeze([ Object.freeze({ sourcePath: "LICENSE", packagedPath: "resources/LICENSE", sha256: "f2c1bc02d9ba5235cc67dfea734e7dc90559b00d8cb2d142bad7a984ff96d3f6" }), Object.freeze({ sourcePath: "THIRD_PARTY_NOTICES.md", packagedPath: "resources/THIRD_PARTY_NOTICES.md", sha256: "28f9d1f8692811758c41584e59d0db6cbf26654b4b93e335626921bef86bdf76" }), Object.freeze({ sourcePath: "resources/extractor-jvm/licenses/LGPL-2.1.txt", packagedPath: "resources/app.asar.unpacked/resources/extractor-jvm/licenses/LGPL-2.1.txt", sha256: "20e50fe7aae3e56378ebf0417d9de904f55a0e61e4df315333e632a4d3555d95" }), Object.freeze({ sourcePath: "resources/extractor-jvm/licenses/7-Zip-license.txt", packagedPath: "resources/app.asar.unpacked/resources/extractor-jvm/licenses/7-Zip-license.txt", sha256: "477e15d4033026edb25d36c9f078bb0beafc9318f6505473648972a536ece263" }), Object.freeze({ sourcePath: "resources/extractor-jvm/licenses/Apache-2.0.txt", packagedPath: "resources/app.asar.unpacked/resources/extractor-jvm/licenses/Apache-2.0.txt", sha256: "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30" }), Object.freeze({ sourcePath: "resources/extractor-jvm/THIRD_PARTY_NOTICES.txt", packagedPath: "resources/app.asar.unpacked/resources/extractor-jvm/THIRD_PARTY_NOTICES.txt", sha256: "d4ab6ee9ba293f9d25d22e0506a465d79794259274a0ec944d3bb3d001b46ca9" }) ]); function readRequiredFile(filePath) { requireNonEmptyFile(filePath, "required file"); return fs.readFileSync(filePath, "utf8"); } function parseYamlDocument(content, sourceName) { let value; try { value = parse(content, { uniqueKeys: true }); } catch (error) { throw new Error(`${sourceName} contains invalid YAML: ${String(error?.message || error)}`); } if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error(`${sourceName} must contain a YAML mapping`); } return value; } function assertEqual(actual, expected, label) { if (actual !== expected) { throw new Error(`${label} must be ${expected}, received ${String(actual)}`); } } function resolveReleaseDir(rootDir) { const directLatest = path.join(rootDir, "latest.yml"); if (fs.existsSync(directLatest)) { return rootDir; } return path.join(rootDir, "release"); } function requireNonEmptyFile(filePath, label) { let stat; try { stat = fs.lstatSync(filePath); } catch (error) { if (error?.code === "ENOENT") { throw new Error(`Missing ${label}: ${filePath}`); } throw error; } if (stat.isSymbolicLink()) { throw new Error(`${label} must be a regular file, not a symbolic link: ${filePath}`); } if (!stat.isFile() || stat.size === 0) { throw new Error(`${label} must be a non-empty regular file: ${filePath}`); } return stat; } function sha256NormalizedText(filePath) { const normalized = fs.readFileSync(filePath, "utf8").replace(/\r\n/g, "\n").replace(/\r/g, "\n"); return crypto.createHash("sha256").update(normalized, "utf8").digest("hex"); } function verifyRedistributionFiles(baseDir, pathKey, label) { for (const file of REDISTRIBUTION_FILES) { const relativePath = file[pathKey]; const filePath = path.join(baseDir, ...relativePath.split("/")); requireNonEmptyFile(filePath, `${label} redistribution file`); const actualDigest = sha256NormalizedText(filePath); if (actualDigest !== file.sha256) { throw new Error(`${label} redistribution content mismatch for ${relativePath}`); } } } function hasExtraResource(extraResources, expected) { const entries = Array.isArray(extraResources) ? extraResources : [extraResources]; return entries.some((entry) => ( entry && typeof entry === "object" && !Array.isArray(entry) && entry.from === expected.from && entry.to === expected.to )); } function readPackagedMainVersion(asarPath) { requireNonEmptyFile(asarPath, "packaged app.asar"); uncache(asarPath); try { const packagePayload = JSON.parse(extractFile(asarPath, "package.json").toString("utf8")); const mainBundle = extractFile(asarPath, path.win32.join("build", "main", "main", "main.js")).toString("utf8"); const bundled = /name:\s*["']multi-debrid-downloader["']\s*,\s*version:\s*["']([^"']+)["']/.exec(mainBundle)?.[1] || ""; return { packageVersion: String(packagePayload?.version || ""), bundledVersion: bundled }; } finally { uncache(asarPath); } } function sha256File(filePath) { return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); } function isJvmRuntimeArtifact(relativePath) { const normalized = relativePath.split(path.sep).join("/"); return /^classes\/.+\.class$/i.test(normalized) || /(?:^|\/)\.source\.sha256$/i.test(normalized) || /^lib\/[^/]+\.jar$/i.test(normalized); } function readJvmRuntimeInventory(runtimeRoot, label) { let rootStat; try { rootStat = fs.lstatSync(runtimeRoot); } catch (error) { if (error?.code === "ENOENT") { throw new Error(`Missing ${label} JVM runtime: ${runtimeRoot}`); } throw error; } if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) { throw new Error(`${label} JVM runtime must be a regular directory: ${runtimeRoot}`); } const inventory = new Map(); for (const filePath of listFiles(runtimeRoot)) { const relativePath = normalizeRelativePath(runtimeRoot, filePath); if (!isJvmRuntimeArtifact(relativePath)) { continue; } const stat = fs.lstatSync(filePath); if (stat.isSymbolicLink() || !stat.isFile() || stat.size === 0) { throw new Error(`${label} JVM runtime artifact must be a non-empty regular file: ${relativePath}`); } inventory.set(relativePath, sha256File(filePath)); } const paths = [...inventory.keys()]; if (!paths.some((relativePath) => /^classes\/.+\.class$/i.test(relativePath))) { throw new Error(`${label} JVM runtime contains no class files`); } if (!paths.some((relativePath) => /(?:^|\/)\.source\.sha256$/i.test(relativePath))) { throw new Error(`${label} JVM runtime omits .source.sha256`); } if (!paths.some((relativePath) => /^lib\/[^/]+\.jar$/i.test(relativePath))) { throw new Error(`${label} JVM runtime contains no library JAR files`); } return inventory; } function verifyJvmRuntimeEquality(sourceRoot, packagedRoot, label) { const sourceInventory = readJvmRuntimeInventory(sourceRoot, "source"); const packagedInventory = readJvmRuntimeInventory(packagedRoot, label); for (const [relativePath, sourceDigest] of sourceInventory) { if (!packagedInventory.has(relativePath)) { throw new Error(`${label} JVM runtime is missing ${relativePath}`); } if (packagedInventory.get(relativePath) !== sourceDigest) { throw new Error(`${label} JVM runtime SHA256 mismatch for ${relativePath}`); } } for (const relativePath of packagedInventory.keys()) { if (!sourceInventory.has(relativePath)) { throw new Error(`${label} JVM runtime contains unexpected artifact ${relativePath}`); } } } function findArchiveJvmRuntimeRoots(extractionRoot) { const roots = new Map(); const marker = ["resources", "app.asar.unpacked", "resources", "extractor-jvm"]; for (const filePath of listFiles(extractionRoot)) { const parts = normalizeRelativePath(extractionRoot, filePath).split("/"); for (let index = 0; index <= parts.length - marker.length; index += 1) { const candidate = parts.slice(index, index + marker.length); if (candidate.every((part, markerIndex) => part.toLowerCase() === marker[markerIndex])) { const relativeRoot = parts.slice(0, index + marker.length).join("/"); roots.set(relativeRoot.toLowerCase(), path.join(extractionRoot, ...relativeRoot.split("/"))); break; } } } return [...roots.values()]; } function verifyArchiveJvmRuntime(extractionRoot, archiveName, sourceRoot) { const runtimeRoots = findArchiveJvmRuntimeRoots(extractionRoot); if (runtimeRoots.length === 0) { throw new Error(`Missing JVM runtime in archive ${archiveName}`); } const sourceRuntimeRoot = path.join(sourceRoot, "resources", "extractor-jvm"); for (const runtimeRoot of runtimeRoots) { verifyJvmRuntimeEquality(sourceRuntimeRoot, runtimeRoot, `${archiveName}`); } } function verifyPackagedIcon(sourceRoot, packagedRoot, label) { const sourcePath = path.join(sourceRoot, "assets", "app_icon.ico"); const packagedPath = path.join(packagedRoot, "resources", "assets", "app_icon.ico"); requireNonEmptyFile(sourcePath, "source application icon"); requireNonEmptyFile(packagedPath, `${label} application icon`); if (sha256File(sourcePath) !== sha256File(packagedPath)) { throw new Error(`${label} application icon content mismatch for resources/assets/app_icon.ico`); } } function runCommand(command, args) { const result = spawnSync(command, args, { encoding: "utf8", windowsHide: true }); return { status: result.status, stdout: String(result.stdout || ""), stderr: String(result.stderr || ""), error: result.error }; } function listFiles(rootDir) { const files = []; const pending = [rootDir]; while (pending.length > 0) { const current = pending.pop(); for (const entry of fs.readdirSync(current, { withFileTypes: true })) { const entryPath = path.join(current, entry.name); const stat = fs.lstatSync(entryPath); if (stat.isSymbolicLink()) { files.push(entryPath); } else if (stat.isDirectory()) { pending.push(entryPath); } else if (stat.isFile()) { files.push(entryPath); } } } return files; } function extractArchive(archivePath, outputDir, sevenZipPath, commandRunner) { fs.mkdirSync(outputDir, { recursive: true }); const args = ["x", archivePath, `-o${outputDir}`, "-y", "-bb0", "-bd"]; const result = commandRunner(sevenZipPath, args); if (result?.error || result?.status !== 0) { const detail = String(result?.error?.message || result?.stderr || result?.stdout || "unknown error").trim(); throw new Error(`7-Zip command failed for ${archivePath}: ${detail}`); } } function extractArchiveTree(archivePath, outputDir, sevenZipPath, commandRunner, depth = 0) { if (depth > 5) { throw new Error(`Archive nesting limit exceeded for ${archivePath}`); } extractArchive(archivePath, outputDir, sevenZipPath, commandRunner); const nestedArchives = listFiles(outputDir).filter((filePath) => /\.7z$/i.test(filePath)); for (let index = 0; index < nestedArchives.length; index += 1) { const nestedArchive = nestedArchives[index]; const nestedOutput = path.join(outputDir, `.nested-${depth}-${index}`); extractArchiveTree(nestedArchive, nestedOutput, sevenZipPath, commandRunner, depth + 1); } } function normalizeRelativePath(rootDir, filePath) { return path.relative(rootDir, filePath).split(path.sep).join("/"); } function verifyArchiveRedistributionFiles(extractionRoot, archiveName, sourceRoot) { const extractedFiles = listFiles(extractionRoot); for (const file of REDISTRIBUTION_FILES) { const suffix = file.packagedPath.toLowerCase(); const matches = extractedFiles.filter((filePath) => { const relativePath = normalizeRelativePath(extractionRoot, filePath).toLowerCase(); return relativePath === suffix || relativePath.endsWith(`/${suffix}`); }); if (matches.length === 0) { throw new Error(`Missing redistribution file ${file.packagedPath} in archive ${archiveName}`); } for (const filePath of matches) { requireNonEmptyFile(filePath, `${archiveName} redistribution file`); const actualDigest = sha256NormalizedText(filePath); if (actualDigest !== file.sha256) { throw new Error(`Archive redistribution content mismatch for ${file.packagedPath} in ${archiveName}`); } } } const iconMatches = extractedFiles.filter((filePath) => normalizeRelativePath(extractionRoot, filePath).toLowerCase().endsWith("resources/assets/app_icon.ico")); if (iconMatches.length === 0) { throw new Error(`Missing application icon resources/assets/app_icon.ico in archive ${archiveName}`); } const sourceIconPath = path.join(sourceRoot, "assets", "app_icon.ico"); requireNonEmptyFile(sourceIconPath, "source application icon"); const sourceDigest = sha256File(sourceIconPath); for (const iconPath of iconMatches) { requireNonEmptyFile(iconPath, `${archiveName} application icon`); if (sha256File(iconPath) !== sourceDigest) { throw new Error(`Archive application icon content mismatch in ${archiveName}`); } } } function readPackageVersion(rootDir) { const packageJson = JSON.parse(readRequiredFile(path.join(rootDir, "package.json"))); const version = String(packageJson.version || "").trim(); if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(version)) { throw new Error(`package.json contains invalid version ${version || ""}`); } return { packageJson, version }; } function sha512Base64(filePath) { const hash = crypto.createHash("sha512"); const handle = fs.openSync(filePath, "r"); const buffer = Buffer.allocUnsafe(1024 * 1024); try { let bytesRead; do { bytesRead = fs.readSync(handle, buffer, 0, buffer.length, null); if (bytesRead > 0) { hash.update(buffer.subarray(0, bytesRead)); } } while (bytesRead > 0); } finally { fs.closeSync(handle); } return hash.digest("base64"); } export function verifyPublicRelease(rootDir = process.cwd()) { const absoluteRoot = path.resolve(rootDir); const { packageJson, version } = readPackageVersion(absoluteRoot); const build = packageJson.build || {}; const publish = build.publish || {}; assertEqual(build.productName, EXPECTED_PRODUCT_NAME, "package.json build.productName"); assertEqual(publish.provider, EXPECTED_PUBLISH.provider, "package.json publish provider"); assertEqual(publish.owner, EXPECTED_PUBLISH.owner, "package.json publish owner"); assertEqual(publish.repo, EXPECTED_PUBLISH.repo, "package.json publish repo"); assertEqual(build.nsis?.artifactName, EXPECTED_NSIS_ARTIFACT_NAME, "package.json NSIS artifactName"); assertEqual(build.nsis?.include, EXPECTED_NSIS_INCLUDE, "package.json NSIS update include"); assertEqual(build.portable?.artifactName, EXPECTED_PORTABLE_ARTIFACT_NAME, "package.json portable artifactName"); const nsisInclude = readRequiredFile(path.join(absoluteRoot, ...EXPECTED_NSIS_INCLUDE.split("/"))); for (const requiredText of ["!macro customCheckAppRunning", "${isUpdated}", "FIND_PROCESS", "taskkill /f /im"]) { if (!nsisInclude.includes(requiredText)) { throw new Error(`NSIS update include omits ${requiredText}`); } } const buildFiles = Array.isArray(build.files) ? build.files : []; const missingBuildFiles = REQUIRED_BUILD_FILES.filter((entry) => !buildFiles.includes(entry)); if (missingBuildFiles.length > 0) { throw new Error(`package.json build.files omits redistribution content: ${missingBuildFiles.join(", ")}`); } const asarUnpack = Array.isArray(build.asarUnpack) ? build.asarUnpack : [build.asarUnpack]; if (!asarUnpack.includes(EXPECTED_JVM_ASAR_UNPACK)) { throw new Error(`package.json build.asarUnpack must include ${EXPECTED_JVM_ASAR_UNPACK}`); } if (!hasExtraResource(build.extraResources, EXPECTED_EXTRA_RESOURCE)) { throw new Error("package.json build.extraResources must copy LICENSE to LICENSE"); } if (!hasExtraResource(build.extraResources, EXPECTED_ICON_RESOURCE)) { throw new Error("package.json build.extraResources must copy assets/app_icon.ico to assets/app_icon.ico"); } const releaseDir = resolveReleaseDir(absoluteRoot); const latestFields = parseYamlDocument( readRequiredFile(path.join(releaseDir, "latest.yml")), "latest.yml" ); const appUpdateFields = parseYamlDocument( readRequiredFile(path.join(releaseDir, "win-unpacked", "resources", "app-update.yml")), "app-update.yml" ); assertEqual(latestFields.version, version, "latest.yml version"); assertEqual(appUpdateFields.provider, EXPECTED_PUBLISH.provider, "app-update.yml provider"); assertEqual(appUpdateFields.owner, EXPECTED_PUBLISH.owner, "app-update.yml owner"); assertEqual(appUpdateFields.repo, EXPECTED_PUBLISH.repo, "app-update.yml repo"); const expectedSetup = `${EXPECTED_PRODUCT_NAME}-Setup-${version}.exe`; const expectedPortable = `${EXPECTED_PRODUCT_NAME}-${version}-portable.exe`; const expectedBlockmap = `${expectedSetup}.blockmap`; assertEqual(latestFields.path, expectedSetup, "latest.yml path"); const packagedVersions = readPackagedMainVersion(path.join(releaseDir, "win-unpacked", "resources", "app.asar")); assertEqual(packagedVersions.packageVersion, version, "packaged package version"); assertEqual(packagedVersions.bundledVersion, version, "packaged main bundle version"); const requiredArtifacts = [expectedSetup, expectedPortable, expectedBlockmap]; const missingArtifacts = requiredArtifacts.filter((fileName) => !fs.existsSync(path.join(releaseDir, fileName))); if (missingArtifacts.length > 0) { throw new Error(`Missing release artifacts: ${missingArtifacts.join(", ")}`); } const artifactStats = new Map(); for (const fileName of requiredArtifacts) { artifactStats.set(fileName, requireNonEmptyFile(path.join(releaseDir, fileName), "release artifact")); } if (!Array.isArray(latestFields.files) || latestFields.files.length !== 1) { throw new Error("latest.yml files must contain exactly one canonical installer entry"); } const installerEntry = latestFields.files[0]; if (!installerEntry || typeof installerEntry !== "object" || Array.isArray(installerEntry)) { throw new Error("latest.yml files entry must be a mapping"); } assertEqual(installerEntry.url, expectedSetup, "latest.yml files url"); const expectedSetupSize = artifactStats.get(expectedSetup).size; assertEqual(installerEntry.size, expectedSetupSize, "latest.yml files size"); const expectedSetupDigest = sha512Base64(path.join(releaseDir, expectedSetup)); assertEqual(installerEntry.sha512, expectedSetupDigest, "latest.yml files SHA512 digest"); assertEqual(latestFields.sha512, expectedSetupDigest, "latest.yml SHA512 digest"); verifyRedistributionFiles(absoluteRoot, "sourcePath", "source"); verifyRedistributionFiles(path.join(releaseDir, "win-unpacked"), "packagedPath", "win-unpacked"); verifyPackagedIcon(absoluteRoot, path.join(releaseDir, "win-unpacked"), "win-unpacked"); verifyJvmRuntimeEquality( path.join(absoluteRoot, "resources", "extractor-jvm"), path.join(releaseDir, "win-unpacked", "resources", "app.asar.unpacked", "resources", "extractor-jvm"), "win-unpacked" ); return { publish: { provider: publish.provider, owner: publish.owner, repo: publish.repo }, latestArtifact: latestFields.path, missingArtifacts }; } export function verifyReleaseArchives(rootDir = process.cwd(), options = {}) { const absoluteRoot = path.resolve(rootDir); const { version } = readPackageVersion(absoluteRoot); const releaseDir = resolveReleaseDir(absoluteRoot); const sevenZipPath = String(options.sevenZipPath || process.env.SEVEN_ZIP_PATH || "7z").trim(); const commandRunner = options.runCommand || runCommand; const archiveNames = [ `${EXPECTED_PRODUCT_NAME}-Setup-${version}.exe`, `${EXPECTED_PRODUCT_NAME}-${version}-portable.exe` ]; const verifiedArchives = []; for (const archiveName of archiveNames) { const archivePath = path.join(releaseDir, archiveName); requireNonEmptyFile(archivePath, "release archive"); const extractionRoot = fs.mkdtempSync(path.join(os.tmpdir(), "public-release-archive-")); try { extractArchiveTree(archivePath, extractionRoot, sevenZipPath, commandRunner); verifyArchiveRedistributionFiles(extractionRoot, archiveName, absoluteRoot); verifyArchiveJvmRuntime(extractionRoot, archiveName, absoluteRoot); verifiedArchives.push(archiveName); } finally { fs.rmSync(extractionRoot, { recursive: true, force: true }); } } return { verifiedArchives }; } function parseCliArgs(argv) { let rootDir = ""; let verifyArchives = false; let sevenZipPath = ""; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--verify-archives") { verifyArchives = true; } else if (arg === "--seven-zip") { index += 1; sevenZipPath = String(argv[index] || "").trim(); if (!sevenZipPath) { throw new Error("--seven-zip requires an executable path"); } } else if (arg.startsWith("--")) { throw new Error(`Unknown argument: ${arg}`); } else if (!rootDir) { rootDir = arg; } else { throw new Error(`Unexpected argument: ${arg}`); } } if (sevenZipPath && !verifyArchives) { throw new Error("--seven-zip requires --verify-archives"); } return { rootDir: rootDir || process.cwd(), verifyArchives, sevenZipPath: sevenZipPath || process.env.SEVEN_ZIP_PATH || "7z" }; } const isCli = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); if (isCli) { try { const args = parseCliArgs(process.argv.slice(2)); const result = verifyPublicRelease(args.rootDir); const archiveResult = args.verifyArchives ? verifyReleaseArchives(args.rootDir, { sevenZipPath: args.sevenZipPath }) : { verifiedArchives: [] }; process.stdout.write(`${JSON.stringify({ ...result, ...archiveResult })}\n`); } catch (error) { process.stderr.write(`${String(error?.message || error)}\n`); process.exitCode = 1; } }