#!/usr/bin/env php
<?php
/**
 * zenith-shell — the only door Zenith's SSH key opens.
 *
 * Installed as the forced command of that key in ~/.ssh/authorized_keys,
 * it replaces whatever Zenith asks by one of a handful of verbs: describe
 * the machine, read the site's inventory, diagnose a site that no longer
 * answers, read its error log, and — only when this file's configuration
 * allows it — carry out a few remediation actions or an update.
 *
 * Zenith always sends one argument: a base64 JSON request. Everything in it
 * is checked here; nothing in it is ever handed to a shell.
 *
 * Verbs: describe, inventory, diagnostic, log, audit, integrity, weblogs,
 * put, control, update. The first seven only read; the last three need the
 * configuration below to say yes.
 *
 * Configuration, first file found (or the file named by ZENITH_SHELL_CONF):
 *   ~/.config/zenith/shell.conf
 *   /etc/zenith/shell.conf
 * with lines such as:
 *   control=yes      allow cache flush, plugin on/off, cron, maintenance
 *   update=yes       allow updates through wp-cli
 *   self_update=yes  allow install_shell alone: Zenith may replace this script
 *                    (and nothing else) where control stays closed
 *   wp=/usr/local/bin/wp
 *   php=/usr/bin/php8.2
 *   shell=/usr/local/bin/zenith-shell   where install_shell writes the new copy
 *   weblogs=system   also read /var/log/apache2, /var/log/nginx, /var/log/httpd
 *   log=file         write the request log to ~/.zenith/shell.log even when syslog works
 *
 * Every request leaves one line in syslog (facility auth, ident zenith-shell)
 * or, where syslog is not available, in ~/.zenith/shell.log.
 *
 * Runs on PHP 7.4 and later. Answers in JSON on stdout, exit 0; on refusal
 * or failure, JSON with ok:false and a non-zero exit. Every list is capped,
 * every child process is bounded by the deadline the request carries, and
 * nothing from the request is ever interpreted by a shell.
 */

define('ZENITH_SHELL_VERSION', '1.7.1');

// The wp-cli release install_wpcli fetches, and the SHA-512 its authors
// published for it: both move together, at each new release of wp-cli.
define('ZENITH_WPCLI_VERSION', '2.12.0');
define('ZENITH_WPCLI_SHA512', 'be928f6b8ca1e8dfb9d2f4b75a13aa4aee0896f8a9a0a1c45cd5d2c98605e6172e6d014dda2e27f88c98befc16c040cbb2bd1bfa121510ea5cdf5f6a30fe8832');

// File transfer (verb put): what one transfer may weigh in all, per piece,
// and how long an unfinished one is kept before being swept.
define('ZENITH_PUT_MAX_BYTES', 32 * 1024 * 1024);
define('ZENITH_PUT_CHUNK_MAX_BYTES', 256 * 1024);
define('ZENITH_PUT_MAX_CHUNKS', 1024);
define('ZENITH_PUT_STALE_SECONDS', 3600);

// The audit and the integrity walk have a budget, told by Zenith in the
// request, with a default and a ceiling of their own.
define('ZENITH_DEADLINE_DEFAULT', 480);
define('ZENITH_DEADLINE_MAX', 900);

// Lists never grow past this: find is cut by head, and the answer says so.
define('ZENITH_FIND_MAX_LINES', 2000);

// The integrity report names at most this many files, all sections taken
// together: the answer must still fit in one SSH exchange.
define('ZENITH_INTEGRITY_MAX_FILES', 30000);

// The maintenance page is never left up by accident: WordPress drops it
// ten minutes after the stamp, and the stamp is set two hours ahead at most.
define('ZENITH_MAINTENANCE_MAX_SECONDS', 7200);
define('ZENITH_MAINTENANCE_GRACE_SECONDS', 600);

// Cron: one hook may not hold the run longer than this, and the whole run
// stops handing out hooks past the budget.
define('ZENITH_CRON_HOOK_SECONDS', 30);
define('ZENITH_CRON_BUDGET_SECONDS', 150);

// The request log written next to the snapshots, rotated once, at 1 MB.
define('ZENITH_LOG_MAX_BYTES', 1024 * 1024);

// Snapshots kept per plugin: enough to go back, not enough to fill a disk.
define('ZENITH_SNAPSHOTS_KEPT', 3);

main(isset($argv) ? $argv : []);

/**
 * @param string[] $argv
 */
function main(array $argv)
{
    $GLOBALS['zenith_started'] = microtime(true);
    $GLOBALS['zenith_log'] = ['verb' => '-', 'action' => '-', 'actor' => '-', 'path' => '-'];
    $GLOBALS['zenith_config'] = null;
    $original = getenv('SSH_ORIGINAL_COMMAND');
    $restricted = false;

    if (count($argv) < 2 && is_string($original) && '' !== trim($original)) {
        // Forced command: what Zenith typed is in the environment, and the
        // key can do nothing but reach this line.
        $restricted = true;
        $parts = preg_split('/\s+/', trim($original)) ?: [];

        if (isset($parts[0]) && 'zenith-shell' === basename($parts[0])) {
            array_shift($parts);
        }

        $token = isset($parts[0]) ? $parts[0] : '';
    } else {
        $token = isset($argv[1]) ? $argv[1] : '';
    }

    if ('--version' === $token) {
        echo ZENITH_SHELL_VERSION, "\n";
        exit(0);
    }

    $request = decode_request($token);

    if (null === $request) {
        refuse('requête illisible : un seul argument attendu, JSON en base64', 2);
    }

    $config = load_config();
    $GLOBALS['zenith_config'] = $config;
    $verb = isset($request['verb']) && is_string($request['verb']) ? $request['verb'] : '';
    $GLOBALS['zenith_log'] = [
        'verb' => '' === $verb ? '-' : $verb,
        'action' => isset($request['action']) && is_string($request['action']) && '' !== $request['action'] ? $request['action'] : '-',
        'actor' => null !== actor($request) ? actor($request) : '-',
        'path' => isset($request['path']) && is_string($request['path']) && '' !== $request['path'] ? $request['path'] : '-',
    ];
    set_deadline($request);
    // The path of the site's public address: a multisite by path answers
    // for another blog on every other path (see wp_bootstrap).
    $GLOBALS['zenith_site_uri'] = site_uri($request);

    try {
        switch ($verb) {
            case 'describe':
                $data = verb_describe($request, $config, $restricted);
                break;
            case 'inventory':
                $data = verb_inventory($request, $config);
                break;
            case 'diagnostic':
                $data = verb_diagnostic($request, $config);
                break;
            case 'log':
                $data = verb_log($request);
                break;
            case 'audit':
                $data = verb_audit($request, $config);
                break;
            case 'integrity':
                $data = verb_integrity($request, $config);
                break;
            case 'weblogs':
                $data = verb_weblogs($request, $config);
                break;
            case 'put':
                $data = verb_put($request, $config);
                break;
            case 'control':
                $data = verb_control($request, $config);
                break;
            case 'update':
                $data = verb_update($request, $config);
                break;
            default:
                refuse(sprintf('verbe inconnu : %s', $verb), 2);
                return;
        }
    } catch (RuntimeException $e) {
        refuse($e->getMessage(), 1);
        return;
    }

    request_log(0);
    echo json_encode(['ok' => true, 'verb' => $verb, 'shell' => ZENITH_SHELL_VERSION, 'data' => $data], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PARTIAL_OUTPUT_ON_ERROR), "\n";
    exit(0);
}

function refuse($message, $code)
{
    request_log($code, $message);
    echo json_encode(['ok' => false, 'shell' => ZENITH_SHELL_VERSION, 'error' => $message], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), "\n";
    exit($code);
}

// ------------------------------------------------------------ request log

/**
 * One line per request, whatever its outcome: the verb and action, who
 * asked, on which path, the exit code and how long it took. Written to
 * syslog under the auth facility, where a host's login trail already is;
 * where syslog cannot be reached, or when the configuration says so, to
 * ~/.zenith/shell.log, rotated once at ZENITH_LOG_MAX_BYTES so a busy
 * fleet never fills a home directory with its own audit trail.
 */
function request_log($code, $error = null)
{
    $context = isset($GLOBALS['zenith_log']) && is_array($GLOBALS['zenith_log']) ? $GLOBALS['zenith_log'] : [];
    $started = isset($GLOBALS['zenith_started']) ? (float) $GLOBALS['zenith_started'] : microtime(true);
    $config = isset($GLOBALS['zenith_config']) && is_array($GLOBALS['zenith_config']) ? $GLOBALS['zenith_config'] : null;
    $field = function ($key) use ($context) {
        $value = isset($context[$key]) && is_string($context[$key]) ? $context[$key] : '-';

        return str_cut((string) preg_replace('/[\s"]+/', '_', $value), 0, 200);
    };
    $line = sprintf(
        'verb=%s action=%s actor=%s path=%s code=%d duration=%.2f%s',
        $field('verb'),
        $field('action'),
        $field('actor'),
        $field('path'),
        (int) $code,
        microtime(true) - $started,
        null === $error ? '' : ' error="' . str_cut((string) preg_replace('/[\s"]+/', ' ', (string) $error), 0, 200) . '"'
    );

    $toFile = null !== $config && isset($config['log']) && 'file' === $config['log'];

    if (!$toFile && function_exists('openlog') && function_exists('syslog') && @openlog('zenith-shell', LOG_PID, LOG_AUTH)) {
        $sent = @syslog(LOG_INFO, $line);
        closelog();

        if ($sent) {
            return;
        }
    }

    $file = zenith_home() . '/.zenith/shell.log';
    $dir = dirname($file);

    if (!is_dir($dir) && !@mkdir($dir, 0700, true)) {
        return;
    }

    if (is_file($file) && filesize($file) > ZENITH_LOG_MAX_BYTES) {
        @rename($file, $file . '.1');
    }

    @file_put_contents($file, gmdate('Y-m-d\TH:i:s\Z') . ' ' . $line . "\n", FILE_APPEND | LOCK_EX);
}

/**
 * Where the shell keeps its own files: the home of the account, or the
 * temporary directory when the environment names none.
 */
function zenith_home()
{
    $home = getenv('HOME');

    return is_string($home) && '' !== $home ? rtrim($home, '/') : sys_get_temp_dir();
}

// ---------------------------------------------------------------- deadline

/**
 * The whole request has one budget, told by Zenith so that both sides
 * agree on when to give up: past it, every child process is cut short
 * rather than left running after the connection is gone.
 */
function set_deadline(array $request)
{
    $seconds = isset($request['deadline']) && is_int($request['deadline']) && $request['deadline'] > 0 ? min(ZENITH_DEADLINE_MAX, $request['deadline']) : ZENITH_DEADLINE_DEFAULT;
    $GLOBALS['zenith_deadline'] = (isset($GLOBALS['zenith_started']) ? (float) $GLOBALS['zenith_started'] : microtime(true)) + $seconds;
}

/**
 * How many seconds a step may take: what it asks for, or what is left of
 * the request's budget, whichever is smaller — never less than one, so a
 * step past the deadline still gets to fail cleanly.
 */
function budget($seconds)
{
    if (!isset($GLOBALS['zenith_deadline'])) {
        return (int) $seconds;
    }

    return max(1, min((int) $seconds, (int) floor((float) $GLOBALS['zenith_deadline'] - microtime(true))));
}

function out_of_time()
{
    return isset($GLOBALS['zenith_deadline']) && microtime(true) >= (float) $GLOBALS['zenith_deadline'];
}

/**
 * @return array<string, mixed>|null
 */
function decode_request($token)
{
    if (!is_string($token)) {
        return null;
    }

    // Under a forced command the line arrives as typed, quotes included:
    // the argument Zenith wraps in single quotes must be unwrapped here.
    $token = trim($token, " \t'\"");

    if ('' === $token || !preg_match('/^[A-Za-z0-9+\/=_-]+$/', $token)) {
        return null;
    }

    $json = base64_decode(strtr($token, '-_', '+/'), true);

    if (false === $json) {
        return null;
    }

    $request = json_decode($json, true);

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

/**
 * @return array{file: ?string, control: bool, update: bool, wp: ?string, php: string, shell: ?string, weblogs_system: bool, log: string}
 */
function load_config()
{
    $home = getenv('HOME');
    $candidates = [];
    $named = getenv('ZENITH_SHELL_CONF');

    // A file named by the environment comes first: how a test, or a host
    // with an unusual layout, points the shell at its configuration.
    if (is_string($named) && '' !== $named) {
        $candidates[] = $named;
    }

    if (is_string($home) && '' !== $home) {
        $candidates[] = $home . '/.config/zenith/shell.conf';
    }

    $candidates[] = '/etc/zenith/shell.conf';
    $config = ['file' => null, 'control' => false, 'update' => false, 'self_update' => false, 'wp' => null, 'php' => PHP_BINARY ?: 'php', 'shell' => null, 'weblogs_system' => false, 'log' => 'auto'];

    foreach ($candidates as $file) {
        if (!is_readable($file)) {
            continue;
        }

        $config['file'] = $file;

        foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
            $line = trim($line);

            if ('' === $line || '#' === $line[0] || false === strpos($line, '=')) {
                continue;
            }

            list($key, $value) = array_map('trim', explode('=', $line, 2));

            switch (strtolower($key)) {
                case 'control':
                    $config['control'] = is_yes($value);
                    break;
                case 'update':
                    $config['update'] = is_yes($value);
                    break;
                case 'self_update':
                    $config['self_update'] = is_yes($value);
                    break;
                case 'wp':
                    $config['wp'] = '' === $value ? null : $value;
                    break;
                case 'php':
                    $config['php'] = '' === $value ? $config['php'] : $value;
                    break;
                case 'shell':
                    $config['shell'] = '' === $value ? null : $value;
                    break;
                case 'weblogs':
                    $config['weblogs_system'] = 'system' === strtolower($value);
                    break;
                case 'log':
                    $config['log'] = 'file' === strtolower($value) ? 'file' : 'auto';
                    break;
            }
        }

        break;
    }

    return $config;
}

function is_yes($value)
{
    return in_array(strtolower((string) $value), ['1', 'yes', 'on', 'true', 'oui'], true);
}

// ---------------------------------------------------------------- helpers

/**
 * Runs a program with arguments, never through a shell, and waits for it
 * up to the given number of seconds — or up to the request's deadline,
 * whichever comes first.
 *
 * The command is handed to proc_open as an array: each element reaches
 * the program as one argument, whatever it holds, so nothing here needs
 * quoting and nothing can be misread as an option separator by a shell.
 *
 * @param string[] $command
 * @return array{exit: int, stdout: string, stderr: string, seconds: float, timeout: bool}
 */
function run(array $command, $timeout = 60, $cwd = null, $stdin = null)
{
    $timeout = budget($timeout);
    $spec = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
    $process = proc_open(array_values(array_map('strval', $command)), $spec, $pipes, $cwd);

    if (!is_resource($process)) {
        return ['exit' => 255, 'stdout' => '', 'stderr' => 'proc_open indisponible', 'seconds' => 0.0, 'timeout' => false];
    }

    if (null !== $stdin) {
        fwrite($pipes[0], $stdin);
    }

    fclose($pipes[0]);
    stream_set_blocking($pipes[1], false);
    stream_set_blocking($pipes[2], false);

    $started = microtime(true);
    $stdout = '';
    $stderr = '';
    $timedOut = false;

    while (true) {
        $stdout .= (string) stream_get_contents($pipes[1]);
        $stderr .= (string) stream_get_contents($pipes[2]);
        $status = proc_get_status($process);

        if (!$status['running']) {
            break;
        }

        if (microtime(true) - $started > $timeout) {
            proc_terminate($process, 9);
            $timedOut = true;
            break;
        }

        usleep(50000);
    }

    $stdout .= (string) stream_get_contents($pipes[1]);
    $stderr .= (string) stream_get_contents($pipes[2]);
    fclose($pipes[1]);
    fclose($pipes[2]);
    $exit = proc_close($process);

    if (isset($status) && !$status['running'] && isset($status['exitcode']) && -1 !== $status['exitcode']) {
        $exit = $status['exitcode'];
    }

    return ['exit' => $timedOut ? 124 : (int) $exit, 'stdout' => $stdout, 'stderr' => $stderr, 'seconds' => round(microtime(true) - $started, 2), 'timeout' => $timedOut];
}

/**
 * The site directory, checked before anything runs from it: it must exist
 * and hold the framework it claims to.
 */
function site_path(array $request, $required = true)
{
    $path = isset($request['path']) && is_string($request['path']) ? $request['path'] : '';

    if ('' === $path) {
        if ($required) {
            throw new RuntimeException('chemin du site manquant dans la requête');
        }

        return null;
    }

    $real = realpath($path);

    if (false === $real || !is_dir($real)) {
        throw new RuntimeException(sprintf('chemin introuvable : %s', $path));
    }

    return rtrim($real, '/');
}

function framework(array $request)
{
    $framework = isset($request['framework']) && is_string($request['framework']) ? strtolower($request['framework']) : 'wordpress';

    if (!in_array($framework, ['wordpress'], true)) {
        throw new RuntimeException(sprintf('framework non pris en charge par ce shell : %s', $framework));
    }

    return $framework;
}

function is_wordpress($path)
{
    return null !== $path && (is_file($path . '/wp-load.php') || is_file($path . '/wp-config.php'));
}

function site_host(array $request)
{
    $host = isset($request['host']) && is_string($request['host']) ? $request['host'] : 'localhost';

    return preg_match('/^[A-Za-z0-9.\-:]+$/', $host) ? $host : 'localhost';
}

/**
 * The path under which the site answers, always with a trailing slash:
 * "/" for a site at the root of its host, "/blog/" for a sub-directory
 * site of a multisite by path. Anything that is not a plain path is read
 * as the root rather than handed to WordPress.
 */
function site_uri(array $request)
{
    $uri = isset($request['url_path']) && is_string($request['url_path']) ? $request['url_path'] : '/';

    if (!preg_match('#^/[A-Za-z0-9._~/-]*$#', $uri) || false !== strpos($uri, '..')) {
        return '/';
    }

    $trimmed = trim($uri, '/');

    return '' === $trimmed ? '/' : '/' . $trimmed . '/';
}

/**
 * A short list of clean strings taken from the request: plugin slugs or
 * plugin files. A leading dash is refused because the items end on a
 * wp-cli command line, where it would read as an option; a ".." is
 * refused because they also name directories under wp-content/plugins.
 *
 * @return string[]
 */
function items(array $request, $key = 'items')
{
    $items = isset($request[$key]) && is_array($request[$key]) ? $request[$key] : [];
    $clean = [];

    foreach ($items as $item) {
        if (is_string($item) && is_clean_slug($item, 120)) {
            $clean[] = $item;
        }
    }

    return array_values(array_unique($clean));
}

/**
 * Whether a string can safely name a plugin, a file under it, or a
 * snapshot: letters, digits, dots, underscores, dashes and slashes, not
 * starting with a dash, with no ".." anywhere.
 */
function is_clean_slug($value, $maxLength = 120)
{
    return is_string($value)
        && strlen($value) <= $maxLength
        && 1 === preg_match('/^[A-Za-z0-9._][A-Za-z0-9._\/-]*$/', $value)
        && false === strpos($value, '..');
}

/**
 * mb_substr where the mbstring extension is loaded, substr where it is
 * not: a host without mbstring must still get its answers, cut a little
 * roughly in the middle of a multibyte character at worst.
 */
function str_cut($text, $start, $length)
{
    return function_exists('mb_substr') ? mb_substr((string) $text, $start, $length) : substr((string) $text, $start, $length);
}

