<?php
/**
 * files.php - HtpFiles: schlanker, gejailter Dateimanager-Orchestrator (2.7.2).
 *
 * ZWECK
 *   Dashboard-Dateimanager (Site-Detailansicht). Die UI lebt AUSSCHLIESSLICH im Dashboard;
 *   der Connector stellt nur eine kleine, signierte Menge gejailter Datei-Operationen bereit.
 *   Diese Klasse ist ein DUENNER Orchestrator: die schwere Arbeit (Jail-Containment-Logik,
 *   Chunked-Save, Malware-Scan, Snapshot) stammt aus den vorhandenen Primitiven (HtpScanner/
 *   HtpStorage) bzw. wird als kleine, klar umrissene Helfer hier gehalten. Ziel: deutlich unter
 *   dem Umfang vergleichbarer Fremd-Connectoren bleiben.
 *
 * SICHERHEITSMODELL
 *   - Jede verAENDERNDE Aktion ist im Operator-Browser Ed25519-signiert (relay-blind, exakt wie
 *     der uebrige Befehlskanal, link.php::verifyCommand). Diese Klasse fuehrt selbst KEINE
 *     Signatur-/Transport-Logik aus - der Aufrufer (onAjax-Endpunkt bzw. rescue.php) hat den
 *     Befehl bereits verifiziert und reicht nur bereits geoeffnete Parameter herein.
 *   - Datei-INHALTE reisen E2E-versiegelt an den Operator (sealToOperator) bzw. kommen vom
 *     Operator versiegelt (openSealedForSite) - der Relay sieht Inhalte nie im Klartext.
 *   - Pfad-Jail: realpath-Containment auf die Site-Wurzel (htp_root(), voller Baum wie der
 *     Connector-Boden), Nullbyte-/Backslash-/'..'-Abweisung, Symlink-Leafs werden gemeldet, nie
 *     betreten/beschrieben; harte Deny-Liste fuer das eigene Bauteil + Schluessel/State.
 *   - Schreiben laeuft, wo Joomla lebt, ueber die vorhandenen Chunked-Save-/Scan-/Snapshot-Pfade.
 *
 * BEWUSSTE AUSNAHME (Zero-Trust-Grenze, PROTOCOL.md 6 / link.php::caps-Kommentar)
 *   Der Connector schliesst "Datei-Editor/-Schreiben" sonst BEWUSST vom Fern-Zugriff aus. Der
 *   Dateimanager hebt diese Grenze auf - deshalb ist er STANDARDMAESSIG AUS und muss je Site
 *   EINMAL signiert freigeschaltet werden (isEnabled/enable). caps() meldet die files.*-
 *   Faehigkeiten NUR bei gesetztem Schalter; Sites ohne Freischaltung behalten die alte Garantie
 *   "nie Fern-Schreibzugriff" vollstaendig. Der Schalter ist DATEIBASIERT (kein DB-Zugriff) ->
 *   auch im Rescue-Fall (Joomla/DB tot) lesbar.
 *
 * SERVER-VIRENSCANNER (Imunify u. ae.)
 *   Keine Kommando-Ausfuehrung, keine Laufzeit-Code-Auswertung, keine dynamische Aufruf-
 *   Indirektion, keine De-Obfuskation von Roh-Eingaben - und diese Begriffe stehen bewusst NICHT
 *   als Funktions-Literale im Code (auch nicht in Kommentaren/Strings), da naive Signatur-Scanner
 *   den blossen Text matchen. Nur benannte, gewoehnliche Dateisystem-Aufrufe in klar kommentiertem
 *   OOP - liest sich als regulaerer Komponenten-Code, nicht als abgelegtes Fremd-Skript.
 *
 * @package     com_htprotect
 * @copyright   (C) 2026 Pascal Lohmann / website-bereinigung.de
 * @license     GNU General Public License version 2 or later
 */

defined('_JEXEC') or die;

final class HtpFiles
{
    /** Dateiname des Freischalt-Schalters im geschuetzten data/-Ordner (0600-Zone). */
    const STATE_FILE = 'htprotect_files.on';

    /**
     * FESTES Freischalt-Fenster (2.7.2): ab enable() genau DEFAULT_TTL lang, ohne Verlaengerung durch
     * Nutzung (touch() ist No-op). Nach Ablauf schliesst es hart; neu freischalten per Knopf. MAX_TTL
     * dient nur noch als obere Klemme fuer eine explizit angeforderte Laufzeit in enable(). Parity zu HtpDb.
     */
    const DEFAULT_TTL = 3600;  // 1 h festes Fenster ab Freischaltung (Reaktivierung ist ein Klick)
    const MAX_TTL     = 43200; // 12 h Obergrenze fuer enable($ttl)

    /** Max. gelistete Eintraege je browse-Seite (Pagination-Cursor darueber hinaus). */
    const BROWSE_CAP = 500;

    /** Max. Inline-Lese-/Schreibgroesse ohne Chunking (darueber: Chunked-Transfer). */
    const INLINE_CAP = 2097152; // 2 MB

    /** Gechunktes Schreiben/Upload (= grosse Edits + Datei-Upload): Obergrenze + max. Chunk. */
    const UPLOAD_MAX = 67108864; // 64 MB je Datei
    const CHUNK_MAX  = 8388608;  // 8 MB je Chunk

    /** Walk-Grenzen fuer rekursive Operationen (Loeschen/Kopieren/Suche): Zip-Bomb-/Endlos-Schutz. */
    const WALK_DEPTH = 24;
    const WALK_NODES = 50000;

    /** Suche: max. Treffer, Mindest-Suchlaenge, max. Datei fuer Inhaltssuche, Zeitbudget. */
    const SEARCH_CAP    = 500;
    const SEARCH_MIN    = 3;
    const CONTENT_MAX   = 2097152; // 2 MB je Datei fuer die Inhaltssuche
    const SEARCH_BUDGET = 4.0;     // s

    /** ZIP: max. gelistete Eintraege. */
    const ZIP_ENTRIES = 2000;

    /** Entpacken: Grenzen gegen Zip-Bomb (Datei-Anzahl + entpackte Gesamtgroesse). */
    const EXTRACT_MAX_FILES = 20000;
    const EXTRACT_MAX_TOTAL = 536870912; // 512 MB entpackt

    /** chmod-Allow-Listen (oktale Strings). Andere Modi werden abgelehnt, nicht geklemmt. */
    private static function fileModes()
    {
        return array('0600', '0640', '0644', '0664', '0700', '0750', '0755', '0775');
    }

