release: harden archive recovery and lifecycle for v2.0.63
Corroborate CRC failures across extraction backends, retry only implicated multipart volumes, and distinguish corruption, missing volumes, I/O failures, and wrong passwords across native, Zip4j, and JBinding paths. Make disk retries generation-safe, preserve selective run scopes and cooldowns, protect shared output files during cleanup, validate manual extraction batches atomically, and restore interrupted integrity work safely. Prioritize active package operations in the UI, strengthen extraction IPC validation, compile the JVM sidecar before release builds, and verify shipped JVM resources byte-for-byte in every Windows artifact.
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const defaultRuntimeRoot = path.resolve(scriptDir, "..", "resources", "extractor-jvm");
|
||||
const requiredLibNames = [
|
||||
"sevenzipjbinding.jar",
|
||||
"sevenzipjbinding-all-platforms.jar",
|
||||
"zip4j.jar"
|
||||
];
|
||||
const stampName = ".source.sha256";
|
||||
|
||||
function parseArguments(args) {
|
||||
let runtimeRoot = defaultRuntimeRoot;
|
||||
let checkOnly = false;
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const value = args[index];
|
||||
if (value === "--check") {
|
||||
checkOnly = true;
|
||||
continue;
|
||||
}
|
||||
if (value === "--runtime-root") {
|
||||
const next = args[index + 1];
|
||||
if (!next || next.startsWith("--")) {
|
||||
throw new Error("--runtime-root benötigt einen Pfad");
|
||||
}
|
||||
runtimeRoot = path.resolve(next);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unbekanntes Argument: ${value}`);
|
||||
}
|
||||
return { runtimeRoot, checkOnly };
|
||||
}
|
||||
|
||||
function requireFile(filePath, label) {
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.lstatSync(filePath);
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") {
|
||||
throw new Error(`${label} fehlt: ${filePath}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!stat.isFile() || stat.size === 0 || stat.isSymbolicLink()) {
|
||||
throw new Error(`${label} ist keine reguläre, nicht leere Datei: ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function listJavaSources(sourceRoot) {
|
||||
const sources = [];
|
||||
const pending = [sourceRoot];
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const entryPath = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
pending.push(entryPath);
|
||||
} else if (entry.isFile() && entry.name.endsWith(".java")) {
|
||||
sources.push(entryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
sources.sort((left, right) => left.localeCompare(right, "en"));
|
||||
if (sources.length === 0) {
|
||||
throw new Error(`Keine Java-Quellen gefunden: ${sourceRoot}`);
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
function updateDigest(hash, root, filePath) {
|
||||
const relativePath = path.relative(root, filePath).replace(/\\/g, "/");
|
||||
const content = fs.readFileSync(filePath);
|
||||
hash.update(relativePath, "utf8");
|
||||
hash.update("\0", "utf8");
|
||||
hash.update(String(content.length), "utf8");
|
||||
hash.update("\0", "utf8");
|
||||
hash.update(content);
|
||||
}
|
||||
|
||||
function sourceDigest(runtimeRoot, sources, libs) {
|
||||
const hash = crypto.createHash("sha256");
|
||||
hash.update("javac:-source=8:-target=8:-encoding=UTF-8:-g:none\0", "utf8");
|
||||
for (const filePath of [...sources, ...libs]) {
|
||||
updateDigest(hash, runtimeRoot, filePath);
|
||||
}
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
function verifyClassesDirectory(classesDir, expectedDigest) {
|
||||
const mainClass = path.join(classesDir, "com", "sucukdeluxe", "extractor", "JBindExtractorMain.class");
|
||||
const stampPath = path.join(classesDir, stampName);
|
||||
requireFile(mainClass, "JVM-Extractor-Hauptklasse");
|
||||
requireFile(stampPath, "JVM-Extractor-Quellhash");
|
||||
const mainClassBytes = fs.readFileSync(mainClass);
|
||||
if (mainClassBytes.length < 8 || mainClassBytes.readUInt16BE(6) !== 52) {
|
||||
throw new Error("JVM-Extractor-Hauptklasse muss Java-8-Bytecode verwenden");
|
||||
}
|
||||
const actualDigest = fs.readFileSync(stampPath, "utf8").trim();
|
||||
if (actualDigest !== expectedDigest) {
|
||||
throw new Error("JVM-Extractor-Klassen sind veraltet; Java-Quelle oder Bibliotheken wurden seit dem letzten Build geändert");
|
||||
}
|
||||
}
|
||||
|
||||
function verifyClasses(runtimeRoot, expectedDigest) {
|
||||
verifyClassesDirectory(path.join(runtimeRoot, "classes"), expectedDigest);
|
||||
}
|
||||
|
||||
function resolveJavac() {
|
||||
const javaHome = String(process.env.JAVA_HOME || "").trim();
|
||||
const javaHomeCompiler = javaHome
|
||||
? path.join(javaHome, "bin", process.platform === "win32" ? "javac.exe" : "javac")
|
||||
: "";
|
||||
if (javaHomeCompiler && fs.existsSync(javaHomeCompiler)) {
|
||||
return javaHomeCompiler;
|
||||
}
|
||||
return "javac";
|
||||
}
|
||||
|
||||
function requireJavac() {
|
||||
const command = resolveJavac();
|
||||
const probe = spawnSync(command, ["-version"], { encoding: "utf8", windowsHide: true });
|
||||
if (probe.error?.code === "ENOENT" || probe.status === null) {
|
||||
throw new Error("JDK fehlt: javac wurde nicht gefunden; installiere ein JDK und setze JAVA_HOME oder PATH");
|
||||
}
|
||||
if (probe.status !== 0) {
|
||||
throw new Error(`JDK-Compiler javac ist nicht verwendbar: ${String(probe.stderr || probe.stdout || `Exit ${probe.status}`).trim()}`);
|
||||
}
|
||||
return command;
|
||||
}
|
||||
|
||||
function replaceClasses(runtimeRoot, temporaryClasses) {
|
||||
const classesDir = path.join(runtimeRoot, "classes");
|
||||
const backupDir = path.join(runtimeRoot, `.classes-previous-${process.pid}-${Date.now()}`);
|
||||
let previousMoved = false;
|
||||
try {
|
||||
if (fs.existsSync(classesDir)) {
|
||||
fs.renameSync(classesDir, backupDir);
|
||||
previousMoved = true;
|
||||
}
|
||||
fs.renameSync(temporaryClasses, classesDir);
|
||||
if (previousMoved) {
|
||||
fs.rmSync(backupDir, { recursive: true, force: true });
|
||||
}
|
||||
} catch (error) {
|
||||
if (!fs.existsSync(classesDir) && previousMoved && fs.existsSync(backupDir)) {
|
||||
fs.renameSync(backupDir, classesDir);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function compileClasses(runtimeRoot, sources, libs, digest) {
|
||||
const javac = requireJavac();
|
||||
const temporaryClasses = fs.mkdtempSync(path.join(runtimeRoot, ".classes-build-"));
|
||||
try {
|
||||
const result = spawnSync(javac, [
|
||||
"-source", "8",
|
||||
"-target", "8",
|
||||
"-encoding", "UTF-8",
|
||||
"-g:none",
|
||||
"-classpath", libs.join(path.delimiter),
|
||||
"-d", temporaryClasses,
|
||||
...sources
|
||||
], { encoding: "utf8", windowsHide: true });
|
||||
if (result.error?.code === "ENOENT") {
|
||||
throw new Error("JDK fehlt: javac wurde nicht gefunden; installiere ein JDK und setze JAVA_HOME oder PATH");
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`javac fehlgeschlagen: ${String(result.stderr || result.stdout || `Exit ${result.status}`).trim()}`);
|
||||
}
|
||||
fs.writeFileSync(path.join(temporaryClasses, stampName), `${digest}\n`, "utf8");
|
||||
verifyClassesDirectory(temporaryClasses, digest);
|
||||
replaceClasses(runtimeRoot, temporaryClasses);
|
||||
} finally {
|
||||
if (fs.existsSync(temporaryClasses)) {
|
||||
fs.rmSync(temporaryClasses, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { runtimeRoot, checkOnly } = parseArguments(process.argv.slice(2));
|
||||
const sourceRoot = path.join(runtimeRoot, "src");
|
||||
const sources = listJavaSources(sourceRoot);
|
||||
const libs = requiredLibNames.map((name) => path.join(runtimeRoot, "lib", name));
|
||||
for (const source of sources) {
|
||||
requireFile(source, "Java-Quelle");
|
||||
}
|
||||
for (const lib of libs) {
|
||||
requireFile(lib, "JVM-Extractor-Bibliothek");
|
||||
}
|
||||
const digest = sourceDigest(runtimeRoot, sources, libs);
|
||||
if (checkOnly) {
|
||||
verifyClasses(runtimeRoot, digest);
|
||||
process.stdout.write(`JVM-Extractor-Klassen aktuell: ${digest}\n`);
|
||||
return;
|
||||
}
|
||||
compileClasses(runtimeRoot, sources, libs, digest);
|
||||
verifyClasses(runtimeRoot, digest);
|
||||
process.stdout.write(`JVM-Extractor-Klassen gebaut: ${digest}\n`);
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
process.stderr.write(`JVM-Extractor-Build fehlgeschlagen: ${String(error?.message || error)}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ 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",
|
||||
@@ -158,6 +159,97 @@ 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");
|
||||
@@ -308,11 +400,15 @@ export function verifyPublicRelease(rootDir = process.cwd()) {
|
||||
throw new Error(`NSIS update include omits ${requiredText}`);
|
||||
}
|
||||
}
|
||||
const buildFiles = Array.isArray(build.files) ? build.files : [];
|
||||
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 (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");
|
||||
}
|
||||
@@ -370,6 +466,11 @@ export function verifyPublicRelease(rootDir = process.cwd()) {
|
||||
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: {
|
||||
@@ -399,8 +500,9 @@ export function verifyReleaseArchives(rootDir = process.cwd(), options = {}) {
|
||||
requireNonEmptyFile(archivePath, "release archive");
|
||||
const extractionRoot = fs.mkdtempSync(path.join(os.tmpdir(), "public-release-archive-"));
|
||||
try {
|
||||
extractArchiveTree(archivePath, extractionRoot, sevenZipPath, commandRunner);
|
||||
extractArchiveTree(archivePath, extractionRoot, sevenZipPath, commandRunner);
|
||||
verifyArchiveRedistributionFiles(extractionRoot, archiveName, absoluteRoot);
|
||||
verifyArchiveJvmRuntime(extractionRoot, archiveName, absoluteRoot);
|
||||
verifiedArchives.push(archiveName);
|
||||
} finally {
|
||||
fs.rmSync(extractionRoot, { recursive: true, force: true });
|
||||
|
||||
Reference in New Issue
Block a user