function actor(array $request)
{
    $actor = isset($request['actor']) && is_string($request['actor']) ? trim($request['actor']) : '';

    return '' === $actor ? null : str_cut($actor, 0, 60);
}

function tool_version(array $command, $pattern)
{
    $result = run($command, 15);

    if (0 !== $result['exit'] || '' === trim($result['stdout'])) {
        return null;
    }

    return preg_match($pattern, $result['stdout'], $m) ? $m[1] : trim(strtok($result['stdout'], "\n"));
}

function which($binary)
{
    $result = run(['sh', '-c', 'command -v ' . escapeshellarg($binary)], 5);

    return 0 === $result['exit'] ? trim($result['stdout']) : null;
}

/**
 * The wp-cli this machine has, as the start of a command line, or null:
 * the configured path first, then the PATH and the usual places, then a
 * wp-cli.phar left at the site, at its docroot or in the home — the way
 * a shared host without a global binary carries it.
 *
 * @return string[]|null
 */
function wp_binary(array $config, $path = null)
{
    $candidates = [];

    if (null !== $config['wp']) {
        $candidates[] = $config['wp'];
    }

    $found = which('wp');

    if (null !== $found) {
        $candidates[] = $found;
    }

    $home = getenv('HOME');

    if (is_string($home) && '' !== $home) {
        $candidates[] = $home . '/bin/wp';
        $candidates[] = $home . '/.local/bin/wp';
    }

    $candidates[] = '/usr/local/bin/wp';

    if (null !== $path) {
        $dir = $path;

        for ($i = 0; $i < 4; ++$i) {
            $candidates[] = $dir . '/wp-cli.phar';
            $parent = dirname($dir);

            if ($parent === $dir) {
                break;
            }

            $dir = $parent;
        }
    }

    if (is_string($home) && '' !== $home) {
        $candidates[] = $home . '/wp-cli.phar';
        $candidates[] = $home . '/bin/wp-cli.phar';
    }

    foreach ($candidates as $candidate) {
        if (!is_file($candidate)) {
            continue;
        }

        // A phar, or anything not executable by itself, runs through PHP.
        if ('phar' === strtolower((string) pathinfo($candidate, PATHINFO_EXTENSION)) || !is_executable($candidate)) {
            return [$config['php'], $candidate];
        }

        return [$candidate];
    }

    return null;
}

/**
 * Composer as a global binary, or as a composer.phar left at the site, at
 * its docroot or in the home, run through PHP.
 */
function composer_version(array $config, $path)
{
    $version = tool_version(['composer', '--version', '--no-ansi'], '/Composer(?: version)?\s+([0-9][0-9a-z.\-]*)/i');

    if (null !== $version) {
        return $version;
    }

    $candidates = [];
    $dir = $path;

    for ($i = 0; null !== $dir && $i < 4; ++$i) {
        $candidates[] = $dir . '/composer.phar';
        $parent = dirname($dir);

        if ($parent === $dir) {
            break;
        }

        $dir = $parent;
    }

    $home = getenv('HOME');

    if (is_string($home) && '' !== $home) {
        $candidates[] = $home . '/composer.phar';
        $candidates[] = $home . '/bin/composer.phar';
        $candidates[] = $home . '/bin/composer';
    }

    foreach ($candidates as $candidate) {
        if (is_file($candidate)) {
            return tool_version([$config['php'], $candidate, '--version', '--no-ansi'], '/Composer(?: version)?\s+([0-9][0-9a-z.\-]*)/i');
        }
    }

    return null;
}

function tail_lines($file, $lines = 40, $maxBytes = 262144)
{
    if (!is_file($file) || !is_readable($file)) {
        return null;
    }

    $size = filesize($file);
    $handle = fopen($file, 'r');

    if (false === $handle) {
        return null;
    }

    fseek($handle, max(0, $size - $maxBytes));
    $chunk = (string) stream_get_contents($handle);
    fclose($handle);

    $all = preg_split('/\R/', rtrim($chunk)) ?: [];

    return array_values(array_slice($all, -$lines));
}

/**
 * Bootstraps WordPress in a child process and runs a snippet in it, so a
 * site that dies on load kills the child and not this shell — and says why.
 *
 * @return array{ok: bool, output: string, error: ?string, seconds: float, json: mixed}
 */
function wp_bootstrap($path, $host, $code, array $config, $timeout = 90)
{
    // A multisite by path picks the blog from the request path: the site
    // is asked for under its own address, not under the root of its host.
    $uri = isset($GLOBALS['zenith_site_uri']) && is_string($GLOBALS['zenith_site_uri']) ? $GLOBALS['zenith_site_uri'] : '/';

    // Page caches (WP Rocket, W3TC, Super Cache) hook in through WP_CACHE
    // and advanced-cache.php, and would answer with the cached home page
    // and exit before the snippet runs: the cache is switched off for this
    // process, and the request made one no cache would serve anyway.
    $script = '<?php'
        . ' error_reporting(E_ALL & ~E_WARNING & ~E_NOTICE & ~E_DEPRECATED); ini_set("display_errors", "stderr");'
        . ' define("WP_USE_THEMES", false); define("ZENITH_SSH", true); define("WP_CACHE", false); define("DONOTCACHEPAGE", true); define("DONOTCACHEOBJECT", false);'
        . ' $_SERVER["HTTP_HOST"] = ' . var_export($host, true) . ';'
        . ' $_SERVER["SERVER_NAME"] = ' . var_export($host, true) . ';'
        . ' $_SERVER["REQUEST_URI"] = ' . var_export($uri . '?zenith-shell=1&nowprocket=1', true) . '; $_SERVER["QUERY_STRING"] = "zenith-shell=1&nowprocket=1"; $_GET = ["zenith-shell" => "1", "nowprocket" => "1"];'
        . ' $_SERVER["REQUEST_METHOD"] = "GET"; $_SERVER["SERVER_PORT"] = 443; $_SERVER["HTTPS"] = "on"; $_SERVER["HTTP_USER_AGENT"] = "zenith-shell";'
        . ' chdir(' . var_export($path, true) . ');'
        . ' require ' . var_export($path . '/wp-load.php', true) . ';'
        . ' ' . $code;

    $result = run([$config['php'], '-d', 'memory_limit=512M', '-d', 'max_execution_time=0'], $timeout, $path, $script);
    $output = trim($result['stdout']);
    $error = trim($result['stderr']);

    // The snippet prints one JSON document last; whatever the site echoed
    // before it (a notice, a plugin's stray output) is skipped.
    $json = null;
    $start = strrpos($output, "\n{");
    $candidate = false === $start ? $output : substr($output, $start + 1);

    if ('' !== $candidate && '{' === $candidate[0]) {
        $json = json_decode($candidate, true);
    }

    $failure = null;

    if ($result['timeout']) {
        $failure = sprintf('WordPress ne s\'est pas chargé en %d s', $timeout);
    } elseif (0 !== $result['exit'] || null === $json) {
        if ('' !== $error) {
            $failure = last_error_line($error);
        } elseif (preg_match('/^\s*<(!doctype|html)/i', $output)) {
            $failure = 'WordPress a rendu une page au lieu de répondre : un cache de page ou une redirection a pris la main avant la sonde';
        } elseif ('' !== $output) {
            $failure = str_cut(strip_tags($output), 0, 300);
        } else {
            $failure = sprintf('sortie %d sans réponse', $result['exit']);
        }
    }

    return ['ok' => null === $failure, 'output' => $output, 'error' => $failure, 'seconds' => $result['seconds'], 'json' => $json];
}

function last_error_line($stderr)
{
    $lines = array_values(array_filter(preg_split('/\R/', trim($stderr)) ?: [], 'strlen'));

    foreach (array_reverse($lines) as $line) {
        if (false !== stripos($line, 'fatal') || false !== stripos($line, 'error')) {
            return str_cut(trim($line), 0, 300);
        }
    }

    return str_cut((string) end($lines), 0, 300);
}

/**
 * Database settings read off wp-config.php without loading it, for the
 * case where loading it is exactly what fails.
 *
 * @return array<string, string>
 */
function wp_config_constants($path)
{
    $file = is_file($path . '/wp-config.php') ? $path . '/wp-config.php' : dirname($path) . '/wp-config.php';
    $constants = [];

    if (!is_readable($file)) {
        return $constants;
    }

    $source = (string) file_get_contents($file);

    if (preg_match_all('/define\s*\(\s*[\'"](DB_NAME|DB_USER|DB_PASSWORD|DB_HOST|WP_DEBUG_LOG|WP_DEBUG|WP_DEBUG_DISPLAY|DISABLE_WP_CRON|DISALLOW_FILE_MODS|DISALLOW_FILE_EDIT|WP_CACHE|WPLANG|FS_METHOD)[\'"]\s*,\s*(.+?)\s*\)\s*;/s', $source, $matches, PREG_SET_ORDER)) {
        foreach ($matches as $m) {
            $value = trim($m[2]);

            if (preg_match('/^[\'"](.*)[\'"]$/s', $value, $q)) {
                $value = stripslashes($q[1]);
            }

            $constants[$m[1]] = $value;
        }
    }

    return $constants;
}

function json_from_request($request, $key)
{
    return isset($request[$key]) ? $request[$key] : null;
}

// ------------------------------------------------------------------ verbs

/**
 * @return array<string, mixed>
 */
function verb_describe(array $request, array $config, $restricted)
{
    $path = site_path($request, false);
    $osRelease = is_readable('/etc/os-release') ? parse_ini_string((string) file_get_contents('/etc/os-release'), false, INI_SCANNER_RAW) : [];
    $load = is_readable('/proc/loadavg') ? array_map('floatval', array_slice(explode(' ', trim((string) file_get_contents('/proc/loadavg'))), 0, 3)) : null;
    $uptime = is_readable('/proc/uptime') ? (int) floatval(strtok((string) file_get_contents('/proc/uptime'), ' ')) : null;
    $memory = meminfo();
    $diskPath = null !== $path ? $path : (getenv('HOME') ?: '/');
    $free = @disk_free_space($diskPath);
    $total = @disk_total_space($diskPath);
    $wp = wp_binary($config, $path);

    return [
        'shell' => [
            'version' => ZENITH_SHELL_VERSION,
            'restricted' => $restricted,
            'config_file' => $config['file'],
            'control' => $config['control'],
            'update' => $config['update'],
            'self_update' => $config['self_update'],
        ],
        'authorized' => authorized_keys_options($request),
        'tools' => [
            'php' => PHP_VERSION,
            'php_binary' => $config['php'],
            'site_php' => site_php_version($config),
            'wp' => null === $wp ? null : tool_version(array_merge($wp, ['--version', '--skip-plugins', '--skip-themes']), '/WP-CLI\s+([0-9][0-9a-z.\-]*)/i'),
            'wp_binary' => null === $wp ? null : implode(' ', $wp),
            'git' => tool_version(['git', '--version'], '/git version\s+([0-9][0-9a-z.\-]*)/i'),
            'composer' => composer_version($config, $path),
        ],
        'host' => [
            'hostname' => php_uname('n'),
            'os' => isset($osRelease['PRETTY_NAME']) ? $osRelease['PRETTY_NAME'] : php_uname('s'),
            'os_id' => isset($osRelease['ID']) ? $osRelease['ID'] : null,
            'os_version' => isset($osRelease['VERSION_ID']) ? $osRelease['VERSION_ID'] : null,
            'kernel' => php_uname('r'),
            'arch' => php_uname('m'),
            'uptime_seconds' => $uptime,
            'load' => $load,
            'cpus' => cpu_count(),
            'memory' => $memory,
            'disk' => array_merge([
                'path' => $diskPath,
                'free_bytes' => false === $free ? null : (int) $free,
                'total_bytes' => false === $total ? null : (int) $total,
            ], inodes($diskPath)),
            'time_utc' => gmdate('Y-m-d H:i:s'),
            'user' => get_current_user(),
        ],
        'sshd' => sshd_settings(),
        'site' => [
            'path' => $path,
            'framework' => isset($request['framework']) ? $request['framework'] : null,
            'found' => is_wordpress($path),
        ],
        'updates' => system_updates(),
        'crontab' => crontab_status(),
        'git' => git_status($path),
        'wp_config' => null !== $path ? wp_config_status($path) : null,
        'ownership' => null !== $path ? ownership_status($path) : null,
        'clock' => clock_status(),
        'mysql' => null !== $path ? mysql_status(wp_config_constants($path)) : null,
        'logs' => null !== $path ? logs_status($path) : null,
        'snapshots' => snapshots_list(),
        'maintenance' => null !== $path ? maintenance_status($path) : null,
    ];
}

/**
 * The version of the PHP the site is run with from here — the binary the
 * configuration names, or the one running this script. Zenith holds it
 * against the version the web server reports: a shell that bootstraps
 * WordPress with another PHP than the site's reads another site.
 */
function site_php_version(array $config)
{
    if (PHP_BINARY === $config['php']) {
        return PHP_VERSION;
    }

    $result = run([$config['php'], '-n', '-r', 'echo PHP_VERSION;'], 10);

    return 0 === $result['exit'] && preg_match('/^\d+\.\d+\.\d+/', trim($result['stdout']), $m) ? $m[0] : null;
}

/**
 * Whether WordPress is showing its maintenance page, and since when: the
 * .maintenance file at the root, and the stamp it carries.
 *
 * @return array{active: bool, since: ?string, until: ?string, file: string}
 */
function maintenance_status($path)
{
    $file = $path . '/.maintenance';

    if (!is_file($file)) {
        return ['active' => false, 'since' => null, 'until' => null, 'file' => $file];
    }

    $stamp = preg_match('/\$upgrading\s*=\s*(\d+)/', (string) file_get_contents($file, false, null, 0, 4096), $m) ? (int) $m[1] : null;
    $modified = (int) filemtime($file);

    // WordPress keeps the page up for ten minutes past the stamp: a file
    // whose stamp is older than that is a leftover, not a maintenance.
    return [
        'active' => null === $stamp || $stamp + ZENITH_MAINTENANCE_GRACE_SECONDS > time(),
        'since' => gmdate('Y-m-d H:i:s', $modified),
        'until' => null === $stamp ? null : gmdate('Y-m-d H:i:s', $stamp + ZENITH_MAINTENANCE_GRACE_SECONDS),
        'file' => $file,
    ];
}

/**
 * The lines of ~/.ssh/authorized_keys that concern Zenith, and what each
 * allows. A line is Zenith's when it carries the public key the request
 * names — the one certain sign — or, failing that, the word "zenith" in
 * its comment or its forced command. Every such line is reported: a key
 * installed twice, once restricted and once bare, is a door left open
 * next to the guarded one, and the summary flags must say so.
 *
 * @return array<string, mixed>
 */
function authorized_keys_options(array $request = [])
{
    $home = getenv('HOME');
    $file = is_string($home) ? $home . '/.ssh/authorized_keys' : null;
    $result = ['file' => $file, 'found' => false, 'restrict' => null, 'from' => null, 'command' => null, 'command_path' => null, 'lines' => 0, 'entries' => []];
    $blob = isset($request['public_key']) && is_string($request['public_key']) && preg_match('/(?:^|\s)(ssh-[a-z0-9-]+|ecdsa-[a-z0-9-]+)\s+([A-Za-z0-9+\/=]+)/', $request['public_key'], $m) ? $m[2] : null;

    if (null === $file || !is_readable($file)) {
        $result['readable'] = false;

        return $result;
    }

    $result['readable'] = true;

    foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $number => $line) {
        $line = trim($line);

        if ('' === $line || '#' === $line[0]) {
            continue;
        }

        $byKey = null !== $blob && false !== strpos($line, $blob);
        $byName = false !== stripos($line, 'zenith');

        if (!$byKey && !$byName) {
            continue;
        }

        $entry = [
            'line' => $number + 1,
            'key_match' => $byKey,
            'restrict' => (bool) preg_match('/(^|,)restrict(,|\s)/', $line),
            'from' => (bool) preg_match('/(^|,)from="[^"]+"/', $line),
            'command' => (bool) preg_match('/(^|,)command="[^"]*zenith-shell[^"]*"/', $line),
            'command_path' => preg_match('/(^|,)command="([^"]+)"/', $line, $c) ? $c[2] : null,
            'comment' => preg_match('/\s(ssh-[a-z0-9-]+|ecdsa-[a-z0-9-]+)\s+[A-Za-z0-9+\/=]+\s+(.+)$/', $line, $k) ? str_cut(trim($k[2]), 0, 60) : null,
        ];
        $result['entries'][] = $entry;
        ++$result['lines'];

        // The summary is the weakest line: one bare line among restricted
        // ones is the one an attacker would use.
        $result['found'] = true;
        $result['restrict'] = null === $result['restrict'] ? $entry['restrict'] : ($result['restrict'] && $entry['restrict']);
        $result['from'] = null === $result['from'] ? $entry['from'] : ($result['from'] && $entry['from']);
        $result['command'] = null === $result['command'] ? $entry['command'] : ($result['command'] && $entry['command']);

        if (null === $result['command_path'] && $entry['command'] && null !== $entry['command_path']) {
            $result['command_path'] = $entry['command_path'];
        }
    }

    return $result;
}

/**
 * @return array<string, mixed>
 */
function sshd_settings()
{
    $file = '/etc/ssh/sshd_config';

    if (!is_readable($file)) {
        return ['readable' => false, 'password_authentication' => null, 'permit_root_login' => null];
    }

    $password = null;
    $root = null;

    foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
        $line = trim($line);

        if ('' === $line || '#' === $line[0]) {
            continue;
        }

        if (preg_match('/^PasswordAuthentication\s+(\S+)/i', $line, $m)) {
            $password = strtolower($m[1]);
        } elseif (preg_match('/^PermitRootLogin\s+(\S+)/i', $line, $m)) {
            $root = strtolower($m[1]);
        }
    }

    // OpenSSH defaults when the directive is absent.
    return ['readable' => true, 'password_authentication' => null === $password ? 'yes' : $password, 'permit_root_login' => null === $root ? 'prohibit-password' : $root];
}

function meminfo()
{
    if (!is_readable('/proc/meminfo')) {
        return null;
    }

    $values = [];

    foreach (file('/proc/meminfo', FILE_IGNORE_NEW_LINES) ?: [] as $line) {
        if (preg_match('/^(MemTotal|MemAvailable|SwapTotal|SwapFree):\s+(\d+)/', $line, $m)) {
            $values[$m[1]] = (int) $m[2] * 1024;
        }
    }

    return [
        'total_bytes' => isset($values['MemTotal']) ? $values['MemTotal'] : null,
        'available_bytes' => isset($values['MemAvailable']) ? $values['MemAvailable'] : null,
        'swap_total_bytes' => isset($values['SwapTotal']) ? $values['SwapTotal'] : null,
        'swap_free_bytes' => isset($values['SwapFree']) ? $values['SwapFree'] : null,
    ];
}

function cpu_count()
{
    if (!is_readable('/proc/cpuinfo')) {
        return null;
    }

    return max(1, preg_match_all('/^processor\s*:/m', (string) file_get_contents('/proc/cpuinfo')));
}

/**
 * The site's inventory, exactly as the Zenith plugin serves it over HTTP:
 * the REST route is called from inside the site, with the site's own read
 * key, so what comes back is the same document the scan reads.
 *
 * @return array<string, mixed>
 */
