<?php
declare(strict_types=1);

use SafeTun\Backend\Application;
use SafeTun\Backend\Config\ConfigRepository;
use SafeTun\Backend\Config\ConfigValidator;
use SafeTun\Backend\Config\LiteralConfigParser;
use SafeTun\Backend\Crypto\Base64Url;
use SafeTun\Backend\Crypto\CanonicalJson;
use SafeTun\Backend\Crypto\HttpProof;
use SafeTun\Backend\Crypto\ReleaseManifest;
use SafeTun\Backend\Crypto\SignedEnvelope;
use SafeTun\Backend\Http\Request;
use SafeTun\Backend\Net\CidrMatcher;
use SafeTun\Backend\Net\ClientIpResolver;
use SafeTun\Backend\Net\IpNetwork;
use SafeTun\Backend\Service\AuthorizationService;
use SafeTun\Backend\Service\ReleaseService;
use SafeTun\Backend\Storage\NonceStore;
use SafeTun\Backend\Storage\RateLimiter;
use SafeTun\Backend\Storage\ShardedStore;

require dirname(__DIR__) . '/src/autoload.php';

final class TestFailure extends RuntimeException {}

/** @var int $passed */
$passed = 0;
$failed = 0;
$skipped = 0;
$tests = [];

$test = static function (string $name, callable $callable) use (&$tests): void {
    $tests[] = [$name, $callable];
};
$assert = static function (bool $condition, string $message = 'Assertion failed.'): void {
    if (!$condition) {
        throw new TestFailure($message);
    }
};
$assertSame = static function (mixed $expected, mixed $actual, string $message = ''): void {
    if ($expected !== $actual) {
        throw new TestFailure($message !== '' ? $message : 'Expected ' . var_export($expected, true)
            . ', got ' . var_export($actual, true));
    }
};
$expectThrows = static function (callable $callable, string $class) use ($assert): Throwable {
    try {
        $callable();
    } catch (Throwable $exception) {
        $assert($exception instanceof $class, "Expected {$class}; got " . $exception::class);
        return $exception;
    }
    throw new TestFailure("Expected exception {$class}.");
};

if (PHP_VERSION_ID < 80200 || !extension_loaded('sodium') || !extension_loaded('openssl')) {
    fwrite(STDERR, "Tests require PHP 8.2+, Sodium, and OpenSSL.\n");
    exit(2);
}

$backendRoot = dirname(__DIR__);
$tmpRoot = rtrim(sys_get_temp_dir(), '/\\') . DIRECTORY_SEPARATOR . 'safetun-tests-' . bin2hex(random_bytes(8));
if (!mkdir($tmpRoot, 0700, true)) {
    throw new RuntimeException('Unable to create test root.');
}

$mkdir = static function (string $path): void {
    if (!is_dir($path) && !mkdir($path, 0700, true)) {
        throw new RuntimeException("Unable to create test directory: {$path}");
    }
    @chmod($path, 0700);
};
foreach (['keys', 'release', 'runtime/nonces', 'runtime/rate-limits', 'runtime/cache', 'logs'] as $relative) {
    $mkdir($tmpRoot . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relative));
}

// Mirror the private source tree for the built-in HTTP isolation test.
$mkdir($tmpRoot . '/src');
$sourceIterator = new RecursiveIteratorIterator(
    new RecursiveDirectoryIterator($backendRoot . '/src', FilesystemIterator::SKIP_DOTS),
    RecursiveIteratorIterator::LEAVES_ONLY,
);
foreach ($sourceIterator as $sourceFile) {
    if (!$sourceFile->isFile() || $sourceFile->isLink()) {
        continue;
    }
    $relative = substr($sourceFile->getPathname(), strlen($backendRoot . '/src') + 1);
    $destination = $tmpRoot . '/src/' . str_replace('\\', '/', $relative);
    $mkdir(dirname($destination));
    if (!copy($sourceFile->getPathname(), $destination)) {
        throw new RuntimeException('Unable to mirror source for HTTP test.');
    }
}

$write = static function (string $path, string $bytes, int $mode = 0600): void {
    if (file_put_contents($path, $bytes, LOCK_EX) !== strlen($bytes)) {
        throw new RuntimeException("Unable to write test fixture: {$path}");
    }
    @chmod($path, $mode);
};
$keypairs = [];
foreach (['auth-current', 'pair-current', 'release-root-v1', 'node', 'edge'] as $kid) {
    $pair = sodium_crypto_sign_keypair();
    $keypairs[$kid] = [
        'secret' => sodium_crypto_sign_secretkey($pair),
        'public' => sodium_crypto_sign_publickey($pair),
    ];
}
foreach (['auth-current', 'pair-current'] as $kid) {
    $write($tmpRoot . "/keys/{$kid}.key", $keypairs[$kid]['secret']);
    $write($tmpRoot . "/keys/{$kid}.pub", $keypairs[$kid]['public'], 0644);
}
$write($tmpRoot . '/keys/release.pub', $keypairs['release-root-v1']['public'], 0644);
$write($tmpRoot . '/keys/tunnel-chain.pem', "TEST CERTIFICATE PLACEHOLDER\n", 0644);
$write($tmpRoot . '/keys/tunnel.key', "TEST PRIVATE KEY PLACEHOLDER\n");

