<?php
declare(strict_types=1);

/* Non-interactive, journaled cPanel installer. No database or web editor. */
umask(0077);

$serverArguments = $_SERVER['argv'] ?? null;
if (!is_array($serverArguments)) {
    fwrite(STDERR, "This command must run from a CLI that provides argv.\n");
    exit(2);
}
$arguments = [];
foreach ($serverArguments as $serverArgument) {
    if (!is_string($serverArgument)) {
        fwrite(STDERR, "Invalid CLI argument vector.\n");
        exit(2);
    }
    $arguments[] = $serverArgument;
}

$privateDir = null;
$publicDir = null;
$force = false;
$dryRun = false;
$generateKeys = true;
foreach (array_slice($arguments, 1) as $argument) {
    if (str_starts_with($argument, '--private-dir=')) {
        $privateDir = substr($argument, 14);
    } elseif (str_starts_with($argument, '--public-dir=')) {
        $publicDir = substr($argument, 13);
    } elseif ($argument === '--force') {
        $force = true;
    } elseif ($argument === '--dry-run') {
        $dryRun = true;
    } elseif ($argument === '--no-generate-online-keys') {
        $generateKeys = false;
    } else {
        fwrite(STDERR, "Usage: php safetun-install.php --private-dir=/home/USER/safetun-private --public-dir=/home/USER/public_html/safetun-api [--dry-run] [--force] [--no-generate-online-keys]\n");
        exit(2);
    }
}
if (!is_string($privateDir) || !is_string($publicDir) || $privateDir === '' || $publicDir === '') {
    fwrite(STDERR, "Both --private-dir and --public-dir are required.\n");
    exit(2);
}
if (PHP_VERSION_ID < 80200 || !extension_loaded('sodium') || !extension_loaded('openssl')) {
    fwrite(STDERR, "SafeTun requires PHP 8.2+, Sodium, and OpenSSL.\n");
    exit(2);
}
if (preg_match('/[\r\n"\x00]/', $privateDir . $publicDir)) {
    fwrite(STDERR, "Install paths contain unsafe characters.\n");
    exit(2);
}

$normalize = static function (string $path): string {
    $path = str_replace('\\', '/', $path);
    $prefix = str_starts_with($path, '/') ? '/' : '';
    $segments = [];
    foreach (explode('/', $path) as $segment) {
        if ($segment === '' || $segment === '.') {
            continue;
        }
        if ($segment === '..') {
            array_pop($segments);
            continue;
        }
        $segments[] = $segment;
    }
    return $prefix . implode('/', $segments);
};
$privateNormalized = rtrim($normalize($privateDir), '/');
$publicNormalized = rtrim($normalize($publicDir), '/');
$casePrivate = PHP_OS_FAMILY === 'Windows' ? strtolower($privateNormalized) : $privateNormalized;
$casePublic = PHP_OS_FAMILY === 'Windows' ? strtolower($publicNormalized) : $publicNormalized;
if ($casePrivate === $casePublic || str_starts_with($casePrivate . '/', $casePublic . '/')) {
    fwrite(STDERR, "Private directory must be outside the public document tree.\n");
    exit(2);
}

$packageRoot = dirname(__DIR__);
echo "SafeTun cPanel backend install plan\n";
echo "Private: {$privateDir} (0700)\n";
echo "Public front controller: {$publicDir}\n";
echo "Database/admin panel: none\n";
if ($dryRun) {
    echo "Dry-run complete; no files were changed.\n";
    exit(0);
}