    private static function dirModes()
    {
        return array('0700', '0750', '0755', '0775');
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Freischaltung (dateibasiert, DB-unabhaengig; auch im Rescue lesbar)
    // ─────────────────────────────────────────────────────────────────────────

    /** Pfad des Schalters, '' wenn der State-Ordner nicht ermittelbar ist. */
    private static function stateFile()
    {
        if (!function_exists('htp_state_file')) {
            return '';
        }

        $p = (string) htp_state_file(self::STATE_FILE);

        return $p;
    }

    /**
     * Fensterzustand lesen: {on, enabled_at, expires_at} | null. Migriert einen ALTEN Marker
     * (bare "on ... UTC") einmalig auf ein frisches Fenster (B/C: Sites, die den Dateimanager vor
     * dem Update freigeschaltet hatten, verlieren ihn nicht - er bekommt nur ab jetzt ein Fenster).
     * Rein dateibasiert (kein DB-Zugriff), auch bei toter DB korrekt.
     */
    private static function readWin()
    {
        $f = self::stateFile();

        if ($f === '' || !@is_file($f)) {
            return null;
        }

        $raw = (string) @file_get_contents($f);
        $j   = json_decode($raw, true);

        if (is_array($j) && isset($j['expires_at'])) {
            return $j;
        }

        // Alt-Format -> als frisch freigeschaltet behandeln + einmalig auf ein Fenster migrieren.
        $now = time();
        $win = array('on' => 1, 'enabled_at' => $now, 'expires_at' => $now + self::DEFAULT_TTL);
        self::writeWin($win);

        return $win;
    }

    /** Fensterzustand als JSON in die 0600-Zone schreiben. */
    private static function writeWin($win)
    {
        $f = self::stateFile();

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

        $ok = @file_put_contents($f, (string) json_encode($win), LOCK_EX) !== false;

        if ($ok) {
            @chmod($f, 0600);
        }

        return $ok;
    }

    /**
     * Ist der Dateimanager fuer DIESE Site freigeschaltet? Standard: NEIN. Ein abgelaufenes Fenster
     * gilt als AUS (Sicherheits-Auto-Aus). Rein dateibasiert, auch bei nicht erreichbarer DB korrekt.
     *
     * @return boolean
     */
    public static function isEnabled()
    {
        $w = self::readWin();

        if (!is_array($w) || empty($w['on'])) {
            return false;
        }

        return (isset($w['expires_at']) ? (int) $w['expires_at'] : 0) > time();
    }

    /**
     * Freischalten (idempotent-erneuernd). Wird NUR aus einem signierten Befehl (files.enable/files.arm)
     * aufgerufen. Setzt ein Fenster mit Verfall in der 0600-Zone.
     *
     * @param   int  $ttl  Sekunden (0 = DEFAULT_TTL), geklemmt auf MAX_TTL.
     *
     * @return array{ok:boolean,code:string,expires_at?:int}
     */
    public static function enable($ttl = 0)
    {
        $f = self::stateFile();

        if ($f === '') {
            return array('ok' => false, 'code' => 'no_state_dir');
        }

        $ttl = (int) $ttl;

        if ($ttl <= 0) {
            $ttl = self::DEFAULT_TTL;
        }
        if ($ttl > self::MAX_TTL) {
            $ttl = self::MAX_TTL;
        }

        $now = time();
        $ok  = self::writeWin(array('on' => 1, 'enabled_at' => $now, 'expires_at' => $now + $ttl));

        return $ok
            ? array('ok' => true, 'code' => 'enabled', 'expires_at' => $now + $ttl)
            : array('ok' => false, 'code' => 'write_failed');
    }

    /**
     * Deaktivieren (idempotent). Entfernt den Marker -> caps() meldet files.* nicht mehr.
     *
     * @return array{ok:boolean,code:string}
     */
    public static function disable()
    {
        $f = self::stateFile();

        if ($f === '' || !@is_file($f)) {
            return array('ok' => true, 'code' => 'already_off');
        }

        return @unlink($f)
            ? array('ok' => true, 'code' => 'disabled')
            : array('ok' => false, 'code' => 'unlink_failed');
    }

    /** Anzeige-Status: freigeschaltet + Restlaufzeit (Sekunden). */
    public static function status()
    {
        $w   = self::readWin();
        $on  = self::isEnabled();
        $rem = ($on && is_array($w)) ? max(0, (int) $w['expires_at'] - time()) : 0;

        return array('on' => $on, 'remaining' => $rem);
    }

    /**
     * FESTES Fenster (nicht mehr gleitend): Das Freischalt-Fenster laeuft ab enable() eine feste Laufzeit
     * (DEFAULT_TTL, 1h) und wird durch Nutzung NICHT verlaengert. Laeuft es ab, schliesst es hart zu; der
     * Operator schaltet dann per Knopfdruck neu frei. Bewusst No-op, damit die Aufrufer unveraendert
     * bleiben. Parity zu HtpDb::touch().
     */
    public static function touch()
    {
        // No-op: kein gleitendes Verlaengern mehr - das 1h-Fenster ist ab Freischaltung fix.
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Pfad-Jail (allgemein: Dateien UND Ordner; portierte Containment-Logik)
    // ─────────────────────────────────────────────────────────────────────────

    /** Normalisierte Site-Wurzel (voller Baum, ohne Trailing-Slash) - der Jail-Boden. */
    private static function root()
    {
        return rtrim(str_replace('\\', '/', htp_root()), '/');
    }

    /**
     * Relativen Eingabepfad hart normalisieren: Backslashes zu Slashes, fuehrende Slashes weg,
     * Mehrfach-Slashes zusammenziehen. Gibt '' zurueck, wenn der Pfad grundsaetzlich unzulaessig
     * ist (Nullbyte oder ein '.'/'..'-SEGMENT). Ein blosses ENTHALTEN von ".." (z. B. "..name")
     * ist erlaubt - abgewiesen wird nur das echte Traversal-Segment. Der Wurzelpfad ist ''.
     *
     * @param   string  $rel
     *
     * @return  string|null  normalisierter rel-Pfad ODER null bei Verletzung
     */
    private static function sanitizeRel($rel)
    {
        $rel = str_replace('\\', '/', (string) $rel);

        if (strpos($rel, "\0") !== false) {
            return null;
        }

        // '/' und '' sind die Wurzel.
        $rel = ltrim($rel, '/');

        if ($rel === '' || $rel === '.') {
            return '';
        }

        // Segmentweise pruefen: kein Segment darf '.' oder '..' sein; leere Segmente (//) fallen weg.
        $parts = explode('/', $rel);
        $clean = array();

        foreach ($parts as $seg) {
            if ($seg === '' || $seg === '.') {
                continue;
            }

            if ($seg === '..') {
                return null; // echtes Traversal-Segment -> Verletzung
            }

            $clean[] = $seg;
        }

        return implode('/', $clean);
    }

    /**
     * Liegt der per realpath aufgeloeste Pfad INNERHALB der Site-Wurzel? Faengt auch einen
     * Ausbruch ueber einen ELTERN-Symlink ab (Vergleich gegen root + Separator, kein reiner
     * Praefix -> /foo vs /foobar). Vorlage: HtpScanner::pathWithinDocroot.
     *
     * @param   string  $abs
     *
     * @return  boolean
     */
    private static function withinRoot($abs)
    {
        $real     = @realpath($abs);
        $rootReal = @realpath(self::root());

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

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

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

    /**
     * Rel-Pfad sicher in einen absoluten, gejailten Pfad aufloesen.
     *
     * @param   string   $rel        relativer Pfad ab Site-Wurzel ('' = Wurzel)
     * @param   boolean  $mustExist  true: Ziel muss existieren; false: nur der ELTERNordner muss
     *                               existieren + gejailt sein (fuer Neuanlage/Schreiben)
     *
     * @return  string|null  absoluter Pfad ODER null bei jeder Verletzung
     */
    private static function resolve($rel, $mustExist = true)
    {
        $root  = self::root();
        $clean = self::sanitizeRel($rel);

        if ($root === '' || $clean === null) {
            return null;
        }

        $abs = ($clean === '') ? $root : $root . '/' . $clean;

        if ($mustExist) {
            if (!@file_exists($abs)) {
                return null;
            }

            // Symlink-Leaf: melden/aufloesen ueberlassen wir dem Aufrufer; das Containment prueft
            // ohnehin das realpath-Ziel. withinRoot bindet auch Eltern-Symlink-Ausbrueche ab.
            if (!self::withinRoot($abs)) {
                return null;
            }

            return $abs;
        }

        // Nicht-existierendes Ziel (Neuanlage): der ELTERNordner muss existieren + im Jail liegen,
        // und der Blattname darf kein Symlink sein, der aus dem Jail zeigt.
        $parent = ($clean === '' || strpos($clean, '/') === false) ? $root : $root . '/' . substr($clean, 0, strrpos($clean, '/'));

        if (!@is_dir($parent) || !self::withinRoot($parent)) {
            return null;
        }

        if (@is_link($abs)) {
            return null; // vorhandener Symlink am Ziel -> nie durch ihn hindurch schreiben
        }

        return $abs;
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Deny-Liste (eigenes Bauteil/Schluessel) + Geheimnis-Dateien
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Gehoert der rel-Pfad zum HTProtect-Bauteil selbst (Komponente, Watch-Plugin, State/Keys)?
     * Solche Pfade sind fuer den Dateimanager TABU (Lesen wie Schreiben) - Selbst-Manipulation
     * bzw. Schluessel-Diebstahl aus. Vergleich case-insensitiv auf Segment-Ebene.
     *
     * @param   string  $clean  bereits normalisierter rel-Pfad
     *
     * @return  boolean
     */
    private static function isDenied($clean)
    {
        $p = strtolower($clean);

        $deny = array(
            'administrator/components/com_htprotect',
            'components/com_htprotect',
            'plugins/system/htprotectwatch',
            'api/components/com_htprotect',
            'cli/htprotect',
        );

        foreach ($deny as $d) {
            if ($p === $d || strpos($p, $d . '/') === 0) {
                return true;
            }
        }

        return false;
    }

    /**
     * Ist der rel-Pfad eine GEHEIMNIS-/kritische Datei (configuration.php, .env, Schluessel)?
     * Solche Dateien sind nicht gesperrt, brauchen fuer verAENDERNDE Aktionen aber eine
     * explizite Bestaetigung (confirm) - Schutz vor versehentlichem Zerstoeren der Zugangsdaten.
     *
     * @param   string  $clean
     *
     * @return  boolean
     */
    private static function isSecret($clean)
    {
        $base = strtolower(basename($clean));

        if ($base === 'configuration.php' || $base === '.env' || $base === 'configuration.php-bak') {
            return true;
        }

        // Schluessel-/Zertifikatsdateien ueberall im Baum.
        return (bool) preg_match('/\.(key|pem|p12|pfx|crt)$/i', $base);
    }

    /** index.php ist NIE loeschbar (Verzeichnis-Schutz-Stub); Vorlage mysites DENY_DELETE_NAMES. */
    private static function isUndeletable($clean)
    {
        return strtolower(basename($clean)) === 'index.php';
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Eintrags-Beschreibung + Listing
    // ─────────────────────────────────────────────────────────────────────────

    /** Menschlich lesbarer oktaler Modus, z. B. "0755". '' wenn nicht lesbar. */
    private static function permString($abs)
    {
        $p = @fileperms($abs);

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

        return substr(sprintf('%04o', $p & 0777), -4);
    }

    /**
     * Einen Verzeichniseintrag beschreiben. Symlinks werden als solche gemeldet (type=link) und
     * NIE verfolgt. UTF-8-untaugliche Namen werden base64-kodiert mit Flag geliefert (jsonSafe),
     * damit json_encode nicht scheitert.
     *
     * @param   string  $abs   absoluter Pfad des Eintrags
     * @param   string  $name  Basisname (roh)
     *
     * @return  array
     */
    private static function describeEntry($abs, $name)
    {
        $isLink = @is_link($abs);
        $isDir  = !$isLink && @is_dir($abs);

        $nameOut = $name;
        $b64     = false;

        if (!self::isValidUtf8($name)) {
            $nameOut = base64_encode($name);
            $b64     = true;
        }

        $entry = array(
            'name'     => $nameOut,
            'type'     => $isLink ? 'link' : ($isDir ? 'dir' : 'file'),
            'perms'    => self::permString($abs),
            'writable' => @is_writable($abs),
        );

        if ($b64) {
            $entry['name_b64'] = true;
        }

        // Groesse nur fuer echte Dateien (Ordner: null -> Dashboard zeigt "-").
        $entry['size'] = ($entry['type'] === 'file') ? (int) @filesize($abs) : null;

        // Aenderungszeit als EXAKTER UTC-Zeitstempel (kein "vor 4 Wochen"); sortierbar im Dashboard.
        $mt = @filemtime($abs);
        $entry['mtime'] = ($mt !== false) ? gmdate('Y-m-d H:i:s', $mt) : '';

        if ($entry['type'] === 'link') {
            $tgt = @readlink($abs);
            $entry['link_target'] = ($tgt !== false) ? str_replace('\\', '/', $tgt) : '';
        }

        return $entry;
    }

    /** Ist $s gueltiges UTF-8? (ohne mbstring-Abhaengigkeit) */
    private static function isValidUtf8($s)
    {
        return (bool) preg_match('//u', (string) $s);
    }

    /**
     * Ein Verzeichnis auflisten. Gejailt, symlink-sicher, paginiert. Versteckte Dateien (Dotfiles)
     * werden BEWUSST mitgelistet - .htaccess/.env/Backdoors sind sicherheitsrelevant.
     *
     * @param   string   $rel     Verzeichnis relativ zur Wurzel ('' = Wurzel)
     * @param   string   $sort    name|folders|recent|size (Default folders)
     * @param   integer  $offset  Pagination-Offset
     *
     * @return  array{ok:boolean,code:string,...}
     */
    public static function browse($rel, $sort = 'folders', $offset = 0)
    {
        $clean = self::sanitizeRel($rel);

        if ($clean === null) {
            return array('ok' => false, 'code' => 'bad_path');
        }

        // Das eigene Bauteil (inkl. Schluessel/State-Ordner) wird nicht einmal AUFGELISTET.
        if ($clean !== '' && self::isDenied($clean)) {
            return array('ok' => false, 'code' => 'denied');
        }

        $abs = self::resolve($clean, true);

        if ($abs === null || !@is_dir($abs) || @is_link($abs)) {
            return array('ok' => false, 'code' => 'not_a_dir');
        }

        $names = @scandir($abs);

        if (!is_array($names)) {
            return array('ok' => false, 'code' => 'unreadable');
        }

        $all = array();

        foreach ($names as $name) {
            if ($name === '.' || $name === '..') {
                continue;
            }

            $childAbs   = $abs . '/' . $name;
            $childClean = ($clean === '') ? $name : $clean . '/' . $name;

            $entry = self::describeEntry($childAbs, $name);
            // rel-Pfad (fuer Navigation/Folgeaktionen) + Bauteil-Kennzeichnung (Dashboard graut es aus).
            $entry['path']  = '/' . $childClean;
            $entry['guard'] = self::isDenied($childClean) ? 1 : 0;

            $all[] = $entry;
        }

        self::sortEntries($all, $sort);

        $total   = count($all);
        $offset  = max(0, (int) $offset);
        $page    = array_slice($all, $offset, self::BROWSE_CAP);
        $next    = ($offset + self::BROWSE_CAP < $total) ? ($offset + self::BROWSE_CAP) : null;

        return array(
            'ok'       => true,
            'code'     => 'listed',
            'rel'      => '/' . $clean,
            'entries'  => $page,
            'total'    => $total,
            'offset'   => $offset,
            'next'     => $next,
            'writable' => @is_writable($abs),
        );
    }

    /** Eintragsliste in-place sortieren. 'folders' = Ordner zuerst, dann Name. */
    private static function sortEntries(&$list, $sort)
    {
        $sort = in_array($sort, array('name', 'folders', 'recent', 'size'), true) ? $sort : 'folders';

        usort($list, function ($a, $b) use ($sort) {
            if ($sort === 'folders') {
                $ad = ($a['type'] === 'dir') ? 0 : 1;
                $bd = ($b['type'] === 'dir') ? 0 : 1;

                if ($ad !== $bd) {
                    return $ad - $bd;
                }

                return strcasecmp((string) $a['name'], (string) $b['name']);
            }

            if ($sort === 'name') {
                return strcasecmp((string) $a['name'], (string) $b['name']);
            }

            if ($sort === 'recent') {
                return strcmp((string) $b['mtime'], (string) $a['mtime']); // neueste zuerst
            }

            // size (groesste zuerst; Ordner als 0)
            $as = (int) (isset($a['size']) ? $a['size'] : 0);
            $bs = (int) (isset($b['size']) ? $b['size'] : 0);

            return $bs - $as;
        });
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Lesen (Vorschau/Editor). Inhalt reist E2E-versiegelt an den Operator -
    //  diese Methode liefert nur den Klartext, das Versiegeln macht der Aufrufer.
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Datei-Bytes lesen. Ohne $len: ganze Datei (bis INLINE_CAP). Mit $off/$len: Ausschnitt
     * (Chunked-Read fuer grosse Dateien). Symlinks/Ordner/eigenes Bauteil werden abgewiesen.
     *
     * @param   string   $rel
     * @param   integer  $off  Byte-Offset (>=0)
     * @param   integer  $len  Byte-Anzahl (0 = bis INLINE_CAP ab $off)
     *
     * @return  array
     */
    public static function read($rel, $off = 0, $len = 0)
    {
        $clean = self::sanitizeRel($rel);

        if ($clean === null || $clean === '') {
            return array('ok' => false, 'code' => 'bad_path');
        }

        if (self::isDenied($clean)) {
            return array('ok' => false, 'code' => 'denied');
        }

        $abs = self::resolve($clean, true);

        if ($abs === null) {
            return array('ok' => false, 'code' => 'not_found');
        }

        if (@is_link($abs)) {
            return array('ok' => false, 'code' => 'symlink');
        }

        if (@is_dir($abs)) {
            return array('ok' => false, 'code' => 'is_dir');
        }

        if (!@is_readable($abs)) {
            return array('ok' => false, 'code' => 'unreadable');
        }

        $size = (int) @filesize($abs);
        $off  = max(0, (int) $off);
        $len  = (int) $len;

        // Voll-Lesen nur bis INLINE_CAP; darueber MUSS der Aufrufer chunked (off/len) lesen.
        if ($off === 0 && $len <= 0 && $size > self::INLINE_CAP) {
            return array('ok' => false, 'code' => 'too_big', 'size' => $size);
        }

        $want = ($len > 0) ? min($len, self::INLINE_CAP) : self::INLINE_CAP;

        $fh = @fopen($abs, 'rb');

        if ($fh === false) {
            return array('ok' => false, 'code' => 'open_failed');
        }

        if ($off > 0) {
            @fseek($fh, $off);
        }

        $chunk = @fread($fh, $want);
        @fclose($fh);

        if ($chunk === false) {
            $chunk = '';
        }

        return array(
            'ok'      => true,
            'code'    => 'read',
            'content' => $chunk,               // ROHBYTES - Aufrufer versiegelt an den Operator
            'size'    => $size,
            'off'     => $off,
            'eof'     => ($off + strlen($chunk)) >= $size,
            'md5'     => (string) @md5_file($abs),
            'perms'   => self::permString($abs),
            'writable' => @is_writable($abs),
        );
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Snapshot-Undo (ueber den web-gehaerteten Backup-Store; kein eigener Vault)
    // ─────────────────────────────────────────────────────────────────────────

    /** Undo-Ordner im gehaerteten Backup-Store, '' wenn nicht ermittelbar. */
    private static function snapDir()
    {
        if (!class_exists('HtpStorage')) {
            return '';
        }

        $base = (string) @HtpStorage::backupDir();

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

        $d = rtrim(str_replace('\\', '/', $base), '/') . '/files-undo';

        if (!@is_dir($d)) {
            @mkdir($d, 0700, true);
        }

        return @is_dir($d) ? $d : '';
    }

    /**
     * Vor einer destruktiven Aktion (Ueberschreiben/Loeschen) eine Kopie der Datei sichern.
     * Rueckgabe = Snapshot-Kennung (fuer files.restore) oder '' wenn kein Undo moeglich war.
     *
     * @param   string  $abs
     * @param   string  $clean
     *
     * @return  string
     */
    private static function snapshotFile($abs, $clean)
    {
        $d = self::snapDir();

        if ($d === '' || !@is_file($abs) || @is_link($abs)) {
            return '';
        }

        $safe = preg_replace('/[^A-Za-z0-9._-]/', '_', $clean);
        $safe = substr($safe, -120);
        $id   = $safe . '.' . gmdate('Ymd-His') . '.' . substr(bin2hex(random_bytes(4)), 0, 8) . '.snap';
        $dst  = $d . '/' . $id;

        if (!@copy($abs, $dst)) {
            return '';
        }

        @chmod($dst, 0600);
        @file_put_contents(
            $dst . '.json',
            json_encode(array('rel' => '/' . $clean, 'ts' => gmdate('Y-m-d H:i:s'), 'perms' => self::permString($abs))),
            LOCK_EX
        );

        return $id;
    }

    /**
     * Inhalt vor dem Commit durch die Malware-Engine pruefen (scan-vor-Commit). Schreibt in eine
     * kurzlebige Temp-Datei mit passender Endung, scannt sie, raeumt auf. '' = sauber (oder Scanner
     * nicht verfuegbar, z. B. im Rescue -> fail-open, dokumentiert); sonst der Fund-Grund.
     *
     * @param   string  $content
     * @param   string  $name     Zielname (fuer die Endung)
     *
     * @return  string
     */
    private static function scanContent($content, $name)
    {
        if (!class_exists('HtpScanner') || !function_exists('htp_tmp_path')) {
            return '';
        }

        $tmpDir = rtrim(str_replace('\\', '/', (string) htp_tmp_path()), '/');

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

        if (!@is_dir($tmpDir)) {
            @mkdir($tmpDir, 0700, true);

            if (!@is_dir($tmpDir)) {
                return '';
            }
        }

        $ext = preg_replace('/[^A-Za-z0-9]/', '', (string) pathinfo($name, PATHINFO_EXTENSION));
        $tmp = $tmpDir . '/htpfiles-scan-' . substr(bin2hex(random_bytes(6)), 0, 12) . ($ext !== '' ? '.' . $ext : '');

        if (@file_put_contents($tmp, $content, LOCK_EX) === false) {
            return '';
        }

        $hits = @HtpScanner::scanMalwareUnder(array($tmp), 5, 3.0);
        @unlink($tmp);

        if (is_array($hits) && !empty($hits)) {
            $h = $hits[0];

            return isset($h['reason']) && $h['reason'] !== '' ? (string) $h['reason'] : 'suspect';
        }

        return '';
    }

    /**
     * Loeschen mit Entsperrung (Vorlage HtpScanner::unlinkHard): scheitert unlink am
     * schreibgeschuetzten ELTERNordner, Schreibbit temporaer setzen und zuruecksetzen. TOCTOU-sicher.
     *
     * @param   string  $abs
     *
     * @return  boolean
     */
    private static function deleteHard($abs)
    {
        if (@unlink($abs)) {
            return true;
        }

        if (@is_link($abs)) {
            return false; // nie den chmod auf ein Symlink-Ziel dereferenzieren
        }

        $dir   = dirname($abs);
        $dmode = @fileperms($dir);

        if (class_exists('HtpStorage')) {
            @HtpStorage::htpChmod($abs, 0644);
            @HtpStorage::htpChmod($dir, 0755);
        } else {
            @chmod($abs, 0644);
            @chmod($dir, 0755);
        }

        $ok = @unlink($abs);

        if ($dmode !== false) {
            $back = $dmode & 0777;

            if (class_exists('HtpStorage')) {
                @HtpStorage::htpChmod($dir, $back);
            } else {
                @chmod($dir, $back);
            }
        }

        return $ok;
    }

    /**
     * Atomar schreiben: Temp-Datei -> rename (POSIX-atomar). Schreibgeschuetztes Ziel wird kurz
     * entsperrt und der ursprungsmodus wiederhergestellt. .php danach im OPcache invalidieren.
     *
     * @param   string   $abs
     * @param   string   $content
     * @param   boolean  $exists
     *
     * @return  boolean
     */
    private static function atomicWrite($abs, $content, $exists)
    {
        $origPerms = $exists ? (@fileperms($abs) & 0777) : false;
        $ro        = $exists && !@is_writable($abs);

        if ($ro) {
            class_exists('HtpStorage') ? @HtpStorage::htpChmod($abs, 0644) : @chmod($abs, 0644);
        }

        $tmp = $abs . '.htptmp' . substr(bin2hex(random_bytes(4)), 0, 8);
        $w   = @file_put_contents($tmp, $content, LOCK_EX);

        if ($w === false) {
            @unlink($tmp);

            if ($ro && $origPerms !== false) {
                class_exists('HtpStorage') ? @HtpStorage::htpChmod($abs, $origPerms) : @chmod($abs, $origPerms);
            }

            return false;
        }

        $ok = @rename($tmp, $abs);

        if (!$ok) {
            // Windows/Cross-FS: rename ueber eine bestehende Datei kann scheitern -> Fallback.
            $ok = @copy($tmp, $abs);
            @unlink($tmp);
        }

        if ($exists && $origPerms !== false) {
            class_exists('HtpStorage') ? @HtpStorage::htpChmod($abs, $origPerms) : @chmod($abs, $origPerms);
        }

        if ($ok && substr(strtolower($abs), -4) === '.php' && function_exists('opcache_invalidate')) {
            @opcache_invalidate($abs, true);
        }

        return (bool) $ok;
    }

    /**
     * Datei schreiben/anlegen. Optimistisches md5-Locking (kein stilles Ueberschreiben fremder
     * Aenderungen), Snapshot-vor-Ueberschreiben, Scan-vor-Commit, Geheimnis-Confirm, Perms bewahrt.
     * Inline bis INLINE_CAP (grosse Inhalte laufen ueber den Chunked-Pfad des Aufrufers).
     *
     * @param   string   $rel
     * @param   string   $content   ROHBYTES (Aufrufer hat bereits E2E-entsiegelt)
     * @param   string   $expectMd5 md5 der zuletzt gelesenen Fassung ('' = Neuanlage/kein Lock)
     * @param   boolean  $confirm   Override fuer Geheimnis-Datei bzw. Malware-Fund
     *
     * @return  array
     */
    public static function write($rel, $content, $expectMd5 = '', $confirm = false)
    {
        if (strlen((string) $content) > self::INLINE_CAP) {
            return array('ok' => false, 'code' => 'too_big'); // grosse Inhalte via writeBegin/Chunk/Commit
        }

        return self::commitBytes($rel, (string) $content, (string) $expectMd5, $confirm);
    }

    /**
     * Bytes an einen gejailten Pfad committen - der gemeinsame Kern von write() (inline) und
     * writeCommit() (gechunkt/Upload): Deny/Symlink/Ordner-Schutz, Geheimnis-Confirm, optimistisches
     * md5-Locking, Scan-vor-Commit, Snapshot-vor-Ueberschreiben, atomares Schreiben, Perms bewahrt.
     *
     * @param   string   $rel
     * @param   string   $content
     * @param   string   $expectMd5
     * @param   boolean  $confirm
     *
     * @return  array
     */
    private static function commitBytes($rel, $content, $expectMd5, $confirm)
    {
        if (strlen($content) > self::UPLOAD_MAX) {
            return array('ok' => false, 'code' => 'too_big');
        }

        $clean = self::sanitizeRel($rel);

        if ($clean === null || $clean === '') {
            return array('ok' => false, 'code' => 'bad_path');
        }

        if (self::isDenied($clean)) {
            return array('ok' => false, 'code' => 'denied');
        }

        $abs = self::resolve($clean, false);

        if ($abs === null) {
            return array('ok' => false, 'code' => 'bad_path');
        }

        if (@is_link($abs)) {
            return array('ok' => false, 'code' => 'symlink'); // nie durch einen Symlink schreiben
        }

        if (@is_dir($abs)) {
            return array('ok' => false, 'code' => 'is_dir');
        }

        $exists = @is_file($abs);

        // Geheimnis-Datei (configuration.php/.env/*.key) nur mit expliziter Bestaetigung.
        if (self::isSecret($clean) && !$confirm) {
            return array('ok' => false, 'code' => 'need_confirm');
        }

        // Optimistisches Locking: erwartete md5 muss zur aktuellen Datei passen.
        if ($exists) {
            $cur = (string) @md5_file($abs);

            if ($expectMd5 !== '' && !hash_equals($cur, $expectMd5)) {
                return array('ok' => false, 'code' => 'stale', 'md5' => $cur);
            }
        } elseif ($expectMd5 !== '') {
            return array('ok' => false, 'code' => 'gone'); // erwartet vorhanden, ist aber weg
        }

        // Scan-vor-Commit: Fund blockiert, ausser der Operator bestaetigt bewusst (confirm).
        $scan = self::scanContent($content, basename($clean));

        if ($scan !== '' && !$confirm) {
            return array('ok' => false, 'code' => 'malware', 'reason' => $scan);
        }

        // Snapshot vor dem Ueberschreiben (Undo).
        $undo = $exists ? self::snapshotFile($abs, $clean) : '';

        if (!self::atomicWrite($abs, $content, $exists)) {
            return array('ok' => false, 'code' => 'write_failed');
        }

        return array(
            'ok'      => true,
            'code'    => $exists ? 'written' : 'created',
            'md5'     => md5($content),
            'size'    => strlen($content),
            'undo'    => ($undo !== '' ? $undo : null),
            'scanned' => ($scan !== '' ? $scan : null), // bewusst uebernommen bei confirm
        );
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Gechunktes Schreiben = grosser Edit UND Upload (kein externer Fetch noetig).
    //  Bytes reisen E2E-versiegelt ueber denselben signierten Kanal; hier nur assembliert.
    // ─────────────────────────────────────────────────────────────────────────

    /** Transclient-Ordner (geschuetzte 0600-Zone) fuer Part-Dateien; '' wenn nicht ermittelbar. */
    private static function partsDir()
    {
        if (!function_exists('htp_state_dir')) {
            return '';
        }

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

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

        $d = $base . '/filesparts';

        if (!@is_dir($d)) {
            @mkdir($d, 0700, true);
        }

        return @is_dir($d) ? $d : '';
    }

    /** Part-Pfad zu einer STRENG validierten Transfer-Kennung (nur Hex); '' bei Unfug. */
    private static function partPath($xfer)
    {
        $x = (string) $xfer;

        if (!preg_match('/^[a-f0-9]{16,64}$/', $x)) {
            return '';
        }

        $d = self::partsDir();

        return $d === '' ? '' : $d . '/' . $x . '.part';
    }

    /**
     * Gechunktes Schreiben beginnen (Upload einer neuen Datei ODER grosser Edit). Prueft das Ziel
     * fruehzeitig (Jail/Deny) und liefert eine Transfer-Kennung.
     *
     * @param   string  $rel
     *
     * @return  array
     */
    public static function writeBegin($rel)
    {
        $clean = self::sanitizeRel($rel);

        if ($clean === null || $clean === '') {
            return array('ok' => false, 'code' => 'bad_path');
        }

        if (self::isDenied($clean)) {
            return array('ok' => false, 'code' => 'denied');
        }

        $d = self::partsDir();

        if ($d === '') {
            return array('ok' => false, 'code' => 'no_tmp');
        }

        $xfer = bin2hex(random_bytes(16));
        $pp   = $d . '/' . $xfer . '.part';

        if (@file_put_contents($pp, '', LOCK_EX) === false) {
            return array('ok' => false, 'code' => 'begin_failed');
        }

        @chmod($pp, 0600);

        return array('ok' => true, 'code' => 'begin', 'xfer' => $xfer);
    }

    /**
     * Einen Chunk anhaengen. seq=0 setzt neu auf (Neustart), sonst wird angehaengt. Chunk- und
     * Gesamtgroesse werden gedeckelt.
     *
     * @param   string   $xfer
     * @param   integer  $seq
     * @param   string   $bytes  ROHBYTES (Aufrufer hat bereits E2E-entsiegelt)
     *
     * @return  array
     */
    public static function writeChunk($xfer, $seq, $bytes)
    {
        $pp = self::partPath($xfer);

        if ($pp === '' || !@is_file($pp)) {
            return array('ok' => false, 'code' => 'bad_xfer');
        }

        $bytes = (string) $bytes;

        if (strlen($bytes) > self::CHUNK_MAX) {
            return array('ok' => false, 'code' => 'chunk_too_big');
        }

        $have = ((int) $seq === 0) ? 0 : (int) @filesize($pp);

        if ($have + strlen($bytes) > self::UPLOAD_MAX) {
            return array('ok' => false, 'code' => 'too_big');
        }

        $flags = ((int) $seq === 0) ? LOCK_EX : (FILE_APPEND | LOCK_EX);

        if (@file_put_contents($pp, $bytes, $flags) === false) {
            return array('ok' => false, 'code' => 'chunk_failed');
        }

        return array('ok' => true, 'code' => 'chunk', 'received' => (int) @filesize($pp));
    }

    /**
     * Gechunktes Schreiben abschliessen: zusammengesetzte Bytes wie ein normales write() committen
     * (Scan/Snapshot/md5-Lock/atomar), danach die Part-Datei aufraeumen.
     *
     * @param   string   $rel
     * @param   string   $xfer
     * @param   string   $expectMd5
     * @param   boolean  $confirm
     *
     * @return  array
     */
    public static function writeCommit($rel, $xfer, $expectMd5 = '', $confirm = false)
    {
        $pp = self::partPath($xfer);

        if ($pp === '' || !@is_file($pp)) {
            return array('ok' => false, 'code' => 'bad_xfer');
        }

        $content = @file_get_contents($pp);

        if ($content === false) {
            @unlink($pp);

            return array('ok' => false, 'code' => 'read_failed');
        }

        $res = self::commitBytes($rel, $content, (string) $expectMd5, $confirm);
        @unlink($pp);

        return $res;
    }

    /** Verwaiste Part-Dateien aufraeumen (aelter als $ttl Sekunden). */
    public static function cleanParts($ttl = 86400)
    {
        $d = self::partsDir();

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

        $now = time();
        $it  = @scandir($d);

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

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

                if (@is_file($p) && ($now - (int) @filemtime($p)) > (int) $ttl) {
                    @unlink($p);
                }
            }
        }
    }

    /**
     * Einen (einzelnen) Ordner anlegen (nicht rekursiv), 0755.
     *
     * @param   string  $rel
     *
     * @return  array
     */
    public static function mkdir($rel)
    {
        $clean = self::sanitizeRel($rel);

        if ($clean === null || $clean === '') {
            return array('ok' => false, 'code' => 'bad_path');
        }

        if (self::isDenied($clean)) {
            return array('ok' => false, 'code' => 'denied');
        }

        $abs = self::resolve($clean, false);

        if ($abs === null) {
            return array('ok' => false, 'code' => 'bad_path');
        }

        if (@file_exists($abs)) {
            return array('ok' => false, 'code' => 'exists');
        }

        return @mkdir($abs, 0755)
            ? array('ok' => true, 'code' => 'created')
            : array('ok' => false, 'code' => 'mkdir_failed');
    }

    /**
     * Loeschen. Datei -> Snapshot + entsperrtes unlink. Symlink -> nur den Link entfernen (nie das
     * Ziel). Leerer Ordner -> rmdir. Nicht-leerer Ordner -> 'dir_not_empty' (rekursiv folgt separat).
     * index.php ist tabu; Geheimnis-Dateien nur mit confirm.
     *
     * @param   string   $rel
     * @param   boolean  $confirm
     *
     * @return  array
     */
    public static function delete($rel, $confirm = false)
    {
        $clean = self::sanitizeRel($rel);

        if ($clean === null || $clean === '') {
            return array('ok' => false, 'code' => 'bad_path');
        }

        if (self::isDenied($clean)) {
            return array('ok' => false, 'code' => 'denied');
        }

        if (self::isUndeletable($clean)) {
            return array('ok' => false, 'code' => 'protected'); // index.php
        }

        if (self::isSecret($clean) && !$confirm) {
            return array('ok' => false, 'code' => 'need_confirm');
        }

        $abs = self::resolve($clean, true);

        if ($abs === null) {
            return array('ok' => false, 'code' => 'not_found');
        }

        if (@is_link($abs)) {
            return @unlink($abs)
                ? array('ok' => true, 'code' => 'deleted_link')
                : array('ok' => false, 'code' => 'unlink_failed');
        }

        if (@is_dir($abs)) {
            $kids = @scandir($abs);

            if (is_array($kids) && count($kids) <= 2) {
                return @rmdir($abs)
                    ? array('ok' => true, 'code' => 'deleted_dir')
                    : array('ok' => false, 'code' => 'rmdir_failed');
            }

            return array('ok' => false, 'code' => 'dir_not_empty');
        }

        $undo = self::snapshotFile($abs, $clean);

        return self::deleteHard($abs)
            ? array('ok' => true, 'code' => 'deleted', 'undo' => ($undo !== '' ? $undo : null))
            : array('ok' => false, 'code' => 'delete_failed');
    }

    /**
     * Umbenennen ODER Verschieben (beide Enden gejailt). Kein Ueberschreiben. index.php bleibt.
     *
     * @param   string  $rel
     * @param   string  $dest
     *
     * @return  array
     */
    public static function rename($rel, $dest)
    {
        $cs = self::sanitizeRel($rel);
        $cd = self::sanitizeRel($dest);

        if ($cs === null || $cd === null || $cs === '' || $cd === '') {
            return array('ok' => false, 'code' => 'bad_path');
        }

        if (self::isDenied($cs) || self::isDenied($cd)) {
            return array('ok' => false, 'code' => 'denied');
        }

        if (self::isUndeletable($cs)) {
            return array('ok' => false, 'code' => 'protected'); // index.php nicht wegbewegen
        }

        $absS = self::resolve($cs, true);
        $absD = self::resolve($cd, false);

        if ($absS === null) {
            return array('ok' => false, 'code' => 'not_found');
        }

        if ($absD === null) {
            return array('ok' => false, 'code' => 'bad_dest');
        }

        if (@file_exists($absD) || @is_link($absD)) {
            return array('ok' => false, 'code' => 'dest_exists'); // nie ueberschreiben
        }

        return @rename($absS, $absD)
            ? array('ok' => true, 'code' => 'renamed')
            : array('ok' => false, 'code' => 'rename_failed');
    }

    /** Modus-Eingabe ('644' / '0644') zu '0NNN' normalisieren; null bei Unfug. */
    private static function normalizeMode($mode)
    {
        $m = ltrim(trim((string) $mode));

        if (!preg_match('/^0?[0-7]{3}$/', $m)) {
            return null;
        }

        return '0' . substr($m, -3);
    }

    /**
     * Rechte setzen - nur aus der festen Allow-Liste (Datei bzw. Ordner), sonst Ablehnung (kein
     * Klemmen). Liest den tatsaechlichen Modus zurueck (der Host kann abweichen).
     *
     * @param   string  $rel
     * @param   string  $mode
     *
     * @return  array
     */
    public static function chmod($rel, $mode)
    {
        $clean = self::sanitizeRel($rel);

        if ($clean === null || $clean === '') {
            return array('ok' => false, 'code' => 'bad_path');
        }

        if (self::isDenied($clean)) {
            return array('ok' => false, 'code' => 'denied');
        }

        $abs = self::resolve($clean, true);

        if ($abs === null) {
            return array('ok' => false, 'code' => 'not_found');
        }

        if (@is_link($abs)) {
            return array('ok' => false, 'code' => 'symlink'); // nie ein Symlink-Ziel dereferenzieren
        }

        $m = self::normalizeMode($mode);

        if ($m === null) {
            return array('ok' => false, 'code' => 'bad_mode');
        }

        $isDir = @is_dir($abs);
        $allow = $isDir ? self::dirModes() : self::fileModes();

        if (!in_array($m, $allow, true)) {
            return array('ok' => false, 'code' => 'mode_not_allowed');
        }

        class_exists('HtpStorage') ? @HtpStorage::htpChmod($abs, octdec($m)) : @chmod($abs, octdec($m));

        return array('ok' => true, 'code' => 'chmod', 'perms' => self::permString($abs));
    }

    /**
     * Frei-/Gesamtspeicher der Site-Partition (fuer die Kopf-Anzeige). Billig, kein Geheimnis.
     *
     * @return  array
     */
    public static function disk()
    {
        $root  = self::root();
        $free  = @disk_free_space($root);
        $total = @disk_total_space($root);

        return array(
            'ok'    => true,
            'code'  => 'disk',
            'free'  => ($free !== false) ? (int) $free : null,
            'total' => ($total !== false) ? (int) $total : null,
        );
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Suche (Name-Substring ODER Inhalt), gejailt, symlink-sicher, budgetiert
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Rekursiv ab $rel suchen. mode='name': case-insensitiver Substring des Basisnamens.
     * mode='content': Substring im Dateiinhalt (bis CONTENT_MAX/Datei) + entschaerftes Snippet.
     * Zeit-/Knoten-Budget (fail-open -> partial), Symlinks werden nie verfolgt, eigenes Bauteil
     * uebersprungen. Antwort reist E2E an den Operator (Snippets sehen nur er, nie der Relay).
     *
     * @param   string  $rel
     * @param   string  $query
     * @param   string  $mode   name|content
     *
     * @return  array
     */
    public static function search($rel, $query, $mode = 'name')
    {
        $clean = self::sanitizeRel($rel);

        if ($clean === null) {
            return array('ok' => false, 'code' => 'bad_path');
        }

        $base = self::resolve($clean, true);

        if ($base === null || !@is_dir($base) || @is_link($base)) {
            return array('ok' => false, 'code' => 'not_a_dir');
        }

        $q = (string) $query;

        if (strlen($q) < self::SEARCH_MIN) {
            return array('ok' => false, 'code' => 'query_too_short');
        }

        $mode    = ($mode === 'content') ? 'content' : 'name';
        $rootLen = strlen(self::root()) + 1;
        $hits    = array();
        $nodes   = 0;
        $partial = false;
        $start   = microtime(true);
        $stack   = array($base);

        while (!empty($stack)) {
            if (count($hits) >= self::SEARCH_CAP) {
                $partial = true;
                break;
            }

            if ((microtime(true) - $start) > self::SEARCH_BUDGET || ++$nodes > self::WALK_NODES) {
                $partial = true;
                break;
            }

            $cur = array_pop($stack);

            if (@is_link($cur)) {
                continue; // Symlinks nie verfolgen
            }

            $relCur = (strlen($cur) > $rootLen) ? substr($cur, $rootLen) : '';

            if ($relCur !== '' && self::isDenied($relCur)) {
                continue; // eigenes Bauteil ueberspringen (Teilbaum)
            }

            if (@is_file($cur)) {
                $name = basename($cur);

                if ($mode === 'name') {
                    if (stripos($name, $q) !== false) {
                        $hits[] = self::searchHit($cur, $relCur, '');
                    }

                    continue;
                }

                // content: erste CONTENT_MAX Bytes lesen, Substring suchen, Snippet entschaerfen.
                $data = @file_get_contents($cur, false, null, 0, self::CONTENT_MAX);

                if ($data !== false) {
                    $pos = stripos($data, $q);

                    if ($pos !== false) {
                        $s    = max(0, $pos - 40);
                        $snip = substr($data, $s, 160);
                        $snip = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '.', $snip);

                        if (!self::isValidUtf8($snip)) {
                            $snip = '';
                        }

                        $hits[] = self::searchHit($cur, $relCur, (string) $snip);
                    }
                }

                continue;
            }

            $entries = @scandir($cur);

            if (is_array($entries)) {
                foreach ($entries as $e) {
                    if ($e !== '.' && $e !== '..') {
                        $stack[] = $cur . '/' . $e;
                    }
                }
            }
        }

        return array(
            'ok'      => true,
            'code'    => 'searched',
            'mode'    => $mode,
            'query'   => $q,
            'hits'    => $hits,
            'count'   => count($hits),
            'partial' => $partial,
        );
    }

    /** Einen Suchtreffer beschreiben (leichtgewichtig) + optionalem Snippet. */
    private static function searchHit($abs, $relCur, $snippet)
    {
        $hit = array(
            'name'  => basename($abs),
            'path'  => '/' . $relCur,
            'size'  => (int) @filesize($abs),
            'perms' => self::permString($abs),
        );

        $mt = @filemtime($abs);
        $hit['mtime'] = ($mt !== false) ? gmdate('Y-m-d H:i:s', $mt) : '';

        if ($snippet !== '') {
            $hit['snippet'] = $snippet;
        }

        return $hit;
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Kopieren (Datei ODER rekursiver Baum), gejailt, kein Overwrite
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Kopieren. Datei -> Datei. Ordner (nur mit $recursive) -> ganzer Baum. Beide Enden gejailt,
     * kein Ueberschreiben, Symlinks werden ausgelassen (nie kopiert/verfolgt), Knoten-/Tiefen-Limit.
     *
     * @param   string   $rel
     * @param   string   $dest
     * @param   boolean  $recursive
     *
     * @return  array
     */
    public static function copy($rel, $dest, $recursive = false)
    {
        $cs = self::sanitizeRel($rel);
        $cd = self::sanitizeRel($dest);

        if ($cs === null || $cd === null || $cs === '' || $cd === '') {
            return array('ok' => false, 'code' => 'bad_path');
        }

        if (self::isDenied($cs) || self::isDenied($cd)) {
            return array('ok' => false, 'code' => 'denied');
        }

        $absS = self::resolve($cs, true);
        $absD = self::resolve($cd, false);

        if ($absS === null) {
            return array('ok' => false, 'code' => 'not_found');
        }

        if ($absD === null) {
            return array('ok' => false, 'code' => 'bad_dest');
        }

        if (@file_exists($absD) || @is_link($absD)) {
            return array('ok' => false, 'code' => 'dest_exists');
        }

        if (@is_link($absS)) {
            return array('ok' => false, 'code' => 'symlink');
        }

        if (@is_file($absS)) {
            if (!@copy($absS, $absD)) {
                return array('ok' => false, 'code' => 'copy_failed');
            }

            $pm = @fileperms($absS);

            if ($pm !== false) {
                class_exists('HtpStorage') ? @HtpStorage::htpChmod($absD, $pm & 0777) : @chmod($absD, $pm & 0777);
            }

            return array('ok' => true, 'code' => 'copied');
        }

        if (@is_dir($absS)) {
            if (!$recursive) {
                return array('ok' => false, 'code' => 'is_dir');
            }

            $res = self::copyTree($absS, $absD);

            return $res === true
                ? array('ok' => true, 'code' => 'copied_tree')
                : array('ok' => false, 'code' => (string) $res);
        }

        return array('ok' => false, 'code' => 'unsupported');
    }

    /** Rekursiv kopieren (DFS, symlink-sicher, Knoten-/Tiefen-Limit). true ODER Fehlercode-String. */
    private static function copyTree($srcBase, $dstBase)
    {
        if (!@mkdir($dstBase, 0755)) {
            return 'mkdir_failed';
        }

        $nodes = 0;
        $stack = array(array($srcBase, $dstBase, 0));

        while (!empty($stack)) {
            list($src, $dst, $depth) = array_pop($stack);

            if ($depth > self::WALK_DEPTH) {
                return 'too_deep';
            }

            $entries = @scandir($src);

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

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

                if (++$nodes > self::WALK_NODES) {
                    return 'too_many';
                }

                $from = $src . '/' . $e;
                $to   = $dst . '/' . $e;

                if (@is_link($from)) {
                    continue; // Symlinks auslassen (nie kopieren/verfolgen)
                }

                if (@is_dir($from)) {
                    if (!@mkdir($to, 0755)) {
                        return 'mkdir_failed';
                    }

                    $stack[] = array($from, $to, $depth + 1);

                    continue;
                }

                if (@is_file($from) && !@copy($from, $to)) {
                    return 'copy_failed';
                }

                $pm = @fileperms($from);

                if ($pm !== false) {
                    class_exists('HtpStorage') ? @HtpStorage::htpChmod($to, $pm & 0777) : @chmod($to, $pm & 0777);
                }
            }
        }

        return true;
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  ZIP hineinschauen (NUR listen, kein Entpacken)
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Inhalt eines Zip-Archivs auf dem Server auflisten - NICHTS wird entpackt/heruntergeladen.
     *
     * @param   string  $rel
     *
     * @return  array
     */
    public static function ziplist($rel)
    {
        $clean = self::sanitizeRel($rel);

        if ($clean === null || $clean === '') {
            return array('ok' => false, 'code' => 'bad_path');
        }

        if (self::isDenied($clean)) {
            return array('ok' => false, 'code' => 'denied');
        }

        $abs = self::resolve($clean, true);

        if ($abs === null || !@is_file($abs) || @is_link($abs)) {
            return array('ok' => false, 'code' => 'not_found');
        }

        if (!class_exists('ZipArchive')) {
            return array('ok' => false, 'code' => 'no_zip');
        }

        $z = new ZipArchive();

        if ($z->open($abs) !== true) {
            return array('ok' => false, 'code' => 'bad_zip');
        }

        $total   = $z->numFiles;
        $show     = min($total, self::ZIP_ENTRIES);
        $entries = array();

        for ($i = 0; $i < $show; $i++) {
            $s = $z->statIndex($i);

            if ($s === false) {
                continue;
            }

            $entries[] = array(
                'name' => (string) $s['name'],
                'size' => (int) $s['size'],
                'comp' => (int) $s['comp_size'],
                'mtime' => isset($s['mtime']) ? gmdate('Y-m-d H:i:s', (int) $s['mtime']) : '',
            );
        }

        $z->close();

        return array(
            'ok'       => true,
            'code'     => 'ziplist',
            'entries'  => $entries,
            'total'    => $total,
            'listed'   => count($entries),
            'capped'   => $total > $show,
        );
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Wiederherstellen aus einem Snapshot (Undo)
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Eine zuvor per Snapshot gesicherte Datei an ihren Originalpfad zuruecksichern. Die Snapshot-
     * Kennung wird streng validiert (keine Traversal), das Ziel gejailt; ein Ueberschreiben der
     * aktuellen Datei braucht confirm und wird selbst wieder gesnapshottet.
     *
     * @param   string   $snapId
     * @param   boolean  $confirm
     *
     * @return  array
     */
    public static function restore($snapId, $confirm = false)
    {
        $id = basename((string) $snapId); // etwaige Pfadanteile abschneiden

        if (!preg_match('/^[A-Za-z0-9._-]+\.snap$/', $id)) {
            return array('ok' => false, 'code' => 'bad_id');
        }

        $d = self::snapDir();

        if ($d === '') {
            return array('ok' => false, 'code' => 'no_store');
        }

        $snap = $d . '/' . $id;

        if (!@is_file($snap)) {
            return array('ok' => false, 'code' => 'snap_not_found');
        }

        // Containment: der Snapshot MUSS im Undo-Store liegen (kein Ausbruch ueber Symlink).
        $sr = @realpath($snap);
        $dr = @realpath($d);

        if ($sr === false || $dr === false) {
            return array('ok' => false, 'code' => 'bad_id');
        }

        $sr = str_replace('\\', '/', $sr);
        $dr = rtrim(str_replace('\\', '/', $dr), '/');

        if (strpos($sr, $dr . '/') !== 0) {
            return array('ok' => false, 'code' => 'bad_id');
        }

        $meta = @json_decode((string) @file_get_contents($snap . '.json'), true);
        $rel  = (is_array($meta) && isset($meta['rel'])) ? ltrim((string) $meta['rel'], '/') : '';

        if ($rel === '') {
            return array('ok' => false, 'code' => 'no_meta');
        }

        $clean = self::sanitizeRel($rel);

        if ($clean === null || $clean === '' || self::isDenied($clean)) {
            return array('ok' => false, 'code' => 'bad_target');
        }

        $abs = self::resolve($clean, false);

        if ($abs === null || @is_link($abs) || @is_dir($abs)) {
            return array('ok' => false, 'code' => 'bad_target');
        }

        $exists = @is_file($abs);

        if ($exists && !$confirm) {
            return array('ok' => false, 'code' => 'need_confirm'); // wuerde aktuelle Fassung ueberschreiben
        }

        if ($exists) {
            self::snapshotFile($abs, $clean); // Restore selbst wieder undoable machen
        }

        $content = @file_get_contents($snap);

        if ($content === false || !self::atomicWrite($abs, $content, $exists)) {
            return array('ok' => false, 'code' => 'restore_failed');
        }

        // Rechte aus dem Manifest wiederherstellen, sofern gueltig.
        if (is_array($meta) && isset($meta['perms']) && preg_match('/^0[0-7]{3}$/', (string) $meta['perms'])) {
            $pm = octdec((string) $meta['perms']);
            class_exists('HtpStorage') ? @HtpStorage::htpChmod($abs, $pm) : @chmod($abs, $pm);
        }

        return array('ok' => true, 'code' => 'restored', 'rel' => '/' . $clean);
    }

    // ─────────────────────────────────────────────────────────────────────────
    //  Entpacken (Zip-Slip-/Zip-Bomb-/Symlink-sicher, Scan-vor-Commit ueber Staging)
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Ist der Archiv-Eintragsname unsicher (Zip-Slip)? Absoluter Pfad, Windows-Laufwerk,
     * ein '..'-SEGMENT oder Nullbyte -> unsicher. Backslashes zaehlen wie Slashes.
     *
     * @param   string  $name
     *
     * @return  boolean
     */
    private static function entryUnsafe($name)
    {
        $n = str_replace('\\', '/', (string) $name);

        if ($n === '' || strpos($n, "\0") !== false) {
            return true;
        }

        if ($n[0] === '/') {
            return true; // absolut
        }

        if (preg_match('~^[A-Za-z]:~', $n)) {
            return true; // Windows-Laufwerk
        }

        foreach (explode('/', $n) as $seg) {
            if ($seg === '..') {
                return true;
            }
        }

        return false;
    }

    /** Verzeichnisbaum best-effort loeschen (Symlinks werden nur entfernt, nie verfolgt). */
    private static function rmTree($dir)
    {
        if (!@is_dir($dir) || @is_link($dir)) {
            @unlink($dir);

            return;
        }

        $items = @scandir($dir);

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

                $p = $dir . '/' . $e;
                (@is_dir($p) && !@is_link($p)) ? self::rmTree($p) : @unlink($p);
            }
        }

        @rmdir($dir);
    }

    /**
     * Zip-Archiv sicher entpacken. Ablauf: (1) Vorpruefung ALLER Eintraege - Zip-Slip abweisen,
     * entpackte Gesamtgroesse + Anzahl deckeln (Zip-Bomb), Ueberschreiben nur mit confirm; (2) in
     * einen frischen Staging-Ordner UNTER dem Ziel entpacken; (3) Staging scannen (Scan-vor-Commit;
     * Fund blockiert ohne confirm); (4) nur echte Dateien (keine Symlinks) ins Ziel schieben, mit
     * Snapshot-Undo bei Ueberschreiben. Beide Enden gejailt.
     *
     * @param   string   $rel      Zip-Datei
     * @param   string   $dest     Ziel-Ordner ('' = Wurzel)
     * @param   boolean  $confirm
     *
     * @return  array
     */
    public static function extract($rel, $dest, $confirm = false)
    {
        $cs = self::sanitizeRel($rel);
        $cd = self::sanitizeRel($dest);

        if ($cs === null || $cs === '' || $cd === null) {
            return array('ok' => false, 'code' => 'bad_path');
        }

        if (self::isDenied($cs) || self::isDenied($cd)) {
            return array('ok' => false, 'code' => 'denied');
        }

        $absZip = self::resolve($cs, true);

        if ($absZip === null || !@is_file($absZip) || @is_link($absZip)) {
            return array('ok' => false, 'code' => 'not_found');
        }

        $absDest = self::resolve($cd, true);

        if ($absDest === null || !@is_dir($absDest) || @is_link($absDest)) {
            return array('ok' => false, 'code' => 'bad_dest');
        }

        if (!class_exists('ZipArchive')) {
            return array('ok' => false, 'code' => 'no_zip');
        }

        $z = new ZipArchive();

        if ($z->open($absZip) !== true) {
            return array('ok' => false, 'code' => 'bad_zip');
        }

        $num = (int) $z->numFiles;

        if ($num > self::EXTRACT_MAX_FILES) {
            $z->close();

            return array('ok' => false, 'code' => 'too_many');
        }

        $destReal = @realpath($absDest);

        if ($destReal === false) {
            $z->close();

            return array('ok' => false, 'code' => 'bad_dest');
        }

        $destReal  = rtrim(str_replace('\\', '/', $destReal), '/');
        $total     = 0;
        $files     = array();
        $overwrite = false;

        // (1) Vorpruefung
        for ($i = 0; $i < $num; $i++) {
            $s = $z->statIndex($i);

            if ($s === false) {
                continue;
            }

            $nm = (string) $s['name'];

            if (self::entryUnsafe($nm)) {
                $z->close();

                return array('ok' => false, 'code' => 'unsafe_entry', 'entry' => $nm);
            }

            $total += (int) $s['size'];

            if ($total > self::EXTRACT_MAX_TOTAL) {
                $z->close();

                return array('ok' => false, 'code' => 'too_big');
            }

            if (substr($nm, -1) !== '/') {
                $files[] = $nm;

                if (@file_exists($destReal . '/' . $nm)) {
                    $overwrite = true;
                }
            }
        }

        if ($overwrite && !$confirm) {
            $z->close();

            return array('ok' => false, 'code' => 'need_confirm');
        }

        // (2) In ein frisches Staging entpacken
        $stage = $absDest . '/.htpx-x-' . substr(bin2hex(random_bytes(5)), 0, 10);

        if (!@mkdir($stage, 0700)) {
            $z->close();

            return array('ok' => false, 'code' => 'stage_failed');
        }

        $z->extractTo($stage, $files);
        $z->close();

        // (3) Staging scannen (Fund blockiert ohne confirm)
        $scan = '';

        if (class_exists('HtpScanner')) {
            $hits = @HtpScanner::scanMalwareUnder(array($stage), 100, 4.0);

            if (is_array($hits) && !empty($hits)) {
                if (!$confirm) {
                    self::rmTree($stage);
                    $h = $hits[0];

                    return array('ok' => false, 'code' => 'malware', 'reason' => isset($h['reason']) ? (string) $h['reason'] : 'suspect');
                }

                $h    = $hits[0];
                $scan = isset($h['reason']) ? (string) $h['reason'] : 'suspect';
            }
        }

        // (4) Nur echte Dateien (keine Symlinks) ins Ziel schieben, Snapshot bei Ueberschreiben
        $moved = 0;

        foreach ($files as $nm) {
            $from = $stage . '/' . $nm;
            $to   = $destReal . '/' . $nm;

            if (!@is_file($from) || @is_link($from)) {
                continue; // nie einen Symlink aus dem Archiv ins Ziel legen
            }

            $pd = dirname($to);

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

            if (@is_file($to)) {
                $cleanTo = self::sanitizeRel($cd . '/' . $nm);

                if ($cleanTo !== null) {
                    self::snapshotFile($to, $cleanTo);
                }

                @unlink($to); // Windows: rename ueber bestehende Datei scheitert -> vorher weg
            }

            if (!@rename($from, $to)) {
                @copy($from, $to);
                @unlink($from);
            }

            $moved++;
        }

        self::rmTree($stage);

        return array(
            'ok'      => true,
            'code'    => 'extracted',
            'files'   => $moved,
            'scanned' => ($scan !== '' ? $scan : null),
        );
    }
}
