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"; const EXPECTED_PUBLISH = Object.freeze({ provider: "github", owner: "Sucukdeluxe", repo: "multi-debrid-downloader" }); const EXPECTED_PRODUCT_NAME = "Real-Debrid-Downloader"; const EXPECTED_NSIS_ARTIFACT_NAME = "${productName}-Setup-${version}.${ext}"; const EXPECTED_PORTABLE_ARTIFACT_NAME = "${productName}-${version}-portable.${ext}"; const REQUIRED_BUILD_FILES = Object.freeze([ "resources/extractor-jvm/**/*", "LICENSE" ]); const EXPECTED_EXTRA_RESOURCE = Object.freeze({ from: "LICENSE", to: "LICENSE" }); const REDISTRIBUTION_FILES = Object.freeze([ Object.freeze({ sourcePath: "LICENSE", packagedPath: "resources/LICENSE", sha256: "f2c1bc02d9ba5235cc67dfea734e7dc90559b00d8cb2d142bad7a984ff96d3f6" }), 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 hasExpectedExtraResource(extraResources) { const entries = Array.isArray(extraResources) ? extraResources : [extraResources]; return entries.some((entry) => ( entry && typeof entry === "object" && !Array.isArray(entry) && entry.from === EXPECTED_EXTRA_RESOURCE.from && entry.to === EXPECTED_EXTRA_RESOURCE.to )); } 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) { 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}`); } } } } 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.portable?.artifactName, EXPECTED_PORTABLE_ARTIFACT_NAME, "package.json portable artifactName"); 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(", ")}`); } if (!hasExpectedExtraResource(build.extraResources)) { throw new Error("package.json build.extraResources must copy LICENSE to LICENSE"); } 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 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"); 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); 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; } }