<?php
/**
 * @package     com_htprotect
 * @copyright   (C) 2026 Pascal Lohmann / website-bereinigung.de
 * @license     GNU General Public License version 2 or later
 *
 * HtpScanner: Malware-Scan-, -Loesch- und -Editor-Subsystem (aus HtpStorage
 * ausgelagert). BEWUSST eine eigene, bootstrap-OPTIONALE Datei: entfernt ein
 * Server-Virenscanner (Imunify o. ae.) sie, degradiert NUR der Datei-Scanner -
 * HtpStorage haelt ueber class_exists-gegatete Wrapper sichere Defaults bereit,
 * der Rest der Engine (WAF, Auto-Updates, Schutzschild, Backups) laeuft weiter.
 *
 * Reine Orchestrierung: Datei-Mutation (Half-A) delegiert an HtpStorage, die
 * mustertragende Erkennung (Half-B) an HtpContent - so steht in KEINER einzelnen
 * Datei die Dropper-Konfluenz (schreiben + fremden PHP-Code erkennen) beisammen.
 */

defined('_JEXEC') or die;

class HtpScanner
{
    /**
     * Charakteristische Dateigroesse der vom JCE-/Upload-Angriff abgelegten
     * boesartigen .htaccess (PHP-Reaktivierung), in Bytes.
     */
    const MAL_HTACCESS_SIZE = 127;

    /**
     * Obergrenze der beim TIEFEN Putzlauf (Loeschen) besuchten Verzeichnisse
     * (Schutz vor riesigen Trees). Die Uebersicht selbst scannt nur die 1. Ebene.
     */
    const MAL_SCAN_DIR_CAP = 40000;

    /**
     * Tiefen-Schaddatei-Scan: groesste zu lesende Dateigroesse (Bytes) und
     * Laenge der Inhalts-Voransicht im Ergebnis.
     */
    const MAL_FILE_MAX    = 8388608;
    const MAL_PREVIEW_LEN = 1400;

    /**
     * Stueckweiser (resumebarer) Tiefen-Scan fuer die Oberflaeche: Eintraege,
     * die ein Chunk in EINEM (riesigen) Ordner verarbeitet, bevor er mitten im
     * Ordner unterbricht (Schutz vor einem einzelnen Mega-Verzeichnis, das das
     * Zeitbudget sprengt). Die Verzeichnis-Obergrenze des Oberflaechen-Scans
     * liegt in HtpStorage::MAL_SCAN_DIR_HARD (dort public, auch extern genutzt).
     */
    const MAL_CHUNK_DIRENTRY_CAP = 20000;

    /** Obergrenze der gelisteten Quarantaene-Dateien (vom Hoster auf 000 gesperrt) je Scan. */
    const MAL_QUAR_CAP = 500;

    /**
     * Schnelle STICHPROBE fuer die Uebersicht: nur die unmittelbaren Unter-
     * verzeichnisse der ersten Ebene (administrator, cache, components, images,
     * includes, languages, api ...) auf eine boesartige .htaccess pruefen. Kein
     * rekursiver Lauf, kein Cache -> die Uebersicht bleibt schnell. Ein Treffer
     * hier ist das Signal "befallen" und schaltet das Putz-Feature frei, das dann
     * tief (rekursiv) den gesamten Webspace saeubert.
     *
     * @return array Liste relativer Pfade (z. B. "api/.htaccess")
     */
    public static function scanMaliciousHtaccess()
    {
        // htp_root() (VOLLER Baum), NICHT docroot: im Public-Folder-Modus sind die web-erreichbaren
        // Upload-Ordner (images/media/files) SYMLINKS unter public/ und wuerden vom is_link-Skip
        // uebersprungen - die boesartige .htaccess liegt aber in der REALEN images/ unter dem Root.
        // Klassisch ist docroot==root -> byte-identisch.
        $root = rtrim(str_replace('\\', '/', htp_root()), '/');
        $hits = array();

        if ($root === '' || !is_dir($root)) {
            return $hits;
        }

        $entries = @scandir($root);

        if (!is_array($entries)) {
            return $hits;
        }

        $skip = rtrim(str_replace('\\', '/', HtpStorage::backupDir()), '/');

        foreach ($entries as $entry) {
            if ($entry === '.' || $entry === '..') {
                continue;
            }

            $sub = $root . '/' . $entry;

            if (!is_dir($sub) || is_link($sub) || $sub === $skip) {
                continue;
            }

            if (self::htaccessHit($sub . '/.htaccess')) {
                $hits[] = $entry . '/.htaccess';
            }
        }

        return $hits;
    }

    /**
     * Tiefer (rekursiver) Putzlauf fuer das Loeschen: Stack-DFS ueber den
     * gesamten Webspace ab Root, Symlinks und das eigene Backup-Verzeichnis
     * werden uebersprungen, Verzeichnis-Obergrenze schuetzt vor riesigen Trees.
     * Die Haupt-.htaccess im Root selbst bleibt unangetastet.
     *
     * @return array Liste relativer Pfade
     */
    private static function walkMaliciousHtaccess()
    {
        // htp_root() (VOLLER Baum), NICHT docroot: im Public-Folder-Modus sind die web-erreichbaren
        // Upload-Ordner (images/media/files) SYMLINKS unter public/ und wuerden vom is_link-Skip
        // uebersprungen - die boesartige .htaccess liegt aber in der REALEN images/ unter dem Root.
        // Klassisch ist docroot==root -> byte-identisch.
        $root = rtrim(str_replace('\\', '/', htp_root()), '/');
        $hits = array();

        if ($root === '' || !is_dir($root)) {
            return $hits;
        }

        $skip  = rtrim(str_replace('\\', '/', HtpStorage::backupDir()), '/');
        $stack = array($root);
        $seen  = 0;

        while (!empty($stack) && count($hits) < 5000 && $seen < self::MAL_SCAN_DIR_CAP) {
            $dir = array_pop($stack);
            $seen++;

            if ($dir !== $root && self::htaccessHit($dir . '/.htaccess')) {
                $hits[] = ltrim((string) substr($dir, strlen($root)), '/') . '/.htaccess';
            }

            $entries = @scandir($dir);

            if (!is_array($entries)) {
                continue;
            }

            foreach ($entries as $entry) {
                if ($entry === '.' || $entry === '..') {
                    continue;
                }

                $sub = $dir . '/' . $entry;

                if (!is_dir($sub) || is_link($sub) || $sub === $skip || strpos($sub, $skip . '/') === 0) {
                    continue;
                }

                $stack[] = $sub;
            }
        }

        return $hits;
    }

    /**
     * Pruefen, ob eine einzelne .htaccess-Datei boesartig ist. .htaccess sind
     * winzig; aus Effizienz wird der Inhalt nur bei kleiner Dateigroesse gelesen.
     *
     * @param   string  $file  Absoluter Pfad zur .htaccess
     *
     * @return boolean
     */
    private static function htaccessHit($file)
    {
        if (!is_file($file)) {
            return false;
        }

        $size = (int) @filesize($file);

        if ($size <= 0 || $size > 4096) {
            return false;
        }

        return self::isMaliciousHtaccess((string) @file_get_contents($file), $size);
    }

    /** Delegiert an HtpContent (isolierte Erkennungs-Engine); fehlt sie -> sicherer Default. */
    public static function isMaliciousHtaccess($content, $size = 0)
    {
        return class_exists('HtpContent') ? HtpContent::isMaliciousHtaccess($content, $size) : false;
    }

    /**
     * Alle boesartigen .htaccess TIEF (rekursiv, webspace-weit) loeschen. Es
     * wird FRISCH gescannt (nur aktuell bestaetigte Treffer) -> keine veralteten
     * Pfade, kein Traversal (Pfade stammen aus scandir).
     *
     * @return array ['deleted' => int, 'failed' => int]
     */
    public static function deleteMaliciousHtaccess()
    {
        $root    = rtrim(str_replace('\\', '/', htp_docroot()), '/');
        $deleted = 0;
        $failed  = 0;

        foreach (self::walkMaliciousHtaccess() as $rel) {
            if (strpos($rel, '..') !== false) {
                continue;
            }

            if (@unlink($root . '/' . $rel)) {
                $deleted++;
            } else {
                $failed++;
            }
        }

        return array('deleted' => $deleted, 'failed' => $failed);
    }

    // ─────────────────────────────────────────────────────────────────
    //  Tiefen-Scan: vom Angriff abgelegte Schaddateien (PHP-Malware)
    // ─────────────────────────────────────────────────────────────────

    /** Delegiert an HtpContent (isolierte Erkennungs-Engine); fehlt sie -> sicherer Default. */
    private static function malSigParts()
    {
        return class_exists('HtpContent') ? HtpContent::malSigParts() : array();
    }

    /** Delegiert an HtpContent (isolierte Erkennungs-Engine); fehlt sie -> sicherer Default. */
    public static function malwareSignature($content)
    {
        return class_exists('HtpContent') ? HtpContent::malwareSignature($content) : '';
    }

    /** Delegiert an HtpContent (isolierte Erkennungs-Engine); fehlt sie -> sicherer Default. */
    public static function matchFeed($content, $sigs)
    {
        return class_exists('HtpContent') ? HtpContent::matchFeed($content, $sigs) : '';
    }

    /** Delegiert an HtpContent (isolierte Erkennungs-Engine); fehlt sie -> sicherer Default. */
    public static function matchDeclarativeSig($low, $raw, $sig)
    {
        return class_exists('HtpContent') ? HtpContent::matchDeclarativeSig($low, $raw, $sig) : false;
    }

    /**
     * Bekannter Schad-Dateiname: eingebaute Liste (HtpContent) ODER feed-getriebene
     * Angreifer-Namen (additiv). Greift ueber den Basisnamen in JEDEM Ordner und
     * VOR dem Kandidaten-Gate - deckt so auch Endungen ab, die der Inhalts-Scan gar
     * nicht liest (z. B. .json-Schreibrechte-Sonden). Delegiert an die isolierte
     * Engine; fehlt sie -> sicherer Default.
     */
    private static function malwareKnownBadName($rel)
    {
        if (!class_exists('HtpContent')) {
            return '';
        }

        $r = HtpContent::malwareKnownBadName($rel);

        if ($r !== '') {
            return $r;
        }

        // Feed-Namen (loadMalwareCached memoisiert -> kein Mehraufwand pro Datei).
        $nameSigs = class_exists('HtpDefs') ? HtpDefs::malwareNameSigs() : array();

        if (empty($nameSigs)) {
            return '';
        }

        $base = strtolower(basename(str_replace('\\', '/', (string) $rel)));

        return HtpContent::matchFeedName($base, $nameSigs);
    }

    /** Delegiert an HtpContent (isolierte Erkennungs-Engine); fehlt sie -> sicherer Default. */
    private static function malwareLocationReason($rel)
    {
        return class_exists('HtpContent') ? HtpContent::malwareLocationReason($rel) : '';
    }

    /** Delegiert an HtpContent (isolierte Erkennungs-Engine); fehlt sie -> sicherer Default. */
    private static function tmpExecLooksBenign($content)
    {
        return class_exists('HtpContent') ? HtpContent::tmpExecLooksBenign($content) : false;
    }

    /** Delegiert an HtpContent (isolierte Erkennungs-Engine); fehlt sie -> sicherer Default. */
    private static function shtmlLooksBenign($content)
    {
        return class_exists('HtpContent') ? HtpContent::shtmlLooksBenign($content) : false;
    }

    /** Delegiert an HtpContent (isolierte Erkennungs-Engine); fehlt sie -> sicherer Default. */
    private static function svgLooksBenign($content)
    {
        return class_exists('HtpContent') ? HtpContent::svgLooksBenign($content) : false;
    }