function verb_inventory(array $request, array $config)
{
    framework($request);
    $path = site_path($request);

    if (!is_wordpress($path)) {
        throw new RuntimeException(sprintf('aucun WordPress dans %s', $path));
    }

    $checks = isset($request['checks']) && is_string($request['checks']) && preg_match('/^[a-z,]+$/', $request['checks']) ? $request['checks'] : 'all';

    $code = 'if (!function_exists("zenith_api_callback")) { fwrite(STDERR, "plugin Zenith absent\n"); exit(3); }'
        . ' $server = rest_get_server();'
        . ' $req = new WP_REST_Request("GET", "/zenith/v1/data");'
        . ' $req->set_header("X-Zenith-Token", function_exists("zenith_setting") ? (string) zenith_setting("api_key", "") : (string) get_option("zenith_api_key"));'
        . ' $req->set_param("checks", ' . var_export($checks, true) . ');'
        . ' $resp = rest_do_request($req);'
        . ' if ($resp->is_error()) { fwrite(STDERR, "erreur REST: " . $resp->as_error()->get_error_message() . "\n"); exit(4); }'
        . ' echo "\n", wp_json_encode($server->response_to_data($resp, false)), "\n";';

    $result = wp_bootstrap($path, site_host($request), $code, $config, 180);

    if (!$result['ok']) {
        throw new RuntimeException(sprintf('inventaire impossible : %s', $result['error']));
    }

    return is_array($result['json']) ? $result['json'] : [];
}

/**
 * What the site looks like from inside the machine when nothing answers
 * from outside: whether WordPress still loads, whether its database still
 * answers, what its logs say last, and what the machine itself is at.
 *
 * @return array<string, mixed>
 */
function verb_diagnostic(array $request, array $config)
{
    framework($request);
    $path = site_path($request);
    $host = site_host($request);
    $constants = wp_config_constants($path);

    $diagnostic = [
        'at' => gmdate('Y-m-d H:i:s'),
        'path' => $path,
        'php' => ['version' => PHP_VERSION, 'binary' => $config['php']],
        'site_found' => is_wordpress($path),
        'bootstrap' => null,
        'database' => null,
        'wordpress' => null,
        'maintenance' => is_file($path . '/.maintenance'),
        'disk' => null,
        'load' => null,
        'memory' => meminfo(),
        'logs' => [],
        'http_local' => null,
    ];

    $free = @disk_free_space($path);
    $total = @disk_total_space($path);
    $diagnostic['disk'] = ['free_bytes' => false === $free ? null : (int) $free, 'total_bytes' => false === $total ? null : (int) $total];
    $diagnostic['load'] = is_readable('/proc/loadavg') ? array_map('floatval', array_slice(explode(' ', trim((string) file_get_contents('/proc/loadavg'))), 0, 3)) : null;

    // The database first and on its own: a site that dies on load usually
    // dies there, and the answer has to come whatever WordPress does next.
    if (isset($constants['DB_HOST'], $constants['DB_USER'], $constants['DB_NAME']) && function_exists('mysqli_init')) {
        $hostParts = explode(':', $constants['DB_HOST'], 2);
        $socket = null;
        $port = 3306;

        if (isset($hostParts[1])) {
            if (is_numeric($hostParts[1])) {
                $port = (int) $hostParts[1];
            } else {
                $socket = $hostParts[1];
            }
        }

        mysqli_report(MYSQLI_REPORT_OFF);
        $mysqli = mysqli_init();
        $mysqli->options(MYSQLI_OPT_CONNECT_TIMEOUT, 5);
        $connected = @$mysqli->real_connect($hostParts[0], $constants['DB_USER'], isset($constants['DB_PASSWORD']) ? $constants['DB_PASSWORD'] : '', $constants['DB_NAME'], $port, $socket);
        $diagnostic['database'] = ['ok' => (bool) $connected, 'host' => $constants['DB_HOST'], 'error' => $connected ? null : str_cut((string) $mysqli->connect_error, 0, 200)];

        if ($connected) {
            $mysqli->close();
        }
    } elseif (!isset($constants['DB_HOST'])) {
        $diagnostic['database'] = ['ok' => null, 'host' => null, 'error' => 'wp-config.php illisible ou sans réglages de base'];
    }

    if (is_wordpress($path)) {
        $code = 'global $wp_version, $wpdb;'
            . ' $active = (array) get_option("active_plugins", []);'
            . ' $theme = wp_get_theme();'
            . ' echo "\n", json_encode(['
            . '   "version" => $wp_version,'
            . '   "db_ok" => (bool) $wpdb->check_connection(false),'
            . '   "active_plugins" => array_values($active),'
            . '   "theme" => ["name" => $theme->get("Name"), "version" => $theme->get("Version")],'
            . '   "paused" => function_exists("wp_paused_plugins") ? array_keys((array) wp_paused_plugins()->get_all()) : [],'
            . '   "home" => home_url(),'
            . '   "zenith_plugin" => defined("ZENITH_VERSION") ? ZENITH_VERSION : null,'
            . ' ]), "\n";';

        $result = wp_bootstrap($path, $host, $code, $config, 60);
        $diagnostic['bootstrap'] = ['ok' => $result['ok'], 'seconds' => $result['seconds'], 'error' => $result['error']];
        $diagnostic['wordpress'] = $result['ok'] ? $result['json'] : null;
    }

    // Logs: the debug log where WordPress writes it, the PHP log next to
    // the site if there is one, and the last fatal error either has seen.
    $logs = [];
    $debugLog = isset($constants['WP_DEBUG_LOG']) && '' !== $constants['WP_DEBUG_LOG'] && !in_array(strtolower($constants['WP_DEBUG_LOG']), ['true', 'false', '1', '0'], true) ? $constants['WP_DEBUG_LOG'] : $path . '/wp-content/debug.log';

    foreach (array_unique([$debugLog, $path . '/error_log', $path . '/wp-admin/error_log', $path . '/php_errorlog', dirname($path) . '/logs/error.log']) as $file) {
        $tail = tail_lines($file, 30);

        if (null === $tail) {
            continue;
        }

        $logs[] = ['path' => $file, 'size_bytes' => (int) filesize($file), 'modified_at' => gmdate('Y-m-d H:i:s', (int) filemtime($file)), 'tail' => $tail, 'last_fatal' => last_fatal($tail)];
    }

    $diagnostic['logs'] = $logs;

    // The web server as seen from the machine itself: a 200 here with
    // nothing from outside points at the network; an error here points at
    // the site.
    $diagnostic['http_local'] = local_http($host, site_uri($request));

    return $diagnostic;
}

function last_fatal(array $lines)
{
    foreach (array_reverse($lines) as $line) {
        if (preg_match('/PHP (Fatal error|Parse error)[^:]*:\s*(.+)$/', $line, $m)) {
            return str_cut(trim($m[2]), 0, 300);
        }
    }

    return null;
}

function local_http($host, $uri = '/')
{
    if (!function_exists('curl_init')) {
        return null;
    }

    $status = null;
    $seconds = null;
    $error = null;

    // The name is resolved to the machine itself rather than sent as a bare
    // Host header: with TLS, the name must also be in the handshake (SNI),
    // or Apache answers 421 to a request it deems misdirected.
    foreach (['https://' . $host . $uri, 'http://' . $host . $uri] as $url) {
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_NOBODY => true,
            CURLOPT_RESOLVE => [$host . ':443:127.0.0.1', $host . ':80:127.0.0.1'],
            CURLOPT_HTTPHEADER => ['Cache-Control: no-cache'],
            CURLOPT_SSL_VERIFYPEER => false,
            CURLOPT_SSL_VERIFYHOST => 0,
            CURLOPT_CONNECTTIMEOUT => 5,
            CURLOPT_TIMEOUT => 20,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_USERAGENT => 'zenith-shell/' . ZENITH_SHELL_VERSION,
        ]);
        curl_exec($ch);
        $code = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        $error = curl_error($ch) ?: null;
        $seconds = round((float) curl_getinfo($ch, CURLINFO_TOTAL_TIME), 2);
        curl_close($ch);

        if ($code > 0) {
            $status = $code;
            $error = null;
            break;
        }
    }

    return ['status' => $status, 'seconds' => $seconds, 'error' => $error];
}

/**
 * @return array<string, mixed>
 */
function verb_log(array $request)
{
    $path = site_path($request);
    $lines = isset($request['lines']) && is_int($request['lines']) ? max(10, min(400, $request['lines'])) : 80;
    $constants = wp_config_constants($path);
    $debugLog = isset($constants['WP_DEBUG_LOG']) && '' !== $constants['WP_DEBUG_LOG'] && !in_array(strtolower($constants['WP_DEBUG_LOG']), ['true', 'false', '1', '0'], true) ? $constants['WP_DEBUG_LOG'] : $path . '/wp-content/debug.log';
    $files = [];

    foreach (array_unique([$debugLog, $path . '/error_log', $path . '/wp-admin/error_log', $path . '/php_errorlog']) as $file) {
        $tail = tail_lines($file, $lines, 1048576);

        if (null !== $tail) {
            $files[] = ['path' => $file, 'size_bytes' => (int) filesize($file), 'modified_at' => gmdate('Y-m-d H:i:s', (int) filemtime($file)), 'lines' => $tail];
        }
    }

    return ['files' => $files];
}

/**
 * The remediation actions, each one carried out inside WordPress and
 * written to the plugin's journal when the plugin is there. The shapes
 * match what the plugin's own control endpoint answers, so the dashboard
 * reads both the same way.
 *
 * @return array<string, mixed>
 */
function verb_control(array $request, array $config)
{
    $action = isset($request['action']) && is_string($request['action']) ? $request['action'] : '';

    // Replacing this very script is the one write self_update=yes opens on
    // its own: a door kept read-only can still receive its next version.
    if (!$config['control'] && !('install_shell' === $action && $config['self_update'])) {
        throw new RuntimeException('pilotage refusé : control=no dans la configuration de zenith-shell' . ('install_shell' === $action ? ' (self_update=yes ouvrirait cette seule action)' : ''));
    }

    framework($request);
    $path = site_path($request);
    $actor = actor($request);

    if ('maintenance_set' === $action) {
        return control_maintenance($path, isset($request['enabled']) && true === $request['enabled'], $actor, site_host($request), $config);
    }

    if ('install_wpcli' === $action) {
        return control_install_wpcli($config);
    }

    if ('install_shell' === $action) {
        return control_install_shell($request, $config);
    }

    if ('install_agent' === $action) {
        return control_install_agent($path, $request, $actor, site_host($request), $config);
    }

    if ('snapshot_restore' === $action) {
        return control_snapshot_restore($path, isset($request['snapshot']) && is_string($request['snapshot']) ? $request['snapshot'] : '', $actor, site_host($request), $config);
    }

    if (!is_wordpress($path)) {
        throw new RuntimeException(sprintf('aucun WordPress dans %s', $path));
    }

    switch ($action) {
        case 'cache_flush':
            $code = '$flushed = ["object_cache" => function_exists("wp_cache_flush") ? (bool) wp_cache_flush() : null, "opcache" => function_exists("opcache_reset") ? @opcache_reset() : null, "transients" => null, "page_cache" => []];'
                . ' if (function_exists("delete_expired_transients")) { delete_expired_transients(true); $flushed["transients"] = true; }'
                . ' foreach (["wp_cache_clear_cache", "w3tc_flush_all", "rocket_clean_domain", "sg_cachepress_purge_cache", "wpfc_clear_all_cache", "LiteSpeed_Cache_API::purge_all"] as $fn) { if (is_callable($fn)) { try { call_user_func($fn); $flushed["page_cache"][$fn] = true; } catch (\Throwable $e) { $flushed["page_cache"][$fn] = false; } } }'
                . ' $result = ["status" => "ok", "flushed" => $flushed];';
            break;
        case 'cron_run':
            $hook = isset($request['hook']) && is_string($request['hook']) && preg_match('/^[A-Za-z0-9_][A-Za-z0-9_\-]{0,119}$/', $request['hook']) ? $request['hook'] : null;
            $force = isset($request['force']) && true === $request['force'];
            $params = ['hook' => $hook, 'force' => $force];
            // Only what WP-Cron itself would run: a hook absent from the
            // cron table is refused, as the plugin answers 404, rather than
            // run without its arguments. The lock is the one wp-cron.php
            // takes, so a run in progress is not doubled; force treats a
            // recent lock as the leftover of a broken loopback, which is
            // the one case it exists for.
            // Each hook gets ZENITH_CRON_HOOK_SECONDS: an alarm where pcntl
            // is there, the CPU clock of set_time_limit otherwise. The run
            // as a whole stops handing out hooks past its budget, and says
            // which ones it did not reach rather than pretending it did.
            $code = '$ran = []; $errors = []; $skipped = []; $result = null; $only = ' . var_export($hook, true) . '; $force = ' . var_export($force, true) . '; $crons = (array) _get_cron_array(); $now = time(); $runStarted = microtime(true);'
                . ' if (null !== $only) { $known = false; foreach ($crons as $hooks) { if (is_array($hooks) && isset($hooks[$only])) { $known = true; break; } } if (!$known) { $result = ["status" => "failed", "refused" => true, "message" => "hook \'" . $only . "\' non planifié dans WP-Cron"]; } }'
                . ' $lockTimeout = defined("WP_CRON_LOCK_TIMEOUT") ? (int) WP_CRON_LOCK_TIMEOUT : 60; $held = get_transient("doing_cron");'
                . ' if (null === $result && !$force && $held && (float) $held + $lockTimeout > microtime(true)) { $result = ["status" => "failed", "refused" => true, "lock_age_seconds" => round(microtime(true) - (float) $held, 1), "message" => "WP-Cron semble en cours d\'exécution (verrou doing_cron) : relancer en forçant si le loopback du site est cassé"]; }'
                . ' if (null === $result) { $lock = sprintf("%.22F", microtime(true)); set_transient("doing_cron", $lock);'
                . ' $hookLimit = ' . ZENITH_CRON_HOOK_SECONDS . '; $runBudget = ' . ZENITH_CRON_BUDGET_SECONDS . '; $alarm = function_exists("pcntl_alarm") && function_exists("pcntl_signal") && function_exists("pcntl_async_signals");'
                . ' if ($alarm) { pcntl_async_signals(true); pcntl_signal(SIGALRM, function () use ($hookLimit) { throw new \RuntimeException("délai de " . $hookLimit . " s dépassé"); }); }'
                . ' try { foreach ($crons as $timestamp => $hooks) { if (!is_array($hooks)) { continue; } if ($timestamp > $now && null === $only) { continue; } foreach ($hooks as $hookName => $events) { if (null !== $only && $hookName !== $only) { continue; } foreach ((array) $events as $sig => $event) { $args = isset($event["args"]) ? (array) $event["args"] : [];'
                . '   if (microtime(true) - $runStarted > $runBudget) { $skipped[] = ["hook" => $hookName, "reason" => "budget de " . $runBudget . " s épuisé"]; continue; }'
                . '   $started = microtime(true); if ($alarm) { pcntl_alarm($hookLimit); } else { set_time_limit($hookLimit); }'
                . '   try { do_action_ref_array($hookName, $args); $ran[] = ["hook" => $hookName, "seconds" => round(microtime(true) - $started, 2), "forced" => $timestamp > $now]; } catch (\Throwable $e) { $errors[] = ["hook" => $hookName, "message" => $e->getMessage(), "seconds" => round(microtime(true) - $started, 2)]; }'
                . '   if ($alarm) { pcntl_alarm(0); } else { set_time_limit(0); }'
                . '   if (isset($event["schedule"]) && $event["schedule"] && isset($event["interval"])) { wp_reschedule_event($timestamp, $event["schedule"], $hookName, $args); } wp_unschedule_event($timestamp, $hookName, $args); } } } }'
                . ' finally { if (get_transient("doing_cron") === $lock) { delete_transient("doing_cron"); } }'
                . ' $result = ["status" => [] === $errors && [] === $skipped ? "ok" : "partial", "ran" => $ran, "errors" => $errors, "skipped" => $skipped, "message" => [] === $skipped ? null : count($skipped) . " tâche(s) non exécutée(s), budget épuisé"]; }';
            break;
        case 'plugin_deactivate':
        case 'plugin_activate':
            $items = items($request);

            if ([] === $items) {
                throw new RuntimeException('aucune extension indiquée');
            }

            $activate = 'plugin_activate' === $action;
            $code = 'require_once ABSPATH . "wp-admin/includes/plugin.php"; $results = []; $items = ' . var_export($items, true) . ';'
                . ' foreach ($items as $item) { $file = false === strpos($item, "/") && !preg_match("/\.php$/", $item) ? null : $item;'
                . '   if (null === $file) { foreach (array_keys(get_plugins()) as $candidate) { if (dirname($candidate) === $item || $candidate === $item . ".php") { $file = $candidate; break; } } }'
                . '   if (null === $file) { $results[] = ["item" => $item, "status" => "failed", "message" => "extension inconnue"]; continue; }'
                . ($activate
                    ? '   if (is_plugin_active($file)) { $results[] = ["item" => $file, "status" => "skipped", "message" => "déjà active"]; continue; } $r = activate_plugin($file); $results[] = is_wp_error($r) ? ["item" => $file, "status" => "failed", "message" => $r->get_error_message()] : ["item" => $file, "status" => "activated", "message" => null];'
                    : '   if (!is_plugin_active($file)) { $results[] = ["item" => $file, "status" => "skipped", "message" => "déjà inactive"]; continue; } deactivate_plugins($file); $results[] = ["item" => $file, "status" => is_plugin_active($file) ? "failed" : "deactivated", "message" => null];')
                . ' }'
                . ' $failed = count(array_filter($results, function ($r) { return "failed" === $r["status"]; }));'
                . ' $result = ["status" => 0 === $failed ? "ok" : ($failed === count($results) ? "failed" : "partial"), "results" => $results];';
            break;
        default:
            throw new RuntimeException(sprintf('action inconnue de zenith-shell : %s', $action));
    }

    $code .= journal_snippet($action, $actor, isset($params) ? $params : (isset($items) ? ['items' => $items] : []));
    $code .= ' echo "\n", json_encode($result), "\n";';

    $result = wp_bootstrap($path, site_host($request), $code, $config, 'cron_run' === $action ? ZENITH_CRON_BUDGET_SECONDS + ZENITH_CRON_HOOK_SECONDS + 30 : 180);

    if (!$result['ok']) {
        throw new RuntimeException(sprintf('%s : %s', $action, $result['error']));
    }

    $answer = is_array($result['json']) ? $result['json'] : ['status' => 'failed', 'message' => 'réponse illisible'];

    // Refused before anything ran — a hook WP-Cron does not know, a lock
    // still held: the answer is a refusal, as the plugin's 404 and 409
    // are, not a run that happened to do nothing.
    if (isset($answer['refused']) && true === $answer['refused']) {
        throw new RuntimeException(isset($answer['message']) && is_string($answer['message']) ? $answer['message'] : sprintf('%s refusé', $action));
    }

    return $answer;
}

/**
 * A line in the plugin's journal, when the plugin is there to keep one: a
 * zenith.control event, the type the plugin writes for its own /control
 * and the one /actions reads, with the action, its parameters, its
 * outcome, and who asked for it over SSH.
 *
 * The plugin only knows how to name an actor for a REST request: the
 * entry is patched right after it is written, under the plugin's own
 * lock, to say the action came from the command line (actor.type cli),
 * over SSH (actor.via), and from whom (actor.login). The immediate push
 * of that entry is switched off first: it would carry the copy the
 * plugin made before the patch, and the next heartbeat carries the
 * stored one.
 *
 * @param array<string, mixed> $params
 */