$createdDirs = [];
$pathGuard = static function (string $path) use ($normalize): void {
    // Bootstrap guard for the two selected roots themselves. Walk every
    // existing ancestor before recursive mkdir so a symlink cannot redirect
    // initial root creation. A stricter exact-root guard replaces this after
    // both roots have resolved.
    $normalized = str_replace('\\', '/', $normalize($path));
    if (preg_match('~^[A-Za-z]:/~D', $normalized) === 1) {
        $current = substr($normalized, 0, 3);
        $relative = substr($normalized, 3);
    } elseif (str_starts_with($normalized, '/')) {
        $current = '/';
        $relative = substr($normalized, 1);
    } else {
        throw new RuntimeException("Install root must be absolute: {$path}");
    }
    $parts = preg_split('~/+~', $relative, -1, PREG_SPLIT_NO_EMPTY);
    if (!is_array($parts)) {
        throw new RuntimeException("Unable to validate install root: {$path}");
    }
    foreach ($parts as $index => $part) {
        $current = rtrim($current, '/\\') . DIRECTORY_SEPARATOR . $part;
        if (!file_exists($current) && !is_link($current)) {
            continue;
        }
        $information = @lstat($current);
        if (!is_array($information) || is_link($current)) {
            throw new RuntimeException("Install root contains a symlink: {$current}");
        }
        if ($index < count($parts) - 1 && ($information['mode'] & 0170000) !== 0040000) {
            throw new RuntimeException("Install root ancestor is not a directory: {$current}");
        }
    }
};
$enforceMode = static function (string $path, int $mode): void {
    if (!@chmod($path, $mode)) {
        throw new RuntimeException("Unable to set required permissions on: {$path}");
    }
    clearstatcache(true, $path);
    if (PHP_OS_FAMILY !== 'Windows' && (fileperms($path) & 0777) !== $mode) {
        throw new RuntimeException("Required permissions were not applied to: {$path}");
    }
};
$requireMode = static function (string $path, int $mode): void {
    clearstatcache(true, $path);
    if (PHP_OS_FAMILY !== 'Windows' && (fileperms($path) & 0777) !== $mode) {
        throw new RuntimeException("Path has unsafe permissions: {$path}");
    }
};
$syncFile = static function (string $path): void {
    if (PHP_OS_FAMILY === 'Windows') {
        return;
    }
    $handle = @fopen($path, 'rb');
    if (!is_resource($handle)) {
        throw new RuntimeException("Unable to open file for durability sync: {$path}");
    }
    try {
        if (!fsync($handle)) {
            throw new RuntimeException("Unable to durability-sync file: {$path}");
        }
    } finally {
        fclose($handle);
    }
};
$syncDirectory = static function (string $path): void {
    if (PHP_OS_FAMILY === 'Windows') {
        return;
    }
    $handle = @fopen($path, 'rb');
    if (!is_resource($handle)) {
        throw new RuntimeException("Unable to open directory for durability sync: {$path}");
    }
    try {
        if (!fsync($handle)) {
            throw new RuntimeException("Unable to durability-sync directory: {$path}");
        }
    } finally {
        fclose($handle);
    }
};
$ensureDir = static function (string $path, int $mode) use (&$createdDirs, &$pathGuard, $enforceMode, $syncDirectory): void {
    $pathGuard($path);
    if (is_link($path)) {
        throw new RuntimeException("Directory path is a symlink: {$path}");
    }
    if (is_dir($path)) {
        clearstatcache(true, $path);
        if (PHP_OS_FAMILY !== 'Windows' && (fileperms($path) & 0777) !== $mode) {
            throw new RuntimeException("Existing directory has unsafe permissions: {$path}");
        }
        return;
    }
    if (!@mkdir($path, $mode, true) && !is_dir($path)) {
        throw new RuntimeException("Unable to create directory: {$path}");
    }
    $enforceMode($path, $mode);
    $syncDirectory(dirname($path));
    $createdDirs[] = $path;
};
$removeTree = null;
$removeTree = static function (string $path) use (&$removeTree, $syncDirectory): void {
    if (!file_exists($path) && !is_link($path)) {
        return;
    }
    if (is_link($path) || is_file($path)) {
        if (!@unlink($path)) {
            throw new RuntimeException("Unable to remove transaction entry: {$path}");
        }
        $syncDirectory(dirname($path));
        return;
    }
    $entries = scandir($path);
    if (!is_array($entries)) {
        throw new RuntimeException("Unable to inspect transaction directory: {$path}");
    }
    foreach ($entries as $entry) {
        if ($entry !== '.' && $entry !== '..') {
            $removeTree($path . DIRECTORY_SEPARATOR . $entry);
        }
    }
    if (!@rmdir($path)) {
        throw new RuntimeException("Unable to remove transaction directory: {$path}");
    }
    $syncDirectory(dirname($path));
};
$atomicBytes = static function (string $target, string $bytes, int $mode) use (&$pathGuard, $enforceMode, $syncDirectory): void {
    $pathGuard($target);
    $temporary = dirname($target) . DIRECTORY_SEPARATOR . '.' . basename($target) . '.new-' . bin2hex(random_bytes(6));
    $handle = @fopen($temporary, 'x+b');
    if (!is_resource($handle)) {
        throw new RuntimeException("Unable to stage target: {$target}");
    }
    try {
        if (fwrite($handle, $bytes) !== strlen($bytes) || !fflush($handle)) {
            throw new RuntimeException("Unable to write staged target: {$target}");
        }
        if (function_exists('fsync') && !fsync($handle)) {
            throw new RuntimeException("Unable to sync staged target: {$target}");
        }
    } finally {
        fclose($handle);
    }
    try {
        $enforceMode($temporary, $mode);
    } catch (Throwable $exception) {
        @unlink($temporary);
        throw $exception;
    }
    if (PHP_OS_FAMILY === 'Windows' && is_file($target) && !@unlink($target)) {
        @unlink($temporary);
        throw new RuntimeException("Unable to replace target: {$target}");
    }
    if (!@rename($temporary, $target)) {
        @unlink($temporary);
        throw new RuntimeException("Unable to atomically promote target: {$target}");
    }
    $enforceMode($target, $mode);
    $syncDirectory(dirname($target));
};
$writeJson = static function (string $target, array $value) use ($atomicBytes): void {
    $json = json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES) . "\n";
    $atomicBytes($target, $json, 0600);
};