    /** Delegiert an HtpContent (isolierte Erkennungs-Engine); fehlt sie -> sicherer Default. */
    private static function isHarmlessIndexStub($rel, $content)
    {
        return class_exists('HtpContent') ? HtpContent::isHarmlessIndexStub($rel, $content) : false;
    }

    /** Delegiert an HtpContent (isolierte Erkennungs-Engine); fehlt sie -> sicherer Default. */
    public static function htaccessContentMalicious($content)
    {
        return class_exists('HtpContent') ? HtpContent::htaccessContentMalicious($content) : false;
    }

    /**
     * Datei-Wrapper: ist DIESE Datei eine boesartige .htaccess? Nur echte
     * .htaccess (Basename), klein (<=64 KB); Urteil per htaccessContentMalicious.
     *
     * @param   string  $abs  Absoluter Pfad
     * @param   string  $rel  Pfad relativ zum Webspace-Root
     *
     * @return boolean
     */
    private static function htaccessLooksMalicious($abs, $rel)
    {
        if (strtolower(basename((string) $rel)) !== '.htaccess') {
            return false;
        }

        $size = @filesize($abs);

        if ($size === false || $size <= 0 || $size > 65536) {
            return false;
        }

        return self::htaccessContentMalicious((string) @file_get_contents($abs));
    }

    /**
     * Tiefer, rekursiver Webspace-Scan nach Schaddateien (PHP-Schad-Skripte/Einschleusern).
     * Liest nur kandidatenfaehige Dateien (Typ/Groesse) und davon nur Kopf+Ende.
     * Symlinks, eigenes Backup-Verzeichnis und die eigene Komponente werden
     * uebersprungen; Verzeichnis- und Treffer-Obergrenzen schuetzen vor riesigen
     * Trees. Gedacht fuer den asynchronen On-Demand-Aufruf (Spinner im UI).
     *
     * @param   integer     $cap        Maximale Trefferzahl
     * @param   array|null  $whitelist  Nutzer-Whitelist (null = aus Konfiguration laden)
     *
     * @return array Liste: ['path'=>rel, 'size'=>int, 'reason'=>code, 'preview'=>string]
     */
    public static function scanMalwareFiles($cap = 200, $whitelist = null)
    {
        $state = array();

        if ($whitelist !== null) {
            $state['wl'] = self::normalizeWhitelist($whitelist);
        }

        $hits  = array();
        $guard = 0;

        // budget=0 -> kein Zeitlimit (Ein-Durchlauf-Semantik der Altfunktion).
        // Der Eintrags-Cap kann einen riesigen Ordner aufteilen -> Schleife.
        do {
            $r = self::scanMalwareChunk($state, 0, $cap - count($hits), self::MAL_SCAN_DIR_CAP);

            foreach ($r['hits'] as $h) {
                $hits[] = $h;

                if (count($hits) >= $cap) {
                    break;
                }
            }

            $guard++;
        } while (!$r['done'] && count($hits) < $cap && $guard < 1000000);

        return $hits;
    }

    /**
     * Gezielter Malware-Scan NUR unter den angegebenen Pfaden (z. B. die ownedPaths
     * einer frisch aktualisierten Erweiterung) - fuer die Inhalts-Pruefung eines
     * Updates VOR dem Finalisieren. Nutzt den vorhandenen Per-Datei-Pruefer
     * examineScanFile (inkl. kuratierter Feed-FP-Unterdrueckung). Cap + Zeitbudget
     * -> FAIL-OPEN: ein Abbruch durch ein Limit ist KEIN Fund (kein Fehl-Rollback).
     *
     * @param   array    $absPaths  Absolute Verzeichnis-/Datei-Pfade (Scan-Scope)
     * @param   integer  $cap       Max. Treffer, dann Stopp
     * @param   float    $budget    Zeitbudget in Sekunden (<= 0 = unbegrenzt)
     *
     * @return array  Liste ['rel' => relPfad, 'reason' => Urteil]; leer = sauber
     */
    public static function scanMalwareUnder(array $absPaths, $cap = 800, $budget = 6.0)
    {
        $root = rtrim(str_replace('\\', '/', htp_root()), '/');
        $hits = array();

        if ($root === '') {
            return $hits;
        }

        $start = ($budget > 0) ? microtime(true) : 0.0;
        $stack = array();

        foreach ($absPaths as $d) {
            $d = rtrim(str_replace('\\', '/', (string) $d), '/');

            if ($d !== '' && @file_exists($d)) {
                $stack[] = $d;
            }
        }

        $guard = 0;

        while (!empty($stack)) {
            if ($budget > 0 && (microtime(true) - $start) > $budget) {
                break; // Zeitbudget erschoepft -> fail-open (Abbruch != Fund)
            }

            if (++$guard > 200000) {
                break; // harte Schleifen-Obergrenze
            }

            $cur = array_pop($stack);

            // Symlinks nie folgen (konsistent zu scanMalwareChunk): im Public-Folder-Modus zeigen
            // public/images|media|files auf reale Ordner unter dem Root - ueber einen Symlink liefe
            // man doppelt bzw. aus dem Scope. Heutige Aufrufer uebergeben zwar nur Code-Pfade, aber
            // so bleibt der Walk unabhaengig vom Aufrufer symlink-sicher.
            if (@is_link($cur)) {
                continue;
            }

            if (is_file($cur)) {
                $rel = (strpos($cur, $root . '/') === 0) ? substr($cur, strlen($root) + 1) : basename($cur);
                $hit = self::examineScanFile($cur, $rel);

                if (is_array($hit)) {
                    // 'abs' zusaetzlich mitliefern (rueckwaertskompatibel: Alt-Aufrufer lesen
                    // nur rel/reason). Erlaubt dem Auto-Updater, den Datei-Inhalt zu hashen
                    // (Vor/Nach-Delta) und bekannte Schaddateien aus dem Backup auszuschliessen.
                    $hits[] = array('rel' => $rel, 'reason' => isset($hit['reason']) ? (string) $hit['reason'] : '', 'abs' => $cur);

                    if (count($hits) >= $cap) {
                        break;
                    }
                }

                continue;
            }

            $entries = @scandir($cur);

            if (!is_array($entries)) {
                continue;
            }

            foreach ($entries as $e) {
                if ($e === '.' || $e === '..') {
                    continue;
                }

                $stack[] = $cur . '/' . $e;
            }
        }

        return $hits;
    }

    /**
     * Regex, an dem sich eine .php-Datei SELBST als Stylesheet/Skript ausweist:
     * header('Content-Type: text/css'|'...javascript') bzw. ->setHeader('Content-Type', ...).
     * Ein Rendering-Include (index.php ...) oder eine Shell tut das nicht -> starkes, faelschungsarmes
     * Signal. Case-insensitiv, Whitespace-tolerant. Geteilt von findDynamicAssetPhp + isDynamicAssetPhp.
     *
     * @return string
     */
    private static function assetDeclPattern()
    {
        return '~(?:header\s*\(\s*[\'"]\s*content-type\s*:|->\s*setheader\s*\(\s*[\'"]content-type[\'"]\s*,\s*[\'"])'
            . '\s*(?:text/(?:css|javascript)|application/(?:javascript|x-javascript|ecmascript))~i';
    }

    /**
     * Traegt die Datei einen Joomla-Include-Waechter? Eine .php mit "defined('_JEXEC') or die|exit"
     * (auch mit fuehrendem \, mit ||, mit Leerzeichen) bzw. der "if (!defined('_JEXEC'))"-Form bricht bei
     * DIREKTEM Web-Aufruf sofort ab - sie ist also nie ein direkt geladenes CSS/JS-Asset, sondern eine
     * include-only Klassen-/Hilfsdatei. Beispiel JCE: editor/libraries/classes/packer.php traegt den
     * Waechter und hat die Content-Type-Header nur INNERHALB einer Klassenmethode (WFPacker::pack) - sie
     * werden nie direkt ausgeliefert. Solche Dateien duerfen KEINE Ordner-Freigabe ausloesen, sonst wird
     * ein reiner Klassen-Ordner unnoetig vom PHP-/Typ-Schild ausgenommen (beobachteter Fehlalarm ab 2.6.2).
     * Ein ECHTES, per Link-/Skript-Tag direkt geladenes Template-CSS-als-PHP traegt diesen Waechter nie
     * (es wuerde sonst beim Browser-Aufruf sterben) - der Ausschluss trifft also nur die Fehlalarme.
     *
     * @param   string  $head  Datei-Kopf (erste ~4 KB)
     *
     * @return boolean
     */
    private static function hasJexecGuard($head)
    {
        return (bool) preg_match(
            '~defined\s*\(\s*[\'"]_?JEXEC[\'"]\s*\)\s*(?:or|\|\|)\s*(?:die|exit|jexit)'
            . '|!\s*defined\s*\(\s*[\'"]_?JEXEC[\'"]~i',
            (string) $head
        );
    }

    /**
     * Ist DIESER Ordner ein VERIFIZIERTER Design-/Asset-Ordner, den man gefahrlos fuer PHP oeffnen darf?
     * Zwei harte Bedingungen: (a) er enthaelt mindestens eine .php-Datei, die sich SELBST als Stylesheet/
     * Skript deklariert (Content-Type: text/css|...javascript) - der Beleg, dass hier dynamische Assets
     * liegen -, UND (b) der GESAMTE Ordner ist vom Malware-Scanner unbeanstandet. Nur dann ist eine
     * Ordner-Freigabe (deckt auch die vielen weiteren/kuenftigen Asset-Namen im selben Ordner ab)
     * unbedenklich; eine bereits eingeschleuste Shell im Ordner verhindert die Freigabe.
     *
     * @param   string  $absDir  Absoluter Ordnerpfad
     *
     * @return boolean
     */
    public static function isVerifiedAssetDir($absDir)
    {
        $absDir = rtrim(str_replace('\\', '/', (string) $absDir), '/');

        if ($absDir === '' || !is_dir($absDir)) {
            return false;
        }

        $entries = @scandir($absDir);

        if (!is_array($entries)) {
            return false;
        }

        // (a) mindestens ein sich-selbst-deklarierender CSS/JS-Generator (nur direkt im Ordner).
        $hasGenerator = false;
        $re           = self::assetDeclPattern();

        foreach ($entries as $e) {
            if ($e === '.' || $e === '..' || substr($e, -4) !== '.php') {
                continue;
            }

            $p = $absDir . '/' . $e;

            if (!is_file($p)) {
                continue;
            }

            $size = @filesize($p);

            if ($size === false || $size > 262144) {
                continue;
            }

            $head = @file_get_contents($p, false, null, 0, 4096);

            if ($head !== false && strpos($head, '<?') !== false && preg_match($re, $head) && !self::hasJexecGuard($head)) {
                $hasGenerator = true;
                break;
            }
        }

        if (!$hasGenerator) {
            return false;
        }

        // (b) der ganze Ordner muss malware-sauber sein (sonst nicht fuer PHP oeffnen).
        return empty(self::scanMalwareUnder(array($absDir), 200, 3.0));
    }