function journal_snippet($action, $actor, array $params)
{
    $actorValue = var_export(['type' => 'cli', 'id' => null, 'login' => $actor, 'via' => 'ssh'], true);

    return ' if (function_exists("zenith_event_record")) { try {'
        . ' if (function_exists("remove_all_actions")) { remove_all_actions("zenith_event_recorded"); }'
        . ' $zenithId = (int) zenith_event_record("zenith.control", ["action" => ' . var_export($action, true) . ', "params" => ' . var_export($params, true) . ', "status" => isset($result["status"]) ? $result["status"] : null, "message" => isset($result["message"]) ? $result["message"] : null], "warning");'
        . ' $zenithLock = function_exists("zenith_lock_acquire") ? zenith_lock_acquire("events", 5, 2000) : null;'
        . ' $zenithStore = get_option("zenith_events", null);'
        . ' if (is_array($zenithStore) && isset($zenithStore["items"]) && is_array($zenithStore["items"])) { foreach ($zenithStore["items"] as $zenithKey => $zenithEvent) { if (is_array($zenithEvent) && isset($zenithEvent["id"]) && (int) $zenithEvent["id"] === $zenithId) { $zenithStore["items"][$zenithKey]["actor"] = ' . $actorValue . '; update_option("zenith_events", $zenithStore, "no"); break; } } }'
        . ' if (function_exists("zenith_lock_release")) { zenith_lock_release($zenithLock); }'
        . ' } catch (\Throwable $e) {} }';
}

/**
 * The maintenance page WordPress shows while a file named .maintenance
 * sits at its root: the way to take a broken site off the air, or to put
 * a fixed one back, without loading anything.
 *
 * @return array<string, mixed>
 */
function control_maintenance($path, $enabled, $actor, $host, array $config)
{
    $file = $path . '/.maintenance';
    $was = is_file($file);

    $until = time() + ZENITH_MAINTENANCE_MAX_SECONDS - ZENITH_MAINTENANCE_GRACE_SECONDS;

    if ($enabled) {
        // WordPress drops the page by itself ten minutes after this stamp:
        // set two hours ahead at most, so a site put on hold and forgotten
        // comes back on its own. Asking again pushes the stamp forward.
        if (false === file_put_contents($file, "<?php \$upgrading = " . $until . ";\n")) {
            throw new RuntimeException(sprintf('impossible d\'écrire %s', $file));
        }
    } elseif ($was && !@unlink($file)) {
        throw new RuntimeException(sprintf('impossible de supprimer %s', $file));
    }

    if (is_wordpress($path)) {
        wp_bootstrap($path, $host, '$result = ["status" => "ok"];' . journal_snippet('maintenance_set', $actor, ['enabled' => $enabled]) . ' echo "\n{}\n";', $config, 30);
    }

    return [
        'status' => 'ok',
        'maintenance' => $enabled,
        'changed' => $was !== $enabled,
        'until' => $enabled ? gmdate('Y-m-d H:i:s', $until + ZENITH_MAINTENANCE_GRACE_SECONDS) : null,
        'message' => $enabled ? sprintf('Page de maintenance en place jusqu\'à %s UTC au plus tard.', gmdate('H:i', $until + ZENITH_MAINTENANCE_GRACE_SECONDS)) : 'Page de maintenance retirée.',
    ];
}

/**
 * Updates through wp-cli, the one tool that knows how to swap WordPress's
 * files safely from the command line: the core, the plugins named, and
 * the themes WordPress reports behind when asked. A major release of the
 * core is only crossed when the request says so.
 *
 * @return array<string, mixed>
 */
function verb_update(array $request, array $config)
{
    if (!$config['update']) {
        throw new RuntimeException('mise à jour refusée : update=no dans la configuration de zenith-shell');
    }

    framework($request);
    $path = site_path($request);
    $wp = wp_binary($config, $path);

    if (null === $wp) {
        throw new RuntimeException('wp-cli introuvable sur cette machine : les mises à jour par SSH passent par lui');
    }

    $core = isset($request['core']) && true === $request['core'];
    $themes = isset($request['themes']) && true === $request['themes'];
    $allowMajor = isset($request['allow_major']) && true === $request['allow_major'];
    $items = items($request);
    $dryRun = isset($request['dry_run']) && true === $request['dry_run'];
    $results = [];
    $base = array_merge($wp, ['--path=' . $path, '--skip-themes', '--no-color', '--format=json']);

    if ($core) {
        // What WordPress offers, minor and major told apart: a major
        // release is named but not crossed unless allowed.
        $check = run(array_merge($base, ['core', 'check-update']), 60, $path);
        $updates = json_decode(trim($check['stdout']), true);
        $minor = null;
        $major = null;

        foreach (is_array($updates) ? $updates : [] as $update) {
            if (!is_array($update) || !isset($update['version'])) {
                continue;
            }

            if (isset($update['update_type']) && 'major' === $update['update_type']) {
                $major = null === $major ? (string) $update['version'] : $major;
            } else {
                $minor = null === $minor ? (string) $update['version'] : $minor;
            }
        }

        $target = $allowMajor ? (null !== $major ? $major : $minor) : $minor;

        if ($dryRun) {
            $message = null !== $target ? sprintf('vers %s', $target) : (null !== $major ? sprintf('montée majeure %s disponible, non autorisée', $major) : 'déjà à jour');
            $results[] = ['item' => 'core', 'status' => null !== $target ? 'planned' : 'skipped', 'message' => $message];
        } elseif (null === $target) {
            $results[] = ['item' => 'core', 'status' => 'skipped', 'message' => null !== $major ? sprintf('montée majeure %s disponible, non autorisée', $major) : 'déjà à jour'];
        } else {
            $args = array_merge($wp, ['--path=' . $path, '--skip-themes', '--no-color', 'core', 'update']);

            if (!$allowMajor) {
                $args[] = '--minor';
            }

            $run = run($args, 300, $path);
            $results[] = ['item' => 'core', 'status' => 0 === $run['exit'] ? 'updated' : 'failed', 'message' => str_cut(trim(0 === $run['exit'] ? $run['stdout'] : ($run['stderr'] ?: $run['stdout'])), 0, 200)];
        }
    }

    foreach ($items as $item) {
        $slug = false === strpos($item, '/') ? preg_replace('/\.php$/', '', $item) : dirname($item);

        if (!is_clean_slug($slug, 120) || false !== strpos($slug, '/')) {
            $results[] = ['item' => $item, 'status' => 'failed', 'message' => 'nom d\'extension invalide'];
            continue;
        }

        if ($dryRun) {
            $results[] = ['item' => $slug, 'status' => 'planned', 'message' => null];
            continue;
        }

        // The directory as it is, kept aside before wp-cli replaces it: the
        // way back when the new version breaks the site, premium plugins
        // included, which wordpress.org cannot reinstall. The "--" closes
        // the options before the slug, whatever the slug looks like.
        $snapshot = snapshot_plugin($path, $slug);
        $run = run(array_merge($wp, ['--path=' . $path, '--skip-themes', '--no-color', 'plugin', 'update', '--', $slug]), 300, $path);
        $results[] = [
            'item' => $slug,
            'status' => 0 === $run['exit'] ? 'updated' : 'failed',
            'message' => str_cut(trim(0 === $run['exit'] ? $run['stdout'] : ($run['stderr'] ?: $run['stdout'])), 0, 200),
            'snapshot' => $snapshot,
        ];
    }

    if ($themes) {
        foreach (update_themes($wp, $path, $dryRun) as $row) {
            $results[] = $row;
        }
    }

    $failed = count(array_filter($results, function ($r) { return 'failed' === $r['status']; }));

    return ['status' => $dryRun ? 'planned' : (0 === $failed ? 'ok' : ($failed === count($results) ? 'failed' : 'partial')), 'dry_run' => $dryRun, 'results' => $results];
}

/**
 * The themes WordPress reports behind, one by one: named in a rehearsal,
 * updated otherwise. Plugins are left loaded here, since a premium theme
 * gets its updates through one of them. Never more than fifty: a site
 * with more themes than that has another problem.
 *
 * @param string[] $wp
 *
 * @return list<array<string, mixed>>
 */
function update_themes(array $wp, $path, $dryRun)
{
    $list = run(array_merge($wp, ['--path=' . $path, '--no-color', 'theme', 'list', '--update=available', '--fields=name,version,update_version', '--format=json']), 120, $path);
    $rows = json_decode(trim($list['stdout']), true);

    if (0 !== $list['exit'] || !is_array($rows)) {
        return [['item' => 'thèmes', 'status' => 'failed', 'message' => sprintf('liste des thèmes illisible : %s', last_error_line($list['stderr'] ?: ($list['stdout'] ?: 'wp theme list a échoué')))]];
    }

    if ([] === $rows) {
        return [['item' => 'thèmes', 'status' => 'skipped', 'message' => 'aucun thème en retard selon WordPress']];
    }

    $results = [];

    foreach (array_slice($rows, 0, 50) as $row) {
        $name = is_array($row) && isset($row['name']) ? (string) $row['name'] : '';

        if (!is_clean_slug($name, 120) || false !== strpos($name, '/')) {
            continue;
        }

        $transition = isset($row['version'], $row['update_version']) ? sprintf('%s → %s', $row['version'], $row['update_version']) : null;

        if ($dryRun) {
            $results[] = ['item' => 'thème ' . $name, 'status' => 'planned', 'message' => $transition];
            continue;
        }

        $run = run(array_merge($wp, ['--path=' . $path, '--no-color', 'theme', 'update', '--', $name]), 300, $path);
        $results[] = [
            'item' => 'thème ' . $name,
            'status' => 0 === $run['exit'] ? 'updated' : 'failed',
            'message' => 0 === $run['exit'] && null !== $transition ? $transition : str_cut(trim($run['stderr'] ?: $run['stdout']), 0, 200),
        ];
    }

    return $results;
}

// --------------------------------------------------- describe, the sections

/**
 * @return array<string, mixed>
 */
function inodes($path)
{
    $result = run(['df', '-Pi', $path], 10);

    if (0 !== $result['exit']) {
        return ['inodes_total' => null, 'inodes_free' => null];
    }

    $lines = preg_split('/\R/', trim($result['stdout'])) ?: [];
    $last = preg_split('/\s+/', trim((string) end($lines))) ?: [];

    // Filesystem Inodes IUsed IFree IUse% Mounted
    if (count($last) < 5 || !is_numeric($last[1]) || !is_numeric($last[3])) {
        return ['inodes_total' => null, 'inodes_free' => null];
    }

    return ['inodes_total' => (int) $last[1], 'inodes_free' => (int) $last[3]];
}

/**
 * Packages waiting for an upgrade, as apt simulates it; readable without
 * root on Debian and Ubuntu, meaningless on a shared host.
 *
 * @return array<string, mixed>|null
 */
function system_updates()
{
    if (!is_executable('/usr/bin/apt-get') || !is_readable('/var/lib/apt/lists')) {
        return ['available' => false, 'pending' => null, 'security' => null, 'reboot_required' => is_file('/var/run/reboot-required'), 'lists_updated_at' => null];
    }

    $result = run(['/usr/bin/apt-get', '-s', '-o', 'Debug::NoLocking=true', 'upgrade'], 30);

    if (0 !== $result['exit']) {
        return ['available' => false, 'pending' => null, 'security' => null, 'reboot_required' => is_file('/var/run/reboot-required'), 'lists_updated_at' => null, 'error' => last_error_line($result['stderr'] ?: $result['stdout'])];
    }

    $pending = 0;
    $security = 0;
    $samples = [];

    foreach (preg_split('/\R/', $result['stdout']) ?: [] as $line) {
        if (!preg_match('/^Inst\s+(\S+)\s+\[([^\]]*)\]\s+\(([^\s]+)\s+([^)]*)\)/', $line, $m)) {
            continue;
        }

        ++$pending;

        if (false !== stripos($m[4], 'security')) {
            ++$security;

            if (count($samples) < 10) {
                $samples[] = $m[1] . ' ' . $m[2] . ' → ' . $m[3];
            }
        }
    }

    $stamp = is_file('/var/lib/apt/periodic/update-success-stamp') ? filemtime('/var/lib/apt/periodic/update-success-stamp') : (is_dir('/var/lib/apt/lists') ? filemtime('/var/lib/apt/lists') : false);

    return [
        'available' => true,
        'pending' => $pending,
        'security' => $security,
        'security_samples' => $samples,
        'reboot_required' => is_file('/var/run/reboot-required'),
        'lists_updated_at' => false === $stamp ? null : gmdate('Y-m-d H:i:s', (int) $stamp),
    ];
}

/**
 * @return array<string, mixed>
 */
function crontab_status()
{
    $result = run(['crontab', '-l'], 10);

    if (0 !== $result['exit']) {
        $reason = trim($result['stderr'] ?: $result['stdout']);

        return ['available' => false, 'lines' => [], 'wp_cron' => false, 'zenith_heartbeat' => false, 'reason' => '' === $reason ? 'crontab indisponible' : str_cut($reason, 0, 120)];
    }

    $lines = [];
    $wpCron = false;
    $heartbeat = false;

    foreach (preg_split('/\R/', $result['stdout']) ?: [] as $line) {
        $line = trim($line);

        if ('' === $line || '#' === $line[0]) {
            continue;
        }

        $lines[] = str_cut($line, 0, 200);

        if (preg_match('/wp-cron\.php|wp\s+cron\s+event\s+run|cron\s+event\s+run/i', $line)) {
            $wpCron = true;
        }

        // The heartbeat scheduled by hand (plugin 1.12, DISABLE_WP_CRON):
        // the site's push is alive even though WP-Cron itself is not run.
        if (preg_match('/wp\s+zenith\s+heartbeat/i', $line)) {
            $heartbeat = true;
        }
    }

    return ['available' => true, 'lines' => array_slice($lines, 0, 30), 'count' => count($lines), 'wp_cron' => $wpCron || $heartbeat, 'zenith_heartbeat' => $heartbeat, 'reason' => null];
}

/**
 * The repository the site is deployed from, looked for at the site's root
 * and up to four levels above it.
 *
 * @return array<string, mixed>|null
 */
function git_status($path)
{
    if (null === $path) {
        return null;
    }

    $root = null;
    $dir = $path;

    for ($i = 0; $i < 5; ++$i) {
        if (is_dir($dir . '/.git') || is_file($dir . '/.git')) {
            $root = $dir;
            break;
        }

        $parent = dirname($dir);

        if ($parent === $dir) {
            break;
        }

        $dir = $parent;
    }

    if (null === $root) {
        return ['available' => false, 'reason' => 'aucun dépôt Git au-dessus de la racine du site', 'repo_root' => null];
    }

    if (null === which('git')) {
        return ['available' => false, 'reason' => 'binaire git absent', 'repo_root' => $root, 'in_docroot' => $root === $path];
    }

    $git = function (array $args) use ($root) {
        $r = run(array_merge(['git', '-C', $root], $args), 20);

        return 0 === $r['exit'] ? trim($r['stdout']) : null;
    };

    $status = $git(['status', '--porcelain=v1', '--branch', '--untracked-files=normal']);
    $lines = null === $status ? [] : (preg_split('/\R/', $status) ?: []);
    $header = [] === $lines ? '' : array_shift($lines);
    $ahead = null;
    $behind = null;
    $upstream = null;

    if (preg_match('/^## (\S+?)(?:\.\.\.(\S+))?(?: \[(.*)\])?$/', $header, $m)) {
        $upstream = isset($m[2]) && '' !== $m[2] ? $m[2] : null;
        $ahead = isset($m[3]) && preg_match('/ahead (\d+)/', $m[3], $a) ? (int) $a[1] : 0;
        $behind = isset($m[3]) && preg_match('/behind (\d+)/', $m[3], $b) ? (int) $b[1] : 0;
    }

    $changed = count(array_filter($lines, 'strlen'));
    $untrackedLines = array_values(array_filter($lines, function ($l) { return 0 === strpos($l, '??'); }));
    $untracked = count($untrackedLines);
    $untrackedSamples = array_map(function ($l) { return trim(substr($l, 3)); }, array_slice($untrackedLines, 0, 20));
    $head = $git(['rev-parse', 'HEAD']);
    $branch = $git(['rev-parse', '--abbrev-ref', 'HEAD']);
    $lastCommit = $git(['log', '-1', '--format=%cI']);

    return [
        'available' => true,
        'reason' => null,
        'repo_root' => $root,
        'in_docroot' => $root === $path,
        'branch' => 'HEAD' === $branch ? null : $branch,
        'detached' => 'HEAD' === $branch,
        'head' => $head,
        'remote' => $git(['remote', 'get-url', 'origin']),
        'upstream' => $upstream,
        'ahead' => $ahead,
        'behind' => $behind,
        'last_commit_at' => $lastCommit,
        'worktree' => ['available' => null !== $status, 'dirty' => $changed > 0, 'changed_files' => $changed, 'untracked' => $untracked, 'untracked_samples' => $untrackedSamples, 'reason' => null === $status ? 'git status a échoué' : null],
    ];
}

/**
 * @return array<string, mixed>
 */
function wp_config_status($path)
{
    $file = is_file($path . '/wp-config.php') ? $path . '/wp-config.php' : dirname($path) . '/wp-config.php';
    $constants = wp_config_constants($path);
    $flag = function ($name) use ($constants) {
        if (!isset($constants[$name])) {
            return null;
        }

        return in_array(strtolower($constants[$name]), ['true', '1'], true) ? true : (in_array(strtolower($constants[$name]), ['false', '0', ''], true) ? false : $constants[$name]);
    };
    $perms = is_file($file) ? fileperms($file) : false;
    $owner = is_file($file) ? fileowner($file) : false;
    $me = function_exists('posix_geteuid') ? posix_geteuid() : null;

    return [
        'file' => is_file($file) ? $file : null,
        'outside_docroot' => is_file($file) && dirname($file) !== $path,
        'mode' => false === $perms ? null : substr(sprintf('%o', $perms), -4),
        'world_readable' => false === $perms ? null : (bool) ($perms & 0004),
        'group_readable' => false === $perms ? null : (bool) ($perms & 0040),
        'owned_by_me' => false === $owner || null === $me ? null : $owner === $me,
        'disable_wp_cron' => $flag('DISABLE_WP_CRON'),
        'wp_debug' => $flag('WP_DEBUG'),
        'wp_debug_log' => $flag('WP_DEBUG_LOG'),
        'wp_debug_display' => $flag('WP_DEBUG_DISPLAY'),
        'disallow_file_mods' => $flag('DISALLOW_FILE_MODS'),
        'disallow_file_edit' => $flag('DISALLOW_FILE_EDIT'),
        'wp_cache' => $flag('WP_CACHE'),
        'fs_method' => isset($constants['FS_METHOD']) ? $constants['FS_METHOD'] : null,
    ];
}

/**
 * The database server behind the site, as the site's own account sees it:
 * what it was given, how it is used, and how much of the site it can hold
 * in memory.
 *
 * @param array<string, string> $constants
 *
 * @return array<string, mixed>
 */