$cleanupCreated = static function () use (&$createdDirs, $syncDirectory): void {
    $directories = array_values(array_unique($createdDirs));
    usort($directories, static fn (string $left, string $right): int => strlen($right) <=> strlen($left));
    foreach ($directories as $directory) {
        if (is_dir($directory) && !is_link($directory) && @rmdir($directory)) {
            $syncDirectory(dirname($directory));
        }
    }
};
$lockHandles = [];
$lockPaths = [];
/** @param list<resource> $handles @param list<string> $paths */
$releaseLocks = static function (array &$handles, array &$paths): void {
    foreach (array_reverse($handles) as $handle) {
        flock($handle, LOCK_UN);
        fclose($handle);
    }
    $handles = [];
    // Lock files are stable inodes. Unlinking after unlock permits a second
    // process to hold the old inode while a third locks a newly created one.
    $paths = [];
};

try {
    $ensureDir($privateDir, 0700);
    $ensureDir($publicDir, 0755);
    $realPrivate = realpath($privateDir);
    $realPublic = realpath($publicDir);
    if (!is_string($realPrivate) || !is_string($realPublic)) {
        throw new RuntimeException('Unable to resolve install paths.');
    }
    $comparePrivate = PHP_OS_FAMILY === 'Windows' ? strtolower($realPrivate) : $realPrivate;
    $comparePublic = PHP_OS_FAMILY === 'Windows' ? strtolower($realPublic) : $realPublic;
    $privatePrefix = rtrim($comparePrivate, '/\\') . DIRECTORY_SEPARATOR;
    $publicPrefix = rtrim($comparePublic, '/\\') . DIRECTORY_SEPARATOR;
    if ($comparePrivate === $comparePublic || str_starts_with($privatePrefix, $publicPrefix)
        || str_starts_with($publicPrefix, $privatePrefix)) {
        throw new RuntimeException('Resolved private directory must be outside the public document tree.');
    }

    // PHP lacks a portable openat(O_NOFOLLOW) API. Reject every existing
    // component beneath the selected roots before each managed operation, so
    // a pre-existing nested symlink cannot redirect staging, promotion, or
    // rollback outside those roots.
    $pathGuard = static function (string $path) use ($realPrivate, $realPublic, $normalize): void {
        $normalized = $normalize($path);
        $comparison = PHP_OS_FAMILY === 'Windows' ? strtolower($normalized) : $normalized;
        $private = rtrim($normalize($realPrivate), '/') . '/';
        $public = rtrim($normalize($realPublic), '/') . '/';
        if (PHP_OS_FAMILY === 'Windows') {
            $private = strtolower($private);
            $public = strtolower($public);
        }
        if ($comparison === rtrim($private, '/') || str_starts_with($comparison, $private)) {
            $root = $realPrivate;
        } elseif ($comparison === rtrim($public, '/') || str_starts_with($comparison, $public)) {
            $root = $realPublic;
        } else {
            throw new RuntimeException("Managed path is outside selected roots: {$path}");
        }
        $relative = substr($normalized, strlen(rtrim($normalize($root), '/')) + 1);
        $parts = preg_split('~[/\\\\]+~', $relative, -1, PREG_SPLIT_NO_EMPTY);
        if (!is_array($parts)) {
            throw new RuntimeException("Unable to validate managed path: {$path}");
        }
        $current = rtrim($root, '/\\');
        foreach ($parts as $index => $part) {
            if ($part === '.' || $part === '..') {
                throw new RuntimeException("Managed path contains traversal: {$path}");
            }
            $current .= DIRECTORY_SEPARATOR . $part;
            if (!file_exists($current) && !is_link($current)) {
                continue;
            }
            $information = @lstat($current);
            if (!is_array($information) || is_link($current)) {
                throw new RuntimeException("Managed path contains a symlink: {$current}");
            }
            if ($index < count($parts) - 1 && ($information['mode'] & 0170000) !== 0040000) {
                throw new RuntimeException("Managed path ancestor is not a directory: {$current}");
            }
            $resolved = realpath($current);
            if (!is_string($resolved)) {
                throw new RuntimeException("Unable to resolve managed path component: {$current}");
            }
            $resolvedComparison = PHP_OS_FAMILY === 'Windows' ? strtolower($resolved) : $resolved;
            $rootComparison = PHP_OS_FAMILY === 'Windows' ? strtolower($root) : $root;
            if ($resolvedComparison !== $rootComparison
                && !str_starts_with($resolvedComparison . DIRECTORY_SEPARATOR, rtrim($rootComparison, '/\\') . DIRECTORY_SEPARATOR)) {
                throw new RuntimeException("Managed path escapes selected root: {$current}");
            }
        }
    };

    $resourceLocks = [];
    if (PHP_OS_FAMILY !== 'Windows') {
        $privateParts = explode('/', trim(str_replace('\\', '/', $realPrivate), '/'));
        $publicParts = explode('/', trim(str_replace('\\', '/', $realPublic), '/'));
        $sharedParts = [];
        foreach ($privateParts as $index => $part) {
            if (!isset($publicParts[$index]) || $publicParts[$index] !== $part) {
                break;
            }
            $sharedParts[] = $part;
        }
        $accountRoot = '/' . implode('/', $sharedParts);
        if ($accountRoot === '/' || !is_dir($accountRoot) || is_link($accountRoot)) {
            throw new RuntimeException('Install roots must share a safe account-owned ancestor.');
        }
        $accountOwner = fileowner($accountRoot);
        if (!is_int($accountOwner)) {
            throw new RuntimeException('Unable to determine install account ownership.');
        }
        // Walk to the highest same-owner ancestor (normally /home/USER). This
        // gives every overlapping/nested install for one cPanel account the
        // same stable lock without requiring a root-owned global directory.
        while (true) {
            $parent = dirname($accountRoot);
            if ($parent === $accountRoot || $parent === '/' || is_link($parent)
                || fileowner($parent) !== $accountOwner) {
                break;
            }
            $accountRoot = $parent;
        }
        $resourceLocks[] = $accountRoot . DIRECTORY_SEPARATOR . '.safetun-install-account.lock';
    }
    foreach ([$realPrivate, $realPublic] as $resourceRoot) {
        $resourceLocks[] = dirname($resourceRoot) . DIRECTORY_SEPARATOR . '.safetun-install-'
            . substr(hash('sha256', $resourceRoot), 0, 20) . '.lock';
    }
    $resourceLocks = array_values(array_unique($resourceLocks));
    sort($resourceLocks, SORT_STRING);
    foreach ($resourceLocks as $lockPath) {
        if (is_link($lockPath) || (file_exists($lockPath) && !is_file($lockPath))) {
            throw new RuntimeException('Installer lock path is unsafe.');
        }
        $lockHandle = @fopen($lockPath, 'c+b');
        if (!is_resource($lockHandle) || !flock($lockHandle, LOCK_EX | LOCK_NB)) {
            if (is_resource($lockHandle)) {
                fclose($lockHandle);
            }
            throw new RuntimeException('Another backend install transaction overlaps a selected resource root.');
        }
        $enforceMode($lockPath, 0600);
        $lockHandles[] = $lockHandle;
        $lockPaths[] = $lockPath;
    }
} catch (Throwable $exception) {
    $releaseLocks($lockHandles, $lockPaths);
    $cleanupCreated();
    fwrite(STDERR, "Installation stopped safely before staging: {$exception->getMessage()}\n");
    exit(1);
}