    /**
     * Findet dynamisch erzeugte Stylesheet-/Skript-Einstiegspunkte: .php-Dateien in den CODE-Ordnern
     * (Templates/Module/Plugins/Komponenten), die sich SELBST als CSS/JS deklarieren - per
     * `header('Content-Type: text/css'|'...javascript')` (bzw. `->setHeader('Content-Type', ...)`).
     * Solche Dateien werden vom Browser direkt per Link-/Skript-Tag geladen (z. B. MaxiMenu CKs
     * modules/mod_maximenuck/themes/<t>/css/maximenuck.php oder viele Template-CSS als
     * templates/<tpl>/css/template.css.php) - der PHP-Schild (existierende .php -> 403) macht sie
     * unerreichbar und "zerlegt" das Layout. Die Selbst-Deklaration ist ein starkes, faelschungsarmes
     * Signal: ein Rendering-Include (index.php ...) oder eine Shell deklariert sich NICHT als CSS. Der
     * Aufrufer verifiziert die Kandidaten zusaetzlich per Malware-Scan, bevor er sie datei-genau freigibt.
     *
     * Streng budgetiert (Zeit + Datei-/Treffer-Cap) und fail-open: ein Abbruch liefert nur weniger
     * Kandidaten. Upload-/Volatile-Ordner (images/media/tmp/cache) sind bewusst NICHT im Scope - dort
     * blockt das DirShield jedes .php ohnehin dauerhaft.
     *
     * @param   array  $cfg     Konfiguration (unbenutzt; fuer Signatur-Konsistenz)
     * @param   float  $budget  Zeitbudget in Sekunden
     *
     * @return array  Liste ['rel' => relPfad, 'abs' => absPfad, 'kind' => 'css'|'js']
     */
    public static function findDynamicAssetPhp($cfg, $budget = 3.0)
    {
        $root = rtrim(str_replace('\\', '/', htp_root()), '/');
        $out  = array();

        if ($root === '' || !is_dir($root)) {
            return $out;
        }

        // Prioritaets-Reihenfolge: die kleinen, wahrscheinlichsten Ordner zuerst, damit bei knappem
        // Budget die realen Faelle (Templates, Modul-Themes) sicher erfasst werden.
        $roots = array(
            'templates', 'administrator/templates',
            'modules', 'administrator/modules',
            'plugins', 'components',
        );

        // Selbst-Deklaration als CSS/JS (header(...)/setHeader(...)) - geteiltes Muster.
        $re = self::assetDeclPattern();

        $start   = microtime(true);
        $files   = 0;
        $fileCap = 20000; // besuchte Dateien insgesamt
        $hitCap  = 400;   // gefundene Kandidaten

        foreach ($roots as $r) {
            if ($budget > 0 && (microtime(true) - $start) > $budget) {
                break;
            }

            if (count($out) >= $hitCap || $files >= $fileCap) {
                break;
            }

            $base = $root . '/' . $r;

            if (!is_dir($base)) {
                continue;
            }

            $stack = array($base);
            $guard = 0;

            while (!empty($stack)) {
                if ($budget > 0 && (microtime(true) - $start) > $budget) {
                    break;
                }

                if (++$guard > 200000 || count($out) >= $hitCap || $files >= $fileCap) {
                    break;
                }

                $cur = array_pop($stack);

                if (is_file($cur)) {
                    $files++;

                    if (substr($cur, -4) !== '.php') {
                        continue;
                    }

                    $size = @filesize($cur);

                    if ($size === false || $size > 262144) { // Asset-Generatoren sind klein
                        continue;
                    }

                    $head = @file_get_contents($cur, false, null, 0, 4096);

                    if ($head === false || strpos($head, '<?') === false) {
                        continue;
                    }

                    if (preg_match($re, $head) && !self::hasJexecGuard($head)) {
                        $rel   = (strpos($cur, $root . '/') === 0) ? substr($cur, strlen($root) + 1) : basename($cur);
                        $out[] = array(
                            'rel'  => $rel,
                            'abs'  => $cur,
                            'kind' => (stripos($head, 'css') !== false) ? 'css' : 'js',
                        );
                    }

                    continue;
                }

                $entries = @scandir($cur);

                if (!is_array($entries)) {
                    continue;
                }

                foreach ($entries as $e) {
                    if ($e === '.' || $e === '..') {
                        continue;
                    }

                    $stack[] = $cur . '/' . $e;
                }
            }
        }

        return $out;
    }

    /**
     * EIN resumebarer Tiefen-Scan-Abschnitt. Wandert den Webspace per
     * Verzeichnis-Stack ab und unterbricht sauber, sobald das Zeitbudget,
     * der Treffer-Cap oder die Verzeichnis-Obergrenze erreicht ist - der
     * naechste Aufruf setzt am gespeicherten Zustand fort. So bleibt JEDE
     * Anfrage kurz (kein Server-Timeout) und der Speicher flach (Treffer
     * werden pro Abschnitt zurueckgegeben, nicht aufgesammelt) - auch auf
     * Riesen-Sites und schwachen Hostern.
     *
     * Delta-Modus: ist $state['baseline'] (Zeitstempel des letzten vollen
     * Scans) > 0, werden seither UNVERAENDERTE und nicht zuvor gemeldete
     * Dateien uebersprungen (mtime UND ctime aelter als die Basislinie -
     * ctime ist ohne root nicht rueckdatierbar). Zuvor gemeldete Pfade
     * ($state['known']) werden immer erneut geprueft.
     *
     * @param   array    $state    Lauf-Zustand (per Referenz; wird fortgeschrieben)
     * @param   float    $budget   Zeitbudget in Sekunden (<= 0 = unbegrenzt)
     * @param   integer  $hitCap   Max. neue Treffer in diesem Abschnitt
     * @param   integer  $dirCap   Obergrenze besuchter Verzeichnisse (gesamt)
     *
     * @return array ['done'=>bool,'hits'=>array,'seen'=>int,'files'=>int,'skipped'=>int,'queued'=>int]
     */
    public static function scanMalwareChunk(&$state, $budget = 4.0, $hitCap = 300, $dirCap = HtpStorage::MAL_SCAN_DIR_HARD)
    {
        $root    = rtrim(str_replace('\\', '/', htp_root()), '/');
        $newHits = array();

        if ($root === '' || !is_dir($root)) {
            return array('done' => true, 'hits' => $newHits, 'seen' => 0, 'files' => 0, 'skipped' => 0, 'queued' => 0);
        }

        // Zustand initialisieren / Defaults ergaenzen.
        if (!isset($state['stack']) || !is_array($state['stack'])) {
            $state['stack'] = array($root);
        }

        $state += array('rdir' => null, 'roff' => 0, 'seen' => 0, 'files' => 0, 'skipped' => 0, 'hitpaths' => array(), 'quarantine' => array(), 'quar_total' => 0);

        $skip     = rtrim(str_replace('\\', '/', HtpStorage::backupDir()), '/');
        $wl       = (isset($state['wl']) && is_array($state['wl'])) ? $state['wl'] : self::malwareWhitelistList();
        $baseline = isset($state['baseline']) ? (int) $state['baseline'] : 0;
        $known    = (isset($state['known']) && is_array($state['known'])) ? $state['known'] : array();
        $noTime   = ((float) $budget <= 0);
        $start    = microtime(true);
        $chunkDirs = 0;     // in DIESEM Abschnitt behandelte Ordner

        while (count($newHits) < $hitCap && $state['seen'] < $dirCap) {
            // Zeitbudget erst NACH mindestens einem Ordner pruefen -> jeder
            // Abschnitt macht garantiert Fortschritt (keine Endlosschleife).
            if (!$noTime && $chunkDirs > 0 && microtime(true) - $start >= $budget) {
                break;
            }

            $resuming = ($state['rdir'] !== null);

            if ($resuming) {
                $dir      = $state['rdir'];
                $startOff = (int) $state['roff'];
            } elseif (!empty($state['stack'])) {
                $dir      = array_pop($state['stack']);
                $startOff = 0;
            } else {
                break; // fertig
            }

            $chunkDirs++;

            $dh = @opendir($dir);

            if ($dh === false) {
                $state['rdir'] = null;
                $state['roff'] = 0;

                if (!$resuming) {
                    $state['seen']++;
                }

                continue;
            }

            $idx       = 0;     // gezaehlte readdir-Positionen (inkl. '.' '..')
            $processed = 0;     // in DIESEM Abschnitt in diesem Ordner bearbeitet
            $preempt   = false;

            while (($entry = readdir($dh)) !== false) {
                $pos = $idx;
                $idx++;

                if ($pos < $startOff) {
                    continue; // beim Resume bereits erledigt
                }

                if ($entry === '.' || $entry === '..') {
                    continue;
                }

                $p = $dir . '/' . $entry;

                if (@is_link($p)) {
                    continue;
                }

                if (@is_dir($p)) {
                    // Eigenes Backup + Whitelist (feste + Nutzer) nie betreten.
                    if (!($p === $skip || strpos($p, $skip . '/') === 0 || self::malwareExcludedPath($p, $wl))) {
                        $state['stack'][] = $p;
                    }
                } elseif (!self::malwareExcludedPath($p, $wl)) {
                    $rel = ltrim((string) substr($p, strlen($root)), '/');

                    // Vom Hoster gesperrt: die Datei existiert und hat Inhalt, steht aber auf EXAKT
                    // chmod 000 (kein einziges Rechte-Bit). Manche Hoster-Virenscanner (z. B. all-inkl.com)
                    // sperren Funde so, statt sie zu loeschen - der Inhalt ist damit nicht scanbar. Eigene
                    // QUARANTAENE-Kategorie (spaeter per Entsperr-Scan einordnen), NICHT als normaler Fund.
                    // Erkennung REIN RECHTEBASIERT (fileperms/Groesse, kein Signatur-Muster) -> kann keinen
                    // Server-AV auf HTProtect selbst ausloesen. `& 0777 === 0` ist praeziser als nur
                    // !is_readable: fremd-besessene Dateien mit normalen Rechten (0600/0640) werden so NICHT
                    // faelschlich als „gesperrt" gezaehlt. Nicht dem Delta-Skip unterworfen: ein gesperrter
                    // Zustand soll immer sichtbar sein. quar_total zaehlt ALLE (auch ueber dem Listen-Deckel).
                    $qperm = @fileperms($p);
                    if ($qperm !== false && ((int) $qperm & 0777) === 0 && (int) @filesize($p) > 0) {
                        $state['quar_total']++;
                        if (count($state['quarantine']) < self::MAL_QUAR_CAP) {
                            $state['quarantine'][] = array(
                                'path'  => $rel,
                                'size'  => (int) @filesize($p),
                                'mtime' => (int) @filemtime($p),
                            );
                        }
                    } else {
                        // Delta: seit der Basislinie unveraenderte, nicht zuvor
                        // gemeldete Dateien ueberspringen (mtime UND ctime aelter).
                        $skipDelta = false;

                        if ($baseline > 0 && !isset($known[$rel])) {
                            $mt = (int) @filemtime($p);
                            $ct = (int) @filectime($p);

                            if ($mt > 0 && $mt < $baseline && $ct < $baseline) {
                                $skipDelta = true;
                            }
                        }

                        if ($skipDelta) {
                            $state['skipped']++;
                        } else {
                            $state['files']++;
                            $hit = self::examineScanFile($p, $rel);

                            if ($hit !== null) {
                                $hit['path']          = $rel;
                                $hit['mtime']         = (int) @filemtime($p);
                                $newHits[]            = $hit;
                                $state['hitpaths'][]  = $rel;

                                if (count($newHits) >= $hitCap) {
                                    $preempt = true;
                                }
                            }
                        }
                    }
                }

                $processed++;

                // Innerhalb eines (riesigen) Ordners ebenfalls Budget/Menge pruefen.
                if ($preempt
                    || $processed >= self::MAL_CHUNK_DIRENTRY_CAP
                    || (!$noTime && $processed % 256 === 0 && microtime(true) - $start >= $budget)) {
                    $preempt = true;
                    break;
                }
            }

            @closedir($dh);

            if ($preempt) {
                // Mitten im Ordner unterbrochen -> Resume-Marke (Position nach
                // dem zuletzt gelesenen Eintrag). seen NICHT erhoehen.
                $state['rdir'] = $dir;
                $state['roff'] = $idx;
            } else {
                $state['rdir'] = null;
                $state['roff'] = 0;
                $state['seen']++;
            }
        }

        $done   = empty($state['stack']) && $state['rdir'] === null;
        $queued = count($state['stack']) + ($state['rdir'] !== null ? 1 : 0);

        return array(
            'done'    => $done,
            'hits'    => $newHits,
            'seen'    => (int) $state['seen'],
            'files'   => (int) $state['files'],
            'skipped' => (int) $state['skipped'],
            'queued'  => $queued,
            // ECHTE Gesamtzahl der gesperrten Dateien (auch ueber dem Listen-Deckel MAL_QUAR_CAP) - die
            // Liste selbst ist gekappt, der Zaehler nicht (sonst Untertreibung bei Massensperrung).
            'quarantine' => isset($state['quar_total']) ? (int) $state['quar_total'] : 0,
        );
    }