function mysql_status(array $constants)
{
    if (!isset($constants['DB_HOST'], $constants['DB_USER'], $constants['DB_NAME']) || !function_exists('mysqli_init')) {
        return ['available' => false, 'reason' => isset($constants['DB_HOST']) ? 'extension mysqli absente' : 'réglages de base illisibles dans wp-config.php'];
    }

    $hostParts = explode(':', $constants['DB_HOST'], 2);
    $socket = null;
    $port = 3306;

    if (isset($hostParts[1])) {
        if (is_numeric($hostParts[1])) {
            $port = (int) $hostParts[1];
        } else {
            $socket = $hostParts[1];
        }
    }

    mysqli_report(MYSQLI_REPORT_OFF);
    $mysqli = mysqli_init();
    $mysqli->options(MYSQLI_OPT_CONNECT_TIMEOUT, 4);

    if (!@$mysqli->real_connect($hostParts[0], $constants['DB_USER'], isset($constants['DB_PASSWORD']) ? $constants['DB_PASSWORD'] : '', $constants['DB_NAME'], $port, $socket)) {
        return ['available' => false, 'reason' => str_cut((string) $mysqli->connect_error, 0, 200)];
    }

    $status = [];
    $variables = [];
    $wanted = ['Uptime', 'Questions', 'Slow_queries', 'Created_tmp_disk_tables', 'Created_tmp_tables', 'Threads_connected', 'Max_used_connections', 'Aborted_connects', 'Aborted_clients', 'Innodb_buffer_pool_read_requests', 'Innodb_buffer_pool_reads', 'Innodb_buffer_pool_wait_free', 'Select_full_join', 'Sort_merge_passes', 'Table_locks_waited', 'Opened_tables', 'Open_tables', 'Connections'];
    $result = @$mysqli->query("SHOW GLOBAL STATUS WHERE Variable_name IN ('" . implode("','", $wanted) . "')");

    if ($result) {
        while ($row = $result->fetch_assoc()) {
            $status[$row['Variable_name']] = is_numeric($row['Value']) ? (float) $row['Value'] : $row['Value'];
        }

        $result->free();
    }

    $wantedVars = ['version', 'version_comment', 'innodb_buffer_pool_size', 'max_connections', 'slow_query_log', 'long_query_time', 'query_cache_size', 'table_open_cache', 'tmp_table_size', 'max_heap_table_size', 'innodb_log_file_size', 'character_set_server', 'sql_mode', 'wait_timeout', 'max_allowed_packet'];
    $result = @$mysqli->query("SHOW GLOBAL VARIABLES WHERE Variable_name IN ('" . implode("','", $wantedVars) . "')");

    if ($result) {
        while ($row = $result->fetch_assoc()) {
            $variables[$row['Variable_name']] = is_numeric($row['Value']) ? (float) $row['Value'] : $row['Value'];
        }

        $result->free();
    }

    $size = null;
    $tables = null;
    $engines = [];
    $result = @$mysqli->query('SELECT ENGINE, COUNT(*) AS n, COALESCE(SUM(DATA_LENGTH + INDEX_LENGTH), 0) AS bytes FROM information_schema.TABLES WHERE TABLE_SCHEMA = ' . "'" . $mysqli->real_escape_string($constants['DB_NAME']) . "'" . ' GROUP BY ENGINE');

    if ($result) {
        $size = 0;
        $tables = 0;

        while ($row = $result->fetch_assoc()) {
            $size += (int) $row['bytes'];
            $tables += (int) $row['n'];
            $engines[(string) $row['ENGINE']] = (int) $row['n'];
        }

        $result->free();
    }

    $mysqli->close();

    $reads = isset($status['Innodb_buffer_pool_reads'], $status['Innodb_buffer_pool_read_requests']) && $status['Innodb_buffer_pool_read_requests'] > 0
        ? round(100 * (1 - $status['Innodb_buffer_pool_reads'] / $status['Innodb_buffer_pool_read_requests']), 2)
        : null;

    return [
        'available' => true,
        'reason' => null,
        'host' => $constants['DB_HOST'],
        'version' => isset($variables['version']) ? (string) $variables['version'] : null,
        'flavour' => isset($variables['version_comment']) ? (string) $variables['version_comment'] : null,
        'uptime_seconds' => isset($status['Uptime']) ? (int) $status['Uptime'] : null,
        'questions' => isset($status['Questions']) ? (int) $status['Questions'] : null,
        'slow_queries' => isset($status['Slow_queries']) ? (int) $status['Slow_queries'] : null,
        'slow_query_log' => isset($variables['slow_query_log']) ? 'ON' === strtoupper((string) $variables['slow_query_log']) : null,
        'long_query_time' => isset($variables['long_query_time']) ? (float) $variables['long_query_time'] : null,
        'tmp_disk_tables' => isset($status['Created_tmp_disk_tables']) ? (int) $status['Created_tmp_disk_tables'] : null,
        'tmp_tables' => isset($status['Created_tmp_tables']) ? (int) $status['Created_tmp_tables'] : null,
        'threads_connected' => isset($status['Threads_connected']) ? (int) $status['Threads_connected'] : null,
        'max_used_connections' => isset($status['Max_used_connections']) ? (int) $status['Max_used_connections'] : null,
        'max_connections' => isset($variables['max_connections']) ? (int) $variables['max_connections'] : null,
        'aborted_connects' => isset($status['Aborted_connects']) ? (int) $status['Aborted_connects'] : null,
        'connections' => isset($status['Connections']) ? (int) $status['Connections'] : null,
        'buffer_pool_bytes' => isset($variables['innodb_buffer_pool_size']) ? (int) $variables['innodb_buffer_pool_size'] : null,
        'buffer_pool_hit_ratio' => $reads,
        'buffer_pool_wait_free' => isset($status['Innodb_buffer_pool_wait_free']) ? (int) $status['Innodb_buffer_pool_wait_free'] : null,
        'select_full_join' => isset($status['Select_full_join']) ? (int) $status['Select_full_join'] : null,
        'sort_merge_passes' => isset($status['Sort_merge_passes']) ? (int) $status['Sort_merge_passes'] : null,
        'table_locks_waited' => isset($status['Table_locks_waited']) ? (int) $status['Table_locks_waited'] : null,
        'tmp_table_size' => isset($variables['tmp_table_size']) ? (int) $variables['tmp_table_size'] : null,
        'max_allowed_packet' => isset($variables['max_allowed_packet']) ? (int) $variables['max_allowed_packet'] : null,
        'character_set_server' => isset($variables['character_set_server']) ? (string) $variables['character_set_server'] : null,
        'sql_mode' => isset($variables['sql_mode']) ? (string) $variables['sql_mode'] : null,
        'database_bytes' => $size,
        'tables' => $tables,
        'engines' => $engines,
    ];
}

/**
 * The site's own logs and what they carried lately: a fatal error a day
 * is a site that breaks for someone every day.
 *
 * @return array<string, mixed>
 */
function logs_status($path)
{
    $constants = wp_config_constants($path);
    $files = [];

    foreach (log_candidates($path, $constants) as $file) {
        if (!is_file($file) || !is_readable($file)) {
            continue;
        }

        $tail = tail_lines($file, 4000, 2 * 1024 * 1024) ?: [];
        $now = time();
        $day = 0;
        $week = 0;
        $notices = 0;
        $lastFatal = null;
        $lastFatalAt = null;

        foreach ($tail as $line) {
            $stamp = preg_match('/^\[(\d{2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2}(?: [A-Z]+)?)\]/', $line, $m) ? strtotime($m[1]) : false;
            $fatal = (bool) preg_match('/PHP (Fatal error|Parse error)|Uncaught (Error|Exception|TypeError)/', $line);

            if (!$fatal) {
                if (preg_match('/PHP (Warning|Notice|Deprecated)/', $line)) {
                    ++$notices;
                }

                continue;
            }

            if (false !== $stamp) {
                if ($stamp > $now - 86400) {
                    ++$day;
                }

                if ($stamp > $now - 7 * 86400) {
                    ++$week;
                }
            }

            $lastFatal = str_cut(preg_replace('/^\[[^\]]+\]\s*/', '', $line), 0, 300);
            $lastFatalAt = false !== $stamp ? gmdate('Y-m-d H:i:s', $stamp) : null;
        }

        $files[] = [
            'path' => $file,
            'size_bytes' => (int) filesize($file),
            'modified_at' => gmdate('Y-m-d H:i:s', (int) filemtime($file)),
            'fatals_24h' => $day,
            'fatals_7d' => $week,
            'notices_in_tail' => $notices,
            'lines_read' => count($tail),
            'last_fatal' => $lastFatal,
            'last_fatal_at' => $lastFatalAt,
        ];
    }

    return ['files' => $files, 'debug_log_enabled' => isset($constants['WP_DEBUG_LOG']) && !in_array(strtolower($constants['WP_DEBUG_LOG']), ['false', '0', ''], true)];
}

/**
 * @param array<string, string> $constants
 *
 * @return string[]
 */
function log_candidates($path, array $constants)
{
    $debugLog = isset($constants['WP_DEBUG_LOG']) && '' !== $constants['WP_DEBUG_LOG'] && !in_array(strtolower($constants['WP_DEBUG_LOG']), ['true', 'false', '1', '0'], true) ? $constants['WP_DEBUG_LOG'] : $path . '/wp-content/debug.log';

    return array_unique([$debugLog, $path . '/error_log', $path . '/wp-admin/error_log', $path . '/wp-content/error_log', $path . '/php_errorlog', dirname($path) . '/logs/error.log', dirname($path) . '/error_log']);
}

// ----------------------------------------------------------- snapshots

function snapshots_dir()
{
    return zenith_home() . '/.zenith/snapshots';
}

/**
 * The parts of a snapshot file name — slug, version, moment — or null
 * when the name is not one this shell wrote: only the base name counts,
 * and every part is held to the characters snapshot_plugin() uses.
 *
 * @return array{file: string, slug: string, version: string, stamp: string}|null
 */
function snapshot_parts($name)
{
    if (!is_string($name) || '' === $name || false !== strpos($name, '..') || basename($name) !== $name) {
        return null;
    }

    if (!preg_match('/^([A-Za-z0-9._-]+)__([A-Za-z0-9._-]+)__(\d{8}-\d{6})\.tar\.gz$/', $name, $m) || '.' === $m[1][0]) {
        return null;
    }

    return ['file' => $name, 'slug' => $m[1], 'version' => $m[2], 'stamp' => $m[3]];
}

/**
 * @return list<array<string, mixed>>
 */
function snapshots_list()
{
    $dir = snapshots_dir();

    if (!is_dir($dir)) {
        return [];
    }

    $list = [];

    foreach (glob($dir . '/*.tar.gz') ?: [] as $file) {
        $parts = snapshot_parts(basename($file));

        if (null === $parts) {
            continue;
        }

        $list[] = ['file' => $parts['file'], 'slug' => $parts['slug'], 'version' => $parts['version'], 'taken_at' => preg_replace('/^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/', '$1-$2-$3 $4:$5:$6', $parts['stamp']), 'size_bytes' => (int) filesize($file)];
    }

    usort($list, function ($a, $b) { return strcmp($b['taken_at'], $a['taken_at']); });

    return array_slice($list, 0, 30);
}

/**
 * The version a plugin declares in its header, read off its files.
 */
function plugin_version($dir)
{
    foreach (glob($dir . '/*.php') ?: [] as $file) {
        $head = (string) file_get_contents($file, false, null, 0, 8192);

        if (false !== stripos($head, 'Plugin Name:') && preg_match('/^[ \t\/*#@]*Version:\s*(.+?)\s*$/mi', $head, $m)) {
            return trim($m[1]);
        }
    }

    return 'unknown';
}

/**
 * Archives one plugin directory, keeps the last three of each, and says
 * where the archive is — or why there is none.
 *
 * @return array<string, mixed>
 */
function snapshot_plugin($path, $slug)
{
    // The slug is checked before it is joined to any path: a slug is a
    // single directory name, never a dot directory, never a route.
    if (!is_string($slug) || !preg_match('/^[A-Za-z0-9_][A-Za-z0-9._-]{0,119}$/', $slug) || false !== strpos($slug, '..')) {
        return ['taken' => false, 'reason' => 'nom d\'extension invalide', 'file' => null];
    }

    $pluginDir = $path . '/wp-content/plugins/' . $slug;

    if (!is_dir($pluginDir)) {
        return ['taken' => false, 'reason' => 'répertoire introuvable', 'file' => null];
    }

    $dir = snapshots_dir();

    if (!is_dir($dir) && !@mkdir($dir, 0700, true)) {
        return ['taken' => false, 'reason' => sprintf('impossible de créer %s', $dir), 'file' => null];
    }

    $version = preg_replace('/[^A-Za-z0-9._-]/', '_', plugin_version($pluginDir));
    $file = sprintf('%s/%s__%s__%s.tar.gz', $dir, $slug, '' === $version ? 'unknown' : $version, gmdate('Ymd-His'));
    $result = run(['tar', '-czf', $file, '-C', $path . '/wp-content/plugins', '--', $slug], 300);

    if (0 !== $result['exit']) {
        @unlink($file);

        return ['taken' => false, 'reason' => last_error_line($result['stderr'] ?: 'tar a échoué'), 'file' => null];
    }

    // Three per plugin is enough to go back; more is a disk that fills.
    // Sorted on the moment in the name rather than on the whole name, so a
    // version that sorts oddly ("10.0" before "9.0") never keeps an old
    // archive alive at the expense of a newer one.
    $own = [];

    foreach (glob(sprintf('%s/%s__*.tar.gz', $dir, $slug)) ?: [] as $candidate) {
        $parts = snapshot_parts(basename($candidate));

        if (null !== $parts && $parts['slug'] === $slug) {
            $own[$parts['stamp'] . '|' . basename($candidate)] = $candidate;
        }
    }

    ksort($own);

    while (count($own) > ZENITH_SNAPSHOTS_KEPT) {
        @unlink(array_shift($own));
    }

    return ['taken' => true, 'reason' => null, 'file' => basename($file), 'size_bytes' => (int) filesize($file)];
}

/**
 * Puts a plugin back as one of its archives holds it. The current
 * directory is set aside first and only dropped once the archive is out.
 *
 * @return array<string, mixed>
 */
function control_snapshot_restore($path, $snapshot, $actor, $host, array $config)
{
    $parts = snapshot_parts($snapshot);

    if (null === $parts) {
        throw new RuntimeException('nom de snapshot invalide');
    }

    if (!is_wordpress($path)) {
        throw new RuntimeException(sprintf('aucun WordPress dans %s', $path));
    }

    $file = snapshots_dir() . '/' . $parts['file'];

    if (!is_file($file)) {
        throw new RuntimeException(sprintf('snapshot introuvable : %s', $parts['file']));
    }

    $slug = $parts['slug'];
    $pluginsDir = $path . '/wp-content/plugins';
    $current = $pluginsDir . '/' . $slug;
    $aside = $pluginsDir . '/.' . $slug . '.zenith-aside';

    // The archive is read before anything moves: every entry must sit
    // under the plugin's own directory. An archive that reaches anywhere
    // else — a sibling plugin, wp-config.php through "..", an absolute
    // path — is not a snapshot this shell wrote, and is refused whole.
    assert_archive_confined($file, $slug);

    if (is_dir($aside)) {
        run(['rm', '-rf', '--', $aside], 60);
    }

    $had = is_dir($current);

    if ($had && !@rename($current, $aside)) {
        throw new RuntimeException(sprintf('impossible de mettre de côté %s', $current));
    }

    $result = run(['tar', '-xzf', $file, '-C', $pluginsDir, '--no-same-owner', '--', $slug], 300);

    if (0 !== $result['exit'] || !is_dir($current)) {
        if ($had) {
            run(['rm', '-rf', '--', $current], 60);
            @rename($aside, $current);
        }

        throw new RuntimeException(sprintf('extraction en échec : %s', last_error_line($result['stderr'] ?: 'tar a échoué')));
    }

    if ($had) {
        run(['rm', '-rf', '--', $aside], 60);
    }

    wp_bootstrap($path, $host, '$result = ["status" => "ok"];' . journal_snippet('snapshot_restore', $actor, ['snapshot' => $parts['file'], 'items' => [$slug]]) . ' echo "\n{}\n";', $config, 30);

    return ['status' => 'ok', 'results' => [['item' => $slug, 'status' => 'updated', 'message' => sprintf('version %s remise en place depuis %s', $parts['version'], $parts['file'])]], 'restored' => $slug, 'version' => $parts['version']];
}

/**
 * Lists a tar.gz and refuses it unless every entry is the plugin's own
 * directory or something under it, with no ".." and no absolute path.
 */
function assert_archive_confined($file, $slug)
{
    $listing = run(['tar', '-tzf', $file], 120);

    if (0 !== $listing['exit']) {
        throw new RuntimeException(sprintf('archive illisible : %s', last_error_line($listing['stderr'] ?: 'tar a échoué')));
    }

    $entries = array_values(array_filter(preg_split('/\R/', $listing['stdout']) ?: [], 'strlen'));

    if ([] === $entries) {
        throw new RuntimeException('archive vide');
    }

    foreach ($entries as $entry) {
        $clean = preg_replace('#^\./#', '', $entry);

        if ($clean !== $slug && $clean !== $slug . '/' && 0 !== strpos($clean, $slug . '/')) {
            throw new RuntimeException(sprintf('archive refusée : entrée hors de %s/ (%s)', $slug, str_cut($entry, 0, 120)));
        }

        if ('/' === $entry[0] || false !== strpos($entry, '..') || false !== strpos($entry, "\\")) {
            throw new RuntimeException(sprintf('archive refusée : chemin suspect (%s)', str_cut($entry, 0, 120)));
        }
    }
}

/**
 * Fetches wp-cli into ~/bin, checked against the hash its authors publish:
 * what opens updates through the shell on a machine that had none.
 *
 * @return array<string, mixed>
 */
function control_install_wpcli(array $config)
{
    $home = getenv('HOME');

    if (!is_string($home) || '' === $home) {
        throw new RuntimeException('HOME inconnu : nulle part où installer wp-cli');
    }

    if (!function_exists('curl_init')) {
        throw new RuntimeException('extension curl absente : impossible de télécharger wp-cli');
    }

    $dir = $home . '/bin';

    if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
        throw new RuntimeException(sprintf('impossible de créer %s', $dir));
    }

    // One release, one digest, both written into this script: what comes
    // down the wire is held against a value that did not come down the
    // wire with it. A new wp-cli means a new ZENITH_WPCLI_VERSION and a
    // new ZENITH_WPCLI_SHA512, shipped together with the shell.
    $url = sprintf('https://github.com/wp-cli/wp-cli/releases/download/v%s/wp-cli-%s.phar', ZENITH_WPCLI_VERSION, ZENITH_WPCLI_VERSION);
    $phar = fetch_url($url, 120);

    if (null === $phar) {
        throw new RuntimeException(sprintf('téléchargement de wp-cli %s impossible depuis github.com', ZENITH_WPCLI_VERSION));
    }

    if (!hash_equals(ZENITH_WPCLI_SHA512, hash('sha512', $phar))) {
        throw new RuntimeException(sprintf('empreinte SHA-512 de wp-cli %s incorrecte : archive rejetée', ZENITH_WPCLI_VERSION));
    }

    $target = $dir . '/wp';

    if (false === file_put_contents($target, $phar)) {
        throw new RuntimeException(sprintf('impossible d\'écrire %s', $target));
    }

    chmod($target, 0755);
    $version = tool_version([$config['php'], $target, '--version', '--skip-plugins', '--skip-themes'], '/WP-CLI\s+([0-9][0-9a-z.\-]*)/i');

    if (null === $version) {
        @unlink($target);

        throw new RuntimeException('wp-cli téléchargé mais ne s\'exécute pas avec ' . $config['php']);
    }

    return ['status' => 'ok', 'installed' => $target, 'version' => $version, 'message' => sprintf('wp-cli %s installé dans %s. Renseigner wp=%s dans shell.conf si ~/bin n\'est pas dans le PATH.', $version, $target, $target)];
}