$transactionDir = $realPrivate . DIRECTORY_SEPARATOR . '.install-transaction';
$journalPath = $transactionDir . DIRECTORY_SEPARATOR . 'journal.json';
$bootstrapPath = $transactionDir . DIRECTORY_SEPARATOR . 'bootstrap.json';
$withinRoots = static function (string $path) use ($privatePrefix, $publicPrefix): bool {
    $normalized = PHP_OS_FAMILY === 'Windows' ? strtolower($path) : $path;
    $private = PHP_OS_FAMILY === 'Windows' ? strtolower($privatePrefix) : $privatePrefix;
    $public = PHP_OS_FAMILY === 'Windows' ? strtolower($publicPrefix) : $publicPrefix;
    return $normalized === rtrim($private, '/\\') || $normalized === rtrim($public, '/\\')
        || str_starts_with($normalized, $private) || str_starts_with($normalized, $public);
};
$rollback = static function () use (
    $transactionDir, $journalPath, $bootstrapPath, $atomicBytes, $removeTree, $withinRoots,
    $requireMode, $syncFile, $syncDirectory, &$pathGuard
): void {
    if (!is_dir($transactionDir) || is_link($transactionDir)) {
        throw new RuntimeException('Transaction directory is missing or unsafe.');
    }
    $requireMode($transactionDir, 0700);
    $created = [];
    if (is_file($bootstrapPath) && !is_link($bootstrapPath)) {
        $requireMode($bootstrapPath, 0600);
        $bootstrap = json_decode((string) file_get_contents($bootstrapPath), true, 16, JSON_THROW_ON_ERROR);
        if (!is_array($bootstrap) || ($bootstrap['schema'] ?? null) !== 1 || !is_array($bootstrap['created_dirs'] ?? null)) {
            throw new RuntimeException('Transaction bootstrap record is invalid.');
        }
        foreach ($bootstrap['created_dirs'] as $directory) {
            if (!is_string($directory) || !$withinRoots($directory)) {
                throw new RuntimeException('Transaction directory record is unsafe.');
            }
            $created[] = $directory;
        }
    }
    if (is_file($journalPath) && !is_link($journalPath)) {
        $requireMode($journalPath, 0600);
        $journal = json_decode((string) file_get_contents($journalPath), true, 32, JSON_THROW_ON_ERROR);
        if (!is_array($journal) || ($journal['schema'] ?? null) !== 1 || !is_array($journal['files'] ?? null)) {
            throw new RuntimeException('Install transaction journal is invalid.');
        }
        foreach (array_reverse($journal['files']) as $entry) {
            if (!is_array($entry) || !is_string($entry['target'] ?? null) || !$withinRoots($entry['target'])
                || !is_bool($entry['present'] ?? null) || !is_int($entry['mode'] ?? null)) {
                throw new RuntimeException('Install transaction file record is invalid.');
            }
            $target = $entry['target'];
            $pathGuard($target);
            if (file_exists($target) || is_link($target)) {
                if (!is_file($target) || is_link($target)) {
                    throw new RuntimeException("Unsafe rollback target: {$target}");
                }
            }
            if ($entry['present']) {
                if (!is_string($entry['backup'] ?? null) || !preg_match('/^[0-9]{4}\.bin$/D', $entry['backup'])) {
                    throw new RuntimeException('Install transaction backup record is invalid.');
                }
                $backup = $transactionDir . DIRECTORY_SEPARATOR . 'backups' . DIRECTORY_SEPARATOR . $entry['backup'];
                if (!is_file($backup) || is_link($backup)) {
                    throw new RuntimeException("Rollback backup is unavailable: {$target}");
                }
                $requireMode($backup, 0600);
                $syncFile($backup);
                $bytes = file_get_contents($backup);
                if (!is_string($bytes)) {
                    throw new RuntimeException("Unable to read rollback backup: {$target}");
                }
                $atomicBytes($target, $bytes, $entry['mode']);
            } elseif (is_file($target) && !@unlink($target)) {
                throw new RuntimeException("Unable to remove newly installed file: {$target}");
            }
        }
    }
    if (is_file($journalPath) && !@unlink($journalPath)) {
        throw new RuntimeException('Unable to commit completed transaction recovery.');
    }
    $syncDirectory($transactionDir);
    $removeTree($transactionDir);
    usort($created, static fn (string $left, string $right): int => strlen($right) <=> strlen($left));
    foreach ($created as $directory) {
        if (is_dir($directory) && !is_link($directory)) {
            @rmdir($directory);
        }
    }
};