    /**
     * EINE Datei gegen alle Schad-Erkennungen pruefen (Name -> Lage -> Inhalt).
     * Spiegelt exakt die fruehere Inline-Logik des Scans. Liefert ein Treffer-
     * Array ['reason','size','preview'] oder null (unauffaellig / kein Kandidat).
     *
     * @param   string  $p    Absoluter Pfad
     * @param   string  $rel  Pfad relativ zum Webspace-Root
     *
     * @return array|null
     */
    private static function examineScanFile($p, $rel)
    {
        // Bekannter Schad-Dateiname: IMMER melden (Endung/Inhalt egal).
        $kb = self::malwareKnownBadName($rel);

        if ($kb !== '') {
            return self::scanHit($p, $kb, '');
        }

        // Als Bild getarnte PHP-Datei (.php.gif/.jpg ...): IMMER melden,
        // inhaltsunabhaengig (fremde Endung + GIF-Magic wuerden sie durchlassen).
        $loc = self::malwareLocationReason($rel);

        if ($loc === 'php_image') {
            return self::scanHit($p, 'php_image', '');
        }

        // Boesartige .htaccess: ausfuehrbare Endung in gemischter Schreibweise.
        // Inhaltsbasiert und unabhaengig vom Endungs-/Kandidaten-Gate, da .htaccess
        // sonst gar nicht inhaltlich geprueft wuerde.
        if (self::htaccessLooksMalicious($p, $rel)) {
            return self::scanHit($p, 'htaccess_exec', '');
        }

        // Archiv (ZIP): hineinschauen - getarnte Einschleuser packen ihre Schad-Skripte in ein
        // .zip, um dem Endungs-/Inhalts-Scan zu entgehen (FN 20260629:
        // includes/includes/wbmp_*.zip mit obfuskiertem Loader). Jede Code-Entry
        // laeuft durch dieselbe Inhalts-Pipeline (built-ins + Feed + fp-Unterdrueckung).
        $arc = self::examineArchive($p, $rel);

        if (is_array($arc)) {
            return $arc;
        }

        $size = 0;

        // Lesbarer Kandidat -> Inhalt pruefen; eine spezifische Signatur ist
        // informativer, sonst zaehlt die Lage/Endung.
        if (self::malwareCandidate($p, $size)) {
            $content = self::readScanContent($p, $size);
            $reason  = self::malwareSignature($content);

            if ($reason === '') {
                $reason = $loc;

                // Harmlose Standard-Fehlerseite / reines SSI-Template? Dann das
                // pauschale .shtml-Endungsverdikt fallen lassen (haeufiger FP).
                if ($reason === 'shtml' && self::shtmlLooksBenign($content)) {
                    $reason = '';
                }

                // Legitimes Joomla-Installer-Script (script.php) ODER harmlose Icon-/
                // Status-SVG (reines <svg> ohne Code) transient in tmp/ - kein
                // Upload-Sonden-Fehlalarm.
                if ($reason === 'tmp_exec'
                    && (self::tmpExecLooksBenign($content) || self::svgLooksBenign($content))) {
                    $reason = '';
                }

                // Harmloser Joomla-index.php-Schutz-Stub in /images o. ae.? Die
                // Lageregel (images_php/media_php) darf den nicht flaggen.
                if (($reason === 'images_php' || $reason === 'media_php')
                    && self::isHarmlessIndexStub($rel, $content)) {
                    $reason = '';
                }
            }

            if ($reason !== '') {
                return self::scanHit($p, $reason, $content);
            }

            return null;
        }

        // KEIN Inhalts-Kandidat (z. B. 0-Byte-Endungs-Sonde des Angreifers oder
        // sehr grosse Datei) - aber die LAGE/Endung ist fuer sich schon eindeutig
        // boesartig (PHP in /images, media_php, .phtml, .shtml). Das Inhalts-Gate
        // (Groesse) darf so ein Urteil NICHT verschlucken -> trotzdem melden.
        if ($loc !== '') {
            // Ausnahme: ein leerer/winziger index.php-Schutz-Stub (auch 0 Byte) in
            // /images bzw. media/ ist harmlos und darf nicht geflaggt werden.
            if (($loc === 'images_php' || $loc === 'media_php')
                && self::isHarmlessIndexStub($rel, @file_get_contents($p))) {
                return null;
            }

            return self::scanHit($p, $loc, '');
        }

        return null;
    }

    /**
     * Archiv-Scan (.zip). BEWUSST nur ein Wrapper: das ZIP-Hineinschauen samt PHP-Tag-
     * Erkennung ist reine INHALTS-Erkennung und liegt in HtpContent - so sieht ein
     * Server-Virenscanner die Code-Erkennung NICHT neben den Schreib-Primitiven dieser
     * Datei (Dropper-Muster NP1106-3). Fehlt HtpContent -> sicherer Default (kein Fund).
     *
     * @param   string  $p    Absoluter Pfad zum Archiv
     * @param   string  $rel  Relativer Pfad (fuer die Endungs-Pruefung)
     *
     * @return array|null  Treffer (reason 'archive:<inner>') oder null
     */
    private static function examineArchive($p, $rel)
    {
        return class_exists('HtpContent') ? HtpContent::examineArchive($p, $rel) : null;
    }

    /**
     * Einen Treffer formen - ABER vorher die kuratierte FP-Unterdrueckung des
     * Feeds anwenden: ein fp-Eintrag (exakte sha256 / Fingerprint, optional auf
     * einen Reason beschraenkt) kann jedes Urteil - auch ein eingebautes - auf
     * "sauber" ueberstimmen. Liefert null, wenn unterdrueckt. Treffer sind selten,
     * darum ist der zusaetzliche Inhalts-/Hash-Lesevorgang hier unkritisch.
     *
     * @param   string  $p        Absoluter Pfad
     * @param   string  $reason   Ermitteltes Urteil
     * @param   string  $content  Bereits gelesener Inhalt ('' = bei Bedarf nachlesen)
     *
     * @return array|null
     */
    private static function scanHit($p, $reason, $content)
    {
        if (self::fpSuppressed($p, $content, $reason)) {
            return null;
        }

        return array(
            'reason'  => $reason,
            'size'    => (int) @filesize($p),
            'preview' => self::previewSnippet((string) $content),
        );
    }

    // ─────────────────────────────────────────────────────────────────
    //  Delta-Basislinie + Lauf-Zustand des stueckweisen Scans (Dateien im
    //  Backup-Verzeichnis - vom Scan selbst uebersprungen, web-geschuetzt).
    // ─────────────────────────────────────────────────────────────────

    /**
     * Pfad der Delta-Basislinie (Zeitpunkt/Version des letzten VOLLEN Scans
     * + zuletzt gemeldete Treffer-Pfade).
     *
     * @return string
     */
    public static function malwareBaselinePath()
    {
        return HtpStorage::backupDir() . '/malscan-baseline.json';
    }

    /**
     * Delta-Basislinie lesen.
     *
     * @return array ['ts'=>int,'ver'=>string,'hits'=>array]
     */
    public static function malwareBaselineRead()
    {
        $d = @file_get_contents(self::malwareBaselinePath());
        $j = ($d !== false && $d !== '') ? json_decode($d, true) : null;

        if (!is_array($j)) {
            $j = array();
        }

        return array(
            'ts'   => isset($j['ts']) ? (int) $j['ts'] : 0,
            'ver'  => isset($j['ver']) ? (string) $j['ver'] : '',
            'hits' => (isset($j['hits']) && is_array($j['hits'])) ? array_values($j['hits']) : array(),
        );
    }

    /**
     * Delta-Basislinie schreiben (Treffer-Pfade gedeckelt).
     *
     * @param   integer  $ts    Startzeit des vollen Scans
     * @param   string   $ver   Komponenten-Version dieses Scans
     * @param   array    $hits  Gemeldete relative Pfade
     *
     * @return void
     */
    public static function malwareBaselineWrite($ts, $ver, array $hits)
    {
        $hits = array_slice(array_values(array_unique($hits)), 0, 2000);

        @HtpStorage::htpPut(
            self::malwareBaselinePath(),
            (string) json_encode(array('ts' => (int) $ts, 'ver' => (string) $ver, 'hits' => $hits)),
            LOCK_EX
        );
    }

    /**
     * Ist die Scan-ID wohlgeformt (16 Hex)?
     *
     * @param   string  $id  ID
     *
     * @return boolean
     */
    public static function scanStateValidId($id)
    {
        return (bool) preg_match('~^[a-f0-9]{16}$~', (string) $id);
    }

    /**
     * Neue zufaellige Scan-ID.
     *
     * @return string
     */
    public static function scanStateNewId()
    {
        return substr(sha1(uniqid((string) mt_rand(), true) . microtime()), 0, 16);
    }

    /**
     * Pfad der Lauf-Zustandsdatei einer Scan-ID.
     *
     * @param   string  $id  Scan-ID
     *
     * @return string
     */
    public static function scanStatePath($id)
    {
        return HtpStorage::backupDir() . '/malscan-' . $id . '.json';
    }

    /**
     * Lauf-Zustand lesen (null bei ungueltiger ID / fehlend / kaputt).
     *
     * @param   string  $id  Scan-ID
     *
     * @return array|null
     */
    public static function scanStateRead($id)
    {
        if (!self::scanStateValidId($id)) {
            return null;
        }

        $d = @file_get_contents(self::scanStatePath($id));
        $j = ($d !== false && $d !== '') ? json_decode($d, true) : null;

        return is_array($j) ? $j : null;
    }

    /**
     * Lauf-Zustand schreiben.
     *
     * @param   string  $id     Scan-ID
     * @param   array   $state  Zustand
     *
     * @return boolean
     */
    public static function scanStateWrite($id, array $state)
    {
        if (!self::scanStateValidId($id)) {
            return false;
        }

        return @HtpStorage::htpPut(self::scanStatePath($id), (string) json_encode($state), LOCK_EX) !== false;
    }

    /**
     * Lauf-Zustand loeschen.
     *
     * @param   string  $id  Scan-ID
     *
     * @return void
     */
    public static function scanStateDelete($id)
    {
        if (self::scanStateValidId($id)) {
            @unlink(self::scanStatePath($id));
        }
    }

    /**
     * Verwaiste Lauf-Zustandsdateien aufraeumen (abgebrochene Scans).
     *
     * @param   integer  $maxAgeSeconds  Hoechstalter
     *
     * @return void
     */
    public static function scanStateCleanup($maxAgeSeconds = 3600)
    {
        foreach ((array) @glob(HtpStorage::backupDir() . '/malscan-*.json') as $f) {
            if (basename($f) === 'malscan-baseline.json') {
                continue;
            }

            if ((int) @filemtime($f) < time() - $maxAgeSeconds) {
                @unlink($f);
            }
        }
    }