function fetch_url($url, $timeout)
{
    $ch = curl_init($url);
    curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 3, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_TIMEOUT => budget($timeout), CURLOPT_USERAGENT => 'zenith-shell/' . ZENITH_SHELL_VERSION, CURLOPT_SSL_VERIFYPEER => true]);
    $body = curl_exec($ch);
    $code = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);

    return is_string($body) && 200 === $code && '' !== $body ? $body : null;
}

/**
 * Replaces this script with the copy Zenith brought over by verb_put, at
 * the path the key's forced command names — the one place where a new
 * version takes effect at the next connection. Nothing is written until
 * the copy proves to be a zenith-shell that runs and states its version.
 *
 * @return array<string, mixed>
 */
function control_install_shell(array $request, array $config)
{
    $file = incoming_file($request, 'file');
    $target = shell_install_path($config);
    $source = (string) file_get_contents($file);

    if (!preg_match('/^#!.*\n<\?php\n/', $source) || !preg_match('/define\(\'ZENITH_SHELL_VERSION\', \'([0-9][0-9A-Za-z.\-]*)\'\)/', $source, $m)) {
        @unlink($file);

        throw new RuntimeException('le fichier reçu n\'est pas un zenith-shell');
    }

    $new = $m[1];
    $lint = run([$config['php'], '-n', '-l', $file], 30);

    if (0 !== $lint['exit']) {
        @unlink($file);

        throw new RuntimeException(sprintf('le shell reçu ne passe pas php -l avec %s : %s', $config['php'], last_error_line($lint['stderr'] ?: $lint['stdout'])));
    }

    $dir = dirname($target);

    if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
        throw new RuntimeException(sprintf('impossible de créer %s', $dir));
    }

    if (!is_writable($dir) && !(is_file($target) && is_writable($target))) {
        @unlink($file);

        throw new RuntimeException(sprintf('%s n\'est pas modifiable par %s : réinstaller le shell avec app:ssh:enroll', $target, get_current_user()));
    }

    $old = is_file($target) ? tool_version([$config['php'], $target, '--version'], '/^([0-9][0-9A-Za-z.\-]*)/') : null;

    // Written next to the target and swapped in by rename: the running
    // copy — this very process — is never half a file.
    $staged = $target . '.zenith-new';

    if (false === file_put_contents($staged, $source) || !chmod($staged, 0755)) {
        @unlink($staged);

        throw new RuntimeException(sprintf('impossible d\'écrire %s', $staged));
    }

    $check = tool_version([$config['php'], $staged, '--version'], '/^([0-9][0-9A-Za-z.\-]*)/');

    if ($check !== $new) {
        @unlink($staged);

        throw new RuntimeException(sprintf('le shell reçu annonce %s au lieu de %s', null === $check ? 'rien' : $check, $new));
    }

    if (!@rename($staged, $target)) {
        @unlink($staged);

        throw new RuntimeException(sprintf('impossible de remplacer %s', $target));
    }

    @unlink($file);

    return [
        'status' => 'ok',
        'path' => $target,
        'previous' => $old,
        'version' => $new,
        'message' => sprintf('zenith-shell %s installé dans %s%s.', $new, $target, null === $old ? '' : sprintf(' (remplace %s)', $old)),
    ];
}

/**
 * Where the shell lives on this machine: the path the configuration
 * names, else the forced command of Zenith's key in authorized_keys,
 * else this very file when it runs from a fixed place.
 */
function shell_install_path(array $config)
{
    if (null !== $config['shell'] && '' !== $config['shell']) {
        return $config['shell'];
    }

    $authorized = authorized_keys_options();

    if (is_string($authorized['command_path']) && '' !== $authorized['command_path']) {
        return $authorized['command_path'];
    }

    $self = realpath(__FILE__);

    if (is_string($self) && 'zenith-shell' === basename($self)) {
        return $self;
    }

    throw new RuntimeException('emplacement du shell inconnu : renseigner shell= dans shell.conf, ou command= dans authorized_keys');
}

// ---------------------------------------------------------------- audit

/**
 * The heavy reading: the whole tree, walked with find and grep, for what
 * the web process cannot see in the time it is given. Every list is capped
 * and says so.
 *
 * @return array<string, mixed>
 */
function verb_audit(array $request, array $config)
{
    framework($request);
    $path = site_path($request);

    // The audit walks a whole tree and reads the directories above it: it
    // is only ever pointed at a site, never at an arbitrary path.
    if (!is_wordpress($path)) {
        throw new RuntimeException(sprintf('aucun WordPress dans %s', $path));
    }

    $started = microtime(true);
    $me = function_exists('posix_geteuid') && function_exists('posix_getpwuid') ? (posix_getpwuid(posix_geteuid())['name'] ?? get_current_user()) : get_current_user();
    $uploads = $path . '/wp-content/uploads';

    $worldWritable = find_list([$path, '-xdev', '(', '-type', 'f', '-o', '-type', 'd', ')', '-perm', '-o+w', '-not', '-path', '*/node_modules/*', '-not', '-path', '*/cache/*'], 180);
    $otherOwner = find_list([$path, '-xdev', '-not', '-user', $me, '-not', '-path', '*/node_modules/*'], 180);
    // The empty index.php plugins drop in every uploads folder to keep
    // listings closed is not code: only files with something in them count.
    $phpInUploads = is_dir($uploads) ? find_list([$uploads, '-type', 'f', '(', '-iname', '*.php', '-o', '-iname', '*.phtml', '-o', '-iname', '*.php5', '-o', '-iname', '*.php7', '-o', '-iname', '*.phar', '-o', '-iname', '*.phps', ')', '-not', '(', '-name', 'index.php', '-size', '-2k', ')'], 120) : ['count' => 0, 'samples' => [], 'truncated' => false, 'error' => null];
    $recentPhp = find_list([$path, '-xdev', '-type', 'f', '-name', '*.php', '-mtime', '-2', '-not', '-path', '*/cache/*', '-not', '-path', '*/node_modules/*', '-not', '-path', '*/.zenith-aside/*'], 180, '%TY-%Tm-%Td %TH:%TM %p\n');
    $signatures = signature_scan($path . '/wp-content', 240);
    $htaccessInUploads = is_file($uploads . '/.htaccess');
    $wpConfig = wp_config_status($path);

    // Above and beside the site: what a deploy or a hurried backup leaves
    // where no PHP process is meant to look, and where nothing serves it —
    // unless the parent is itself served.
    $exposure = [];
    $seen = [];

    foreach (array_unique([dirname($path), dirname(dirname($path)), (string) getenv('HOME'), $path]) as $dir) {
        if ('' === $dir || !is_dir($dir) || isset($seen[$dir])) {
            continue;
        }

        $seen[$dir] = true;
        $found = find_list([$dir, '-maxdepth', 2, '-type', 'f', '(', '-iname', '*.sql', '-o', '-iname', '*.sql.gz', '-o', '-iname', '*.sql.zip', '-o', '-iname', '*.zip', '-o', '-iname', '*.tar', '-o', '-iname', '*.tar.gz', '-o', '-iname', '*.tgz', '-o', '-name', '.env', '-o', '-name', '.env.*', '-o', '-iname', '*.bak', '-o', '-iname', '*.old', '-o', '-iname', '*.orig', '-o', '-iname', 'wp-config.php.*', ')', '-size', '+0', '-not', '-path', '*/node_modules/*', '-not', '-path', '*/.zenith/*', '-not', '-path', '*/vendor/*'], 60, '%s %p\n');

        foreach ($found['samples'] as $line) {
            list($size, $file) = array_pad(explode(' ', $line, 2), 2, '');
            // The same file is met from the site and from its parent: once.
            $exposure[$file] = ['file' => $file, 'size_bytes' => (int) $size, 'in_docroot' => 0 === strpos($file, $path . '/')];
        }
    }

    $exposure = array_values($exposure);

    // What weighs: the heaviest directories of the site, the files large
    // enough to be an archive or a dump wherever they sit, and the cache.
    $bigFiles = [];

    foreach (array_unique([$path, dirname($path), dirname(dirname($path)), (string) getenv('HOME')]) as $dir) {
        if ('' === $dir || !is_dir($dir)) {
            continue;
        }

        $args = $dir === $path
            ? [$dir, '-xdev', '-type', 'f', '-size', '+50M', '-not', '-path', $uploads . '/*']
            : [$dir, '-maxdepth', 2, '-xdev', '-type', 'f', '-size', '+50M', '-not', '-path', $path . '/*'];
        $found = find_list($args, 60, '%s %p\n');

        foreach ($found['samples'] as $line) {
            list($size, $file) = array_pad(explode(' ', $line, 2), 2, '');
            $bigFiles[$file] = ['file' => $file, 'size_bytes' => (int) $size, 'in_docroot' => 0 === strpos($file, $path . '/')];
        }
    }

    usort($bigFiles, function ($a, $b) { return $b['size_bytes'] - $a['size_bytes']; });

    return [
        'at' => gmdate('Y-m-d H:i:s'),
        'path' => $path,
        'user' => $me,
        'disk_usage' => disk_usage($path),
        'stale' => stale_directories($path),
        'big_files' => array_slice(array_values($bigFiles), 0, 20),
        'cache_bytes' => is_dir($path . '/wp-content/cache') ? dir_bytes($path . '/wp-content/cache') : null,
        'deadline_reached' => out_of_time(),
        'filesystem' => [
            'world_writable' => $worldWritable,
            'other_owner' => $otherOwner,
            'php_in_uploads' => $phpInUploads,
            'recent_php' => $recentPhp,
            'signatures' => $signatures,
            'uploads_htaccess' => $htaccessInUploads,
            'wp_config' => $wpConfig,
        ],
        'exposure' => array_slice($exposure, 0, 40),
        'uploads' => uploads_stats($uploads, $path),
        'duration_seconds' => round(microtime(true) - $started, 2),
    ];
}

/**
 * Total size of one directory in bytes, as du counts it.
 */
function dir_bytes($dir)
{
    $result = run(['du', '-sxk', $dir], 120);

    if (0 !== $result['exit']) {
        return null;
    }

    return (int) strtok(trim($result['stdout']), "\t ") * 1024;
}

/**
 * The heaviest directories of the site, three levels deep: where the
 * gigabytes are, whichever plugin or cache put them there.
 *
 * @return array<string, mixed>
 */
function disk_usage($path)
{
    $result = run(['du', '-xk', '--max-depth=3', $path], 240);

    if ($result['timeout'] || '' === trim($result['stdout'])) {
        return ['available' => false, 'total_bytes' => null, 'top' => [], 'reason' => $result['timeout'] ? 'du interrompu' : last_error_line($result['stderr'] ?: 'du a échoué')];
    }

    $rows = [];
    $total = null;

    foreach (preg_split('/\R/', trim($result['stdout'])) ?: [] as $line) {
        if (!preg_match('/^(\d+)\s+(.+)$/', $line, $m)) {
            continue;
        }

        $bytes = (int) $m[1] * 1024;
        $dir = $m[2];

        if ($dir === $path) {
            $total = $bytes;
            continue;
        }

        $rows[] = ['dir' => substr($dir, strlen($path) + 1), 'bytes' => $bytes];
    }

    usort($rows, function ($a, $b) { return $b['bytes'] - $a['bytes']; });

    return ['available' => true, 'total_bytes' => $total, 'top' => array_slice($rows, 0, 12), 'reason' => null];
}

/**
 * Runs find with the given arguments and returns a count and a capped
 * sample, whatever the size of the answer.
 *
 * The output is cut by head at ZENITH_FIND_MAX_LINES: a tree with a
 * million matches would otherwise be read whole into this process. The
 * pipe is the only reason a shell appears here, and find's arguments
 * reach it as positional parameters — never as part of the command
 * string, so nothing in a path is ever parsed.
 *
 * @param array<int, string|int> $args
 *
 * @return array{count: int, samples: list<string>, truncated: bool, error: ?string}
 */
function find_list(array $args, $timeout, $printf = null)
{
    $limit = ZENITH_FIND_MAX_LINES + 1;
    $command = array_merge(
        ['sh', '-c', 'find "$@" | head -n ' . $limit, 'find'],
        array_map('strval', $args),
        null === $printf ? ['-print'] : ['-printf', $printf]
    );
    $result = run($command, $timeout);
    $lines = array_values(array_filter(preg_split('/\R/', $result['stdout']) ?: [], 'strlen'));
    $capped = count($lines) >= $limit;
    $lines = array_slice($lines, 0, ZENITH_FIND_MAX_LINES);
    $count = count($lines);

    return [
        'count' => $count,
        'samples' => array_slice($lines, 0, 40),
        'truncated' => $count > 40 || $capped || $result['timeout'],
        'error' => $result['timeout'] ? sprintf('parcours interrompu après %d s', budget($timeout)) : ($capped ? sprintf('liste coupée à %d entrées', ZENITH_FIND_MAX_LINES) : (0 !== $result['exit'] && '' !== trim($result['stderr']) ? last_error_line($result['stderr']) : null)),
    ];
}

/**
 * PHP files carrying the constructs malware is made of, in wp-content
 * only: the core is covered by its checksums.
 *
 * @return array<string, mixed>
 */
function signature_scan($dir, $timeout)
{
    if (!is_dir($dir)) {
        return ['count' => 0, 'samples' => [], 'truncated' => false, 'error' => 'wp-content introuvable'];
    }

    $pattern = 'eval\s*\(\s*(base64_decode|gzinflate|gzuncompress|str_rot13|strrev|gzdecode)\s*\(|\\\\x65\\\\x76\\\\x61\\\\x6c|preg_replace\s*\([^,]*/[a-z]*e[a-z]*[\'"]\s*,|\$GLOBALS\s*\[\s*\$GLOBALS|assert\s*\(\s*(base64_decode|\$_(POST|GET|REQUEST|COOKIE))|(system|passthru|shell_exec|exec)\s*\(\s*\$_(POST|GET|REQUEST|COOKIE)|FilesMan|c99shell|r57shell|WSO\s*2\.|b374k';
    $result = run(['grep', '-rlIE', '--include=*.php', '--include=*.phtml', '--include=*.inc', '--exclude-dir=cache', '--exclude-dir=node_modules', '-e', $pattern, $dir], $timeout);
    $lines = array_values(array_filter(preg_split('/\R/', $result['stdout']) ?: [], 'strlen'));

    // grep answers 1 when nothing matched, which is the good news.
    return [
        'count' => count($lines),
        'samples' => array_slice($lines, 0, 40),
        'truncated' => count($lines) > 40 || $result['timeout'],
        'error' => $result['timeout'] ? sprintf('analyse interrompue après %d s', $timeout) : ($result['exit'] > 1 && '' !== trim($result['stderr']) ? last_error_line($result['stderr']) : null),
    ];
}

/**
 * What the media library weighs, file by file: totals by format, and the
 * images heavy enough to cost a page on their own.
 *
 * @return array<string, mixed>
 */
function uploads_stats($uploads, $site = null)
{
    if (!is_dir($uploads)) {
        return ['available' => false, 'reason' => 'wp-content/uploads introuvable'];
    }

    $result = run(['find', $uploads, '-type', 'f', '-printf', '%s %p\n'], 240);
    $total = 0;
    $files = 0;
    $byExtension = [];
    $big = [];
    $imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg', 'bmp', 'tiff', 'heic'];
    $images = 0;
    $imageBytes = 0;
    $modern = 0;
    $derived = 0;
    $derivedBytes = 0;
    $years = [];

    foreach (preg_split('/\R/', $result['stdout']) ?: [] as $line) {
        if ('' === $line || false === ($space = strpos($line, ' '))) {
            continue;
        }

        $size = (int) substr($line, 0, $space);
        $file = substr($line, $space + 1);
        $ext = strtolower((string) pathinfo($file, PATHINFO_EXTENSION));
        ++$files;
        $total += $size;
        $byExtension[$ext] = isset($byExtension[$ext]) ? $byExtension[$ext] + 1 : 1;

        $rel = substr($file, strlen($uploads) + 1);

        if (preg_match('#^(\d{4})/#', $rel, $y)) {
            $years[$y[1]] = isset($years[$y[1]]) ? $years[$y[1]] + $size : $size;
        }

        if (in_array($ext, $imageExtensions, true)) {
            ++$images;
            $imageBytes += $size;

            // The sizes WordPress derives from each original: "-300x200",
            // "-scaled". Often more than half the library, and regenerable.
            if (preg_match('/-(\d+x\d+|scaled)(\.[a-z0-9]+)?\.[a-z0-9]+$/i', $rel)) {
                ++$derived;
                $derivedBytes += $size;
            }

            if (in_array($ext, ['webp', 'avif'], true)) {
                ++$modern;
            }

            if ($size > 2 * 1024 * 1024) {
                $big[] = ['file' => substr($file, strlen($uploads) + 1), 'size_bytes' => $size];
            }
        }
    }

    usort($big, function ($a, $b) { return $b['size_bytes'] - $a['size_bytes']; });
    arsort($byExtension);
    krsort($years);

    return [
        'available' => true,
        'reason' => null,
        'path' => $uploads,
        'relative_path' => null !== $site && 0 === strpos($uploads, $site . '/') ? substr($uploads, strlen($site) + 1) : basename($uploads),
        'truncated' => $result['timeout'],
        'files' => $files,
        'total_bytes' => $total,
        'derived_images' => $derived,
        'derived_bytes' => $derivedBytes,
        'by_year' => array_slice($years, 0, 6, true),
        'images' => $images,
        'image_bytes' => $imageBytes,
        'modern_images' => $modern,
        'big_images' => count($big),
        'big_images_bytes' => array_sum(array_map(function ($b) { return $b['size_bytes']; }, $big)),
        'big_samples' => array_slice($big, 0, 20),
        'by_extension' => array_slice($byExtension, 0, 12, true),
    ];
}

// ------------------------------------------------------------ integrity

/**
 * MD5 of every file of the core and of every plugin, for Zenith to hold
 * against the checksums wordpress.org publishes. Nothing is judged here:
 * the machine has no reason to reach wordpress.org, and no time limit
 * from the web server to worry about.
 *
 * @return array<string, mixed>
 */
