|
|
|
|
@ -10,7 +10,7 @@ import { removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
|
|
|
|
|
import crypto from "node:crypto";
|
|
|
|
|
|
|
|
|
|
const DEFAULT_ARCHIVE_PASSWORDS = ["", "serienfans.org", "serienjunkies.org"];
|
|
|
|
|
const NO_EXTRACTOR_MESSAGE = "WinRAR/UnRAR nicht gefunden. Bitte WinRAR installieren.";
|
|
|
|
|
const NO_EXTRACTOR_MESSAGE = "Kein nativer Entpacker gefunden (7-Zip/WinRAR). Bitte 7-Zip oder WinRAR installieren.";
|
|
|
|
|
const NO_JVM_EXTRACTOR_MESSAGE = "7-Zip-JBinding Runtime nicht gefunden. Bitte resources/extractor-jvm prüfen.";
|
|
|
|
|
const JVM_EXTRACTOR_MAIN_CLASS = "com.sucukdeluxe.extractor.JBindExtractorMain";
|
|
|
|
|
const JVM_EXTRACTOR_CLASSES_SUBDIR = "classes";
|
|
|
|
|
@ -123,6 +123,8 @@ export interface ExtractProgressUpdate {
|
|
|
|
|
passwordAttempt?: number;
|
|
|
|
|
passwordTotal?: number;
|
|
|
|
|
passwordFound?: boolean;
|
|
|
|
|
archiveDone?: boolean;
|
|
|
|
|
archiveSuccess?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const MAX_EXTRACT_OUTPUT_BUFFER = 48 * 1024;
|
|
|
|
|
@ -133,6 +135,8 @@ const EXTRACT_MAX_TIMEOUT_MS = 120 * 60 * 1000;
|
|
|
|
|
const ARCHIVE_SORT_COLLATOR = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
|
|
|
|
|
const DISK_SPACE_SAFETY_FACTOR = 1.1;
|
|
|
|
|
const NESTED_EXTRACT_BLACKLIST_RE = /\.(iso|img|bin|dmg|vhd|vhdx|vmdk|wim)$/i;
|
|
|
|
|
const PACKAGE_PASSWORD_CACHE_LIMIT = 256;
|
|
|
|
|
const packageLearnedPasswords = new Map<string, string>();
|
|
|
|
|
|
|
|
|
|
export type ArchiveSignature = "rar" | "7z" | "zip" | "gzip" | "bzip2" | "xz" | null;
|
|
|
|
|
|
|
|
|
|
@ -145,6 +149,54 @@ const ARCHIVE_SIGNATURES: { prefix: string; type: ArchiveSignature }[] = [
|
|
|
|
|
{ prefix: "fd377a585a00", type: "xz" },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
function packagePasswordCacheKey(packageDir: string, packageId?: string): string {
|
|
|
|
|
const normalizedPackageId = String(packageId || "").trim();
|
|
|
|
|
if (normalizedPackageId) {
|
|
|
|
|
return `pkg:${normalizedPackageId}`;
|
|
|
|
|
}
|
|
|
|
|
return `dir:${pathSetKey(path.resolve(packageDir))}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function packagePasswordCacheLabel(packageDir: string, packageId?: string): string {
|
|
|
|
|
const normalizedPackageId = String(packageId || "").trim();
|
|
|
|
|
if (normalizedPackageId) {
|
|
|
|
|
return `packageId=${normalizedPackageId.slice(0, 8)}`;
|
|
|
|
|
}
|
|
|
|
|
return `packageDir=${path.basename(path.resolve(packageDir))}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readCachedPackagePassword(cacheKey: string): string {
|
|
|
|
|
const cached = packageLearnedPasswords.get(cacheKey);
|
|
|
|
|
if (!cached) {
|
|
|
|
|
return "";
|
|
|
|
|
}
|
|
|
|
|
// Refresh insertion order to keep recently used package caches alive.
|
|
|
|
|
packageLearnedPasswords.delete(cacheKey);
|
|
|
|
|
packageLearnedPasswords.set(cacheKey, cached);
|
|
|
|
|
return cached;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function writeCachedPackagePassword(cacheKey: string, password: string): void {
|
|
|
|
|
const normalized = String(password || "").trim();
|
|
|
|
|
if (!normalized) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (packageLearnedPasswords.has(cacheKey)) {
|
|
|
|
|
packageLearnedPasswords.delete(cacheKey);
|
|
|
|
|
}
|
|
|
|
|
packageLearnedPasswords.set(cacheKey, normalized);
|
|
|
|
|
if (packageLearnedPasswords.size > PACKAGE_PASSWORD_CACHE_LIMIT) {
|
|
|
|
|
const oldestKey = packageLearnedPasswords.keys().next().value as string | undefined;
|
|
|
|
|
if (oldestKey) {
|
|
|
|
|
packageLearnedPasswords.delete(oldestKey);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clearCachedPackagePassword(cacheKey: string): void {
|
|
|
|
|
packageLearnedPasswords.delete(cacheKey);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function detectArchiveSignature(filePath: string): Promise<ArchiveSignature> {
|
|
|
|
|
let fd: fs.promises.FileHandle | null = null;
|
|
|
|
|
try {
|
|
|
|
|
@ -378,6 +430,12 @@ function parseProgressPercent(chunk: string): number | null {
|
|
|
|
|
return latest;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function nextArchivePercent(previous: number, incoming: number): number {
|
|
|
|
|
const prev = Math.max(0, Math.min(100, Math.floor(Number(previous) || 0)));
|
|
|
|
|
const next = Math.max(0, Math.min(100, Math.floor(Number(incoming) || 0)));
|
|
|
|
|
return next >= prev ? next : prev;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function shouldPreferExternalZip(archivePath: string): Promise<boolean> {
|
|
|
|
|
if (extractorBackendMode() !== "legacy") {
|
|
|
|
|
return true;
|
|
|
|
|
@ -529,32 +587,63 @@ function prioritizePassword(passwords: string[], successful: string): string[] {
|
|
|
|
|
return passwords;
|
|
|
|
|
}
|
|
|
|
|
const index = passwords.findIndex((candidate) => candidate === target);
|
|
|
|
|
if (index <= 0) {
|
|
|
|
|
if (index === 0) {
|
|
|
|
|
return passwords;
|
|
|
|
|
}
|
|
|
|
|
if (index < 0) {
|
|
|
|
|
return [target, ...passwords.filter((candidate) => candidate !== target)];
|
|
|
|
|
}
|
|
|
|
|
const next = [...passwords];
|
|
|
|
|
const [value] = next.splice(index, 1);
|
|
|
|
|
next.unshift(value);
|
|
|
|
|
return next;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function winRarCandidates(): string[] {
|
|
|
|
|
function nativeExtractorCandidates(): string[] {
|
|
|
|
|
const programFiles = process.env.ProgramFiles || "C:\\Program Files";
|
|
|
|
|
const programFilesX86 = process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)";
|
|
|
|
|
const localAppData = process.env.LOCALAPPDATA || "";
|
|
|
|
|
|
|
|
|
|
const installed = [
|
|
|
|
|
const sevenZipInstalled = [
|
|
|
|
|
process.env.RD_7Z_BIN || "",
|
|
|
|
|
path.join(programFiles, "7-Zip", "7z.exe"),
|
|
|
|
|
path.join(programFilesX86, "7-Zip", "7z.exe")
|
|
|
|
|
];
|
|
|
|
|
if (localAppData) {
|
|
|
|
|
sevenZipInstalled.push(path.join(localAppData, "Programs", "7-Zip", "7z.exe"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const winRarInstalled = [
|
|
|
|
|
path.join(programFiles, "WinRAR", "UnRAR.exe"),
|
|
|
|
|
path.join(programFilesX86, "WinRAR", "UnRAR.exe")
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
if (localAppData) {
|
|
|
|
|
installed.push(path.join(localAppData, "Programs", "WinRAR", "UnRAR.exe"));
|
|
|
|
|
winRarInstalled.push(path.join(localAppData, "Programs", "WinRAR", "UnRAR.exe"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ordered = resolvedExtractorCommand
|
|
|
|
|
? [resolvedExtractorCommand, ...installed, "UnRAR.exe", "unrar"]
|
|
|
|
|
: [...installed, "UnRAR.exe", "unrar"];
|
|
|
|
|
? [
|
|
|
|
|
resolvedExtractorCommand,
|
|
|
|
|
...sevenZipInstalled,
|
|
|
|
|
"7z.exe",
|
|
|
|
|
"7z",
|
|
|
|
|
"7za.exe",
|
|
|
|
|
"7za",
|
|
|
|
|
...winRarInstalled,
|
|
|
|
|
"UnRAR.exe",
|
|
|
|
|
"unrar"
|
|
|
|
|
]
|
|
|
|
|
: [
|
|
|
|
|
...sevenZipInstalled,
|
|
|
|
|
"7z.exe",
|
|
|
|
|
"7z",
|
|
|
|
|
"7za.exe",
|
|
|
|
|
"7za",
|
|
|
|
|
...winRarInstalled,
|
|
|
|
|
"UnRAR.exe",
|
|
|
|
|
"unrar"
|
|
|
|
|
];
|
|
|
|
|
return Array.from(new Set(ordered.filter(Boolean)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -849,7 +938,7 @@ type JvmExtractResult = {
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function extractorBackendMode(): ExtractBackendMode {
|
|
|
|
|
const defaultMode = process.env.VITEST ? "legacy" : "jvm";
|
|
|
|
|
const defaultMode = "legacy";
|
|
|
|
|
const raw = String(process.env.RD_EXTRACT_BACKEND || defaultMode).trim().toLowerCase();
|
|
|
|
|
if (raw === "legacy") {
|
|
|
|
|
return "legacy";
|
|
|
|
|
@ -961,9 +1050,12 @@ function parseJvmLine(
|
|
|
|
|
|
|
|
|
|
if (trimmed.startsWith("RD_PROGRESS ")) {
|
|
|
|
|
const parsed = parseProgressPercent(trimmed);
|
|
|
|
|
if (parsed !== null && parsed > state.bestPercent) {
|
|
|
|
|
state.bestPercent = parsed;
|
|
|
|
|
onArchiveProgress?.(parsed);
|
|
|
|
|
if (parsed !== null) {
|
|
|
|
|
const next = nextArchivePercent(state.bestPercent, parsed);
|
|
|
|
|
if (next !== state.bestPercent) {
|
|
|
|
|
state.bestPercent = next;
|
|
|
|
|
onArchiveProgress?.(next);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
@ -998,6 +1090,9 @@ interface DaemonRequest {
|
|
|
|
|
signal?: AbortSignal;
|
|
|
|
|
timeoutMs?: number;
|
|
|
|
|
parseState: { bestPercent: number; usedPassword: string; backend: string; reportedError: string };
|
|
|
|
|
archiveName: string;
|
|
|
|
|
startedAt: number;
|
|
|
|
|
passwordCount: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let daemonProcess: ChildProcess | null = null;
|
|
|
|
|
@ -1060,6 +1155,11 @@ function handleDaemonLine(line: string): void {
|
|
|
|
|
const code = parseInt(trimmed.slice("RD_REQUEST_DONE ".length).trim(), 10);
|
|
|
|
|
const req = daemonCurrentRequest;
|
|
|
|
|
if (!req) return;
|
|
|
|
|
const elapsedMs = Date.now() - req.startedAt;
|
|
|
|
|
logger.info(
|
|
|
|
|
`JVM Daemon Request Ende: archive=${req.archiveName}, code=${code}, ms=${elapsedMs}, pwCandidates=${req.passwordCount}, ` +
|
|
|
|
|
`bestPercent=${req.parseState.bestPercent}, backend=${req.parseState.backend || "unknown"}, usedPassword=${req.parseState.usedPassword ? "yes" : "no"}`
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (code === 0) {
|
|
|
|
|
req.onArchiveProgress?.(100);
|
|
|
|
|
@ -1087,6 +1187,8 @@ function handleDaemonLine(line: string): void {
|
|
|
|
|
|
|
|
|
|
function startDaemon(layout: JvmExtractorLayout): boolean {
|
|
|
|
|
if (daemonProcess && daemonReady) return true;
|
|
|
|
|
// Don't kill a daemon that's still booting — it will become ready soon
|
|
|
|
|
if (daemonProcess) return false;
|
|
|
|
|
shutdownDaemon();
|
|
|
|
|
|
|
|
|
|
const jvmTmpDir = path.join(os.tmpdir(), `rd-extract-daemon-${crypto.randomUUID()}`);
|
|
|
|
|
@ -1182,6 +1284,22 @@ function isDaemonAvailable(layout: JvmExtractorLayout): boolean {
|
|
|
|
|
return Boolean(daemonProcess && daemonReady && !daemonBusy);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Wait for the daemon to become ready (boot phase) or free (busy phase), with timeout. */
|
|
|
|
|
function waitForDaemonReady(maxWaitMs: number, signal?: AbortSignal): Promise<boolean> {
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
const start = Date.now();
|
|
|
|
|
const check = () => {
|
|
|
|
|
if (signal?.aborted) { resolve(false); return; }
|
|
|
|
|
if (daemonProcess && daemonReady && !daemonBusy) { resolve(true); return; }
|
|
|
|
|
// Daemon died while we were waiting
|
|
|
|
|
if (!daemonProcess) { resolve(false); return; }
|
|
|
|
|
if (Date.now() - start >= maxWaitMs) { resolve(false); return; }
|
|
|
|
|
setTimeout(check, 50);
|
|
|
|
|
};
|
|
|
|
|
check();
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sendDaemonRequest(
|
|
|
|
|
archivePath: string,
|
|
|
|
|
targetDir: string,
|
|
|
|
|
@ -1194,10 +1312,21 @@ function sendDaemonRequest(
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
const mode = effectiveConflictMode(conflictMode);
|
|
|
|
|
const parseState = { bestPercent: 0, usedPassword: "", backend: "", reportedError: "" };
|
|
|
|
|
const archiveName = path.basename(archivePath);
|
|
|
|
|
|
|
|
|
|
daemonBusy = true;
|
|
|
|
|
daemonOutput = "";
|
|
|
|
|
daemonCurrentRequest = { resolve, onArchiveProgress, signal, timeoutMs, parseState };
|
|
|
|
|
daemonCurrentRequest = {
|
|
|
|
|
resolve,
|
|
|
|
|
onArchiveProgress,
|
|
|
|
|
signal,
|
|
|
|
|
timeoutMs,
|
|
|
|
|
parseState,
|
|
|
|
|
archiveName,
|
|
|
|
|
startedAt: Date.now(),
|
|
|
|
|
passwordCount: passwordCandidates.length
|
|
|
|
|
};
|
|
|
|
|
logger.info(`JVM Daemon Request Start: archive=${archiveName}, pwCandidates=${passwordCandidates.length}, timeoutMs=${timeoutMs || 0}, conflict=${mode}`);
|
|
|
|
|
|
|
|
|
|
// Set up timeout
|
|
|
|
|
if (timeoutMs && timeoutMs > 0) {
|
|
|
|
|
@ -1256,7 +1385,7 @@ function sendDaemonRequest(
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function runJvmExtractCommand(
|
|
|
|
|
async function runJvmExtractCommand(
|
|
|
|
|
layout: JvmExtractorLayout,
|
|
|
|
|
archivePath: string,
|
|
|
|
|
targetDir: string,
|
|
|
|
|
@ -1281,12 +1410,26 @@ function runJvmExtractCommand(
|
|
|
|
|
|
|
|
|
|
// Try persistent daemon first — saves ~5s JVM boot per archive
|
|
|
|
|
if (isDaemonAvailable(layout)) {
|
|
|
|
|
logger.info(`JVM Daemon: Sende Request für ${path.basename(archivePath)}`);
|
|
|
|
|
logger.info(`JVM Daemon: Sofort verfügbar, sende Request für ${path.basename(archivePath)} (pwCandidates=${passwordCandidates.length})`);
|
|
|
|
|
return sendDaemonRequest(archivePath, targetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fallback: spawn a new JVM process (daemon busy or not available)
|
|
|
|
|
logger.info(`JVM Spawn: Neuer Prozess für ${path.basename(archivePath)}${daemonBusy ? " (Daemon busy)" : ""}`);
|
|
|
|
|
// Daemon exists but is still booting or busy — wait up to 15s for it
|
|
|
|
|
if (daemonProcess) {
|
|
|
|
|
const reason = !daemonReady ? "booting" : "busy";
|
|
|
|
|
const waitStartedAt = Date.now();
|
|
|
|
|
logger.info(`JVM Daemon: Warte auf ${reason} Daemon für ${path.basename(archivePath)}...`);
|
|
|
|
|
const ready = await waitForDaemonReady(15_000, signal);
|
|
|
|
|
const waitedMs = Date.now() - waitStartedAt;
|
|
|
|
|
if (ready) {
|
|
|
|
|
logger.info(`JVM Daemon: Bereit nach ${waitedMs}ms — sende Request für ${path.basename(archivePath)}`);
|
|
|
|
|
return sendDaemonRequest(archivePath, targetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs);
|
|
|
|
|
}
|
|
|
|
|
logger.warn(`JVM Daemon: Timeout nach ${waitedMs}ms beim Warten — Fallback auf neuen Prozess für ${path.basename(archivePath)}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fallback: spawn a new JVM process (daemon not available after waiting)
|
|
|
|
|
logger.info(`JVM Spawn: Neuer Prozess für ${path.basename(archivePath)}`);
|
|
|
|
|
|
|
|
|
|
const mode = effectiveConflictMode(conflictMode);
|
|
|
|
|
// Each JVM process needs its own temp dir so parallel SevenZipJBinding
|
|
|
|
|
@ -1530,7 +1673,7 @@ async function resolveExtractorCommandInternal(): Promise<string> {
|
|
|
|
|
resolveFailureAt = 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const candidates = winRarCandidates();
|
|
|
|
|
const candidates = nativeExtractorCandidates();
|
|
|
|
|
for (const command of candidates) {
|
|
|
|
|
if (isAbsoluteCommand(command) && !fs.existsSync(command)) {
|
|
|
|
|
continue;
|
|
|
|
|
@ -1583,7 +1726,11 @@ async function runExternalExtract(
|
|
|
|
|
): Promise<string> {
|
|
|
|
|
const timeoutMs = await computeExtractTimeoutMs(archivePath);
|
|
|
|
|
const backendMode = extractorBackendMode();
|
|
|
|
|
const archiveName = path.basename(archivePath);
|
|
|
|
|
const totalStartedAt = Date.now();
|
|
|
|
|
let jvmFailureReason = "";
|
|
|
|
|
let fallbackFromJvm = false;
|
|
|
|
|
logger.info(`Extract-Backend Start: archive=${archiveName}, mode=${backendMode}, pwCandidates=${passwordCandidates.length}, timeoutMs=${timeoutMs}, hybrid=${hybridMode}`);
|
|
|
|
|
|
|
|
|
|
await fs.promises.mkdir(targetDir, { recursive: true });
|
|
|
|
|
|
|
|
|
|
@ -1604,7 +1751,8 @@ async function runExternalExtract(
|
|
|
|
|
logger.warn(`JVM-Extractor nicht verfügbar, nutze Legacy-Extractor: ${path.basename(archivePath)}`);
|
|
|
|
|
} else {
|
|
|
|
|
const quotedPasswords = passwordCandidates.map((p) => p === "" ? '""' : `"${p}"`);
|
|
|
|
|
logger.info(`JVM-Extractor aktiv (${layout.rootDir}): ${path.basename(archivePath)}, ${passwordCandidates.length} Passwörter: [${quotedPasswords.join(", ")}]`);
|
|
|
|
|
logger.info(`JVM-Extractor aktiv (${layout.rootDir}): ${archiveName}, ${passwordCandidates.length} Passwörter: [${quotedPasswords.join(", ")}]`);
|
|
|
|
|
const jvmStartedAt = Date.now();
|
|
|
|
|
const jvmResult = await runJvmExtractCommand(
|
|
|
|
|
layout,
|
|
|
|
|
archivePath,
|
|
|
|
|
@ -1615,9 +1763,12 @@ async function runExternalExtract(
|
|
|
|
|
signal,
|
|
|
|
|
timeoutMs
|
|
|
|
|
);
|
|
|
|
|
const jvmMs = Date.now() - jvmStartedAt;
|
|
|
|
|
logger.info(`JVM-Extractor Ergebnis: archive=${archiveName}, ok=${jvmResult.ok}, ms=${jvmMs}, timedOut=${jvmResult.timedOut}, aborted=${jvmResult.aborted}, backend=${jvmResult.backend || "unknown"}, usedPassword=${jvmResult.usedPassword ? "yes" : "no"}`);
|
|
|
|
|
|
|
|
|
|
if (jvmResult.ok) {
|
|
|
|
|
logger.info(`Entpackt via ${jvmResult.backend || "jvm"}: ${path.basename(archivePath)}`);
|
|
|
|
|
logger.info(`Entpackt via ${jvmResult.backend || "jvm"}: ${archiveName}`);
|
|
|
|
|
logger.info(`Extract-Backend Ende: archive=${archiveName}, backend=${jvmResult.backend || "jvm"}, mode=${backendMode}, ms=${Date.now() - totalStartedAt}, fallbackFromJvm=false, usedPassword=${jvmResult.usedPassword ? "yes" : "no"}`);
|
|
|
|
|
return jvmResult.usedPassword;
|
|
|
|
|
}
|
|
|
|
|
if (jvmResult.aborted) {
|
|
|
|
|
@ -1628,6 +1779,7 @@ async function runExternalExtract(
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
jvmFailureReason = jvmResult.errorText || "JVM-Extractor fehlgeschlagen";
|
|
|
|
|
fallbackFromJvm = true;
|
|
|
|
|
const jvmFailureLower = jvmFailureReason.toLowerCase();
|
|
|
|
|
const isUnsupportedMethod = jvmFailureReason.includes("UNSUPPORTEDMETHOD");
|
|
|
|
|
const isCodecError = jvmFailureLower.includes("registered codecs")
|
|
|
|
|
@ -1656,6 +1808,7 @@ async function runExternalExtract(
|
|
|
|
|
const effectiveTargetDir = subst ? `${subst.drive}:\\` : targetDir;
|
|
|
|
|
|
|
|
|
|
const command = await resolveExtractorCommand();
|
|
|
|
|
const legacyStartedAt = Date.now();
|
|
|
|
|
const password = await runExternalExtractInner(
|
|
|
|
|
command,
|
|
|
|
|
archivePath,
|
|
|
|
|
@ -1668,12 +1821,14 @@ async function runExternalExtract(
|
|
|
|
|
hybridMode,
|
|
|
|
|
onPasswordAttempt
|
|
|
|
|
);
|
|
|
|
|
const legacyMs = Date.now() - legacyStartedAt;
|
|
|
|
|
const extractorName = path.basename(command).replace(/\.exe$/i, "");
|
|
|
|
|
if (jvmFailureReason) {
|
|
|
|
|
logger.info(`Entpackt via legacy/${extractorName} (nach JVM-Fehler): ${path.basename(archivePath)}`);
|
|
|
|
|
logger.info(`Entpackt via legacy/${extractorName} (nach JVM-Fehler): ${archiveName}`);
|
|
|
|
|
} else {
|
|
|
|
|
logger.info(`Entpackt via legacy/${extractorName}: ${path.basename(archivePath)}`);
|
|
|
|
|
logger.info(`Entpackt via legacy/${extractorName}: ${archiveName}`);
|
|
|
|
|
}
|
|
|
|
|
logger.info(`Extract-Backend Ende: archive=${archiveName}, backend=legacy/${extractorName}, mode=${backendMode}, ms=${Date.now() - totalStartedAt}, legacyMs=${legacyMs}, fallbackFromJvm=${fallbackFromJvm}, usedPassword=${password ? "yes" : "no"}`);
|
|
|
|
|
return password;
|
|
|
|
|
} finally {
|
|
|
|
|
if (subst) removeSubstMapping(subst);
|
|
|
|
|
@ -1712,6 +1867,7 @@ async function runExternalExtractInner(
|
|
|
|
|
onArchiveProgress?.(0);
|
|
|
|
|
}
|
|
|
|
|
passwordAttempt += 1;
|
|
|
|
|
const attemptStartedAt = Date.now();
|
|
|
|
|
const quotedPw = password === "" ? '""' : `"${password}"`;
|
|
|
|
|
logger.info(`Legacy-Passwort-Versuch ${passwordAttempt}/${passwords.length} für ${path.basename(archivePath)}: ${quotedPw}`);
|
|
|
|
|
if (passwords.length > 1) {
|
|
|
|
|
@ -1720,11 +1876,14 @@ async function runExternalExtractInner(
|
|
|
|
|
let args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, usePerformanceFlags, hybridMode);
|
|
|
|
|
let result = await runExtractCommand(command, args, (chunk) => {
|
|
|
|
|
const parsed = parseProgressPercent(chunk);
|
|
|
|
|
if (parsed === null || parsed <= bestPercent) {
|
|
|
|
|
if (parsed === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
bestPercent = parsed;
|
|
|
|
|
onArchiveProgress?.(bestPercent);
|
|
|
|
|
const next = nextArchivePercent(bestPercent, parsed);
|
|
|
|
|
if (next !== bestPercent) {
|
|
|
|
|
bestPercent = next;
|
|
|
|
|
onArchiveProgress?.(bestPercent);
|
|
|
|
|
}
|
|
|
|
|
}, signal, timeoutMs);
|
|
|
|
|
|
|
|
|
|
if (!result.ok && usePerformanceFlags && isUnsupportedExtractorSwitchError(result.errorText)) {
|
|
|
|
|
@ -1734,14 +1893,22 @@ async function runExternalExtractInner(
|
|
|
|
|
args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password, false, hybridMode);
|
|
|
|
|
result = await runExtractCommand(command, args, (chunk) => {
|
|
|
|
|
const parsed = parseProgressPercent(chunk);
|
|
|
|
|
if (parsed === null || parsed <= bestPercent) {
|
|
|
|
|
if (parsed === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
bestPercent = parsed;
|
|
|
|
|
onArchiveProgress?.(bestPercent);
|
|
|
|
|
const next = nextArchivePercent(bestPercent, parsed);
|
|
|
|
|
if (next !== bestPercent) {
|
|
|
|
|
bestPercent = next;
|
|
|
|
|
onArchiveProgress?.(bestPercent);
|
|
|
|
|
}
|
|
|
|
|
}, signal, timeoutMs);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
`Legacy-Passwort-Versuch Ergebnis: archive=${path.basename(archivePath)}, attempt=${passwordAttempt}/${passwords.length}, ` +
|
|
|
|
|
`ms=${Date.now() - attemptStartedAt}, ok=${result.ok}, timedOut=${result.timedOut}, missingCommand=${result.missingCommand}, bestPercent=${bestPercent}`
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (result.ok) {
|
|
|
|
|
onArchiveProgress?.(100);
|
|
|
|
|
return password;
|
|
|
|
|
@ -2209,7 +2376,14 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|
|
|
|
if (options.conflictMode === "ask") {
|
|
|
|
|
logger.warn("Extract-ConflictMode 'ask' wird ohne Prompt als 'skip' behandelt");
|
|
|
|
|
}
|
|
|
|
|
const passwordCacheKey = packagePasswordCacheKey(options.packageDir, options.packageId);
|
|
|
|
|
const passwordCacheLabel = packagePasswordCacheLabel(options.packageDir, options.packageId);
|
|
|
|
|
let passwordCandidates = archivePasswords(options.passwordList || "");
|
|
|
|
|
const cachedPackagePassword = readCachedPackagePassword(passwordCacheKey);
|
|
|
|
|
if (cachedPackagePassword) {
|
|
|
|
|
passwordCandidates = prioritizePassword(passwordCandidates, cachedPackagePassword);
|
|
|
|
|
logger.info(`Passwort-Cache Treffer: ${passwordCacheLabel}, bekanntes Passwort wird zuerst getestet`);
|
|
|
|
|
}
|
|
|
|
|
const resumeCompleted = await readExtractResumeState(options.packageDir, options.packageId);
|
|
|
|
|
const resumeCompletedAtStart = resumeCompleted.size;
|
|
|
|
|
const allCandidateNames = new Set(allCandidates.map((archivePath) => archiveNameKey(path.basename(archivePath))));
|
|
|
|
|
@ -2228,6 +2402,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|
|
|
|
let extracted = candidates.length - pendingCandidates.length;
|
|
|
|
|
let failed = 0;
|
|
|
|
|
let lastError = "";
|
|
|
|
|
let learnedPassword = cachedPackagePassword;
|
|
|
|
|
const extractedArchives = new Set<string>();
|
|
|
|
|
for (const archivePath of candidates) {
|
|
|
|
|
if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) {
|
|
|
|
|
@ -2235,23 +2410,41 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rememberLearnedPassword = (password: string): void => {
|
|
|
|
|
const normalized = String(password || "").trim();
|
|
|
|
|
if (!normalized) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const changed = normalized !== learnedPassword;
|
|
|
|
|
learnedPassword = normalized;
|
|
|
|
|
passwordCandidates = prioritizePassword(passwordCandidates, normalized);
|
|
|
|
|
writeCachedPackagePassword(passwordCacheKey, normalized);
|
|
|
|
|
if (changed) {
|
|
|
|
|
logger.info(`Passwort-Cache Update: ${passwordCacheLabel}, neues Passwort gelernt`);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const emitProgress = (
|
|
|
|
|
current: number,
|
|
|
|
|
archiveName: string,
|
|
|
|
|
phase: "extracting" | "done",
|
|
|
|
|
archivePercent?: number,
|
|
|
|
|
elapsedMs?: number,
|
|
|
|
|
pwInfo?: { passwordAttempt?: number; passwordTotal?: number; passwordFound?: boolean }
|
|
|
|
|
pwInfo?: { passwordAttempt?: number; passwordTotal?: number; passwordFound?: boolean },
|
|
|
|
|
archiveInfo?: { archiveDone?: boolean; archiveSuccess?: boolean }
|
|
|
|
|
): void => {
|
|
|
|
|
if (!options.onProgress) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const total = Math.max(1, candidates.length);
|
|
|
|
|
let percent = Math.max(0, Math.min(100, Math.floor((current / total) * 100)));
|
|
|
|
|
let normalizedArchivePercent = Math.max(0, Math.min(100, Number(archivePercent ?? 0)));
|
|
|
|
|
if (phase !== "done") {
|
|
|
|
|
const boundedCurrent = Math.max(0, Math.min(total, current));
|
|
|
|
|
const boundedArchivePercent = Math.max(0, Math.min(100, Number(archivePercent ?? 0)));
|
|
|
|
|
percent = Math.max(0, Math.min(100, Math.floor(((boundedCurrent + (boundedArchivePercent / 100)) / total) * 100)));
|
|
|
|
|
if (archiveInfo?.archiveDone !== true && normalizedArchivePercent >= 100) {
|
|
|
|
|
normalizedArchivePercent = 99;
|
|
|
|
|
}
|
|
|
|
|
percent = Math.max(0, Math.min(100, Math.floor(((boundedCurrent + (normalizedArchivePercent / 100)) / total) * 100)));
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
options.onProgress({
|
|
|
|
|
@ -2259,9 +2452,10 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|
|
|
|
total,
|
|
|
|
|
percent,
|
|
|
|
|
archiveName,
|
|
|
|
|
archivePercent,
|
|
|
|
|
archivePercent: normalizedArchivePercent,
|
|
|
|
|
elapsedMs,
|
|
|
|
|
phase,
|
|
|
|
|
...(archiveInfo || {}),
|
|
|
|
|
...(pwInfo || {})
|
|
|
|
|
});
|
|
|
|
|
} catch (error) {
|
|
|
|
|
@ -2276,12 +2470,13 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|
|
|
|
// rather than leaving them as "Entpacken - Ausstehend" until all extraction finishes.
|
|
|
|
|
for (const archivePath of candidates) {
|
|
|
|
|
if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) {
|
|
|
|
|
emitProgress(extracted, path.basename(archivePath), "extracting", 100, 0);
|
|
|
|
|
emitProgress(extracted, path.basename(archivePath), "extracting", 100, 0, undefined, { archiveDone: true, archiveSuccess: true });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const maxParallel = Math.max(1, options.maxParallel || 1);
|
|
|
|
|
let noExtractorEncountered = false;
|
|
|
|
|
let lastArchiveFinishedAt: number | null = null;
|
|
|
|
|
|
|
|
|
|
const extractSingleArchive = async (archivePath: string): Promise<void> => {
|
|
|
|
|
if (options.signal?.aborted) {
|
|
|
|
|
@ -2293,17 +2488,36 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|
|
|
|
const archiveName = path.basename(archivePath);
|
|
|
|
|
const archiveResumeKey = archiveNameKey(archiveName);
|
|
|
|
|
const archiveStartedAt = Date.now();
|
|
|
|
|
const startedCurrent = extracted + failed;
|
|
|
|
|
if (lastArchiveFinishedAt !== null) {
|
|
|
|
|
logger.info(`Extract-Trace Gap: before=${archiveName}, prevDoneToStartMs=${archiveStartedAt - lastArchiveFinishedAt}, progress=${startedCurrent}/${candidates.length}`);
|
|
|
|
|
}
|
|
|
|
|
let archivePercent = 0;
|
|
|
|
|
let reached99At: number | null = null;
|
|
|
|
|
let archiveOutcome: "success" | "failed" | "skipped" = "failed";
|
|
|
|
|
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, 0);
|
|
|
|
|
const pulseTimer = setInterval(() => {
|
|
|
|
|
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
|
|
|
|
|
}, 1100);
|
|
|
|
|
const hybrid = Boolean(options.hybridMode);
|
|
|
|
|
// Insert archive-filename-derived passwords after "" but before custom passwords
|
|
|
|
|
// Before the first successful extraction, filename-derived candidates are useful.
|
|
|
|
|
// After a known password is learned, try that first to avoid per-archive delays.
|
|
|
|
|
const filenamePasswords = archiveFilenamePasswords(archiveName);
|
|
|
|
|
const archivePasswordCandidates = filenamePasswords.length > 0
|
|
|
|
|
? Array.from(new Set(["", ...filenamePasswords, ...passwordCandidates.filter((p) => p !== "")]))
|
|
|
|
|
: passwordCandidates;
|
|
|
|
|
const nonEmptyBasePasswords = passwordCandidates.filter((p) => p !== "");
|
|
|
|
|
const orderedNonEmpty = learnedPassword
|
|
|
|
|
? [learnedPassword, ...nonEmptyBasePasswords.filter((p) => p !== learnedPassword), ...filenamePasswords]
|
|
|
|
|
: [...filenamePasswords, ...nonEmptyBasePasswords];
|
|
|
|
|
const archivePasswordCandidates = learnedPassword
|
|
|
|
|
? Array.from(new Set([...orderedNonEmpty, ""]))
|
|
|
|
|
: Array.from(new Set(["", ...orderedNonEmpty]));
|
|
|
|
|
const reportArchiveProgress = (value: number): void => {
|
|
|
|
|
archivePercent = nextArchivePercent(archivePercent, value);
|
|
|
|
|
if (reached99At === null && archivePercent >= 99) {
|
|
|
|
|
reached99At = Date.now();
|
|
|
|
|
logger.info(`Extract-Trace 99%: archive=${archiveName}, elapsedMs=${reached99At - archiveStartedAt}`);
|
|
|
|
|
}
|
|
|
|
|
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Validate generic .001 splits via file signature before attempting extraction
|
|
|
|
|
const isGenericSplit = /\.\d{3}$/i.test(archiveName) && !/\.(zip|7z)\.\d{3}$/i.test(archiveName);
|
|
|
|
|
@ -2316,6 +2530,10 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|
|
|
|
extractedArchives.add(archivePath);
|
|
|
|
|
await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId);
|
|
|
|
|
clearInterval(pulseTimer);
|
|
|
|
|
archiveOutcome = "skipped";
|
|
|
|
|
const skippedAt = Date.now();
|
|
|
|
|
lastArchiveFinishedAt = skippedAt;
|
|
|
|
|
logger.info(`Extract-Trace Archiv Übersprungen: archive=${archiveName}, ms=${skippedAt - archiveStartedAt}, reason=no-signature`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
logger.info(`Generische Split-Datei verifiziert (Signatur: ${sig}): ${archiveName}`);
|
|
|
|
|
@ -2338,10 +2556,9 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|
|
|
|
if (preferExternal) {
|
|
|
|
|
try {
|
|
|
|
|
const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => {
|
|
|
|
|
archivePercent = Math.max(archivePercent, value);
|
|
|
|
|
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
|
|
|
|
|
reportArchiveProgress(value);
|
|
|
|
|
}, options.signal, hybrid, onPwAttempt);
|
|
|
|
|
passwordCandidates = prioritizePassword(passwordCandidates, usedPassword);
|
|
|
|
|
rememberLearnedPassword(usedPassword);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (isNoExtractorError(String(error))) {
|
|
|
|
|
await extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal);
|
|
|
|
|
@ -2359,10 +2576,9 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => {
|
|
|
|
|
archivePercent = Math.max(archivePercent, value);
|
|
|
|
|
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
|
|
|
|
|
reportArchiveProgress(value);
|
|
|
|
|
}, options.signal, hybrid, onPwAttempt);
|
|
|
|
|
passwordCandidates = prioritizePassword(passwordCandidates, usedPassword);
|
|
|
|
|
rememberLearnedPassword(usedPassword);
|
|
|
|
|
} catch (externalError) {
|
|
|
|
|
if (isNoExtractorError(String(externalError)) || isUnsupportedArchiveFormatError(String(externalError))) {
|
|
|
|
|
throw error;
|
|
|
|
|
@ -2373,21 +2589,25 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
const usedPassword = await runExternalExtract(archivePath, options.targetDir, options.conflictMode, archivePasswordCandidates, (value) => {
|
|
|
|
|
archivePercent = Math.max(archivePercent, value);
|
|
|
|
|
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
|
|
|
|
|
reportArchiveProgress(value);
|
|
|
|
|
}, options.signal, hybrid, onPwAttempt);
|
|
|
|
|
passwordCandidates = prioritizePassword(passwordCandidates, usedPassword);
|
|
|
|
|
rememberLearnedPassword(usedPassword);
|
|
|
|
|
}
|
|
|
|
|
extracted += 1;
|
|
|
|
|
extractedArchives.add(archivePath);
|
|
|
|
|
resumeCompleted.add(archiveResumeKey);
|
|
|
|
|
await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId);
|
|
|
|
|
logger.info(`Entpacken erfolgreich: ${path.basename(archivePath)}`);
|
|
|
|
|
archiveOutcome = "success";
|
|
|
|
|
const successAt = Date.now();
|
|
|
|
|
const tailAfter99Ms = reached99At ? (successAt - reached99At) : -1;
|
|
|
|
|
logger.info(`Extract-Trace Archiv Erfolg: archive=${archiveName}, totalMs=${successAt - archiveStartedAt}, tailAfter99Ms=${tailAfter99Ms >= 0 ? tailAfter99Ms : "n/a"}, pwCandidates=${archivePasswordCandidates.length}`);
|
|
|
|
|
lastArchiveFinishedAt = successAt;
|
|
|
|
|
archivePercent = 100;
|
|
|
|
|
if (hasManyPasswords) {
|
|
|
|
|
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, { passwordFound: true });
|
|
|
|
|
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, { passwordFound: true }, { archiveDone: true, archiveSuccess: true });
|
|
|
|
|
} else {
|
|
|
|
|
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
|
|
|
|
|
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, undefined, { archiveDone: true, archiveSuccess: true });
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const errorText = String(error);
|
|
|
|
|
@ -2398,12 +2618,25 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|
|
|
|
lastError = errorText;
|
|
|
|
|
const errorCategory = classifyExtractionError(errorText);
|
|
|
|
|
logger.error(`Entpack-Fehler ${path.basename(archivePath)} [${errorCategory}]: ${errorText}`);
|
|
|
|
|
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
|
|
|
|
|
if (errorCategory === "wrong_password" && learnedPassword) {
|
|
|
|
|
learnedPassword = "";
|
|
|
|
|
clearCachedPackagePassword(passwordCacheKey);
|
|
|
|
|
logger.warn(`Passwort-Cache verworfen: ${passwordCacheLabel} (wrong_password)`);
|
|
|
|
|
}
|
|
|
|
|
const failedAt = Date.now();
|
|
|
|
|
const tailAfter99Ms = reached99At ? (failedAt - reached99At) : -1;
|
|
|
|
|
logger.warn(`Extract-Trace Archiv Fehler: archive=${archiveName}, totalMs=${failedAt - archiveStartedAt}, tailAfter99Ms=${tailAfter99Ms >= 0 ? tailAfter99Ms : "n/a"}, category=${errorCategory}`);
|
|
|
|
|
lastArchiveFinishedAt = failedAt;
|
|
|
|
|
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt, undefined, { archiveDone: true, archiveSuccess: false });
|
|
|
|
|
if (isNoExtractorError(errorText)) {
|
|
|
|
|
noExtractorEncountered = true;
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
clearInterval(pulseTimer);
|
|
|
|
|
if (lastArchiveFinishedAt === null || lastArchiveFinishedAt < archiveStartedAt) {
|
|
|
|
|
lastArchiveFinishedAt = Date.now();
|
|
|
|
|
}
|
|
|
|
|
logger.info(`Extract-Trace Archiv Ende: archive=${archiveName}, outcome=${archiveOutcome}, elapsedMs=${lastArchiveFinishedAt - archiveStartedAt}, percent=${archivePercent}`);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
@ -2528,11 +2761,11 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|
|
|
|
} catch (zipErr) {
|
|
|
|
|
if (!shouldFallbackToExternalZip(zipErr)) throw zipErr;
|
|
|
|
|
const usedPw = await runExternalExtract(nestedArchive, options.targetDir, options.conflictMode, passwordCandidates, (v) => { nestedPercent = Math.max(nestedPercent, v); }, options.signal, hybrid);
|
|
|
|
|
passwordCandidates = prioritizePassword(passwordCandidates, usedPw);
|
|
|
|
|
rememberLearnedPassword(usedPw);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
const usedPw = await runExternalExtract(nestedArchive, options.targetDir, options.conflictMode, passwordCandidates, (v) => { nestedPercent = Math.max(nestedPercent, v); }, options.signal, hybrid);
|
|
|
|
|
passwordCandidates = prioritizePassword(passwordCandidates, usedPw);
|
|
|
|
|
rememberLearnedPassword(usedPw);
|
|
|
|
|
}
|
|
|
|
|
extracted += 1;
|
|
|
|
|
nestedExtracted += 1;
|
|
|
|
|
|