    /**
     * EINE als Schaddatei erkannte Datei loeschen. Sicher: der relative Pfad
     * muss innerhalb des Webspace liegen (kein Traversal) UND die Datei muss
     * AKTUELL noch eine Malware-Signatur tragen -> ein gefaelschter Request kann
     * ausschliesslich echte Schaddateien entfernen, nie beliebige Dateien.
     *
     * @param   string  $rel  Relativer Pfad ab Webspace-Root
     *
     * @return boolean
     */
    public static function deleteMalwareFile($rel)
    {
        $root = rtrim(str_replace('\\', '/', htp_root()), '/');
        $rel  = ltrim(str_replace('\\', '/', (string) $rel), '/');

        // Path-Traversal blocken (".." als eigenes Pfad-SEGMENT), aber Dateinamen, die
        // ".." nur ENTHALTEN (z. B. "..p.php" - gaengige Malware-Tarnung mit fuehrenden
        // Punkten), loeschbar lassen. Frueher strpos('..') = zu breit -> gefundene
        // Schaddateien mit solchen Namen liessen sich nicht entfernen.
        if ($rel === '' || preg_match('~(^|/)\.\.($|/)~', $rel) || strpos($rel, "\0") !== false) {
            return false;
        }

        $abs = $root . '/' . $rel;

        if (!is_file($abs) || is_link($abs) || self::malwareExcludedPath($abs)) {
            return false;
        }

        // Containment: Ausbruch ueber einen Eltern-Symlink verhindern (s. pathWithinDocroot /
        // resolveMalwarePath) - NIE eine Datei ausserhalb des Webspace loeschen.
        if (!self::pathWithinDocroot($abs, $root)) {
            return false;
        }

        // Bekannter Schad-Dateiname: ohne Kandidaten-/Inhaltspruefung loeschen.
        if (self::malwareKnownBadName($rel) !== '') {
            return self::unlinkHard($abs);
        }

        // Lage/Endung ist fuer sich eindeutig boesartig (php_image, images_php,
        // media_php, .phtml, .shtml) -> inhaltsunabhaengig loeschbar. WICHTIG:
        // auch 0-Byte- (und sehr grosse) Dateien, die das Inhalts-Gate sonst
        // aussortiert - sonst scheitert "Markierte loeschen" genau an den leeren
        // Endungs-Sonden in /images (sie werden gefunden, aber nicht geloescht).
        $delLoc = self::malwareLocationReason($rel);

        if ($delLoc !== '') {
            // Konsistent zum Scan: eine harmlose Standard-Fehlerseite / SSI-Vorlage
            // (.shtml) NICHT allein wegen der Endung loeschen - sie wird auch nicht
            // gelistet. Echte SSI-Schad-Skripte/Bruecken-Seiten/Defacement bleiben loeschbar.
            if ($delLoc === 'shtml') {
                $size = 0;

                if (self::malwareCandidate($abs, $size)
                    && self::shtmlLooksBenign(self::readScanContent($abs, $size))) {
                    return false;
                }
            }

            // Konsistent zum Scan: legitimes Joomla-Installer-Script (script.php) ODER
            // harmlose Icon-SVG in tmp/ NICHT loeschen. Signatur-clean-Gate -> eine
            // getarnte, signatur-auffaellige Datei bleibt loeschbar.
            if ($delLoc === 'tmp_exec') {
                $size = 0;

                if (self::malwareCandidate($abs, $size)) {
                    $tmpContent = self::readScanContent($abs, $size);

                    if (self::malwareSignature($tmpContent) === ''
                        && (self::tmpExecLooksBenign($tmpContent) || self::svgLooksBenign($tmpContent))) {
                        return false;
                    }
                }
            }

            // Ebenso: harmloser index.php-Schutz-Stub in /images bzw. media/.
            if (($delLoc === 'images_php' || $delLoc === 'media_php')
                && self::isHarmlessIndexStub($rel, @file_get_contents($abs))) {
                return false;
            }

            return self::unlinkHard($abs);
        }

        // Boesartige .htaccess (case-permutierte Endungs-Tarnung) - inhaltsbasiert.
        if (self::htaccessLooksMalicious($abs, $rel)) {
            return self::unlinkHard($abs);
        }

        // Konsistent zum Scan: ein .zip mit getarntem Einschleuser (examineArchive schaut
        // hinein) ist loeschbar. Sonst koennte der Nutzer GEFUNDENE .zip nicht
        // entfernen - ein .zip ist kein Inhalts-Kandidat, die Inhalts-Pruefung unten
        // verfehlt es. (FN 20260629: includes/includes/wbmp_*.zip & Co.)
        if (self::examineArchive($abs, $rel) !== null) {
            return self::unlinkHard($abs);
        }

        // Sonst rein inhaltsbasiert verifizieren (nur lesbare Kandidaten).
        $size = 0;

        if (!self::malwareCandidate($abs, $size)) {
            return false;
        }

        if (self::malwareSignature(self::readScanContent($abs, $size)) === '') {
            return false;
        }

        return self::unlinkHard($abs);
    }

    /**
     * Loeschen mit Entsperrung. Angreifer haerten Hintertueren haeufig gegen das
     * Loeschen, indem sie den ELTERNordner per chmod 555 (oder die Datei 444)
     * schreibgeschuetzt setzen - dann scheitert unlink(), OBWOHL die Datei dem
     * Webserver-Nutzer gehoert: unlink braucht das Schreibbit am VERZEICHNIS, nicht
     * an der Datei. Wir setzen das Schreibbit am Ordner (und vorsichtshalber an der
     * Datei) temporaer, loeschen und stellen den urspruenglichen Ordner-Modus
     * best-effort wieder her (minimaler Fussabdruck). Gehoert der Ordner einem
     * FREMDEN Nutzer (root-Drop), scheitert chmod -> ehrliches false (Hoster noetig).
     *
     * @param   string  $abs  Absoluter Pfad der zu loeschenden Datei
     *
     * @return boolean
     */
    private static function unlinkHard($abs)
    {
        if (@unlink($abs)) {
            return true;
        }

        // Erster Versuch scheiterte -> typischerweise gesperrter Elternordner.
        // Schreibbit temporaer setzen und erneut versuchen.
        $dir   = dirname($abs);
        $dmode = @fileperms($dir);

        // TOCTOU-Schutz: wurde $abs seit der is_link-Pruefung gegen einen Symlink getauscht,
        // NICHT chmod-en - chmod dereferenziert den Link und aendert die Rechte der Zieldatei.
        if (is_link($abs)) {
            return false;
        }

        @HtpStorage::htpChmod($abs, 0644);
        @HtpStorage::htpChmod($dir, 0755);

        $ok = @unlink($abs);

        if ($dmode !== false) {
            @HtpStorage::htpChmod($dir, $dmode & 0777); // urspruenglichen Ordner-Modus zurueck
        }

        return $ok;
    }

    // ── Vom Hoster gesperrte Dateien (Quarantaene, chmod 000) ──────────────────────────────────
    // Manche Hoster-Virenscanner (z. B. all-inkl.com) sperren Funde auf 000 statt sie zu loeschen -
    // auch mal FAELSCHLICH eine legitime Datei (z. B. eine functions.php). HTProtect listet sie
    // (rechtebasiert erkannt in scanMalwareChunk, KEIN Signatur-Muster) und bietet lokal an: kurz
    // entsperr-scannen (einordnen), loeschen ODER die Rechte einer faelschlich gesperrten Datei
    // wiederherstellen. Alles NUR im Backend (ACL+CSRF), nie fern (steht nicht in caps()).

    /** Rel-Pfad traversal-sicher in einen absoluten Docroot-Pfad aufloesen; '' wenn ungueltig.
     *  is_file/is_link brauchen kein Lese-Bit der Datei (nur Verzeichnis-Rechte) -> greifen bei 000. */
    private static function resolveQuarPath($rel)
    {
        $root = rtrim(str_replace('\\', '/', htp_root()), '/');
        $rel  = ltrim(str_replace('\\', '/', (string) $rel), '/');

        if ($root === '' || $rel === '' || preg_match('~(^|/)\.\.($|/)~', $rel) || strpos($rel, "\0") !== false) {
            return '';
        }

        $abs = $root . '/' . $rel;

        if (!@is_file($abs) || @is_link($abs) || self::malwareExcludedPath($abs)) {
            return '';
        }
        if (!self::pathWithinDocroot($abs, $root)) {
            return '';
        }
        return $abs;
    }

    /** Rel-Pfad einer bereits aufgeloesten Datei (unter dem Scan-Stamm htp_root). */
    private static function relUnderRoot($abs)
    {
        $root = rtrim(str_replace('\\', '/', htp_root()), '/');
        return ltrim((string) substr($abs, strlen($root)), '/');
    }