function verb_integrity(array $request, array $config)
{
    framework($request);
    $path = site_path($request);

    if (!is_wordpress($path)) {
        throw new RuntimeException(sprintf('aucun WordPress dans %s', $path));
    }

    $started = microtime(true);
    $version = null;

    if (is_readable($path . '/wp-includes/version.php') && preg_match('/\$wp_version\s*=\s*[\'"]([^\'"]+)[\'"]/', (string) file_get_contents($path . '/wp-includes/version.php'), $m)) {
        $version = $m[1];
    }

    $core = [];
    $coreCount = 0;
    $budgetFiles = ZENITH_INTEGRITY_MAX_FILES;
    $truncated = false;

    foreach (['wp-admin', 'wp-includes'] as $dir) {
        $coreCount += hash_tree($path . '/' . $dir, $path, $core, max(1, min(12000, $budgetFiles - count($core))));
    }

    foreach (glob($path . '/*.php') ?: [] as $file) {
        if ('wp-config.php' !== basename($file) && count($core) < $budgetFiles) {
            $core[basename($file)] = md5_file($file);
            ++$coreCount;
        }
    }

    $budgetFiles -= count($core);
    $plugins = [];
    $pluginsDir = $path . '/wp-content/plugins';

    foreach (scandir($pluginsDir) ?: [] as $entry) {
        if ('.' === $entry || '..' === $entry || 'index.php' === $entry) {
            continue;
        }

        // Past the budget, or the deadline, the remaining plugins are
        // named without their files: Zenith sees they exist and that the
        // report stopped short, rather than a report that never arrives.
        if ($budgetFiles <= 0 || out_of_time()) {
            $truncated = true;
            $full = $pluginsDir . '/' . $entry;

            if (is_dir($full)) {
                $plugins[] = ['slug' => $entry, 'version' => plugin_version($full), 'single_file' => false, 'files' => [], 'files_count' => 0, 'truncated' => true];
            }

            continue;
        }

        $full = $pluginsDir . '/' . $entry;

        if (is_file($full) && 'php' === pathinfo($full, PATHINFO_EXTENSION)) {
            $head = (string) file_get_contents($full, false, null, 0, 8192);

            if (false === stripos($head, 'Plugin Name:')) {
                continue;
            }

            $plugins[] = ['slug' => preg_replace('/\.php$/', '', $entry), 'version' => preg_match('/^[ \t\/*#@]*Version:\s*(.+?)\s*$/mi', $head, $m) ? trim($m[1]) : null, 'single_file' => true, 'files' => [$entry => md5_file($full)], 'files_count' => 1, 'truncated' => false];
            continue;
        }

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

        $files = [];
        $cap = max(1, min(4000, $budgetFiles));
        $count = hash_tree($full, $full, $files, $cap);
        $budgetFiles -= $count;
        $truncated = $truncated || $count >= $cap;
        $plugins[] = ['slug' => $entry, 'version' => plugin_version($full), 'single_file' => false, 'files' => $files, 'files_count' => $count, 'truncated' => $count >= $cap];
    }

    return [
        'at' => gmdate('Y-m-d H:i:s'),
        'core' => ['version' => $version, 'files' => $core, 'files_count' => $coreCount],
        'plugins' => $plugins,
        'truncated' => $truncated,
        'max_files' => ZENITH_INTEGRITY_MAX_FILES,
        'duration_seconds' => round(microtime(true) - $started, 2),
    ];
}

/**
 * Fills $out with relative path => md5 for every regular file under $dir,
 * up to $cap files. Returns how many were hashed.
 *
 * @param array<string, string> $out
 */
function hash_tree($dir, $base, array &$out, $cap)
{
    if (!is_dir($dir)) {
        return 0;
    }

    $count = 0;
    $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS | FilesystemIterator::CURRENT_AS_PATHNAME), RecursiveIteratorIterator::LEAVES_ONLY);

    foreach ($iterator as $file) {
        if (!is_file($file) || is_link($file)) {
            continue;
        }

        $out[substr($file, strlen($base) + 1)] = (string) md5_file($file);

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

    return $count;
}

// ------------------------------------------------- ownership and clock

/**
 * Who owns what, directory by directory: the code, the media, the
 * configuration. A site whose media belongs to the web server and whose
 * code belongs to the deploy account cannot update itself from the admin,
 * and a code tree the web server can write is a code tree a PHP flaw can
 * rewrite.
 *
 * @return array<string, mixed>
 */
function ownership_status($path)
{
    $me = function_exists('posix_geteuid') ? posix_geteuid() : null;
    $entries = [];

    foreach (['root' => $path, 'wp-config' => is_file($path . '/wp-config.php') ? $path . '/wp-config.php' : dirname($path) . '/wp-config.php', 'wp-content' => $path . '/wp-content', 'plugins' => $path . '/wp-content/plugins', 'themes' => $path . '/wp-content/themes', 'uploads' => $path . '/wp-content/uploads', 'wp-includes' => $path . '/wp-includes'] as $key => $file) {
        if (!file_exists($file)) {
            $entries[$key] = null;
            continue;
        }

        $uid = fileowner($file);
        $gid = filegroup($file);
        $perms = fileperms($file);
        $owner = false !== $uid && function_exists('posix_getpwuid') ? (posix_getpwuid($uid)['name'] ?? (string) $uid) : (string) $uid;
        $group = false !== $gid && function_exists('posix_getgrgid') ? (posix_getgrgid($gid)['name'] ?? (string) $gid) : (string) $gid;

        $entries[$key] = [
            'path' => $file,
            'owner' => $owner,
            'group' => $group,
            'mode' => false === $perms ? null : substr(sprintf('%o', $perms), -4),
            'mine' => null !== $me && false !== $uid ? $uid === $me : null,
            'writable_by_me' => is_writable($file),
            'group_writable' => false === $perms ? null : (bool) ($perms & 0020),
        ];
    }

    return ['user' => null !== $me && function_exists('posix_getpwuid') ? (posix_getpwuid($me)['name'] ?? (string) $me) : get_current_user(), 'entries' => $entries];
}

/**
 * The machine's clock: whether it is kept in sync, and the moment it
 * reads, for Zenith to hold against its own.
 *
 * @return array<string, mixed>
 */
function clock_status()
{
    $ntp = null;
    $source = null;

    if (null !== which('timedatectl')) {
        $result = run(['timedatectl', 'show', '-p', 'NTPSynchronized', '-p', 'NTP'], 10);

        if (0 === $result['exit']) {
            $ntp = false !== stripos($result['stdout'], 'NTPSynchronized=yes');
            $source = 'timedatectl';
        }
    }

    if (null === $ntp) {
        foreach (['/run/chrony', '/run/systemd/timesync', '/var/run/ntpd.pid', '/run/ntpd.pid'] as $marker) {
            if (file_exists($marker)) {
                $ntp = true;
                $source = $marker;
                break;
            }
        }
    }

    return ['time_utc' => gmdate('Y-m-d H:i:s'), 'timestamp' => time(), 'ntp_synchronized' => $ntp, 'source' => $source, 'timezone' => date_default_timezone_get()];
}

// ------------------------------------------------------ stale directories

/**
 * What a hurried fix leaves in wp-content: a plugin copied aside before an
 * update, an unpacked archive, a theme renamed "-old". Still on disk, still
 * served, never updated.
 *
 * @return array<string, mixed>
 */
function stale_directories($path)
{
    $stale = [];
    $pattern = '/(^|[-_. ])(old|bak|backup|orig|copy|copie|save|sauvegarde|tmp|temp|test|v\d+|\(\d+\))$/i';

    foreach (['plugins', 'themes', 'mu-plugins'] as $kind) {
        $dir = $path . '/wp-content/' . $kind;

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

        $suspects = [];

        foreach (scandir($dir) ?: [] as $entry) {
            if ('.' === $entry || '..' === $entry || 'index.php' === $entry) {
                continue;
            }

            $full = $dir . '/' . $entry;
            $reason = null;

            if (preg_match($pattern, $entry) || preg_match('/\.(zip|tar|gz|tgz|bak|old|orig)$/i', $entry)) {
                $reason = 'nom de copie ou d\'archive';
            } elseif ('plugins' === $kind && is_dir($full) && !has_plugin_header($full)) {
                $reason = 'dossier sans en-tête d\'extension';
            } elseif ('themes' === $kind && is_dir($full) && !is_file($full . '/style.css')) {
                $reason = 'dossier sans style.css';
            }

            if (null !== $reason) {
                $suspects[$entry] = ['path' => 'wp-content/' . $kind . '/' . $entry, 'bytes' => is_dir($full) ? 0 : (int) filesize($full), 'reason' => $reason];
            }
        }

        // One find for every suspect directory of this kind, sizes summed
        // per top-level entry: a du per entry was a process per leftover,
        // and a site with fifty of them spent the audit's budget on that.
        $dirs = [];

        foreach ($suspects as $entry => $suspect) {
            if (is_dir($dir . '/' . $entry)) {
                $dirs[] = $dir . '/' . $entry;
            }
        }

        foreach (tree_sizes($dirs, 60) as $top => $bytes) {
            $entry = basename($top);

            if (isset($suspects[$entry])) {
                $suspects[$entry]['bytes'] = $bytes;
            }
        }

        foreach ($suspects as $suspect) {
            $stale[] = $suspect;
        }
    }

    $upgrade = $path . '/wp-content/upgrade';
    $upgradeEntries = is_dir($upgrade) ? array_values(array_diff(scandir($upgrade) ?: [], ['.', '..', 'index.php'])) : [];
    $upgradeBytes = [] === $upgradeEntries ? 0 : (int) array_sum(tree_sizes([$upgrade], 30));

    return [
        'entries' => array_slice($stale, 0, 30),
        'count' => count($stale),
        'bytes' => array_sum(array_map(function ($e) { return (int) $e['bytes']; }, $stale)),
        'upgrade' => ['entries' => count($upgradeEntries), 'bytes' => $upgradeBytes, 'samples' => array_slice($upgradeEntries, 0, 5)],
    ];
}

/**
 * The size of each given directory, in one find over all of them: the
 * bytes of every regular file below, summed under the directory it
 * belongs to. Directories that hold nothing are absent from the result.
 *
 * @param string[] $dirs
 *
 * @return array<string, int>
 */
function tree_sizes(array $dirs, $timeout)
{
    if ([] === $dirs) {
        return [];
    }

    $result = run(array_merge(['find'], $dirs, ['-xdev', '-type', 'f', '-printf', '%s %p\n']), $timeout);
    $sizes = [];
    rsort($dirs);

    foreach (preg_split('/\R/', $result['stdout']) ?: [] as $line) {
        if ('' === $line || false === ($space = strpos($line, ' '))) {
            continue;
        }

        $file = substr($line, $space + 1);

        foreach ($dirs as $dir) {
            if (0 === strpos($file, $dir . '/')) {
                $sizes[$dir] = (isset($sizes[$dir]) ? $sizes[$dir] : 0) + (int) substr($line, 0, $space);
                break;
            }
        }
    }

    return $sizes;
}

function has_plugin_header($dir)
{
    foreach (glob($dir . '/*.php') ?: [] as $file) {
        if (false !== stripos((string) file_get_contents($file, false, null, 0, 8192), 'Plugin Name:')) {
            return true;
        }
    }

    return false;
}

// -------------------------------------------------------------- weblogs

/**
 * The web server's own logs, where the host leaves them readable: who
 * comes back too often, what they ask for, what fails. Read from the end,
 * a bounded slice, on the most recent file of each kind.
 *
 * @return array<string, mixed>
 */
function verb_weblogs(array $request, array $config)
{
    $path = site_path($request, false);
    $maxBytes = 24 * 1024 * 1024;
    $dirs = [];
    $home = getenv('HOME');

    // The account's own logs, and those the host leaves next to the site:
    // what this account is meant to read. The system's log directories
    // hold every vhost of the machine, and are read only where the
    // configuration says weblogs=system.
    $candidates = [is_string($home) ? $home . '/logs' : null, is_string($home) ? $home . '/log' : null, null !== $path ? dirname($path) . '/logs' : null, null !== $path ? dirname(dirname($path)) . '/logs' : null, null !== $path ? dirname($path) . '/log' : null];

    if ($config['weblogs_system']) {
        $candidates = array_merge($candidates, ['/var/log/apache2', '/var/log/nginx', '/var/log/httpd']);
    }

    foreach (array_unique(array_filter($candidates)) as $dir) {
        if (is_dir($dir) && is_readable($dir)) {
            $dirs[] = $dir;
        }
    }

    if ([] === $dirs) {
        return ['available' => false, 'reason' => 'aucun répertoire de logs lisible (~/logs, ../logs' . ($config['weblogs_system'] ? ', /var/log/apache2, /var/log/nginx' : ' ; weblogs=system dans shell.conf pour lire /var/log') . ')', 'access' => null, 'error' => null];
    }

    $access = newest_log($dirs, '/access/i');
    $error = newest_log($dirs, '/error/i');

    // The machine talking to itself and Zenith knocking are not traffic:
    // its own addresses, plus whatever Zenith asks to leave out.
    $exclude = own_ips();

    foreach (isset($request['exclude_ips']) && is_array($request['exclude_ips']) ? $request['exclude_ips'] : [] as $ip) {
        if (is_string($ip) && false !== filter_var($ip, FILTER_VALIDATE_IP)) {
            $exclude[$ip] = true;
        }
    }

    return [
        'available' => null !== $access || null !== $error,
        'reason' => null === $access && null === $error ? 'aucun access.log ni error.log dans ' . implode(', ', $dirs) : null,
        'directories' => $dirs,
        'excluded_ips' => array_keys($exclude),
        'access' => null === $access ? null : access_log_stats($access, $maxBytes, $exclude),
        'error' => null === $error ? null : error_log_stats($error, 4 * 1024 * 1024, $exclude),
    ];
}

/**
 * The addresses this machine answers on, loopback included.
 *
 * @return array<string, bool>
 */
function own_ips()
{
    $ips = ['127.0.0.1' => true, '::1' => true];
    $result = run(['hostname', '-I'], 5);

    if (0 === $result['exit']) {
        foreach (preg_split('/\s+/', trim($result['stdout'])) ?: [] as $ip) {
            if ('' !== $ip && false !== filter_var($ip, FILTER_VALIDATE_IP)) {
                $ips[$ip] = true;
            }
        }
    }

    return $ips;
}

/**
 * The most recently written, uncompressed file whose name matches.
 *
 * @param string[] $dirs
 */
function newest_log(array $dirs, $pattern)
{
    $best = null;
    $bestTime = 0;

    foreach ($dirs as $dir) {
        foreach (scandir($dir) ?: [] as $entry) {
            $file = $dir . '/' . $entry;

            if (!is_file($file) || !is_readable($file) || !preg_match($pattern, $entry) || preg_match('/\.(gz|zip|bz2|xz|\d+)$/', $entry) || filesize($file) < 1) {
                continue;
            }

            $time = (int) filemtime($file);

            if ($time > $bestTime) {
                $best = $file;
                $bestTime = $time;
            }
        }
    }

    return $best;
}

/**
 * @return array<string, mixed>
 */
function access_log_stats($file, $maxBytes, array $exclude = [])
{
    $size = (int) filesize($file);
    $handle = fopen($file, 'r');

    if (false === $handle) {
        return ['file' => $file, 'error' => 'illisible'];
    }

    $start = max(0, $size - $maxBytes);
    fseek($handle, $start);

    if ($start > 0) {
        fgets($handle); // drop the partial first line
    }

    $ips = [];
    $ipLogin = [];
    $ipXmlrpc = [];
    $ipErrors = [];
    $paths404 = [];
    $paths5xx = [];
    $agents = [];
    $statuses = ['2xx' => 0, '3xx' => 0, '4xx' => 0, '5xx' => 0];
    $hours = [];
    $requests = 0;
    $bytes = 0;
    $first = null;
    $last = null;
    $loginPosts = 0;
    $xmlrpc = 0;
    $unparsed = 0;
    $excluded = 0;
    $ipAgents = [];
    $ipSample = [];
    $months = ['Jan' => 1, 'Feb' => 2, 'Mar' => 3, 'Apr' => 4, 'May' => 5, 'Jun' => 6, 'Jul' => 7, 'Aug' => 8, 'Sep' => 9, 'Oct' => 10, 'Nov' => 11, 'Dec' => 12];

    while (false !== ($line = fgets($handle))) {
        // Combined format, with or without a leading vhost: the IP is the
        // first dotted or colon-separated token, the date is bracketed.
        if (!preg_match('/(?:^|\s)(\d{1,3}(?:\.\d{1,3}){3}|[0-9a-f:]{3,39})\s+\S+\s+\S+\s+\[(\d{2})\/([A-Za-z]{3})\/(\d{4}):(\d{2}):(\d{2}):\d{2}[^\]]*\]\s+"([A-Z]+)\s+([^"\s]*)[^"]*"\s+(\d{3})\s+(\S+)(?:\s+"[^"]*"\s+"([^"]*)")?/', $line, $m)) {
            ++$unparsed;
            continue;
        }

        $ip = $m[1];

        if (isset($exclude[$ip])) {
            ++$excluded;
            continue;
        }

        ++$requests;
        $method = $m[7];
        $pathOnly = strtok($m[8], '?') ?: '/';
        $status = (int) $m[9];
        $agent = isset($m[11]) ? $m[11] : '';
        $stamp = isset($months[$m[3]]) ? gmmktime((int) $m[5], (int) $m[6], 0, $months[$m[3]], (int) $m[2], (int) $m[4]) : null;

        if (null !== $stamp) {
            $first = null === $first ? $stamp : min($first, $stamp);
            $last = null === $last ? $stamp : max($last, $stamp);
            $hour = gmdate('Y-m-d H:00', $stamp);
            $hours[$hour] = isset($hours[$hour]) ? $hours[$hour] + 1 : 1;
        }

        $bytes += is_numeric($m[10]) ? (int) $m[10] : 0;
        $ips[$ip] = isset($ips[$ip]) ? $ips[$ip] + 1 : 1;
        $bucket = $status >= 500 ? '5xx' : ($status >= 400 ? '4xx' : ($status >= 300 ? '3xx' : '2xx'));
        ++$statuses[$bucket];

        if (404 === $status) {
            $paths404[$pathOnly] = isset($paths404[$pathOnly]) ? $paths404[$pathOnly] + 1 : 1;
        }

        if ($status >= 500) {
            $paths5xx[$pathOnly] = isset($paths5xx[$pathOnly]) ? $paths5xx[$pathOnly] + 1 : 1;
        }

        if ($status >= 400) {
            $ipErrors[$ip] = isset($ipErrors[$ip]) ? $ipErrors[$ip] + 1 : 1;
        }

        if ('POST' === $method && preg_match('#/wp-login\.php$#', $pathOnly)) {
            ++$loginPosts;
            $ipLogin[$ip] = isset($ipLogin[$ip]) ? $ipLogin[$ip] + 1 : 1;
        }

        if (preg_match('#/xmlrpc\.php$#', $pathOnly)) {
            ++$xmlrpc;
            $ipXmlrpc[$ip] = isset($ipXmlrpc[$ip]) ? $ipXmlrpc[$ip] + 1 : 1;
        }

        if ('' !== $agent) {
            $family = agent_family($agent);
            $agents[$family] = isset($agents[$family]) ? $agents[$family] + 1 : 1;
            $ipAgents[$ip][$family] = isset($ipAgents[$ip][$family]) ? $ipAgents[$ip][$family] + 1 : 1;

            if (!isset($ipSample[$ip])) {
                $ipSample[$ip] = str_cut($agent, 0, 200);
            }
        }
    }

    fclose($handle);

    arsort($ips);
    arsort($ipLogin);
    arsort($ipXmlrpc);
    arsort($ipErrors);
    arsort($paths404);
    arsort($paths5xx);
    arsort($agents);
    ksort($hours);

    $window = null !== $first && null !== $last ? max(1, $last - $first) : null;
    $top = function (array $counts, $n) { $out = []; foreach (array_slice($counts, 0, $n, true) as $k => $v) { $out[] = ['key' => (string) $k, 'count' => (int) $v]; } return $out; };

    // The same rows, each address with the user agent it mostly came with:
    // a crawler names itself, an attack tool rarely bothers.
    $withAgent = function (array $rows) use ($ipAgents, $ipSample) {
        foreach ($rows as &$row) {
            $families = isset($ipAgents[$row['key']]) ? $ipAgents[$row['key']] : [];
            arsort($families);
            $row['agent'] = [] === $families ? null : (string) key($families);
            $row['agent_raw'] = isset($ipSample[$row['key']]) ? $ipSample[$row['key']] : null;
        }

        return $rows;
    };

    return [
        'file' => $file,
        'size_bytes' => $size,
        'read_bytes' => $size - $start,
        'modified_at' => gmdate('Y-m-d H:i:s', (int) filemtime($file)),
        'from' => null === $first ? null : gmdate('Y-m-d H:i:s', $first),
        'to' => null === $last ? null : gmdate('Y-m-d H:i:s', $last),
        'window_seconds' => $window,
        'requests' => $requests,
        'excluded' => $excluded,
        'unparsed' => $unparsed,
        'bytes_sent' => $bytes,
        'unique_ips' => count($ips),
        'statuses' => $statuses,
        'top_ips' => $withAgent($top($ips, 10)),
        'top_error_ips' => $withAgent($top($ipErrors, 5)),
        'login_posts' => $loginPosts,
        'top_login_ips' => $withAgent($top($ipLogin, 5)),
        'xmlrpc_requests' => $xmlrpc,
        'top_xmlrpc_ips' => $withAgent($top($ipXmlrpc, 5)),
        'top_404' => $top($paths404, 8),
        'top_5xx' => $top($paths5xx, 8),
        'agents' => $top($agents, 8),
        'busiest_hours' => $top(array_slice(arsort_copy($hours), 0, 3, true), 3),
        'hours_covered' => count($hours),
    ];
}