$manifestRaw = json_encode([
    'schema_version' => 1,
    'version' => '0.1.0-test',
    'channel' => 'stable',
    'generated_at' => '2026-01-01T00:00:00Z',
    'minimum_config_schema' => 1,
    'minimum_api_version' => 'v1',
    'release_key_id' => 'release-root-v1',
    'artifacts' => [[
        'name' => 'safetun-hub-linux-amd64', 'role' => 'hub', 'os' => 'linux', 'arch' => 'amd64',
        'sha256' => str_repeat('a', 64), 'size' => 123, 'url' => 'https://downloads.example.invalid/hub',
    ]],
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES) . "\n";
$manifestInput = ReleaseManifest::signatureInput($manifestRaw);
$manifestSignature = sodium_crypto_sign_detached($manifestInput, $keypairs['release-root-v1']['secret']);
$write($tmpRoot . '/release/manifest.json', $manifestRaw, 0644);
$write($tmpRoot . '/release/manifest.json.sig', Base64Url::encode($manifestSignature) . "\n", 0644);

$exampleSource = file_get_contents($backendRoot . '/config.example.php');
if (!is_string($exampleSource)) {
    throw new RuntimeException('Unable to load config.example.php.');
}
$withAllowed = static function (string $source, array $cidrs, array $denied = []): string {
    $format = static function (array $items): string {
        return implode("\n", array_map(
            static fn (string $item): string => "            '" . str_replace("'", "\\'", $item) . "',",
            $items,
        ));
    };
    $replacement = "'allowed_iran_cidrs' => [\n" . $format($cidrs)
        . "\n        ],\n        'denied_cidrs' => [\n" . $format($denied) . "\n        ],";
    $result = preg_replace(
        "/'allowed_iran_cidrs'\s*=>\s*\[.*?\],\s*'denied_cidrs'\s*=>\s*\[\],/s",
        $replacement,
        $source,
        1,
    );
    if (!is_string($result) || $result === $source) {
        throw new RuntimeException('Unable to construct test config source.');
    }
    return $result;
};
$configPath = $tmpRoot . '/config.php';
$validSource = $withAllowed($exampleSource, ['8.8.8.8']);
$write($configPath, $validSource);

$parseAndValidate = static function (string $path) {
    $parsed = (new LiteralConfigParser())->parseFile($path);
    return (new ConfigValidator())->validate($parsed, $path);
};
$validated = $parseAndValidate($configPath);

$makeRequest = static function (string $method, string $path, string $body, string $remote = '8.8.8.8'): Request {
    return new Request(
        $method,
        $path,
        '',
        ['content-type' => 'application/json'],
        ['remote_addr' => $remote, 'https' => 'on', 'server_port' => '443'],
        $body,
    );
};

$test('front controller normalizes API routes when installed below the document root', static function () use ($assertSame): void {
    $priorServer = $_SERVER;
    try {
        $_SERVER = [
            'REQUEST_METHOD' => 'GET',
            'REQUEST_URI' => '/safetun-api/api/v1/releases/stable/manifest',
            'SCRIPT_NAME' => '/safetun-api/index.php',
            'REMOTE_ADDR' => '8.8.8.8',
            'HTTPS' => 'on',
            'SERVER_PORT' => '443',
        ];
        $request = Request::fromGlobals();
        $assertSame('/api/v1/releases/stable/manifest', $request->path);

        $_SERVER['SCRIPT_NAME'] = '/index.php';
        $_SERVER['REQUEST_URI'] = '/api/v1/auth/challenge';
        $request = Request::fromGlobals();
        $assertSame('/api/v1/auth/challenge', $request->path);

        $_SERVER['SCRIPT_NAME'] = '/../safetun-api/index.php';
        $_SERVER['REQUEST_URI'] = '/safetun-api/api/v1/auth/challenge';
        $request = Request::fromGlobals();
        $assertSame('/safetun-api/api/v1/auth/challenge', $request->path);

        $_SERVER['SCRIPT_NAME'] = '/safetun-api/../index.php';
        $_SERVER['REQUEST_URI'] = '/safetun-api/../api/v1/auth/challenge';
        $request = Request::fromGlobals();
        $assertSame('/safetun-api/../api/v1/auth/challenge', $request->path);
    } finally {
        $_SERVER = $priorServer;
    }
});
$challenge = static function (Application $app) use ($makeRequest): array {
    $response = $app->handle($makeRequest('POST', '/api/v1/auth/challenge', '{}'));
    if ($response->status !== 200) {
        throw new TestFailure('Challenge failed: ' . json_encode($response->body));
    }
    return $response->body;
};
$proofBody = static function (
    array $challenge,
    string $path,
    array $payload,
    string $secret,
): string {
    $timestamp = time();
    $bodyHash = hash('sha256', CanonicalJson::encode($payload));
    $input = HttpProof::signatureInput(
        'POST', $path, $bodyHash, $timestamp, 'safetun-hub', 'v1',
        $challenge['request_id'], $challenge['nonce'],
    );
    return json_encode([
        'request_id' => $challenge['request_id'],
        'nonce' => $challenge['nonce'],
        'timestamp' => $timestamp,
        'audience' => 'safetun-hub',
        'api_version' => 'v1',
        'payload' => $payload,
        'body_hash' => $bodyHash,
        'sig' => Base64Url::encode(sodium_crypto_sign_detached($input, $secret)),
    ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
};

$test('literal parser accepts the complete example without executing it', static function () use ($assertSame, $backendRoot): void {
    $parsed = (new LiteralConfigParser())->parseFile($backendRoot . '/config.example.php');
    $assertSame('production', $parsed->values['app']['environment']);
    $assertSame([], $parsed->values['authorization']['allowed_iran_cidrs']);
});

$test('literal parser rejects side effects before they can happen', static function () use ($expectThrows, $assert, $tmpRoot, $write): void {
    $marker = $tmpRoot . '/MUST_NOT_EXIST';
    $source = "<?php declare(strict_types=1); file_put_contents('" . addslashes($marker) . "', 'bad'); return [];";
    $path = $tmpRoot . '/malicious.php';
    $write($path, $source);
    $expectThrows(static fn () => (new LiteralConfigParser())->parseFile($path), \SafeTun\Backend\Exception\ConfigException::class);
    $assert(!file_exists($marker), 'Config parser executed a side effect.');
});

$test('literal parser rejects traversal, calls, variables, and duplicate keys', static function () use ($expectThrows, $tmpRoot, $write): void {
    $cases = [
        "<?php declare(strict_types=1); return ['x' => __DIR__ . '/../secret'];",
        "<?php declare(strict_types=1); return ['x' => getenv('X')];",
        "<?php declare(strict_types=1); \$x=[]; return \$x;",
        "<?php declare(strict_types=1); return ['x' => 1, 'x' => 2];",
    ];
    foreach ($cases as $index => $source) {
        $path = $tmpRoot . "/bad-literal-{$index}.php";
        $write($path, $source);
        $expectThrows(static fn () => (new LiteralConfigParser())->parseFile($path), \SafeTun\Backend\Exception\ConfigException::class);
    }
});

$test('schema rejects unknown keys and fails closed on a bad hot reload', static function () use (
    $expectThrows, $configPath, $validSource, $write
): void {
    $repository = new ConfigRepository($configPath);
    $repository->load();
    $bad = str_replace("'debug' => false,", "'debug' => false, 'debugg' => false,", $validSource);
    $write($configPath, $bad);
    clearstatcache(true, $configPath);
    $expectThrows(static fn () => $repository->load(), \SafeTun\Backend\Exception\ConfigException::class);
    $write($configPath, $validSource);
    ConfigRepository::clearCache();
});

$test('IPv4/IPv6 canonicalization, overlap detection, and deny precedence work', static function () use ($assertSame, $assert): void {
    $assertSame('8.8.8.8/32', IpNetwork::parse('8.8.8.8')->canonical);
    $assertSame('2001:4860:4860::8888/128', IpNetwork::parse('2001:4860:4860::8888')->canonical);
    $assertSame('2001:4860::/32', IpNetwork::parse('2001:4860:abcd::1/32')->canonical);
    $assert(IpNetwork::parse('8.8.8.0/24')->overlaps(IpNetwork::parse('8.8.8.8')));
    $matcher = new CidrMatcher(['8.8.8.0/24'], ['8.8.8.8']);
    $assert(!$matcher->isAllowed('8.8.8.8'), 'Deny must win over allow.');
    $assert($matcher->isAllowed('8.8.8.9'));
});

$test('validator rejects duplicate/overlapping allow entries and unsafe /0', static function () use (
    $expectThrows, $exampleSource, $withAllowed, $tmpRoot, $write
): void {
    foreach ([['8.8.8.0/24', '8.8.8.8'], ['0.0.0.0/0']] as $index => $cidrs) {
        $path = $tmpRoot . "/bad-cidr-{$index}.php";
        $write($path, $withAllowed($exampleSource, $cidrs));
        $parsed = (new LiteralConfigParser())->parseFile($path);
        $expectThrows(static fn () => (new ConfigValidator())->validate($parsed, $path), \SafeTun\Backend\Exception\ConfigException::class);
    }
});

$test('validator accepts twenty varied public prefixes plus IPv4/IPv6 single-IP normalization', static function () use (
    $exampleSource, $withAllowed, $tmpRoot, $write, $assertSame
): void {
    $cidrs = [
        '8.0.0.0/8', '9.0.0.0/9', '11.0.0.0/10', '12.0.0.0/11', '13.0.0.0/12',
        '14.0.0.0/13', '15.0.0.0/14', '16.0.0.0/15', '17.0.0.0/16', '18.0.0.0/17',
        '19.0.0.0/18', '20.0.0.0/19', '21.0.0.0/20', '22.0.0.0/21', '23.0.0.0/22',
        '24.0.0.0/23', '25.0.0.0/24', '26.0.0.0/25', '27.0.0.0/26', '28.0.0.0/27',
        '29.0.0.1', '2001:4860:4860::8888',
    ];
    $path = $tmpRoot . '/twenty-cidrs.php';
    $write($path, $withAllowed($exampleSource, $cidrs));
    $parsed = (new LiteralConfigParser())->parseFile($path);
    $validated = (new ConfigValidator())->validate($parsed, $path);
    $assertSame(22, count($validated->values['authorization']['allowed_iran_cidrs']));
    $assertSame('29.0.0.1/32', $validated->values['authorization']['allowed_iran_cidrs'][20]);
    $assertSame('2001:4860:4860::8888/128', $validated->values['authorization']['allowed_iran_cidrs'][21]);
});

$test('direct mode ignores spoofed headers and trusted proxy mode walks XFF right-to-left', static function () use ($assertSame, $expectThrows): void {
    $resolver = new ClientIpResolver();
    $direct = [
        'client_ip_mode' => 'direct', 'trusted_proxy_cidrs' => [],
        'trusted_ip_headers' => ['X-Forwarded-For'], 'require_public_ip' => true,
    ];
    $assertSame('8.8.8.8', $resolver->resolve($direct, '8.8.8.8', ['x-forwarded-for' => '9.9.9.9']));
    $proxy = [
        'client_ip_mode' => 'trusted_proxy', 'trusted_proxy_cidrs' => ['10.0.0.0/8'],
        'trusted_ip_headers' => ['X-Forwarded-For'], 'require_public_ip' => true,
    ];
    $assertSame('8.8.8.8', $resolver->resolve($proxy, '10.0.0.1', ['x-forwarded-for' => '6.6.6.6, 8.8.8.8, 10.1.2.3']));
    $expectThrows(
        static fn () => $resolver->resolve($proxy, '9.9.9.9', ['x-forwarded-for' => '8.8.8.8']),
        \SafeTun\Backend\Exception\PublicApiException::class,
    );
});

$test('signed raw envelope uses the shared domain-separated wire contract', static function () use ($keypairs, $assertSame, $expectThrows): void {
    $payload = ['aud' => 'safetun-hub', 'exp' => 12345];
    $envelope = SignedEnvelope::sign('SAFETUN-TEST-v1', 'test-key', $payload, $keypairs['auth-current']['secret']);
    $assertSame($payload, SignedEnvelope::verify($envelope, 'SAFETUN-TEST-v1', ['test-key' => $keypairs['auth-current']['public']]));
    $tampered = $envelope;
    $tampered['payload'] = Base64Url::encode(CanonicalJson::encode(['aud' => 'other', 'exp' => 12345]));
    $expectThrows(
        static fn () => SignedEnvelope::verify($tampered, 'SAFETUN-TEST-v1', ['test-key' => $keypairs['auth-current']['public']]),
        InvalidArgumentException::class,
    );
});

$test('nonce is single-use and a broken store fails closed', static function () use ($tmpRoot, $mkdir, $write, $expectThrows): void {
    $root = $tmpRoot . '/nonce-unit';
    $mkdir($root);
    $store = new NonceStore($root);
    $nonce = Base64Url::encode(random_bytes(32));
    $clientHash = hash('sha256', 'client');
    $requestId = '12345678-1234-4234-8234-123456789abc';
    $store->issue($nonce, $clientHash, $requestId, time() + 60);
    $store->consume($nonce, $clientHash, $requestId);
    $expectThrows(static fn () => $store->consume($nonce, $clientHash, $requestId), InvalidArgumentException::class);
    $blocked = $tmpRoot . '/not-a-directory';
    $write($blocked, 'x');
    $expectThrows(static fn () => new NonceStore($blocked), RuntimeException::class);
});

$test('rate limiter enforces its exact fixed-window count', static function () use ($tmpRoot, $mkdir, $assert): void {
    $root = $tmpRoot . '/rate-unit';
    $mkdir($root);
    $limiter = new RateLimiter($root);
    $assert($limiter->allow('bucket', 2, 60, 1000));
    $assert($limiter->allow('bucket', 2, 60, 1000));
    $assert(!$limiter->allow('bucket', 2, 60, 1000));
    $assert($limiter->allow('bucket', 2, 60, 1060));
});

$test('cleanup coordinates through a stable shard lock and rechecks live state', static function () use (
    $tmpRoot, $mkdir, $assert, $assertSame
): void {
    $root = $tmpRoot . '/cleanup-lock-unit';
    $mkdir($root);
    $limiter = new RateLimiter($root);
    $assert($limiter->allow('bucket', 1, 60, 1000));
    $store = new ShardedStore($root);
    $path = $store->pathFor('rate', 'bucket', false);
    $lockPath = $store->lockPathFor('rate', 'bucket', false);
    $lock = fopen($lockPath, 'r+b');
    $assert(is_resource($lock));
    $assert(flock($lock, LOCK_EX));
    touch($path, 1);
    $assertSame(0, $limiter->cleanup(2), 'Cleanup must skip a shard being updated.');
    $assert(is_file($path));
    flock($lock, LOCK_UN);
    fclose($lock);

    touch($path, 1);
    $assertSame(1, $limiter->cleanup(2));
    $assert(!is_file($path));
    $assert(is_file($lockPath), 'The coordination lock inode must remain stable after cleanup.');
});

$childCommand = static function (array $arguments): array {
    $command = [PHP_BINARY, '-n'];
    $extensionDir = ini_get('extension_dir');
    if (is_string($extensionDir) && $extensionDir !== '') {
        $command[] = '-d';
        $command[] = 'extension_dir=' . $extensionDir;
    }
    if (PHP_OS_FAMILY === 'Windows') {
        $command[] = '-d'; $command[] = 'extension=php_sodium.dll';
        $command[] = '-d'; $command[] = 'extension=php_openssl.dll';
    }
    return array_merge($command, $arguments);
};
$runConcurrent = static function (array $commands): array {
    if (!function_exists('proc_open')) {
        throw new RuntimeException('SKIP: proc_open is unavailable.');
    }
    $processes = [];
    foreach ($commands as $command) {
        $pipes = [];
        $process = proc_open(
            $command,
            [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
            $pipes,
            null,
            null,
            ['bypass_shell' => true],
        );
        if (!is_resource($process)) {
            throw new RuntimeException('Unable to start concurrent PHP worker.');
        }
        fclose($pipes[0]);
        $processes[] = [$process, $pipes];
    }
    $outputs = [];
    foreach ($processes as [$process, $pipes]) {
        $stdout = stream_get_contents($pipes[1]);
        $stderr = stream_get_contents($pipes[2]);
        fclose($pipes[1]);
        fclose($pipes[2]);
        $status = proc_close($process);
        $outputs[] = [trim((string) $stdout), $status, trim((string) $stderr)];
    }
    return $outputs;
};

$test('concurrent workers consume exactly one nonce', static function () use (
    $tmpRoot, $mkdir, $childCommand, $runConcurrent, $backendRoot, $assertSame, $assert
): void {
    $root = $tmpRoot . '/nonce-concurrent';
    $mkdir($root);
    $nonce = Base64Url::encode(random_bytes(32));
    $clientHash = hash('sha256', 'client-concurrent');
    $requestId = '22345678-1234-4234-8234-123456789abc';
    (new NonceStore($root))->issue($nonce, $clientHash, $requestId, time() + 60);
    $commands = [];
    for ($index = 0; $index < 12; $index++) {
        $commands[] = $childCommand([
            $backendRoot . '/tests/worker.php', 'nonce', $root, $nonce, $clientHash, $requestId, (string) time(),
        ]);
    }
    $outputs = $runConcurrent($commands);
    $values = array_column($outputs, 0);
    $assertSame(1, count(array_filter($values, static fn (string $value): bool => $value === '1')));
    $assert(!in_array('E', $values, true), 'A nonce worker hit a storage error: ' . json_encode($outputs));
});

$test('concurrent workers preserve the rate-limit ceiling', static function () use (
    $tmpRoot, $mkdir, $childCommand, $runConcurrent, $backendRoot, $assertSame, $assert
): void {
    $root = $tmpRoot . '/rate-concurrent';
    $mkdir($root);
    $commands = [];
    for ($index = 0; $index < 16; $index++) {
        $commands[] = $childCommand([
            $backendRoot . '/tests/worker.php', 'rate', $root, 'shared', '4', '60', '2000', 'unused',
        ]);
    }
    $outputs = $runConcurrent($commands);
    $values = array_column($outputs, 0);
    $assertSame(4, count(array_filter($values, static fn (string $value): bool => $value === '1')));
    $assert(!in_array('E', $values, true), 'A rate worker hit a storage error: ' . json_encode($outputs));
});

$test('authorize, renew, and pair endpoints bind node proof and issue minimal envelopes', static function () use (
    $configPath, $makeRequest, $challenge, $proofBody, $keypairs, $assertSame, $assert
): void {
    ConfigRepository::clearCache();
    $app = new Application($configPath);
    $nodePublic = Base64Url::encode($keypairs['node']['public']);
    $challengeOne = $challenge($app);
    $authorizePath = '/api/v1/auth/authorize';
    $authorizeBody = $proofBody($challengeOne, $authorizePath, [
        'node_public_key' => $nodePublic,
        'requested_features' => ['tcp_forward', 'udp_forward'],
    ], $keypairs['node']['secret']);
    $authorized = $app->handle($makeRequest('POST', $authorizePath, $authorizeBody));
    $assertSame(200, $authorized->status, json_encode($authorized->body));
    $lease = $authorized->body['lease'];
    $leasePayload = SignedEnvelope::verify(
        $lease, AuthorizationService::LEASE_DOMAIN, ['auth-current' => $keypairs['auth-current']['public']],
    );
    $leaseKeys = array_keys($leasePayload);
    sort($leaseKeys);
    $assertSame([
        'aud', 'config_hash', 'exp', 'features', 'grace_until', 'iat', 'iss', 'jti', 'kid',
        'nbf', 'node_key_hash', 'observed_ip_hash', 'role',
    ], $leaseKeys, 'Lease claims drifted from the Go LeaseClaims contract.');
    $assertSame('iran', $leasePayload['role']);
    $assertSame(hash('sha256', $keypairs['node']['public']), $leasePayload['node_key_hash']);
    $assert(!array_key_exists('matched_cidr', $leasePayload));
    $assert(!str_contains(json_encode($lease), '8.8.8.8'));

    $replay = $app->handle($makeRequest('POST', $authorizePath, $authorizeBody));
    $assertSame(401, $replay->status);
    $assertSame('AUTHENTICATION_FAILED', $replay->body['error']['code']);

    $renewChallenge = $challenge($app);
    $renewPath = '/api/v1/auth/renew';
    $renewBody = $proofBody($renewChallenge, $renewPath, [
        'node_public_key' => $nodePublic,
        'lease' => $lease,
    ], $keypairs['node']['secret']);
    $renewed = $app->handle($makeRequest('POST', $renewPath, $renewBody));
    $assertSame(200, $renewed->status, json_encode($renewed->body));

    $pairChallenge = $challenge($app);
    $pairPath = '/api/v1/pairs/credential';
    $edgeHash = hash('sha256', $keypairs['edge']['public']);
    $pairBody = $proofBody($pairChallenge, $pairPath, [
        'node_public_key' => $nodePublic,
        'lease' => $renewed->body['lease'],
        'edge_key_hash' => $edgeHash,
        'requested_features' => ['tcp_forward', 'udp_forward'],
    ], $keypairs['node']['secret']);
    $paired = $app->handle($makeRequest('POST', $pairPath, $pairBody));
    $assertSame(200, $paired->status, json_encode($paired->body));
    $edgeCredential = SignedEnvelope::verify(
        $paired->body['edge_credential'], AuthorizationService::EDGE_PAIR_DOMAIN,
        ['pair-current' => $keypairs['pair-current']['public']],
    );
    $pairKeys = array_keys($edgeCredential);
    sort($pairKeys);
    $assertSame([
        'aud', 'edge_key_hash', 'exp', 'features', 'grace_until', 'hub_key_hash', 'iat',
        'iss', 'jti', 'kid', 'nbf', 'pair_id', 'role',
    ], $pairKeys, 'Pair claims drifted from the Go PairClaims contract.');
    $assertSame('kharej', $edgeCredential['role']);
    $assertSame($edgeHash, $edgeCredential['edge_key_hash']);
    $assertSame(hash('sha256', $keypairs['node']['public']), $edgeCredential['hub_key_hash']);
    $encoded = json_encode($edgeCredential);
    $assert(!str_contains((string) $encoded, '8.8.8.8') && !str_contains((string) $encoded, 'allowed_iran'));
});

$test('pair features cannot exceed the verified Hub lease feature set', static function () use (
    $configPath, $makeRequest, $challenge, $proofBody, $keypairs, $assertSame
): void {
    ConfigRepository::clearCache();
    $app = new Application($configPath);
    $nodePublic = Base64Url::encode($keypairs['node']['public']);
    $authorizePath = '/api/v1/auth/authorize';
    $authorizeBody = $proofBody($challenge($app), $authorizePath, [
        'node_public_key' => $nodePublic,
        'requested_features' => ['tcp_forward'],
    ], $keypairs['node']['secret']);
    $authorized = $app->handle($makeRequest('POST', $authorizePath, $authorizeBody));
    $assertSame(200, $authorized->status, json_encode($authorized->body));

    $pairPath = '/api/v1/pairs/credential';
    $pairBody = $proofBody($challenge($app), $pairPath, [
        'node_public_key' => $nodePublic,
        'lease' => $authorized->body['lease'],
        'edge_key_hash' => hash('sha256', $keypairs['edge']['public']),
        'requested_features' => ['tcp_forward', 'udp_forward'],
    ], $keypairs['node']['secret']);
    $response = $app->handle($makeRequest('POST', $pairPath, $pairBody));
    $assertSame(400, $response->status, json_encode($response->body));
    $assertSame('INVALID_REQUEST', $response->body['error']['code']);
});

$test('max_edges_per_hub atomically limits unique Edge identities', static function () use (
    $exampleSource, $withAllowed, $tmpRoot, $write, $makeRequest, $challenge, $proofBody, $keypairs,
    $assertSame
): void {
    $limitedSource = str_replace(
        "'max_edges_per_hub' => null,",
        "'max_edges_per_hub' => 1,",
        $withAllowed($exampleSource, ['8.8.8.8']),
    );
    $limitedPath = $tmpRoot . '/limited-edges-config.php';
    $write($limitedPath, $limitedSource);
    ConfigRepository::clearCache();
    $app = new Application($limitedPath);
    $nodePublic = Base64Url::encode($keypairs['node']['public']);
    $authorizePath = '/api/v1/auth/authorize';
    $authorizeBody = $proofBody($challenge($app), $authorizePath, [
        'node_public_key' => $nodePublic,
        'requested_features' => ['tcp_forward'],
    ], $keypairs['node']['secret']);
    $authorized = $app->handle($makeRequest('POST', $authorizePath, $authorizeBody));
    $assertSame(200, $authorized->status, json_encode($authorized->body));
    $lease = $authorized->body['lease'];
    $pairPath = '/api/v1/pairs/credential';

    $issue = static function (string $edgeHash) use (
        $app, $nodePublic, $lease, $pairPath, $proofBody, $challenge, $keypairs, $makeRequest
    ) {
        $body = $proofBody($challenge($app), $pairPath, [
            'node_public_key' => $nodePublic,
            'lease' => $lease,
            'edge_key_hash' => $edgeHash,
            'requested_features' => ['tcp_forward'],
        ], $keypairs['node']['secret']);
        return $app->handle($makeRequest('POST', $pairPath, $body));
    };
    $firstHash = hash('sha256', $keypairs['edge']['public']);
    $assertSame(200, $issue($firstHash)->status);
    $assertSame(200, $issue($firstHash)->status, 'Renewing the same Edge must not consume another slot.');
    $second = $issue(hash('sha256', 'different-edge-public-key'));
    $assertSame(403, $second->status, json_encode($second->body));
    $assertSame('ACCESS_NOT_ALLOWED', $second->body['error']['code']);
});

$test('unauthorized source gets only ACCESS_NOT_ALLOWED plus a request id', static function () use (
    $configPath, $makeRequest, $assertSame, $assert
): void {
    $response = (new Application($configPath))->handle($makeRequest('POST', '/api/v1/auth/challenge', '{}', '9.9.9.9'));
    $assertSame(403, $response->status);
    $assertSame('ACCESS_NOT_ALLOWED', $response->body['error']['code']);
    $assert(isset($response->body['request_id']));
    $assert(!str_contains(json_encode($response->body), 'CIDR'));
});

$test('explicit CIDR deny and configured node revocation override authorization', static function () use (
    $exampleSource, $withAllowed, $tmpRoot, $write, $makeRequest, $challenge, $proofBody, $keypairs, $assertSame
): void {
    $denyPath = $tmpRoot . '/deny-config.php';
    $write($denyPath, $withAllowed($exampleSource, ['8.8.8.0/24'], ['8.8.8.8']));
    $denied = (new Application($denyPath))->handle($makeRequest('POST', '/api/v1/auth/challenge', '{}'));
    $assertSame(403, $denied->status);
    $assertSame('ACCESS_NOT_ALLOWED', $denied->body['error']['code']);

    $nodeHash = hash('sha256', $keypairs['node']['public']);
    $revokedSource = str_replace(
        "'revoked_node_key_hashes' => [],",
        "'revoked_node_key_hashes' => ['{$nodeHash}'],",
        $withAllowed($exampleSource, ['8.8.8.8']),
    );
    $revokedPath = $tmpRoot . '/revoked-config.php';
    $write($revokedPath, $revokedSource);
    $app = new Application($revokedPath);
    $issued = $challenge($app);
    $path = '/api/v1/auth/authorize';
    $body = $proofBody($issued, $path, [
        'node_public_key' => Base64Url::encode($keypairs['node']['public']),
        'requested_features' => ['tcp_forward'],
    ], $keypairs['node']['secret']);
    $response = $app->handle($makeRequest('POST', $path, $body));
    $assertSame(403, $response->status);
    $assertSame('ACCESS_NOT_ALLOWED', $response->body['error']['code']);
});

$test('expired lease and excessive request clock skew fail authentication', static function () use (
    $configPath, $makeRequest, $challenge, $proofBody, $keypairs, $assertSame, $validated
): void {
    $app = new Application($configPath);
    $nodePublic = Base64Url::encode($keypairs['node']['public']);
    $now = time();
    $expiredClaims = [
        'iss' => 'https://auth.example.invalid', 'aud' => 'safetun-hub', 'role' => 'iran',
        'node_key_hash' => hash('sha256', $keypairs['node']['public']),
        'features' => ['tcp_forward'], 'config_hash' => $validated->sha256,
        'iat' => $now - 1000, 'nbf' => $now - 1000, 'exp' => $now - 900,
        'grace_until' => $now - 800, 'jti' => Base64Url::encode(random_bytes(16)),
        'kid' => 'auth-current',
        'observed_ip_hash' => hash('sha256', "SAFETUN-IP-BIND-v1\0" . '8.8.8.8'),
    ];
    $expiredLease = SignedEnvelope::sign(
        AuthorizationService::LEASE_DOMAIN, 'auth-current', $expiredClaims, $keypairs['auth-current']['secret'],
    );
    $renewChallenge = $challenge($app);
    $renewPath = '/api/v1/auth/renew';
    $renewBody = $proofBody($renewChallenge, $renewPath, [
        'node_public_key' => $nodePublic, 'lease' => $expiredLease,
    ], $keypairs['node']['secret']);
    $expired = $app->handle($makeRequest('POST', $renewPath, $renewBody));
    $assertSame(401, $expired->status);
    $assertSame('AUTHENTICATION_FAILED', $expired->body['error']['code']);

    $skewChallenge = $challenge($app);
    $path = '/api/v1/auth/authorize';
    $payload = ['node_public_key' => $nodePublic, 'requested_features' => ['tcp_forward']];
    $bodyHash = hash('sha256', CanonicalJson::encode($payload));
    $timestamp = $now - 1000;
    $input = HttpProof::signatureInput(
        'POST', $path, $bodyHash, $timestamp, 'safetun-hub', 'v1',
        $skewChallenge['request_id'], $skewChallenge['nonce'],
    );
    $skewBody = json_encode([
        'request_id' => $skewChallenge['request_id'], 'nonce' => $skewChallenge['nonce'],
        'timestamp' => $timestamp, 'audience' => 'safetun-hub', 'api_version' => 'v1',
        'payload' => $payload, 'body_hash' => $bodyHash,
        'sig' => Base64Url::encode(sodium_crypto_sign_detached($input, $keypairs['node']['secret'])),
    ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
    $skewed = $app->handle($makeRequest('POST', $path, $skewBody));
    $assertSame(401, $skewed->status);
    $assertSame('AUTHENTICATION_FAILED', $skewed->body['error']['code']);
});

$test('release endpoint binds a verified manifest to the configured active release key', static function () use (
    $configPath, $makeRequest, $keypairs, $assertSame, $manifestRaw, $tmpRoot, $write, $manifestSignature,
    $parseAndValidate, $expectThrows
): void {
    $app = new Application($configPath);
    $request = $makeRequest('GET', '/api/v1/releases/stable/manifest', '', '9.9.9.9');
    $response = $app->handle($request);
    $assertSame(200, $response->status, json_encode($response->body));
    $raw = ReleaseManifest::verifyEnvelope(
        $response->body, ['release-root-v1' => $keypairs['release-root-v1']['public']],
    );
    $assertSame($manifestRaw, $raw);
    $write($tmpRoot . '/release/manifest.json.sig', Base64Url::encode(str_repeat("\0", 64)) . "\n", 0644);
    $tampered = $app->handle($request);
    $assertSame(503, $tampered->status);
    $write($tmpRoot . '/release/manifest.json.sig', Base64Url::encode($manifestSignature) . "\n", 0644);

    $mismatched = json_decode($manifestRaw, true, 16, JSON_THROW_ON_ERROR);
    $mismatched['release_key_id'] = 'release-other-v1';
    $mismatchedRaw = json_encode($mismatched, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES) . "\n";
    $mismatchedSignature = sodium_crypto_sign_detached(
        ReleaseManifest::signatureInput($mismatchedRaw),
        $keypairs['release-root-v1']['secret'],
    );
    $write($tmpRoot . '/release/manifest.json', $mismatchedRaw, 0644);
    $write($tmpRoot . '/release/manifest.json.sig', Base64Url::encode($mismatchedSignature) . "\n", 0644);
    $mismatchedResponse = $app->handle($request);
    $assertSame(503, $mismatchedResponse->status);
    $expectThrows(static fn () => $parseAndValidate($configPath), \SafeTun\Backend\Exception\ConfigException::class);

    $write($tmpRoot . '/release/manifest.json', $manifestRaw, 0644);
    $write($tmpRoot . '/release/manifest.json.sig', Base64Url::encode($manifestSignature) . "\n", 0644);
});

$test('audit JSONL excludes IPs, nonces, credentials, and private material', static function () use (
    $tmpRoot, $keypairs, $assert
): void {
    $contents = '';
    foreach (glob($tmpRoot . '/logs/*.jsonl') ?: [] as $path) {
        $contents .= (string) file_get_contents($path);
    }
    $assert($contents !== '', 'Expected audit records.');
    foreach (['8.8.8.8', '9.9.9.9', Base64Url::encode($keypairs['node']['public']), Base64Url::encode($keypairs['node']['secret']), 'allowed_iran_cidrs'] as $forbidden) {
        $assert(!str_contains($contents, $forbidden), "Audit log leaked forbidden material: {$forbidden}");
    }
    foreach (explode("\n", trim($contents)) as $line) {
        $decoded = json_decode($line, true, 8, JSON_THROW_ON_ERROR);
        $assert(is_array($decoded) && isset($decoded['ts'], $decoded['event']));
    }
});

$test('public tree contains no config, source, key, state, log, or backup file', static function () use ($backendRoot, $assertSame, $assert): void {
    $files = [];
    $iterator = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($backendRoot . '/public', FilesystemIterator::SKIP_DOTS),
        RecursiveIteratorIterator::LEAVES_ONLY,
    );
    foreach ($iterator as $file) {
        $files[] = str_replace('\\', '/', substr($file->getPathname(), strlen($backendRoot . '/public') + 1));
    }
    sort($files);
    $assertSame(['.htaccess', 'index.php'], $files);
    $index = (string) file_get_contents($backendRoot . '/public/index.php');
    $assert(!str_contains($index, 'config.example.php'));
});

$test('HTTP front controller cannot download config, source, keys, state, logs, or backups', static function () use (
    $backendRoot, $tmpRoot, $childCommand, $assert
): void {
    if (!function_exists('proc_open') || !filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOL)) {
        throw new RuntimeException('SKIP: local PHP HTTP test prerequisites are unavailable.');
    }
    $port = random_int(21000, 45000);
    $command = $childCommand([
        '-S', "127.0.0.1:{$port}", '-t', $backendRoot . '/public', $backendRoot . '/public/index.php',
    ]);
    $pipes = [];
    $environment = getenv();
    $environment['SAFETUN_PRIVATE_DIR'] = $tmpRoot;
    $process = proc_open(
        $command,
        [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
        $pipes,
        null,
        $environment,
        ['bypass_shell' => true],
    );
    $assert(is_resource($process), 'Unable to start local PHP HTTP server.');
    fclose($pipes[0]);
    stream_set_blocking($pipes[1], false);
    stream_set_blocking($pipes[2], false);
    try {
        $ready = false;
        for ($attempt = 0; $attempt < 40; $attempt++) {
            $context = stream_context_create(['http' => ['ignore_errors' => true, 'timeout' => 0.25]]);
            $probe = @file_get_contents("http://127.0.0.1:{$port}/health-probe", false, $context);
            if (is_string($probe)) {
                $ready = true;
                break;
            }
            usleep(50000);
        }
        $assert($ready, 'Local PHP HTTP server did not become ready.');
        foreach ([
            '/config.php', '/config.php.bak', '/src/Config/LiteralConfigParser.php',
            '/keys/auth-current.key', '/runtime/nonces/secret', '/logs/audit.jsonl',
        ] as $path) {
            $context = stream_context_create(['http' => ['ignore_errors' => true, 'timeout' => 2]]);
            $body = @file_get_contents("http://127.0.0.1:{$port}{$path}", false, $context);
            $assert(is_string($body), "No HTTP response for protected path {$path}.");
            // Avoid compiling a direct reference to PHP 8.5's deprecated
            // locally scoped variable while retaining PHP 8.2 compatibility.
            if (function_exists('http_get_last_response_headers')) {
                $headerReader = 'http_get_last_response_headers';
                $rawHeaders = $headerReader();
            } else {
                $scope = get_defined_vars();
                $rawHeaders = $scope['http_response_header'];
            }
            $headers = [];
            if (is_array($rawHeaders)) {
                foreach ($rawHeaders as $header) {
                    $headers[] = $header;
                }
            }
            $assert((bool) array_filter($headers, static fn (string $header): bool => str_starts_with(strtolower($header), 'content-type: application/json')),
                "Protected HTTP path did not return JSON content type: {$path}");
            $assert(!str_contains($body, 'auth-current')
                && !str_contains($body, 'LiteralConfigParser')
                && !str_contains($body, 'allowed_iran_cidrs')
                && !str_contains($body, Base64Url::encode(str_repeat("\0", 32))),
                "Protected HTTP path leaked content: {$path}");
            $decoded = json_decode($body, true);
            $assert(is_array($decoded) && isset($decoded['error']['code']), "Protected HTTP path did not fail closed: {$path}");
        }
    } finally {
        proc_terminate($process);
        fclose($pipes[1]);
        fclose($pipes[2]);
        proc_close($process);
    }
});

$test('HTTP status mapping and configured body bounds fail closed', static function () use (
    $configPath, $makeRequest, $assertSame
): void {
    $app = new Application($configPath);
    $oversized = $app->handle($makeRequest(
        'POST', '/api/v1/auth/challenge', json_encode(['padding' => str_repeat('x', 33000)], JSON_THROW_ON_ERROR),
    ));
    $assertSame(413, $oversized->status);
    $assertSame('INVALID_REQUEST', $oversized->body['error']['code']);

    $wrongType = new Request(
        'POST', '/api/v1/auth/challenge', '', ['content-type' => 'text/plain'],
        ['remote_addr' => '8.8.8.8', 'https' => 'on', 'server_port' => '443'], '{}',
    );
    $assertSame(415, $app->handle($wrongType)->status);
    $assertSame(405, $app->handle($makeRequest('GET', '/api/v1/auth/challenge', ''))->status);
    $assertSame(404, $app->handle($makeRequest('POST', '/api/v1/private/config', '{}'))->status);
    $assertSame(400, $app->handle($makeRequest('GET', '/api/v1/releases/stable/manifest', '{}', '9.9.9.9'))->status);
});

$test('cPanel CLI installer is transactional, idempotent, and keeps keys out of public root', static function () use (
    $tmpRoot, $backendRoot, $childCommand, $mkdir, $assert, $assertSame
): void {
    if (!function_exists('proc_open')) {
        throw new RuntimeException('SKIP: proc_open is unavailable.');
    }
    $private = $tmpRoot . '/install/private';
    $public = $tmpRoot . '/install/public';
    $command = $childCommand([
        $backendRoot . '/bin/safetun-install.php',
        '--private-dir=' . $private,
        '--public-dir=' . $public,
    ]);
    for ($run = 0; $run < 2; $run++) {
        $pipes = [];
        $process = proc_open(
            $command,
            [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
            $pipes,
            null,
            null,
            ['bypass_shell' => true],
        );
        $assert(is_resource($process), 'Unable to invoke cPanel installer.');
        fclose($pipes[0]);
        $stdout = stream_get_contents($pipes[1]);
        $stderr = stream_get_contents($pipes[2]);
        fclose($pipes[1]); fclose($pipes[2]);
        $status = proc_close($process);
        $assertSame(0, $status, "Installer run {$run} failed: {$stdout} {$stderr}");
        $assert(!str_contains((string) $stdout, Base64Url::encode(str_repeat("\0", 32))), 'Installer printed key material.');
    }
    $assert(is_file($private . '/config.php') && is_file($private . '/src/bootstrap.php'));
    $assert(is_file($private . '/keys/auth-current.key') && is_file($private . '/keys/pair-current.key'));
    $publicFiles = [];
    $iterator = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($public, FilesystemIterator::SKIP_DOTS),
        RecursiveIteratorIterator::LEAVES_ONLY,
    );
    foreach ($iterator as $file) {
        $publicFiles[] = str_replace('\\', '/', substr($file->getPathname(), strlen($public) + 1));
    }
    sort($publicFiles);
    $assertSame(['.htaccess', 'index.php'], $publicFiles);

    $bootstrapPath = $private . '/src/bootstrap.php';
    $operatorBytes = "<?php\ndeclare(strict_types=1);\n// operator-owned pre-transaction bytes\n";
    file_put_contents($bootstrapPath, $operatorBytes, LOCK_EX);
    $beforePublic = hash_file('sha256', $public . '/index.php');
    $beforeHtaccess = hash_file('sha256', $public . '/.htaccess');
    $beforeAuthKey = hash_file('sha256', $private . '/keys/auth-current.key');
    $wrapper = 'define("SAFETUN_INSTALL_TEST_FAIL_AFTER_RELATIVE", "public/.htaccess");'
        . '$_SERVER["argv"] = ' . var_export([
            'safetun-install.php', '--private-dir=' . $private, '--public-dir=' . $public, '--force',
        ], true) . ';require ' . var_export($backendRoot . '/bin/safetun-install.php', true) . ';';
    $failureCommand = $childCommand(['-r', $wrapper]);
    $pipes = [];
    $process = proc_open(
        $failureCommand,
        [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
        $pipes,
        null,
        null,
        ['bypass_shell' => true],
    );
    $assert(is_resource($process), 'Unable to invoke fault-injected cPanel installer.');
    fclose($pipes[0]);
    $stdout = stream_get_contents($pipes[1]);
    $stderr = stream_get_contents($pipes[2]);
    fclose($pipes[1]); fclose($pipes[2]);
    $status = proc_close($process);
    $assertSame(1, $status, "Fault-injected installer unexpectedly succeeded: {$stdout} {$stderr}");
    $assert(str_contains((string) $stderr, 'Injected installer failure'));
    $assertSame($operatorBytes, file_get_contents($bootstrapPath), 'Late failure did not restore overwritten private source.');
    $assertSame($beforePublic, hash_file('sha256', $public . '/index.php'));
    $assertSame($beforeHtaccess, hash_file('sha256', $public . '/.htaccess'));
    $assertSame($beforeAuthKey, hash_file('sha256', $private . '/keys/auth-current.key'));
    $assert(!file_exists($private . '/.install-transaction'), 'Completed rollback retained a transaction directory.');

    $realPrivate = realpath($private);
    $assert(is_string($realPrivate));
    $privateLockPath = dirname($realPrivate) . DIRECTORY_SEPARATOR . '.safetun-install-'
        . substr(hash('sha256', $realPrivate), 0, 20) . '.lock';
    $heldLock = fopen($privateLockPath, 'c+b');
    $assert(is_resource($heldLock) && flock($heldLock, LOCK_EX | LOCK_NB), 'Unable to hold installer resource lock.');
    try {
        $overlapCommand = $childCommand([
            $backendRoot . '/bin/safetun-install.php',
            '--private-dir=' . $private,
            '--public-dir=' . $tmpRoot . '/install/other-public',
        ]);
        $pipes = [];
        $process = proc_open(
            $overlapCommand,
            [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
            $pipes,
            null,
            null,
            ['bypass_shell' => true],
        );
        $assert(is_resource($process), 'Unable to invoke overlapping cPanel installer.');
        fclose($pipes[0]);
        $stdout = stream_get_contents($pipes[1]);
        $stderr = stream_get_contents($pipes[2]);
        fclose($pipes[1]); fclose($pipes[2]);
        $status = proc_close($process);
        $assertSame(1, $status, "Overlapping installer bypassed its private-root lock: {$stdout} {$stderr}");
        $assert(str_contains((string) $stderr, 'overlaps a selected resource root'));
    } finally {
        flock($heldLock, LOCK_UN);
        fclose($heldLock);
    }
    $assert(is_file($privateLockPath) && !is_link($privateLockPath), 'Installer resource lock inode was not retained.');

    $symlinkPrivate = $tmpRoot . '/install/symlink-private';
    $symlinkPublic = $tmpRoot . '/install/symlink-public';
    $outside = $tmpRoot . '/install/outside';
    $mkdir($symlinkPrivate);
    $mkdir($outside);
    $sentinel = $outside . '/sentinel';
    file_put_contents($sentinel, 'unchanged');
    if (@symlink($outside, $symlinkPrivate . '/src')) {
        $symlinkCommand = $childCommand([
            $backendRoot . '/bin/safetun-install.php',
            '--private-dir=' . $symlinkPrivate,
            '--public-dir=' . $symlinkPublic,
        ]);
        $pipes = [];
        $process = proc_open(
            $symlinkCommand,
            [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
            $pipes,
            null,
            null,
            ['bypass_shell' => true],
        );
        $assert(is_resource($process), 'Unable to invoke symlink-ancestor installer regression.');
        fclose($pipes[0]);
        $stdout = stream_get_contents($pipes[1]);
        $stderr = stream_get_contents($pipes[2]);
        fclose($pipes[1]); fclose($pipes[2]);
        $status = proc_close($process);
        $assertSame(1, $status, "Installer followed a nested symlink: {$stdout} {$stderr}");
        $assert(str_contains((string) $stderr, 'contains a symlink'));
        $assertSame('unchanged', file_get_contents($sentinel));
        $assertSame(['sentinel'], array_values(array_diff(scandir($outside) ?: [], ['.', '..'])));
    }

    $installerSource = (string) file_get_contents($backendRoot . '/bin/safetun-install.php');
    foreach (['umask(0077)', 'foreach ([$realPrivate, $realPublic]', '$syncFile(', '$syncDirectory(',
        "@unlink(\$journalPath)", 'Lock files are stable inodes', 'Managed path contains a symlink'] as $required) {
        $assert(str_contains($installerSource, $required), "Transactional installer is missing durability guard: {$required}");
    }
});

$test('source has no database, eval, shell, or config execution primitive', static function () use ($backendRoot, $assert): void {
    $combined = '';
    $iterator = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($backendRoot . '/src', FilesystemIterator::SKIP_DOTS),
        RecursiveIteratorIterator::LEAVES_ONLY,
    );
    foreach ($iterator as $file) {
        if ($file->getExtension() === 'php') {
            $combined .= (string) file_get_contents($file->getPathname());
        }
    }
    foreach (['eval(', 'shell_exec(', 'exec(', 'system(', 'passthru(', 'mysqli', 'new PDO', 'sqlite'] as $forbidden) {
        $assert(!str_contains($combined, $forbidden), "Forbidden source primitive found: {$forbidden}");
    }
    $parser = (string) file_get_contents($backendRoot . '/src/Config/LiteralConfigParser.php');
    $assert(!preg_match('/\b(?:include|require)(?:_once)?\b/', $parser));
});

$test('all backend PHP files pass php -l', static function () use ($backendRoot, $childCommand, $assert): void {
    if (!function_exists('proc_open')) {
        throw new RuntimeException('SKIP: proc_open is unavailable.');
    }
    $iterator = new RecursiveIteratorIterator(
        new RecursiveDirectoryIterator($backendRoot, FilesystemIterator::SKIP_DOTS),
        RecursiveIteratorIterator::LEAVES_ONLY,
    );
    foreach ($iterator as $file) {
        if ($file->getExtension() !== 'php') {
            continue;
        }
        $command = $childCommand(['-l', $file->getPathname()]);
        $pipes = [];
        $process = proc_open($command, [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, null, ['bypass_shell' => true]);
        $assert(is_resource($process), 'Unable to invoke php -l.');
        fclose($pipes[0]);
        $output = stream_get_contents($pipes[1]) . stream_get_contents($pipes[2]);
        fclose($pipes[1]); fclose($pipes[2]);
        $status = proc_close($process);
        $assert($status === 0, "php -l failed for {$file->getPathname()}: {$output}");
    }
});

try {
    foreach ($tests as [$name, $callable]) {
        try {
            $callable();
            $passed++;
            echo "[PASS] {$name}\n";
        } catch (RuntimeException $exception) {
            if (str_starts_with($exception->getMessage(), 'SKIP:')) {
                $skipped++;
                echo "[SKIP] {$name}: " . substr($exception->getMessage(), 5) . "\n";
                continue;
            }
            $failed++;
            echo "[FAIL] {$name}: {$exception->getMessage()}\n";
        } catch (Throwable $exception) {
            $failed++;
            echo "[FAIL] {$name}: " . $exception::class . ': ' . $exception->getMessage() . "\n";
        }
    }
} finally {
    $removeTree = static function (string $root): void {
        $resolvedTemp = realpath(sys_get_temp_dir());
        $resolvedRoot = realpath($root);
        if (!is_string($resolvedTemp) || !is_string($resolvedRoot)
            || !str_starts_with(strtolower($resolvedRoot), strtolower(rtrim($resolvedTemp, '/\\') . DIRECTORY_SEPARATOR . 'safetun-tests-'))) {
            return;
        }
        $iterator = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($resolvedRoot, FilesystemIterator::SKIP_DOTS),
            RecursiveIteratorIterator::CHILD_FIRST,
        );
        foreach ($iterator as $file) {
            if ($file->isDir() && !$file->isLink()) {
                @rmdir($file->getPathname());
            } else {
                @unlink($file->getPathname());
            }
        }
        @rmdir($resolvedRoot);
    };
    $removeTree($tmpRoot);
}

echo "\nResult: {$passed} passed, {$failed} failed, {$skipped} skipped.\n";
exit($failed === 0 ? 0 : 1);