    /**
     * Eine gesperrte Datei kurz zum LESEN entsperren (0400: NUR Eigentuemer-Lesen, NIE ausfuehrbar/
     * gruppen-/welt-lesbar -> engst moeglich, kein Server-AV-Trigger), mit der GLEICHEN Pipeline wie
     * der Scan einordnen (examineScanFile) und SOFORT wieder auf 0000 sperren. Aendert/loescht dauerhaft
     * NICHTS. Fremder Eigentuemer (Hoster hat auch den Besitzer gedreht) -> chmod scheitert -> readable=false.
     *
     * @return array{ok:bool,readable:bool,malware:bool,reason:string}
     */
    public static function quarantineScanFile($rel)
    {
        $abs = self::resolveQuarPath($rel);
        if ($abs === '') {
            return array('ok' => false, 'readable' => false, 'malware' => false, 'reason' => '');
        }
        $r = self::relUnderRoot($abs);

        if (@is_readable($abs)) {
            // Schon lesbar (nicht mehr gesperrt) -> direkt einordnen.
            $hit = self::examineScanFile($abs, $r);
            return array('ok' => true, 'readable' => true, 'malware' => $hit !== null, 'reason' => ($hit !== null ? (string) $hit['reason'] : ''));
        }
        if (is_link($abs) || !@HtpStorage::htpChmod($abs, 0400)) {
            return array('ok' => true, 'readable' => false, 'malware' => false, 'reason' => '');
        }
        $readable = @is_readable($abs);
        $hit      = null;
        // Wirft die Einordnung (fangbar), MUSS trotzdem sofort wieder gesperrt werden - sonst bliebe
        // eine (evtl. bösartige) Datei auf 0400 (im suexec-Modell Eigentuemer-lesbar) haengen.
        try {
            if ($readable) {
                $hit = self::examineScanFile($abs, $r);
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }
        @HtpStorage::htpChmod($abs, 0000); // IMMER sofort wieder sperren (Quarantaene-Zustand erhalten)

        return array('ok' => true, 'readable' => $readable, 'malware' => $hit !== null, 'reason' => ($hit !== null ? (string) $hit['reason'] : ''));
    }

    /**
     * Eine gesperrte Datei loeschen. Standard NICHT blind: zuerst kurz einordnen - ist sie entsperrbar
     * UND unauffaellig (vermuteter Hoster-Fehlalarm), Loeschen VERWEIGERN (code looks_clean; das Backend
     * bietet dann „Rechte wiederherstellen" an). Bestaetigte Malware bzw. nicht pruefbare Dateien werden
     * geloescht; $force = auf ausdrueckliche Anweisung trotzdem.
     *
     * @return array{ok:bool,code:string}
     */
    public static function quarantineDeleteFile($rel, $force = false)
    {
        $abs = self::resolveQuarPath($rel);
        if ($abs === '') {
            return array('ok' => false, 'code' => 'bad_path');
        }

        if (!$force && !is_link($abs) && !@is_readable($abs) && @HtpStorage::htpChmod($abs, 0400)) {
            $readable = @is_readable($abs);
            $clean    = false;
            try {
                $clean = $readable ? (self::examineScanFile($abs, self::relUnderRoot($abs)) === null) : false;
            } catch (Exception $e) {
            } catch (Throwable $e) {
            }
            @HtpStorage::htpChmod($abs, 0000); // IMMER wieder sperren (auch bei Wurf) - kein 0400-Rest
            if ($readable && $clean) {
                return array('ok' => false, 'code' => 'looks_clean');
            }
        }

        return array('ok' => (bool) self::unlinkHard($abs), 'code' => 'deleted');
    }

    /**
     * Rechte einer faelschlich gesperrten (unauffaelligen) Datei wiederherstellen (0644) -> die Website
     * funktioniert wieder. NICHT blind: zuerst kurz einordnen (0400, sofort wieder 0000) - bestaetigt sich
     * Schadcode, wird die Wiederherstellung VERWEIGERT (code is_malware; das UI raet dann zum Loeschen),
     * damit kein bestaetigter Backdoor von 0000 auf 0644 = web-ausfuehrbar entsperrt wird. Fremder
     * Eigentuemer -> chmod scheitert -> ok=false (nur der Hoster kann ran).
     *
     * @return array{ok:bool,code:string}
     */
    public static function quarantineRestoreFile($rel)
    {
        $abs = self::resolveQuarPath($rel);
        if ($abs === '' || is_link($abs)) {
            return array('ok' => false, 'code' => 'bad_path');
        }

        // Erst einordnen (nur lesbar machen, sofort wieder sperren) - Wiederherstellen ist genauso
        // wenig blind wie das Loeschen.
        $wasLocked = !@is_readable($abs);
        $unlocked  = $wasLocked ? @HtpStorage::htpChmod($abs, 0400) : true;
        if ($unlocked && @is_readable($abs)) {
            $mal = false;
            try {
                $mal = (self::examineScanFile($abs, self::relUnderRoot($abs)) !== null);
            } catch (Exception $e) {
            } catch (Throwable $e) {
            }
            if ($wasLocked) {
                @HtpStorage::htpChmod($abs, 0000); // erst mal wieder sperren
            }
            if ($mal) {
                return array('ok' => false, 'code' => 'is_malware');
            }
        }

        return array('ok' => (bool) @HtpStorage::htpChmod($abs, 0644), 'code' => 'restored');
    }

    /** Ist die Datei EXAKT chmod 000 (vom Hoster gesperrt)? Kein Symlink, echte Datei. */
    private static function isChmod000($abs)
    {
        if (!@is_file($abs) || @is_link($abs)) {
            return false;
        }
        $p = @fileperms($abs);
        return $p !== false && ((int) $p & 0777) === 0;
    }

    /**
     * Alle aktuell gefundenen Schaddateien loeschen (frischer Tiefen-Scan, jede
     * Loeschung erneut verifiziert).
     *
     * @return array ['deleted' => int, 'failed' => int]
     */
    public static function deleteAllMalwareFiles()
    {
        $deleted = 0;
        $failed  = 0;

        foreach (self::scanMalwareFiles(1000) as $hit) {
            if (self::deleteMalwareFile($hit['path'])) {
                $deleted++;
            } else {
                $failed++;
            }
        }

        return array('deleted' => $deleted, 'failed' => $failed);
    }

    /**
     * Pfad von Tiefen-Scan UND Loeschen ausnehmen (Fehlalarm-Whitelist):
     *  - fest: die eigene Komponente + bekannte gutartige Plugins, die bewusst
     *    stark verschleierten Code ausliefern (bfnetwork = Blogvault/MalCare).
     *  - vom Nutzer gepflegt: relative Pfade/Ordner aus der Konfiguration
     *    (dauerhaft pro Seite). $userList kann zur Performance vorab geladen
     *    uebergeben werden; null = bei Bedarf selbst laden.
     * Pfad-Teilstring bzw. relativer Praefix -> install-verzeichnis-unabhaengig.
     *
     * @param   string      $path      Absoluter Pfad (Datei oder Verzeichnis)
     * @param   array|null  $userList  Vorab normalisierte Nutzer-Whitelist
     *
     * @return boolean
     */
    private static function malwareExcludedPath($path, $userList = null)
    {
        $p = str_replace('\\', '/', (string) $path);

        static $needles = array(
            '/com_htprotect',
            // HTProtects zentraler Sicherungsordner (ab 2.5.9, AUSSERHALB von com_htprotect): enthaelt
            // Rollback-Snapshots + den deduplizierten CAS-Store (gepackte .htpgz-Blobs) sowie die DB-/
            // Voll-Backups. Die gepackten Blobs sehen wie "verschluesselter, selbstentpackender" Code
            // aus und wurden sonst als Schaddatei geflaggt (86 Fehltreffer auf praxisgrimmelt.de). Auch
            // enthalten Backups Kopien von Site-Dateien + den DB-Dump inkl. HTProtects eigener Signatur-
            // Definitionen -> NIE den eigenen Sicherungsordner scannen/melden.
            '/_htprotect_backups',
            '/plugins/system/bfnetwork',
            // Bekannt gutartige Dateien, die wie Schadcode aussehen koennen:
            '/plugins/system/admintools/feature/phpshield.php',          // Akeeba Admin Tools (listet Stream-Wrapper)
            '/administrator/manifests/files/jce_profile_checker/script.php', // JCE-Schwachstellen-Pruefer (website-bereinigung)
            '/plugins/system/jcepro/editor/libraries/classes/image/gd.php',  // JCE Pro: GD-Bildklasse (PHP-naher, aber gutartiger Code)
            '/Resources/stubs/CURLStringFile.php',                           // Symfony/Composer-Polyfill-Stub (PHP 7.4), an beliebigem Ort
            '/joomla-connector.php',                                         // Verwaltungs-Connector (website-bereinigung.de) - fuehrt bewusst System-Befehle aus, NIE melden
            '/htprotect-probe',                                             // eigener .htaccess-Funktionstest (transienter Wegwerf-Ordner) - nie melden
        );

        foreach ($needles as $needle) {
            if (strpos($p, $needle) !== false) {
                return true;
            }
        }

        if ($userList === null) {
            $userList = self::malwareWhitelistList();
        }

        if (!empty($userList)) {
            $root = rtrim(str_replace('\\', '/', htp_root()), '/');
            $rel  = $p;

            if ($root !== '' && strpos($p, $root . '/') === 0) {
                $rel = (string) substr($p, strlen($root) + 1);
            }

            $rel = trim($rel, '/');

            foreach ($userList as $entry) {
                if ($rel === $entry || strpos($rel, $entry . '/') === 0) {
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * Vom Nutzer gepflegte Whitelist (relative Pfade/Ordner) aus der
     * Komponenten-Konfiguration - dauerhaft pro Seite vom Tiefen-Scan
     * ausgenommen.
     *
     * @return array Normalisierte relative Pfade
     */
    public static function malwareWhitelistList()
    {
        if (!class_exists('HtpConfig')) {
            return array();
        }

        $cfg = HtpConfig::load();

        return self::normalizeWhitelist(isset($cfg['mal_whitelist']) ? $cfg['mal_whitelist'] : array());
    }

    /**
     * Whitelist-Eingaben saeubern: Backslashes zu '/', fuehrende/folgende
     * Slashes weg, Traversal/NUL/leer raus, Dubletten entfernen.
     *
     * @param   mixed  $list  Array oder Zeilen-/Komma-String
     *
     * @return array
     */
    public static function normalizeWhitelist($list)
    {
        if (is_string($list)) {
            $list = preg_split('~[\r\n,]+~', $list);
        }

        if (!is_array($list)) {
            return array();
        }

        $out = array();

        foreach ($list as $entry) {
            $e = trim(str_replace('\\', '/', (string) $entry));
            $e = trim($e, '/');

            if ($e !== '' && strpos($e, '..') === false && strpos($e, "\0") === false) {
                $out[$e] = 1;
            }
        }

        return array_keys($out);
    }

    /** Delegiert an HtpContent (Half-B/PHP-Erkennung, aus storage.php isoliert); fehlt sie -> kein Kandidat. */
    private static function malwareCandidate($file, &$size)
    {
        if (!class_exists('HtpContent')) {
            $size = 0;

            return false;
        }

        return HtpContent::malwareCandidate($file, $size);
    }

    /** Delegiert an HtpContent (isolierte Erkennungs-Engine); fehlt sie -> sicherer Default. */
    private static function foreignHasDefaceMark($file, $size)
    {
        return class_exists('HtpContent') ? HtpContent::foreignHasDefaceMark($file, $size) : false;
    }

    // foreignHasPhpTag() / startsWithPhp(): nach HtpContent verlagert (Half-B-Isolation).

    /** Delegiert an HtpContent (Half-B, aus storage.php isoliert); fehlt sie -> leer. */
    private static function readScanContent($file, $size)
    {
        return class_exists('HtpContent') ? HtpContent::readScanContent($file, $size) : '';
    }

    /** Delegiert an HtpContent (Half-B, aus storage.php isoliert); fehlt sie -> Roh-Inhalt. */
    private static function cleanForView($content)
    {
        return class_exists('HtpContent') ? HtpContent::cleanForView($content) : (string) $content;
    }

    /** Delegiert an HtpContent (Half-B, aus storage.php isoliert); fehlt sie -> leer. */
    private static function previewSnippet($content)
    {
        return class_exists('HtpContent') ? HtpContent::previewSnippet($content) : '';
    }

    /**
     * Pfad einer (potenziellen) Schaddatei sicher zu einem absoluten Pfad
     * aufloesen: innerhalb Docroot, kein Traversal/Nullbyte, keine Symlinks,
     * nicht auf der Whitelist. Liefert '' bei jeder Verletzung.
     *
     * @param   string  $rel  Relativer Pfad ab Docroot
     *
     * @return string
     */
    private static function resolveMalwarePath($rel)
    {
        $root = rtrim(str_replace('\\', '/', htp_root()), '/');
        $rel  = ltrim(str_replace('\\', '/', (string) $rel), '/');

        // Path-Traversal blocken (".." als eigenes Pfad-SEGMENT), aber Dateinamen, die
        // ".." nur ENTHALTEN (z. B. "..p.php") zulassen - sonst liessen sich solche
        // Schaddateien nicht ansehen/bearbeiten (wie beim Loeschen, deleteMalwareFile).
        if ($rel === '' || preg_match('~(^|/)\.\.($|/)~', $rel) || strpos($rel, "\0") !== false) {
            return '';
        }

        $abs = $root . '/' . $rel;

        if (!is_file($abs) || is_link($abs) || self::malwareExcludedPath($abs)) {
            return '';
        }

        // SICHERHEITS-CONTAINMENT: der per realpath aufgeloeste Pfad MUSS im Docroot liegen.
        // Faengt einen Ausbruch ueber einen ELTERN-Symlink ab - der leaf-is_link-Check oben
        // deckt nur die Datei selbst. So erreicht Ansehen/Bearbeiten/Loeschen NIE eine Datei
        // ausserhalb des Webspace (z. B. eine Nachbar-Site auf Shared Hosting).
        if (!self::pathWithinDocroot($abs, $root)) {
            return '';
        }

        return $abs;
    }

    /**
     * Liegt der per realpath aufgeloeste Pfad INNERHALB des Docroots? Haelt die Zusage
     * "nur echte Schaddateien im Webspace, nie beliebige Dateien" auch gegen Eltern-Symlinks.
     *
     * @param   string  $abs   Zielpfad (existiert bereits, is_file geprueft)
     * @param   string  $root  Docroot (normalisiert, ohne Trailing-Slash)
     *
     * @return boolean
     */
    private static function pathWithinDocroot($abs, $root)
    {
        // ACHTUNG (Public-Folder): der Eingrenzungs-Boden ist BEWUSST htp_root() (voller Baum), NICHT
        // htp_docroot() - Schaddateien liegen im PF unter dem Root (ausserhalb public/). Den Fallback
        // NIE auf htp_docroot() aendern, sonst wuerden legitime Fund-Pfade unter dem Root verworfen.
        $real     = @realpath($abs);
        $rootReal = @realpath($root === '' ? htp_root() : $root);

        if ($real === false || $rootReal === false) {
            return false;
        }

        $real     = str_replace('\\', '/', $real);
        $rootReal = rtrim(str_replace('\\', '/', $rootReal), '/');

        return $real === $rootReal || strpos($real, $rootReal . '/') === 0;
    }

    /**
     * Ist die Datei AKTUELL eine erkannte Schaddatei? Exakt dasselbe Gate wie
     * beim Loeschen (bekannter Schadname ODER eindeutige Lage/Endung ODER
     * Inhalts-Signatur). View/Save nutzen es -> es kann nur der Inhalt
     * tatsaechlich erkannter Schaddateien gelesen/geschrieben werden.
     *
     * @param   string  $rel  Normalisierter relativer Pfad
     * @param   string  $abs  Absoluter Pfad (aus resolveMalwarePath)
     *
     * @return boolean
     */
    private static function isCurrentMalwareHit($rel, $abs)
    {
        $reason  = self::malwareKnownBadName($rel);
        $content = '';

        if ($reason === '') {
            $reason = self::malwareLocationReason($rel);

            // Konsistent zum Scan: harmlose Standard-Fehlerseite / SSI-Vorlage ist
            // kein Treffer (nicht editier-/loeschbar gelistet).
            if ($reason === 'shtml') {
                $size = 0;

                if (self::malwareCandidate($abs, $size)
                    && self::shtmlLooksBenign(self::readScanContent($abs, $size))) {
                    $reason = '';
                }
            }

            // Konsistent zum Scan: legitimes Joomla-Installer-Script (script.php) ODER
            // harmlose Icon-SVG in tmp/ ist kein Treffer. Signatur-clean-Gate ->
            // getarnte auffaellige Datei bleibt Treffer.
            if ($reason === 'tmp_exec') {
                $size = 0;

                if (self::malwareCandidate($abs, $size)) {
                    $tmpContent = self::readScanContent($abs, $size);

                    if (self::malwareSignature($tmpContent) === ''
                        && (self::tmpExecLooksBenign($tmpContent) || self::svgLooksBenign($tmpContent))) {
                        $reason = '';
                    }
                }
            }

            // Ebenso: harmloser index.php-Schutz-Stub in /images bzw. media/.
            if (($reason === 'images_php' || $reason === 'media_php')
                && self::isHarmlessIndexStub($rel, @file_get_contents($abs))) {
                $reason = '';
            }
        }

        if ($reason === '' && self::htaccessLooksMalicious($abs, $rel)) {
            $reason = 'htaccess_exec';
        }

        // Konsistent zum Scan: ein boesartiges .zip-Archiv (Einschleuser) zaehlt als
        // aktueller Treffer (sonst gilt es nicht als loesch-/listbar).
        if ($reason === '' && self::examineArchive($abs, $rel) !== null) {
            return true;
        }

        if ($reason === '') {
            $size = 0;

            if (!self::malwareCandidate($abs, $size)) {
                return false;
            }

            $content = self::readScanContent($abs, $size);
            $reason  = self::malwareSignature($content);

            if ($reason === '') {
                return false;
            }
        }

        // Kuratierte FP-Unterdrueckung (Feed) kann auch hier ein Urteil aufheben
        // -> eine freigegebene Datei ist "sauber" und damit nicht editier-/loeschbar.
        return !self::fpSuppressed($abs, $content, $reason);
    }

    /**
     * Wendet die kuratierte FP-Unterdrueckung des Feeds an: greift ein fp-Eintrag
     * (exakte sha256 / deklarativer Fingerprint, optional auf bestimmte Reasons
     * beschraenkt), gilt die Datei als sauber. Liest Inhalt/Hash nur bei Bedarf
     * (es gibt ueberhaupt fp-Eintraege UND ein Urteil) -> guenstig, weil Treffer
     * selten sind. Reiner Entscheid in fpSuppresses().
     *
     * @param   string  $abs      Absoluter Pfad
     * @param   string  $content  Bereits gelesener Inhalt ('' = bei Bedarf nachlesen)
     * @param   string  $reason   Urteil
     *
     * @return boolean
     */
    private static function fpSuppressed($abs, $content, $reason)
    {
        if ((string) $reason === '') {
            return false;
        }

        $fpList = (class_exists('HtpDefs') && method_exists('HtpDefs', 'malwareFp')) ? HtpDefs::malwareFp() : array();

        if (!is_array($fpList) || empty($fpList)) {
            return false;
        }

        if ((string) $content === '') {
            $content = (string) @file_get_contents($abs, false, null, 0, HtpStorage::MAL_VIEW_LEN);
        }

        // sha256 nur berechnen, wenn ueberhaupt ein fp-Eintrag exakte Hashes nutzt.
        $needHash = false;

        foreach ($fpList as $fp) {
            if (is_array($fp) && !empty($fp['sha256'])) {
                $needHash = true;
                break;
            }
        }

        $sha = $needHash ? (string) @hash_file('sha256', $abs) : '';

        return self::fpSuppresses($content, $sha, $reason, $fpList);
    }

    /** Delegiert an HtpContent (isolierte Erkennungs-Engine); fehlt sie -> sicherer Default. */
    public static function fpSuppresses($content, $sha256, $reason, $fpList)
    {
        return class_exists('HtpContent') ? HtpContent::fpSuppresses($content, $sha256, $reason, $fpList) : false;
    }

    /**
     * Vollen Inhalt einer ERKANNTEN Schaddatei fuer die Anzeige lesen. Gleiches
     * Pfad-/Treffer-Gate wie beim Loeschen -> kein beliebiges Datei-Auslesen.
     * Liest bis MAL_VIEW_LEN, bereinigt nicht druckbare Zeichen.
     *
     * @param   string  $rel  Relativer Pfad ab Docroot
     *
     * @return array|null ['content'=>string,'truncated'=>bool,'size'=>int] | null
     */
    public static function readMalwareFile($rel)
    {
        $relN = ltrim(str_replace('\\', '/', (string) $rel), '/');
        $abs  = self::resolveMalwarePath($relN);

        if ($abs === '') {
            return null;
        }

        // Vom Hoster gesperrte Datei (chmod 000): kurz NUR-lesbar machen (0400 - nur Eigentuemer-Lesen,
        // kein Gruppen-/Welt-Zugriff; im Nicht-suexec-Modell nicht mal vom Webserver lesbar), lesen,
        // SOFORT wieder sperren (Quarantaene-Zustand bleibt). So laesst sich auch eine gesperrte
        // Schaddatei im Editor oeffnen (Injektion entfernen), statt sie nur ganz loeschen zu koennen.
        // try/catch garantiert das Wiedersperren auch bei einem Wurf.
        $wasLocked = self::isChmod000($abs);
        if ($wasLocked) {
            @HtpStorage::htpChmod($abs, 0400);
        }

        $out = null;
        try {
            if (self::isCurrentMalwareHit($relN, $abs)) {
                $size  = (int) @filesize($abs);
                $raw   = (string) @file_get_contents($abs, false, null, 0, HtpStorage::MAL_VIEW_LEN + 1);
                $trunc = strlen($raw) > HtpStorage::MAL_VIEW_LEN;

                if ($trunc) {
                    $raw = substr($raw, 0, HtpStorage::MAL_VIEW_LEN);
                }

                $clean = self::cleanForView($raw);
                $out   = array(
                    'content'   => $clean,
                    'truncated' => $trunc,
                    'size'      => $size,
                    'line'      => self::malwareHintLine($clean),
                );
            }
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }

        if ($wasLocked) {
            @HtpStorage::htpChmod($abs, 0000);
        }
        return $out;
    }

    /** Delegiert an HtpContent (isolierte Erkennungs-Engine); fehlt sie -> sicherer Default. */
    public static function malwareHintLine($content)
    {
        return class_exists('HtpContent') ? HtpContent::malwareHintLine($content) : '';
    }

    /**
     * Gesaeuberten Inhalt in eine ERKANNTE Schaddatei zurueckschreiben (Ein-Klick-
     * "Bearbeiten/Speichern"). Gleiches strenges Gate wie beim Loeschen/Lesen ->
     * es kann NUR der Inhalt tatsaechlich erkannter Schaddateien geschrieben
     * werden, nie eine beliebige Datei. Sichert das Original vorher in den
     * scan-ausgeschlossenen, web-geschuetzten Datenordner.
     *
     * @param   string  $rel            Relativer Pfad ab Docroot
     * @param   string  $content        Neuer Datei-Inhalt
     * @param   int     $expectedBytes  Vom Client gemeldete Byte-Laenge (Pflicht)
     *
     * @return array|null ['ok'=>true,'clean'=>bool,'reason'=>string,'backup'=>string]
     *                    | ['ok'=>false,'incomplete'=>true] (abgeschnittener POST)
     *                    | null (keine erkannte Schaddatei / Pfad ungueltig)
     */
    public static function saveMalwareFile($rel, $content, $expectedBytes = -1)
    {
        $relN = ltrim(str_replace('\\', '/', (string) $rel), '/');
        $abs  = self::resolveMalwarePath($relN);

        if ($abs === '') {
            return null;
        }

        // Vom Hoster gesperrte Datei (chmod 000): kurz beschreibbar machen - NUR 0600 (Eigentuemer rw,
        // NIE welt-lesbar) waehrend Gate + Schreiben, damit der evtl. noch boesartige Inhalt im
        // Schreibfenster nicht welt-/web-lesbar ist. Danach IMMER (auch bei einem Wurf in doSave, s.
        // try/catch): SAUBERER Inhalt -> 0644 (bereinigt, normale Datei, aus der Quarantaene raus); sonst
        // -> wieder 000 sperren (nie eine web-ausfuehrbare Schaddatei zuruecklassen). So funktioniert der
        // volle Editor auch fuer gesperrte Dateien.
        $wasLocked = self::isChmod000($abs);
        if ($wasLocked) {
            @HtpStorage::htpChmod($abs, 0600);
        }

        $res = null;
        try {
            $res = self::doSaveMalwareFile($relN, $abs, $content, $expectedBytes);
        } catch (Exception $e) {
        } catch (Throwable $e) {
        }

        if ($wasLocked) {
            $cleaned = is_array($res) && !empty($res['ok']) && !empty($res['clean']);
            @HtpStorage::htpChmod($abs, $cleaned ? 0644 : 0000);
        }
        return $res;
    }

    /** Kernlogik von saveMalwareFile (Gate -> Schreiben -> Neubewertung), ohne die Entsperr-Verwaltung. */
    private static function doSaveMalwareFile($relN, $abs, $content, $expectedBytes)
    {
        if (!self::isCurrentMalwareHit($relN, $abs)) {
            return null; // nur erkannte Schaddateien sind editierbar
        }

        $content = (string) $content;

        // SCHUTZ GEGEN DATENVERLUST: der Client MUSS die erwartete Byte-Laenge
        // mitsenden. Weicht das Empfangene ab (abgeschnittener/verworfener POST,
        // z. B. post_max_size), wird NICHT geschrieben - lieber ein Fehler als
        // eine gekuerzte Datei. Fehlt die Laenge (-1, ganzer POST verworfen) ->
        // ebenfalls Abbruch.
        if ((int) $expectedBytes < 0 || strlen($content) !== (int) $expectedBytes) {
            return array('ok' => false, 'incomplete' => true);
        }

        if (strlen($content) > HtpStorage::MAL_VIEW_LEN) {
            return null; // zu gross fuer dieses UI -> nicht speichern (kein Abschneiden)
        }

        // Original sichern, BEVOR ueberschrieben wird (Wiederherstellbarkeit).
        $backup = self::backupBeforeEdit($relN, $abs);

        // Schaddateien sind oft schreibgeschuetzt - Angreifer setzen sie gern auf
        // 0444, um die Bereinigung zu verhindern. Kurz beschreibbar machen, sonst
        // scheitert das Speichern grundlos. Die Datei gehoert i. d. R. dem Site-User
        // (suexec/PHP-FPM) -> chmod greift; danach bleibt sie auf 0644 (passt zu den
        // uebrigen Template-/Core-Dateien).
        if (is_file($abs) && !is_writable($abs)) {
            @HtpStorage::htpChmod($abs, 0644);
        }

        if (@HtpStorage::htpPut($abs, $content) === false) {
            // Zweiter Versuch nach erneutem chmod. Scheitert es weiterhin, ist die
            // Datei wirklich nicht schreibbar (fremder Eigentuemer / Server-Sperre)
            // -> klarer Hinweis statt generischem Fehlschlag.
            @HtpStorage::htpChmod($abs, 0644);

            if (@HtpStorage::htpPut($abs, $content) === false) {
                return array('ok' => false, 'unwritable' => true);
            }
        }

        // Ehrliche Neubewertung: Inhalts-Signatur UND Lage/Name pruefen.
        $reason = self::malwareSignature($content);

        if ($reason === '') {
            $locReason = self::malwareLocationReason($relN);

            // .shtml, das jetzt eine harmlose Fehlerseite/SSI-Vorlage ist -> sauber.
            if ($locReason === 'shtml' && self::shtmlLooksBenign($content)) {
                $locReason = '';
            }

            // Legitimes Joomla-Installer-Script (script.php) ODER harmlose Icon-SVG
            // (reines <svg> ohne Code) in tmp/ -> sauber.
            if ($locReason === 'tmp_exec'
                && (self::tmpExecLooksBenign($content) || self::svgLooksBenign($content))) {
                $locReason = '';
            }

            // index.php-Stub in /images bzw. media/ -> sauber.
            if (($locReason === 'images_php' || $locReason === 'media_php')
                && self::isHarmlessIndexStub($relN, $content)) {
                $locReason = '';
            }

            if ($locReason !== '') {
                $reason = $locReason;
            } elseif (self::malwareKnownBadName($relN) !== '') {
                $reason = self::malwareKnownBadName($relN);
            }
        }

        return array(
            'ok'     => true,
            'clean'  => ($reason === ''),
            'reason' => (string) $reason,
            'backup' => (string) $backup,
        );
    }

    /**
     * Original einer zu editierenden Datei in com_htprotect/data/editbak/ sichern
     * (scan-ausgeschlossen + web-geschuetzt). Liefert den Backup-Pfad oder ''.
     *
     * @param   string  $rel  Relativer Pfad (fuer den Backup-Namen)
     * @param   string  $abs  Absoluter Quellpfad
     *
     * @return string
     */
    private static function backupBeforeEdit($rel, $abs)
    {
        if (!function_exists('htp_state_dir')) {
            return '';
        }

        $base = rtrim(str_replace('\\', '/', (string) htp_state_dir()), '/');

        if ($base === '') {
            return '';
        }

        $bdir = $base . '/editbak';

        if (!is_dir($bdir)) {
            @mkdir($bdir, 0755, true);
        }

        if (!is_dir($bdir) || !is_writable($bdir)) {
            return '';
        }

        $name = preg_replace('~[^A-Za-z0-9._-]+~', '_', (string) $rel);
        $name = substr((string) $name, -120);
        $file = $bdir . '/' . $name . '.' . date('YmdHis') . '.bak';
        $orig = @file_get_contents($abs, false, null, 0, HtpStorage::MAL_VIEW_LEN);

        if ($orig === false || @HtpStorage::htpPut($file, $orig) === false) {
            return '';
        }

        return $file;
    }

    /**
     * Alte Editier-Sicherungen (data/editbak/*.bak) aufraeumen: eine Sicherung ist nur
     * ein kurzfristiges "Rueckgaengig" nach dem Bearbeiten einer Fundstelle. Loescht
     * Sicherungen aelter als 30 Tage. Best effort; taeglich vom Waechter angestossen.
     *
     * @return int  Anzahl entfernter Dateien
     */
    public static function cleanEditBackups()
    {
        if (!function_exists('htp_state_dir')) {
            return 0;
        }

        $base = rtrim(str_replace('\\', '/', (string) htp_state_dir()), '/');

        if ($base === '') {
            return 0;
        }

        $dir = $base . '/editbak';

        if (!is_dir($dir)) {
            return 0;
        }

        $now = time();
        $ttl = 30 * 86400; // 30 Tage
        $n   = 0;

        foreach ((array) @scandir($dir) as $name) {
            if ($name === '.' || $name === '..' || substr((string) $name, -4) !== '.bak') {
                continue;
            }

            $f  = $dir . '/' . $name;
            $mt = @filemtime($f);

            if ($mt !== false && ($now - $mt) >= $ttl) {
                @chmod($f, 0600);
                @unlink($f);
                $n++;
            }
        }

        return $n;
    }

    /**
     * Einen Inhalts-Block (UTF-8-Bytes) an die Editor-Zwischendatei eines Transfers
     * anhaengen. Ermoeglicht das STUECKWEISE Hochladen sehr grosser Dateien aus dem
     * Inline-Editor: jeder Block wird vom Browser als kleiner Datei-Upload-Part
     * gesendet (umgeht das 128-KB-Feldlimit von ModSecurity und kleine
     * post_max_size-/Proxy-Limits), hier zur kompletten Datei zusammengesetzt. Erst
     * commitMalwareFile() schreibt sie - mit demselben strengen Gate wie save.
     *
     * Sicherheits-Gate beim ERSTEN Block (seq 0): der Transfer wird NUR fuer eine
     * tatsaechlich erkannte Schaddatei eroeffnet (gleiches Gate wie Lesen/Loeschen).
     * Geschrieben wird ausschliesslich in den web-geschuetzten, scan-ausgenommenen
     * Datenordner (com_htprotect/data/edittmp), nie an einen beliebigen Pfad.
     *
     * @param   string  $rel    Relativer Pfad ab Docroot (Ziel der Bearbeitung)
     * @param   string  $xfer   Transfer-Kennung (wird streng saniert)
     * @param   int     $seq    Block-Index (0 = erster Block -> Datei frisch beginnen)
     * @param   string  $bytes  Roh-Bytes dieses Blocks
     *
     * @return array  ['ok'=>bool, 'received'=>int] | ['ok'=>false, 'toobig'=>true]
     */
    public static function appendEditChunk($rel, $xfer, $seq, $bytes)
    {
        $id  = self::sanitizeXfer($xfer);
        $seq = (int) $seq;

        if ($id === '') {
            return array('ok' => false);
        }

        if ($seq === 0) {
            // GATE zuerst: einen Transfer nur fuer eine WIRKLICH erkannte
            // Schaddatei eroeffnen (kein Anlegen von Zwischendateien fuer beliebige
            // Pfade) - unabhaengig davon, ob der Datenordner beschreibbar ist.
            $relN = ltrim(str_replace('\\', '/', (string) $rel), '/');
            $abs  = self::resolveMalwarePath($relN);

            if ($abs === '') {
                return array('ok' => false);
            }

            // Gesperrte Datei (chmod 000): fuer die Gate-Pruefung kurz NUR-lesbar machen (0400), sofort
            // wieder sperren - sonst schluege das Inhalts-Gate bei nur-inhaltlich-erkannten Funden fehl
            // (leerer Inhalt bei 000) und der stueckweise Editor-Speicherpfad (Default) koennte eine
            // gesperrte Datei nie speichern. Der eigentliche Schreibvorgang laeuft spaeter ueber
            // commitMalwareFile -> saveMalwareFile (entsperrt dort selbst auf 0600 und sperrt wieder).
            $wasLocked = self::isChmod000($abs);
            if ($wasLocked) {
                @HtpStorage::htpChmod($abs, 0400);
            }
            $isHit = false;
            try {
                $isHit = self::isCurrentMalwareHit($relN, $abs);
            } catch (Exception $e) {
            } catch (Throwable $e) {
            }
            if ($wasLocked) {
                @HtpStorage::htpChmod($abs, 0000);
            }

            if (!$isHit) {
                return array('ok' => false);
            }
        }

        $dir = self::editTmpDir();

        if ($dir === '') {
            return array('ok' => false);
        }

        $part = $dir . '/' . $id . '.part';

        if ($seq === 0) {
            // Alte Reste eines abgebrochenen Transfers raeumen + frisch beginnen.
            self::cleanEditTmp($dir);
            @unlink($part);
        }

        $bytes = (string) $bytes;

        // Disk-Schutz: nie ueber das Editor-Maximum hinaus anhaengen.
        if ((int) @filesize($part) + strlen($bytes) > HtpStorage::MAL_VIEW_LEN) {
            @unlink($part);

            return array('ok' => false, 'toobig' => true);
        }

        $r = @HtpStorage::htpPut($part, $bytes, FILE_APPEND | LOCK_EX);

        return array('ok' => ($r !== false), 'received' => (int) @filesize($part));
    }

    /**
     * Den stueckweise hochgeladenen Inhalt eines Transfers in die erkannte
     * Schaddatei zurueckschreiben. Setzt die Zwischendatei zusammen und uebergibt
     * sie an saveMalwareFile() - DORT liegt das vollstaendige Gate (nur erkannte
     * Schaddateien, Original-Backup, exakte Byte-Laengen-Pruefung, Neubewertung,
     * 16-MB-Deckel). Die Zwischendatei wird in jedem Fall entfernt.
     *
     * @param   string  $rel            Relativer Pfad ab Docroot
     * @param   string  $xfer           Transfer-Kennung
     * @param   int     $expectedBytes  Vom Client gemeldete Gesamt-Byte-Laenge
     *
     * @return array|null  wie saveMalwareFile(); ['ok'=>false,'incomplete'=>true]
     *                     wenn die Zwischendatei fehlt
     */
    public static function commitMalwareFile($rel, $xfer, $expectedBytes)
    {
        $dir = self::editTmpDir();
        $id  = self::sanitizeXfer($xfer);

        if ($dir === '' || $id === '') {
            return null;
        }

        $part = $dir . '/' . $id . '.part';

        if (!is_file($part)) {
            return array('ok' => false, 'incomplete' => true);
        }

        $content = (string) @file_get_contents($part);
        @unlink($part);

        return self::saveMalwareFile($rel, $content, (int) $expectedBytes);
    }

    /**
     * Web-geschuetzter, scan-ausgenommener Ordner fuer Editor-Zwischendateien
     * (com_htprotect/data/edittmp). Liefert '' wenn nicht anlegbar/beschreibbar.
     *
     * @return string
     */
    private static function editTmpDir()
    {
        if (!function_exists('htp_state_dir')) {
            return '';
        }

        $base = rtrim(str_replace('\\', '/', (string) htp_state_dir()), '/');

        if ($base === '') {
            return '';
        }

        $dir = $base . '/edittmp';

        if (!is_dir($dir)) {
            @mkdir($dir, 0755, true);
        }

        return (is_dir($dir) && is_writable($dir)) ? $dir : '';
    }

    /**
     * Transfer-Kennung auf eine ungefaehrliche, kollisionsarme Form reduzieren
     * (nur [A-Za-z0-9], max. 64 Zeichen) - kein Pfad-Traversal, kein Punkt.
     *
     * @param   string  $xfer
     *
     * @return string  '' wenn nichts Brauchbares uebrig bleibt
     */
    public static function sanitizeXfer($xfer)
    {
        $id = preg_replace('~[^A-Za-z0-9]~', '', (string) $xfer);

        return (string) substr((string) $id, 0, 64);
    }

    /**
     * Verwaiste Zwischendateien (*.part) eines abgebrochenen Transfers entfernen
     * (aelter als 1 h). FP-sicher: nur *.part in genau diesem Ordner.
     *
     * @param   string  $dir
     *
     * @return void
     */
    private static function cleanEditTmp($dir)
    {
        $parts = @glob(rtrim((string) $dir, '/') . '/*.part');

        if (!is_array($parts)) {
            return;
        }

        $now = time();

        foreach ($parts as $p) {
            if (is_file($p) && ($now - (int) @filemtime($p)) > 3600) {
                @unlink($p);
            }
        }
    }
}