function arsort_copy(array $a)
{
    arsort($a);

    return $a;
}

/**
 * A user agent reduced to its family, so that ten thousand Chrome builds
 * count as one and the crawlers stand out.
 */
function agent_family($agent)
{
    $known = ['Googlebot', 'bingbot', 'AhrefsBot', 'SemrushBot', 'MJ12bot', 'DotBot', 'PetalBot', 'YandexBot', 'Applebot', 'facebookexternalhit', 'GPTBot', 'ClaudeBot', 'CCBot', 'Bytespider', 'DuckDuckBot', 'WordPress', 'wp-cron', 'curl', 'python-requests', 'Go-http-client', 'Java', 'Scrapy', 'HeadlessChrome', 'Lighthouse', 'UptimeRobot', 'Pingdom', 'Zenith'];

    foreach ($known as $name) {
        if (false !== stripos($agent, $name)) {
            return $name;
        }
    }

    if (preg_match('/bot|crawl|spider|scan/i', $agent)) {
        return 'autre robot';
    }

    foreach (['Edg' => 'Edge', 'OPR' => 'Opera', 'Firefox' => 'Firefox', 'Chrome' => 'Chrome', 'Safari' => 'Safari', 'MSIE' => 'Internet Explorer', 'Trident' => 'Internet Explorer'] as $needle => $family) {
        if (false !== strpos($agent, $needle)) {
            return $family;
        }
    }

    return '-' === $agent ? 'sans user-agent' : 'autre';
}

/**
 * @return array<string, mixed>
 */
function error_log_stats($file, $maxBytes, array $exclude = [])
{
    $tail = tail_lines($file, 6000, $maxBytes) ?: [];
    $levels = [];
    $messages = [];
    $clients = [];
    $recent = [];
    $ignored = ['denied' => 0, 'not_found' => 0, 'excluded' => 0];

    foreach ($tail as $line) {
        $level = preg_match('/\[(?:[a-z_]+:)?(emerg|alert|crit|error|warn|notice|info|debug)\]/i', $line, $m) ? strtolower($m[1]) : 'autre';

        if (preg_match('/\[client ([^\]:\s]+)/', $line, $c) && isset($exclude[$c[1]])) {
            ++$ignored['excluded'];
            continue;
        }

        // Access refused and files not found are the access log's business,
        // already counted there as 4xx: here only what broke is kept.
        if (preg_match('/client denied by server configuration|AH01797|AH01630|AH01276|user .* not found|authentication failure|password mismatch/i', $line)) {
            ++$ignored['denied'];
            continue;
        }

        if (preg_match('/File does not exist|AH00128|script .* not found or unable to stat/i', $line)) {
            ++$ignored['not_found'];
            continue;
        }

        $levels[$level] = isset($levels[$level]) ? $levels[$level] + 1 : 1;

        if (preg_match('/\[client ([^\]:\s]+)/', $line, $c)) {
            $clients[$c[1]] = isset($clients[$c[1]]) ? $clients[$c[1]] + 1 : 1;
        }

        // The message without what varies: timestamp, client, pid, paths
        // ending in numbers, so the same fault counts as one.
        $message = preg_replace(['/^\[[^\]]+\]\s*/', '/\[[a-z_]+:[a-z]+\]\s*/i', '/\[pid \d+[^\]]*\]\s*/', '/\[client [^\]]+\]\s*/', '/\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}[^ ]*/', '/, referer: .*$/', '/\d+/'], ['', '', '', '', '', '', '#'], $line);
        $message = str_cut(trim((string) $message), 0, 160);

        if ('' !== $message) {
            $messages[$message] = isset($messages[$message]) ? $messages[$message] + 1 : 1;
        }
    }

    arsort($messages);
    arsort($clients);
    arsort($levels);

    foreach (array_reverse($tail) as $line) {
        if (count($recent) >= 5) {
            break;
        }

        if (!preg_match('/client denied by server configuration|AH01797|AH01630|File does not exist|AH00128/i', $line)) {
            $recent[] = str_cut($line, 0, 300);
        }
    }

    $top = function (array $counts, $n) { $out = []; foreach (array_slice($counts, 0, $n, true) as $k => $v) { $out[] = ['key' => (string) $k, 'count' => (int) $v]; } return $out; };

    return [
        'file' => $file,
        'size_bytes' => (int) filesize($file),
        'modified_at' => gmdate('Y-m-d H:i:s', (int) filemtime($file)),
        'lines_read' => count($tail),
        'ignored' => $ignored,
        'levels' => $levels,
        'top_messages' => $top($messages, 8),
        'top_clients' => $top($clients, 5),
        'recent' => $recent,
    ];
}

// ------------------------------------------------------ file transfer

/**
 * One piece of a file Zenith is bringing over, appended under ~/.zenith/
 * incoming: the way a plugin archive crosses a channel that only carries
 * one command argument at a time. The last piece names the digest the
 * whole must match.
 *
 * @return array<string, mixed>
 */
function verb_put(array $request, array $config)
{
    if (!$config['control']) {
        throw new RuntimeException('transfert refusé : control=no dans la configuration de zenith-shell');
    }

    $name = isset($request['name']) && is_string($request['name']) && preg_match('/^[A-Za-z0-9_][A-Za-z0-9._-]{0,79}$/', $request['name']) ? $request['name'] : null;
    $transfer = isset($request['transfer']) && is_string($request['transfer']) && preg_match('/^[A-Za-z0-9]{8,32}$/', $request['transfer']) ? $request['transfer'] : null;
    $index = isset($request['index']) && is_int($request['index']) ? $request['index'] : -1;
    $total = isset($request['total']) && is_int($request['total']) ? $request['total'] : 0;
    $chunk = isset($request['chunk']) && is_string($request['chunk']) && strlen($request['chunk']) <= ZENITH_PUT_CHUNK_MAX_BYTES * 2 ? base64_decode($request['chunk'], true) : false;

    if (null === $name || null === $transfer || $index < 0 || $index >= $total || false === $chunk) {
        throw new RuntimeException('morceau invalide');
    }

    if ($total > ZENITH_PUT_MAX_CHUNKS) {
        throw new RuntimeException(sprintf('transfert refusé : %d morceaux, %d au plus', $total, ZENITH_PUT_MAX_CHUNKS));
    }

    if (strlen($chunk) > ZENITH_PUT_CHUNK_MAX_BYTES) {
        throw new RuntimeException(sprintf('morceau trop grand : %d octets, %d au plus', strlen($chunk), ZENITH_PUT_CHUNK_MAX_BYTES));
    }

    $dir = incoming_dir();
    sweep_incoming($dir);

    // The transfer's own identifier is part of the file name: two
    // transfers of the same archive, from two scans or two operators,
    // never append into each other's file.
    $basename = $transfer . '-' . $name;
    $file = $dir . '/' . $basename;

    if (0 === $index) {
        @unlink($file);
    } elseif (!is_file($file)) {
        throw new RuntimeException('transfert interrompu : recommencer au premier morceau');
    }

    $size = is_file($file) ? (int) filesize($file) : 0;

    if ($size + strlen($chunk) > ZENITH_PUT_MAX_BYTES) {
        @unlink($file);

        throw new RuntimeException(sprintf('transfert refusé : plus de %d Mo', (int) (ZENITH_PUT_MAX_BYTES / 1048576)));
    }

    if (false === file_put_contents($file, $chunk, FILE_APPEND)) {
        throw new RuntimeException(sprintf('impossible d\'écrire %s', $file));
    }

    $done = $index === $total - 1;
    $size = (int) filesize($file);

    if ($done) {
        $expected = isset($request['sha256']) && is_string($request['sha256']) ? strtolower($request['sha256']) : '';

        if (!preg_match('/^[a-f0-9]{64}$/', $expected) || !hash_equals($expected, (string) hash_file('sha256', $file))) {
            @unlink($file);

            throw new RuntimeException('empreinte du fichier reçu incorrecte : transfert rejeté');
        }
    }

    return ['name' => $name, 'transfer' => $transfer, 'received' => $index + 1, 'total' => $total, 'bytes' => $size, 'complete' => $done, 'file' => $done ? $basename : null];
}

function incoming_dir()
{
    $dir = zenith_home() . '/.zenith/incoming';

    if (!is_dir($dir) && !@mkdir($dir, 0700, true)) {
        throw new RuntimeException(sprintf('impossible de créer %s', $dir));
    }

    return $dir;
}

/**
 * Drops what an interrupted transfer left behind more than an hour ago:
 * the incoming directory holds pieces in transit, never an archive.
 */
function sweep_incoming($dir)
{
    foreach (glob($dir . '/*') ?: [] as $file) {
        if (is_file($file) && filemtime($file) < time() - ZENITH_PUT_STALE_SECONDS) {
            @unlink($file);
        }
    }
}

/**
 * The completed transfer a control action names, as verb_put returned
 * it: a base name under the incoming directory, nothing else. Absent or
 * unfinished, the action stops here.
 */
function incoming_file(array $request, $key = 'file')
{
    $name = isset($request[$key]) && is_string($request[$key]) && preg_match('/^[A-Za-z0-9]{8,32}-[A-Za-z0-9_][A-Za-z0-9._-]{0,79}$/', $request[$key]) ? $request[$key] : null;
    $file = null === $name ? null : incoming_dir() . '/' . $name;

    if (null === $file || !is_file($file)) {
        throw new RuntimeException('fichier transféré absent : transfert incomplet ou expiré');
    }

    return $file;
}

/**
 * Installs, or reinstalls, the Zenith plugin from an archive brought over
 * by verb_put, activates it, and sets the options Zenith hands along —
 * only the plugin's own, by name.
 *
 * @return array<string, mixed>
 */
function control_install_agent($path, array $request, $actor, $host, array $config)
{
    if (!is_wordpress($path)) {
        throw new RuntimeException(sprintf('aucun WordPress dans %s', $path));
    }

    $archive = incoming_file($request, 'file');

    if (!class_exists('ZipArchive')) {
        throw new RuntimeException('extension zip absente : impossible de décompresser le plugin');
    }

    $zip = new ZipArchive();

    if (true !== $zip->open($archive)) {
        throw new RuntimeException('archive du plugin illisible');
    }

    // Every entry must live under the plugin's own directory: nothing
    // else in wp-content/plugins is touched.
    for ($i = 0; $i < $zip->numFiles; ++$i) {
        $entry = (string) $zip->getNameIndex($i);

        if (0 !== strpos($entry, 'zenith-wordpress/') || false !== strpos($entry, '..')) {
            $zip->close();

            throw new RuntimeException(sprintf('entrée inattendue dans l\'archive : %s', $entry));
        }
    }

    $pluginsDir = $path . '/wp-content/plugins';
    $current = $pluginsDir . '/zenith-wordpress';
    $aside = $pluginsDir . '/.zenith-wordpress.zenith-aside';
    $had = is_dir($current);

    if (is_dir($aside)) {
        run(['rm', '-rf', '--', $aside], 60);
    }

    if ($had && !@rename($current, $aside)) {
        $zip->close();

        throw new RuntimeException('impossible de mettre de côté le plugin en place');
    }

    $extracted = $zip->extractTo($pluginsDir);
    $zip->close();

    if (!$extracted || !is_file($current . '/zenith-wordpress.php')) {
        run(['rm', '-rf', '--', $current], 60);

        if ($had) {
            @rename($aside, $current);
        }

        throw new RuntimeException('extraction du plugin en échec');
    }

    if ($had) {
        run(['rm', '-rf', '--', $aside], 60);
    }

    @unlink($archive);

    $options = [];

    foreach (isset($request['options']) && is_array($request['options']) ? $request['options'] : [] as $key => $value) {
        if (is_string($key) && preg_match('/^zenith_[a-z_]{1,60}$/', $key) && (is_scalar($value) || null === $value)) {
            $options[$key] = $value;
        }
    }

    // Since 1.12 the plugin enrols itself from the command line: wp zenith
    // setup validates as the settings screen does, writes network-wide on
    // a multisite, and refuses what wp-config.php froze. Taken whenever
    // the machine has wp-cli, the plugin is recent enough, and the three
    // secrets are given — setup would otherwise draw the missing ones and
    // print them once, where Zenith is not listening.
    $version = plugin_version($current);
    $wp = wp_binary($config, $path);
    $secretsGiven = true;

    foreach (['zenith_api_key', 'zenith_update_token', 'zenith_push_secret'] as $secret) {
        $secretsGiven = $secretsGiven && isset($options[$secret]) && '' !== (string) $options[$secret];
    }

    if (null !== $wp && 'unknown' !== $version && version_compare($version, '1.12', '>=') && $secretsGiven) {
        $setup = install_agent_by_cli($path, $wp, $options, $version, $had);

        if (null !== $setup) {
            wp_bootstrap($path, $host, '$result = ["status" => "ok"];' . journal_snippet('install_agent', $actor, ['setup' => 'wp zenith setup']) . ' echo "\n{}\n";', $config, 30);

            return $setup;
        }
    }

    // The plugin's own writer once it is loaded — activate_plugin() includes
    // its file — so a multisite gets network options; a setting frozen by
    // a wp-config.php constant is left alone and named in the answer.
    $code = 'require_once ABSPATH . "wp-admin/includes/plugin.php";'
        . ' $r = is_plugin_active("zenith-wordpress/zenith-wordpress.php") ? true : activate_plugin("zenith-wordpress/zenith-wordpress.php");'
        . ' $options = ' . var_export($options, true) . '; $set = []; $locked = [];'
        . ' foreach ($options as $key => $value) { if (null === $value || "" === $value) { continue; } $name = substr($key, 7);'
        . '   if (function_exists("zenith_setting_locked") && zenith_setting_locked($name)) { $locked[] = $name; continue; }'
        . '   if (function_exists("zenith_option_update")) { zenith_option_update($key, $value); } else { update_option($key, $value); } $set[] = $key; }'
        . ' $result = ["status" => is_wp_error($r) ? "failed" : "ok", "message" => is_wp_error($r) ? $r->get_error_message() : null, "activated" => !is_wp_error($r), "options_set" => $set, "options_locked" => $locked, "version" => defined("ZENITH_VERSION") ? ZENITH_VERSION : null];';
    $code .= journal_snippet('install_agent', $actor, ['setup' => 'options']);
    $code .= ' echo "\n", json_encode($result), "\n";';

    $result = wp_bootstrap($path, $host, $code, $config, 120);

    if (!$result['ok']) {
        return ['status' => 'partial', 'message' => sprintf('fichiers en place, mais WordPress n\'a pas pu activer le plugin : %s', $result['error']), 'activated' => false, 'reinstalled' => $had];
    }

    $data = is_array($result['json']) ? $result['json'] : [];
    $data['reinstalled'] = $had;
    $data['message'] = isset($data['message']) && null !== $data['message'] ? $data['message'] : sprintf('Plugin Zenith %s%s et activé.', isset($data['version']) ? $data['version'] : '', $had ? ' réinstallé' : ' installé');

    return $data;
}

/**
 * The enrolment handed to wp zenith setup: the plugin activated by wp-cli,
 * the settings frozen by wp-config.php asked of the plugin and left out,
 * the rest passed as options. Every value is held to the plugin's own
 * rule before it reaches the command line, where it travels as one
 * argument behind its option name. Null when wp-cli could not do it, so
 * that the option path takes over.
 *
 * @param string[]             $wp
 * @param array<string, mixed> $options
 *
 * @return array<string, mixed>|null
 */
function install_agent_by_cli($path, array $wp, array $options, $version, $had)
{
    $base = array_merge($wp, ['--path=' . $path, '--skip-themes', '--no-color']);
    $activate = run(array_merge($base, ['plugin', 'activate', 'zenith-wordpress']), 120, $path);

    if (0 !== $activate['exit']) {
        return null;
    }

    // The code is this script's, not the request's: only its answer varies.
    $probe = run(array_merge($base, ['eval', 'echo json_encode(function_exists("zenith_setting_locked") ? array_values(array_filter(zenith_setting_constant_names(), "zenith_setting_locked")) : []);']), 60, $path);
    $locked = 0 === $probe['exit'] ? json_decode(trim($probe['stdout']), true) : null;

    if (!is_array($locked)) {
        return null;
    }

    $rules = [
        'zenith_api_key' => ['api-key', '/^[A-Za-z0-9_\-]{16,255}$/'],
        'zenith_update_token' => ['update-token', '/^[A-Za-z0-9_\-]{16,255}$/'],
        'zenith_push_secret' => ['push-secret', '/^[A-Za-z0-9_\-]{16,255}$/'],
        'zenith_push_url' => ['push-url', '#^https?://[A-Za-z0-9._~%:/?\#\[\]@!$&\'()*+,;=\-]{1,500}$#'],
        'zenith_write_ip_allowlist' => ['ip-allowlist', '/^[0-9A-Fa-f.:\/,\s]{1,500}$/'],
        'zenith_github_token' => ['github-token', '/^[A-Za-z0-9_\-.]{1,255}$/'],
    ];
    $args = [];
    $set = [];
    $skipped = [];

    foreach ($rules as $option => $rule) {
        if (!isset($options[$option]) || '' === (string) $options[$option]) {
            continue;
        }

        $name = substr($option, 7);

        if (in_array($name, $locked, true)) {
            $skipped[] = $name;
            continue;
        }

        $value = (string) $options[$option];

        if (!preg_match($rule[1], $value)) {
            return null;
        }

        $args[] = '--' . $rule[0] . '=' . ('ip-allowlist' === $rule[0] ? implode(',', array_filter(preg_split('/[\s,]+/', $value) ?: [], 'strlen')) : $value);
        $set[] = $option;
    }

    if (isset($options['zenith_write_require_signature']) && is_yes($options['zenith_write_require_signature'])) {
        $args[] = '--require-signature';
        $set[] = 'zenith_write_require_signature';
    }

    // Both endpoints open unless told otherwise: setup's own default, and
    // what a site under supervision is enrolled for.
    foreach (['zenith_updates_enabled' => '--no-update', 'zenith_control_enabled' => '--no-control'] as $option => $flag) {
        if (isset($options[$option]) && !is_yes($options[$option])) {
            $args[] = $flag;
        }

        $set[] = $option;
    }

    $setup = run(array_merge($base, ['zenith', 'setup', '--format=json'], $args), 120, $path);

    if (0 !== $setup['exit']) {
        return null;
    }

    return [
        'status' => 'ok',
        'activated' => true,
        'version' => $version,
        'options_set' => $set,
        'options_locked' => $skipped,
        'setup' => 'wp zenith setup',
        'reinstalled' => $had,
        'message' => sprintf('Plugin Zenith %s %s, activé et configuré par wp zenith setup.', $version, $had ? 'réinstallé' : 'installé'),
    ];
}