try {
    if (file_exists($transactionDir) || is_link($transactionDir)) {
        $rollback();
        echo "Recovered an interrupted prior backend install transaction.\n";
    }
    $ensureDir($transactionDir, 0700);
    $writeJson($bootstrapPath, ['schema' => 1, 'created_dirs' => $createdDirs]);
    $stageDir = $transactionDir . DIRECTORY_SEPARATOR . 'stage';
    $backupDir = $transactionDir . DIRECTORY_SEPARATOR . 'backups';
    $ensureDir($stageDir, 0700);
    $ensureDir($backupDir, 0700);

    $entries = [];
    $stageEntry = static function (string $source, string $target, int $mode, string $relative) use (
        &$entries, $stageDir, $atomicBytes
    ): void {
        if (!is_file($source) || is_link($source)) {
            throw new RuntimeException("Package source is missing or unsafe: {$source}");
        }
        $bytes = file_get_contents($source);
        if (!is_string($bytes)) {
            throw new RuntimeException("Unable to read package source: {$source}");
        }
        if (str_ends_with($source, '.php')) {
            $tokens = token_get_all($bytes, TOKEN_PARSE);
            if ($tokens === [] || !is_array($tokens[0]) || $tokens[0][0] !== T_OPEN_TAG) {
                throw new RuntimeException("Staged PHP source has no opening tag: {$source}");
            }
        }
        $stage = $stageDir . DIRECTORY_SEPARATOR . sprintf('%04d.bin', count($entries));
        $atomicBytes($stage, $bytes, 0600);
        if (!hash_equals(hash('sha256', $bytes), (string) hash_file('sha256', $stage))) {
            throw new RuntimeException("Staged package source failed verification: {$source}");
        }
        $entries[] = ['relative' => $relative, 'target' => $target, 'stage' => $stage, 'mode' => $mode];
    };
    $stageBytes = static function (string $bytes, string $target, int $mode, string $relative) use (
        &$entries, $stageDir, $atomicBytes
    ): void {
        $stage = $stageDir . DIRECTORY_SEPARATOR . sprintf('%04d.bin', count($entries));
        $atomicBytes($stage, $bytes, 0600);
        $entries[] = ['relative' => $relative, 'target' => $target, 'stage' => $stage, 'mode' => $mode];
    };

    $sourceIterator = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($packageRoot . DIRECTORY_SEPARATOR . 'src', FilesystemIterator::SKIP_DOTS),
        RecursiveIteratorIterator::LEAVES_ONLY,
    );
    foreach ($sourceIterator as $file) {
        if (!$file->isFile() || $file->isLink()) {
            continue;
        }
        $relative = str_replace('\\', '/', substr($file->getPathname(), strlen($packageRoot . DIRECTORY_SEPARATOR . 'src') + 1));
        $stageEntry($file->getPathname(), $realPrivate . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relative), 0600, 'src/' . $relative);
    }
    foreach (['safetun-config-check.php', 'safetun-cleanup.php', 'safetun-keygen.php'] as $script) {
        $stageEntry($packageRoot . DIRECTORY_SEPARATOR . 'bin' . DIRECTORY_SEPARATOR . $script, $realPrivate . DIRECTORY_SEPARATOR . 'bin' . DIRECTORY_SEPARATOR . $script, 0700, 'bin/' . $script);
    }
    $stageEntry($packageRoot . DIRECTORY_SEPARATOR . 'config.example.php', $realPrivate . DIRECTORY_SEPARATOR . 'config.example.php', 0600, 'config.example.php');
    $configPath = $realPrivate . DIRECTORY_SEPARATOR . 'config.php';
    $pathGuard($configPath);
    if (!file_exists($configPath) && !is_link($configPath)) {
        $stageEntry($packageRoot . DIRECTORY_SEPARATOR . 'config.example.php', $configPath, 0600, 'config.php');
    } elseif (!is_file($configPath) || is_link($configPath)) {
        throw new RuntimeException('Existing config.php is unsafe.');
    }
    $stageEntry($packageRoot . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . 'index.php', $realPublic . DIRECTORY_SEPARATOR . 'index.php', 0644, 'public/index.php');
    $htaccessTemplate = file_get_contents($packageRoot . DIRECTORY_SEPARATOR . 'public' . DIRECTORY_SEPARATOR . '.htaccess');
    if (!is_string($htaccessTemplate)) {
        throw new RuntimeException('Unable to read .htaccess template.');
    }
    $stageBytes("SetEnv SAFETUN_PRIVATE_DIR \"{$realPrivate}\"\n" . $htaccessTemplate, $realPublic . DIRECTORY_SEPARATOR . '.htaccess', 0644, 'public/.htaccess');

    if ($generateKeys) {
        foreach (['auth-current', 'pair-current'] as $kid) {
            $secretPath = $realPrivate . DIRECTORY_SEPARATOR . 'keys' . DIRECTORY_SEPARATOR . $kid . '.key';
            $publicPath = $realPrivate . DIRECTORY_SEPARATOR . 'keys' . DIRECTORY_SEPARATOR . $kid . '.pub';
            $pathGuard($secretPath);
            $pathGuard($publicPath);
            $secretExists = file_exists($secretPath) || is_link($secretPath);
            $publicExists = file_exists($publicPath) || is_link($publicPath);
            if ($secretExists || $publicExists) {
                if (!is_file($secretPath) || is_link($secretPath) || !is_file($publicPath) || is_link($publicPath)) {
                    throw new RuntimeException("Incomplete or unsafe existing online key pair: {$kid}");
                }
                $secret = file_get_contents($secretPath);
                $public = file_get_contents($publicPath);
                if (!is_string($secret) || !is_string($public) || strlen($secret) !== SODIUM_CRYPTO_SIGN_SECRETKEYBYTES
                    || strlen($public) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES
                    || !hash_equals(sodium_crypto_sign_publickey_from_secretkey($secret), $public)) {
                    throw new RuntimeException("Existing online key pair is invalid: {$kid}");
                }
                sodium_memzero($secret);
                continue;
            }
            $pair = sodium_crypto_sign_keypair();
            $secret = sodium_crypto_sign_secretkey($pair);
            $public = sodium_crypto_sign_publickey($pair);
            $stageBytes($secret, $secretPath, 0600, 'keys/' . $kid . '.key');
            $stageBytes($public, $publicPath, 0644, 'keys/' . $kid . '.pub');
            sodium_memzero($secret);
        }
    }

    foreach (['src', 'bin', 'keys', 'release', 'runtime', 'runtime/nonces', 'runtime/rate-limits', 'runtime/cache', 'logs', 'install-backups'] as $subdir) {
        $ensureDir($realPrivate . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $subdir), 0700);
    }
    $writeJson($bootstrapPath, ['schema' => 1, 'created_dirs' => $createdDirs]);
    foreach ($entries as $entry) {
        $ensureDir(dirname($entry['target']), str_starts_with($entry['target'], $publicPrefix) ? 0755 : 0700);
    }
    $writeJson($bootstrapPath, ['schema' => 1, 'created_dirs' => $createdDirs]);

    $journalFiles = [];
    $persistentBackupRoot = $realPrivate . DIRECTORY_SEPARATOR . 'install-backups' . DIRECTORY_SEPARATOR
        . gmdate('Ymd-His') . '-' . bin2hex(random_bytes(4));
    foreach ($entries as $index => $entry) {
        $target = $entry['target'];
        if (file_exists($target) || is_link($target)) {
            if (!is_file($target) || is_link($target)) {
                throw new RuntimeException("Install target is unsafe: {$target}");
            }
            $same = hash_file('sha256', $entry['stage']) === hash_file('sha256', $target);
            if (!$same && !$force) {
                throw new RuntimeException("Refusing to overwrite changed file without --force: {$target}");
            }
            $backupName = sprintf('%04d.bin', $index);
            if (!copy($target, $backupDir . DIRECTORY_SEPARATOR . $backupName)) {
                throw new RuntimeException("Unable to journal existing file: {$target}");
            }
            $enforceMode($backupDir . DIRECTORY_SEPARATOR . $backupName, 0600);
            $syncFile($backupDir . DIRECTORY_SEPARATOR . $backupName);
            $syncDirectory($backupDir);
            $journalFiles[] = ['target' => $target, 'present' => true, 'mode' => fileperms($target) & 0777, 'backup' => $backupName];
            if (!$same) {
                $ensureDir($persistentBackupRoot, 0700);
                $persistent = $persistentBackupRoot . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], '_', $entry['relative']);
                if (!copy($target, $persistent)) {
                    throw new RuntimeException("Unable to preserve forced-overwrite backup: {$target}");
                }
                $enforceMode($persistent, 0600);
                $syncFile($persistent);
                $syncDirectory($persistentBackupRoot);
            }
        } else {
            $journalFiles[] = ['target' => $target, 'present' => false, 'mode' => 0, 'backup' => ''];
        }
    }
    $writeJson($bootstrapPath, ['schema' => 1, 'created_dirs' => $createdDirs]);
    $writeJson($journalPath, ['schema' => 1, 'files' => $journalFiles]);

    foreach ($entries as $entry) {
        $bytes = file_get_contents($entry['stage']);
        if (!is_string($bytes)) {
            throw new RuntimeException("Unable to read staged file: {$entry['relative']}");
        }
        $atomicBytes($entry['target'], $bytes, $entry['mode']);
        if (defined('SAFETUN_INSTALL_TEST_FAIL_AFTER_RELATIVE')
            && constant('SAFETUN_INSTALL_TEST_FAIL_AFTER_RELATIVE') === $entry['relative']) {
            throw new RuntimeException('Injected installer failure after ' . $entry['relative']);
        }
    }

    if (!@unlink($journalPath)) {
        throw new RuntimeException('Unable to durably commit backend install transaction.');
    }
    $syncDirectory($transactionDir);
    $removeTree($transactionDir);
    echo "Installed. Edit {$realPrivate}/config.php, install TLS/release files, then run:\n";
    echo "php {$realPrivate}/bin/safetun-config-check.php --docroot=" . dirname($realPublic) . " {$realPrivate}/config.php\n";
    echo "No private key was printed or placed under public_html.\n";
    $releaseLocks($lockHandles, $lockPaths);
    exit(0);
} catch (Throwable $exception) {
    $recovery = '';
    try {
        if (is_dir($transactionDir) && !is_link($transactionDir)) {
            $rollback();
        }
    } catch (Throwable $rollbackException) {
        $recovery = ' Automatic rollback did not complete; rerun the installer to recover its retained journal: '
            . $rollbackException->getMessage();
    }
    $releaseLocks($lockHandles, $lockPaths);
    fwrite(STDERR, "Installation stopped safely: {$exception->getMessage()}{$recovery}\n");
    exit(1);
}
