393 lines
15 KiB
JavaScript
393 lines
15 KiB
JavaScript
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 || "<empty>"}`);
|
|
}
|
|
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;
|
|
}
|
|
}
|