diff --git a/.github/workflows/portable.yml b/.github/workflows/portable.yml new file mode 100644 index 00000000..dfb72074 --- /dev/null +++ b/.github/workflows/portable.yml @@ -0,0 +1,31 @@ +name: Portable startup contracts +on: [push, pull_request] +jobs: + offline: + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest] + php: ['8.3', '8.4', '8.5'] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring, openssl, curl + coverage: none + - run: composer install --no-interaction --no-plugins --no-scripts --no-dev + - run: php tests/windows/constructor.php + - run: php tests/windows/constructor.php vendor/autoload.php --negative-control + - run: php tests/windows/constructor.php vendor/autoload.php --cache-negative-control + - run: php tests/windows/portable.php + - run: php tests/windows/bootstrap.php + - run: php tests/windows/bootstrap.php --negative-control + - run: php tests/windows/process.php + - run: php tests/windows/shell-guard.php + - run: php tests/windows/ready.php + - run: php tests/windows/ready.php vendor/autoload.php --unconfigured + - run: php -l bot.php + - run: php -l src/Civ13/Civ13.php + - run: php -l src/Civ13/GameServer.php diff --git a/bot.php b/bot.php index 80a87c88..2c9d16ab 100644 --- a/bot.php +++ b/bot.php @@ -75,10 +75,7 @@ return \getcwd() ?: __DIR__; })(); -if (! $autoloader = require file_exists(__DIR__.'/vendor/autoload.php') ? __DIR__.'/vendor/autoload.php' : $baseDir.'/vendor/autoload.php') { - throw new \Exception('Composer autoloader not found. Run `composer install`, or keep the binary inside the project directory.'); -} -function loadEnv(string $filePath): void +function loadEnv(string $filePath, bool $explicit = false): array { if (! file_exists($filePath)) { throw new Exception('The .env file does not exist.'); @@ -88,17 +85,42 @@ function loadEnv(string $filePath): void $trimmedLines = array_map('trim', $lines); $filteredLines = array_filter($trimmedLines, fn ($line) => $line && ! str_starts_with($line, '#')); - array_walk($filteredLines, function ($line) { + $names = []; + array_walk($filteredLines, function ($line) use ($explicit, &$names) { [$name, $value] = array_map('trim', explode('=', $line, 2)); - if (! array_key_exists($name, $_ENV)) { + $names[] = $name; + if ($explicit || ! array_key_exists($name, $_ENV)) { putenv(sprintf('%s=%s', $name, $value)); } }); + return $names; +} +require_once __DIR__.'/src/Civ13/PortableProfile.php'; +require_once __DIR__.'/src/Civ13/PortableProcess.php'; +$portable = null; +$portableLock = null; +$portablePath = getenv('CIVILIZATIONBOT_PROFILE'); +if (PHP_OS_FAMILY === 'Windows' && !$portablePath) { + throw new \RuntimeException('Windows requires an explicit portable profile; default production configuration is not permitted'); +} +if ($portablePath) { + $portable = PortableProfile::load($portablePath); + PortableProfile::assertRoot($portable, __DIR__); + $baseDir = __DIR__; + $errors = PortableProfile::preflight($portable, extension_loaded(...)); + if ($errors !== []) { throw new \RuntimeException(implode('; ', $errors)); } + $portableLock = \Civ13\PortableProcess::lock($portable['state_dir']); + if (is_file($portable['state_dir'].'/stop.request')) { throw new \RuntimeException('Unresolved stop request; inspect previous instance before starting'); } + chdir($portable['state_dir']); +} +if (! $autoloader = require file_exists(__DIR__.'/vendor/autoload.php') ? __DIR__.'/vendor/autoload.php' : $baseDir.'/vendor/autoload.php') { + throw new \Exception('Composer autoloader not found. Run `composer install`, or keep the binary inside the project directory.'); } -loadEnv($baseDir.'/.env'); +$loadedNames = loadEnv($portable['env_file'] ?? $baseDir.'/.env', $portable !== null); +if ($portable !== null && (!in_array('TOKEN', $loadedNames, true) || !getenv('TOKEN'))) { throw new \RuntimeException('Separate profile environment must explicitly supply TOKEN'); } -file_put_contents($baseDir.'/output.log', ''); // Clear the contents of 'output.log' -$fileHandler = (new StreamHandler($baseDir.'/output.log', Level::Debug))->setFormatter(new LineFormatter(null, null, true, true, true)); +if ($portable === null) { file_put_contents($baseDir.'/output.log', ''); } // Clear the contents of 'output.log' +$fileHandler = (new StreamHandler(($portable['state_dir'] ?? $baseDir).'/output.log', Level::Debug))->setFormatter(new LineFormatter(null, null, true, true, true)); $stdoutHandler = (new StreamHandler('php://stdout', Level::Debug))->setFormatter(new LineFormatter(null, null, true, true, true)); $logger = new Logger('Civ13', [$fileHandler, $stdoutHandler]); Loop::addPeriodicTimer(60 * 10, fn () => $logger->reset()); // Flush all buffers every 10 minutes @@ -138,7 +160,7 @@ function loadEnv(string $filePath): void $filesystem = FilesystemFactory::create(); include $baseDir.'/variable_functions.php'; -$http_whitelist = [ +$http_whitelist = $portable !== null ? ['127.0.0.1'] : [ $civ13_ip = gethostbyname('www.civ13.com'), $vzg_ip = gethostbyname('www.valzargaming.com'), $val_ip = gethostbyname('www.valgorithms.com'), @@ -286,6 +308,8 @@ function loadEnv(string $filePath): void foreach ($loadedData as $key => $value) $options[$key] = $value; */ +// Portable profiles supply their arrays below; never evaluate production defaults. +if ($portable === null) { // Production server defaults only. //TODO: Move this to a separate file, like .env $civ13_server_settings = [ // Server specific settings, listed in the order in which they appear on the VZG server list. 'tdm' => [ @@ -408,6 +432,11 @@ function loadEnv(string $filePath): void $civ14_server_settings[$key]['key'] = $key; } // Key is intended to be a shortname for the full server, so defining both a full name and short key are required. Individual server settings will also get passed around and lose their primary key, so we need to reassign it. +} else { + $civ13_server_settings = []; + $civ14_server_settings = []; +} + $hidden_options = [ 'loop' => Loop::get(), 'discord' => $discord, @@ -435,13 +464,37 @@ function loadEnv(string $filePath): void ], ], ]; +if ($portable !== null) { + // Replace the entire ambient destination/control configuration, never recursively merge it. + $options = PortableProfile::runtimeOptions($portable); + $hidden_options['http_key'] = PortableProfile::environmentValue('WEBAPI_TOKEN', $loadedNames); + $hidden_options['civ_token'] = PortableProfile::environmentValue('CIV_TOKEN', $loadedNames); + $civ13_server_settings = $portable['servers']; + $civ14_server_settings = []; + $hidden_options['civ13_server_settings'] = $civ13_server_settings; + $hidden_options['http_whitelist'] = ['127.0.0.1']; + $hidden_options['web_address'] = '127.0.0.1'; + $hidden_options['http_port'] = $portable['http_port']; + $hidden_options['functions'] = []; // No ambient production callbacks. + $options['verify_url'] = ''; // No authority writes or fallback to the official verifier. + $options['webserver_url'] = '127.0.0.1'; +} $options = array_merge($options, $hidden_options); +if ($portable !== null) { + Loop::addPeriodicTimer(1, static function () use (&$civ13, $portable): void { + if (is_file($portable['state_dir'].'/stop.request')) { + unlink($portable['state_dir'].'/stop.request'); + if ($civ13 !== null) { $civ13->stop(); } else { Loop::stop(); } + } + }); +} $civ13 = null; $global_error_handler = async(function (int $errno, string $errstr, ?string $errfile, ?int $errline) use (&$civ13, &$logger, &$testing) { /** @var ?Civ13 $civ13 */ if ( $civ13 && // If the bot is running + isset($civ13->channel_ids['staff_bot']) && ($channel = $civ13->discord->getChannel($civ13->channel_ids['staff_bot'])) // fsockopen && ! str_ends_with($errstr, 'Connection timed out') @@ -484,7 +537,7 @@ function loadEnv(string $filePath): void use Psr\Http\Message\ServerRequestInterface; $socket = new SocketServer( - sprintf('%s:%s', '0.0.0.0', getenv('http_port') ?: 55555), + sprintf('%s:%s', $portable !== null ? '127.0.0.1' : '0.0.0.0', $portable['http_port'] ?? (getenv('http_port') ?: 55555)), [ 'tcp' => [ 'so_reuseport' => true, @@ -565,7 +618,9 @@ function loadEnv(string $filePath): void use VerifierServer\Server as VerifierServer; -Loop::futureTick(async(static function () use (&$civ13, &$logger, $options, $civ13_server_settings, $civ14_server_settings) { +Loop::futureTick(async(static function () use (&$civ13, &$logger, $options, $civ13_server_settings, $civ14_server_settings, $portable) { + $verifier_server = null; + if ($portable === null) { $verifier_server = new VerifierServer( getenv('VERIFIER_HOST_ADDR'), intval(getenv('VERIFIER_HOST_PORT')) @@ -591,6 +646,7 @@ function loadEnv(string $filePath): void getenv('dwa_client_secret') ); + } // Portable instances do not construct the official verifier service. $civ13 = new Civ13( $options, $civ13_server_settings, diff --git a/config/examples/windows-test1714.json b/config/examples/windows-test1714.json new file mode 100644 index 00000000..5fe6558b --- /dev/null +++ b/config/examples/windows-test1714.json @@ -0,0 +1,33 @@ +{ + "schema": "civilizationbot.portable.v1", + "root": "D:/GitHub/Civilizationbot", + "env_file": "D:/Civilizationbot-local/separate.env", + "state_dir": "D:/Civilizationbot-local/state", + "gitdir": "D:/GitHub/Civ13", + "http_port": 25565, + "options": { + "civ13_guild_id": "", + "owner_id": "", + "technician_id": "", + "channel_ids": {}, + "role_ids": {}, + "folders": {"json": "json/"}, + "files": {} + }, + "servers": { + "test1714": { + "basedir": "D:/Civ13-local/TEST1714", + "name": "TEST1714", + "host": "local operator", + "ip": "127.0.0.1", + "port": 1714, + "enabled": false, + "supported": false, + "moderate": false, + "legacy_relay": false, + "discussion": "", "playercount": "", "ooc": "", "lobby": "", + "asay": "", "ic": "", "transit": "", "adminlog": "", + "debug": "", "garbage": "", "runtime": "", "attack": "" + } + } +} diff --git a/docs/windows-test1714.md b/docs/windows-test1714.md new file mode 100644 index 00000000..79873461 --- /dev/null +++ b/docs/windows-test1714.md @@ -0,0 +1,114 @@ +# Windows TEST1714 startup (stable branch) + +This profile is an explicit opt-in to the stable bootstrap. It does not import V5, +install a service, run a game server, or share the Debian bot environment file. +Windows refuses the ambient production bootstrap without a profile. Linux without +CIVILIZATIONBOT_PROFILE retains its existing behavior. + +Install PHP 8.3+ CLI and Composer dependencies in this checkout with Composer's +platform checks enabled. Required extensions: json, mbstring, openssl, filter, +zlib, curl. Use a 64-bit PHP build. Optional event-loop accelerators are not +required. The portable preflight checks the PHP 8.3 floor before loading dependencies. +Do not copy an official token, environment file, verification store or runtime data. +A separate application/token must be provisioned directly by its operator. + +Copy config/examples/windows-test1714.json to an operator-owned absolute path and +edit that copy. Set your own civ13_guild_id, owner_id and technician_id explicitly; +empty identity strings disable those identity matches and never inherit production +owner/technician IDs. owner_id must match the target guild owner's Discord ID for +message commands. The bot must already belong to the configured guild, with the +required gateway intents enabled for its separate application. Missing optional +verifier-status channels are ignored; a missing guild skips guild command +registration with a warning. Create the configured state directory and game directory first. +root must match this checkout; basedir must be the intended PC TEST1714 runtime +root (SQL files are relative to it). The game remains independently operated. +The sample server is disabled and destination maps empty intentionally. Before +activation, supply reviewed channel/role IDs and confirm both bot identities and +Administrator bypass: channel denies cannot isolate an Administrator. No broad +guild permission changes are implied. Keep the local bot unable to view official +channels and the official bot unable to view local channels. + +From PowerShell, with explicit executable and profile paths: + +```powershell +C:/php/php.exe scripts/windows/control.php check D:/Civilizationbot-local/profile.json +# Only after the separate activation gate: +C:/php/php.exe scripts/windows/control.php start D:/Civilizationbot-local/profile.json +# From another terminal, requests graceful shutdown of this profile only: +C:/php/php.exe scripts/windows/control.php stop D:/Civilizationbot-local/profile.json +``` + +check reads profile metadata and file presence only, never the environment contents +or Discord. start runs one shell-free foreground PHP child; the bot holds an +exclusive state lock. A duplicate launch cannot enter the bot bootstrap after +that lock. stop writes a one-use marker consumed by the owning loop, not a process +name kill. A stop marker left by an absent/crashed process requires operator +inspection before restart. A stop request is not evidence that a process exited. +Only WEBAPI_TOKEN and CIV_TOKEN explicitly present in the separate environment file +are used; inherited values are ignored. Cache files are pinned under state/json. +The check/start/stop tools reject a profile belonging to a different checkout. + +Logs append to state/output.log, stdout.log and stderr.log. Protect this directory +with operator-only filesystem permissions and apply an external retention policy. +Restart commands exit gracefully in portable mode; the operator relaunches the +foreground supervisor. No self-spawning sudo/nohup path is used. + +Portable construction skips public-IP discovery, so startup does not depend on +ipecho.net or grant that discovered address local trust. Linux defaults are unchanged. +The HTTP listener binds 127.0.0.1 on the explicit profile port (distinct from1714). +Portable mode removes ambient callback functions, official verifier server +construction and the official verification URL. Verification integration remains +unavailable until a separate reviewed authoritative read-only contract is supplied. +Native Host/Kill/Restart/MapSwap fail closed in portable/Windows mode before game +messages or timers. This change supplies no game lifecycle replacement. + +Legacy shell maintenance is also unavailable in Windows/portable mode: chat +updatedeps/pullrepo/ts/ss14 and slash pull/update report unavailable rather than +claiming success; the staff panel omits its Git/Composer buttons. Legacy HTTP +/reset, /githubupdated, /pull, /update, /restart, /kill, /clearenv and +/cancelupdaterestart return 503 before any maintenance side effects. Use the +explicit foreground supervisor and stop marker for local bot lifecycle. The shared OS +helpers reject shell spawn, self-restart, process disown and port-based killing +before touching processes. This does not restrict the explicit owned argv-based +PHP supervisor or the separately configured local OpenDream adapter. Do code and +dependency maintenance manually while the bot is stopped. + +## Offline verification and activation boundary + +`php tests/windows/portable.php` uses temporary storage, synthetic environment +contents and an injected FakeProcess. It checks destination/path/port rejection, +dependency reporting, exclusive locking, stop replay, shell-free argv and actual +GameServer entry-point rejection before uninitialized game/Discord dependencies. +It never loads bot.php, connects Discord or launches a child process. + +`php tests/windows/constructor.php [ABSOLUTE_VENDOR_AUTOLOAD]` additionally runs +actual Civ13 construction through afterConstruct with an injected Discord event +emitter, non-running loop, temporary state and disabled DNS connector. It verifies +slash listeners omit unavailable SS14 verification and the registered read route +returns 503, never a successful empty list. It does not invoke the credential- +loading test bootstrap or start an event loop. CI installs dependencies with +Composer plugins/scripts disabled before running this contract. + +`php tests/windows/process.php` launches a real synthetic PHP child, checks shell +metacharacters survive as literal arguments, paths containing spaces, separate +stdout/stderr logs, cross-process lock exclusion, graceful stop marker consumption +and exit-code propagation. It cannot launch the bot or a game. CI runs these +checks on Windows and Linux with PHP 8.3, 8.4 and 8.5. + +`php tests/windows/shell-guard.php` installs process-function tripwires and uses +synthetic messages to prove unsupported maintenance rejects before any shell, +process inspection, timer-based restart or misleading success reaction. + +`php tests/windows/ready.php` emits init and fires the delayed bootstrap with a +controlled loop and synthetic Discord command repositories. It exercises real +Slash/Panels setup without connecting to Discord, and rejects network attempts. +The --unconfigured variant covers empty identities/channels and an absent guild; +both variants verify unsupported slash maintenance responds without adding timers. + +These tests do not establish live startup, gateway intents, command registration, +permissions, TEST1714 file IPC, process binding, or delivery. Before an authorized +activation: review the exact candidate/CI, provision separate credentials and +private destinations directly, verify runtime root/ports and channel isolation, +then obtain the operational coordinator's explicit launch/registration/test gate. +Debian deployment, game save/load/restart, and official token reuse remain outside +this change. diff --git a/scripts/windows/control.php b/scripts/windows/control.php new file mode 100644 index 00000000..be824082 --- /dev/null +++ b/scripts/windows/control.php @@ -0,0 +1,23 @@ +getMessage().PHP_EOL); exit(1); } diff --git a/scripts/windows/preflight.php b/scripts/windows/preflight.php new file mode 100644 index 00000000..999a6084 --- /dev/null +++ b/scripts/windows/preflight.php @@ -0,0 +1,16 @@ +'civilizationbot.preflight.v1','ok'=>$errors===[],'errors'=>$errors], JSON_THROW_ON_ERROR|JSON_PRETTY_PRINT).PHP_EOL; + exit($errors === [] ? 0 : 1); +} catch (Throwable $error) { + fwrite(STDERR, 'Portable configuration failed validation'.PHP_EOL); + exit(1); +} diff --git a/src/Civ13/Civ13.php b/src/Civ13/Civ13.php index 1beea869..50f70379 100644 --- a/src/Civ13/Civ13.php +++ b/src/Civ13/Civ13.php @@ -149,7 +149,7 @@ class Civ13 public Byond $byond; public Moderator $moderator; public Verifier $verifier; - public SS14Verifier $ss14verifier; + public ?SS14Verifier $ss14verifier = null; public string $welcome_message = ''; @@ -302,7 +302,7 @@ public function __construct( $this->filecache_path = $options['filecache_path']; } if (! is_dir($this->filecache_path)) { - mkdir($this->filecache_path, 0664, true); + mkdir($this->filecache_path, 0755, true); } if (isset($options['command_symbol']) && $options['command_symbol']) { @@ -424,7 +424,7 @@ private function afterConstruct( $this->__loadOrInitializeVariables(); new Moderator($this); new Verifier($this, $options); - new SS14Verifier($this); + if ($this->verifier_server !== null) { new SS14Verifier($this); } foreach ($civ13_server_settings as $gameserver_options) { new GameServer($this, $gameserver_options); } @@ -475,6 +475,8 @@ private function resolveOptions(array &$options = []): array 'loop', 'browser', 'filesystem', + 'filecache_path', + 'discover_external_ip', 'civ13_guild_id', 'civ_token', 'command_symbol', @@ -503,6 +505,7 @@ private function resolveOptions(array &$options = []): array 'webserver_url', ]) ->setDefaults([ + 'discover_external_ip' => true, 'welcome_message' => '', 'logger' => null, 'onFulfilledDefault' => null, @@ -555,6 +558,8 @@ private function resolveOptions(array &$options = []): array ->setAllowedTypes('functions', 'array') ->setAllowedTypes('loop', LoopInterface::class) ->setAllowedTypes('browser', ['null', Browser::class]) + ->setAllowedTypes('filecache_path', 'string') + ->setAllowedTypes('discover_external_ip', 'bool') ->setAllowedTypes('filesystem', ['null', AdapterInterface::class]) ->setAllowedTypes('civ13_guild_id', 'string') ->setAllowedTypes('civ_token', 'string') @@ -644,7 +649,7 @@ private function resolveOptions(array &$options = []): array $this->then = new PromiseMiddleware($this->onFulfilledDefault, $this->onRejectedDefault); foreach ($options['folders'] as $key => $value) { - if (! is_string($value) || (! is_dir($value) && ! @mkdir($value, 0664, true))) { + if (! is_string($value) || (! is_dir($value) && ! @mkdir($value, 0755, true))) { $this->logger->warning("`$value` is not a valid folder path!"); unset($options['folders'][$key]); } @@ -928,6 +933,10 @@ public function stop(bool $closeLoop = true): void */ public function restart(?string $file = null): PromiseInterface { + if (getenv('CIVILIZATIONBOT_PROFILE')) { + $this->stop(); + return \React\Promise\resolve(null); // Portable supervisor owns relaunch. + } return $this->restartPolicy->restart($file, fn () => $this->stop(), fn (?string $entry) => OSFunctions::restart($entry ?? $this->constructed_file)); } diff --git a/src/Civ13/GameServer.php b/src/Civ13/GameServer.php index 819a4829..dec01eed 100644 --- a/src/Civ13/GameServer.php +++ b/src/Civ13/GameServer.php @@ -768,6 +768,7 @@ public function DirectMessage(string $message, string $sender, string $recipient */ public function Host(?Message $message = null): void { + if (PHP_OS_FAMILY === 'Windows' || getenv('CIVILIZATIONBOT_PROFILE')) { throw new \RuntimeException('Native lifecycle controls are unavailable on Windows'); } OSFunctions::execInBackground("python3 {$this->basedir}".Civ13::updateserverabspaths); if (file_exists($this->basedir.Civ13::serverdata)) { OSFunctions::execInBackground("rm -f {$this->basedir}".Civ13::serverdata); @@ -789,6 +790,7 @@ public function Host(?Message $message = null): void /** Shuts the server down (via `killciv13`), optionally announcing it in OOC first. */ public function Kill(?Message $message = null, bool $notify = true): void { + if (PHP_OS_FAMILY === 'Windows' || getenv('CIVILIZATIONBOT_PROFILE')) { throw new \RuntimeException('Native lifecycle controls are unavailable on Windows'); } if ($notify) { $sender = ($message && $message->user_id) ? $this->civ13->verifier->getVerifiedItem($message->user_id)['ss13'] : ($this->civ13->discord->id ?? $this->civ13->discord->username); $this->OOCMessage("Server is shutting down. To get notified when we go live again, please join us on Discord at {$this->civ13->discord_formatted}", $sender); @@ -803,6 +805,7 @@ public function Kill(?Message $message = null, bool $notify = true): void /** Kills the server and re-hosts it 20s later, optionally announcing the restart in OOC. */ public function Restart(?Message $message = null, bool $notify = true): void { + if (PHP_OS_FAMILY === 'Windows' || getenv('CIVILIZATIONBOT_PROFILE')) { throw new \RuntimeException('Native lifecycle controls are unavailable on Windows'); } $this->Kill(null, false); $this->civ13->loop->addTimer(20, function () use ($message, &$gameserver): void { $this->Host($message); @@ -820,6 +823,7 @@ public function Restart(?Message $message = null, bool $notify = true): void */ public function MapSwap(string $mapto, string $admin): PromiseInterface { + if (PHP_OS_FAMILY === 'Windows' || getenv('CIVILIZATIONBOT_PROFILE')) { return reject(new \RuntimeException('Native map control is unavailable on Windows')); } $mapto = strtoupper($mapto); if (! file_exists($fp = $this->civ13->gitdir.Civ13::maps) || ! $file = @fopen($fp, 'r')) { $this->logger->error($err = "Unable to open `$fp` for reading."); diff --git a/src/Civ13/HttpHandler.php b/src/Civ13/HttpHandler.php index c90efa20..03d779ad 100644 --- a/src/Civ13/HttpHandler.php +++ b/src/Civ13/HttpHandler.php @@ -95,7 +95,7 @@ public function __construct(Civ13 &$civ13, array $handlers = [], array $whitelis $this->attributes['ratelimits'] = []; //$this->attributes['endpoints'] = []; - if ($external_ip = @file_get_contents('http://ipecho.net/plain')) { + if (($civ13->options['discover_external_ip'] ?? true) && ($external_ip = @file_get_contents('http://ipecho.net/plain'))) { $this->external_ip = $external_ip; } foreach ($whitelist as $ip) { diff --git a/src/Civ13/HttpServiceManager.php b/src/Civ13/HttpServiceManager.php index 9e3a3287..9bb5e2a0 100644 --- a/src/Civ13/HttpServiceManager.php +++ b/src/Civ13/HttpServiceManager.php @@ -431,6 +431,9 @@ function (ServerRequestInterface $request, string $endpoint, bool $whitelisted): ->offsetSet( '/reset', function (ServerRequestInterface $request, string $endpoint, bool $whitelisted): HttpResponse { + if (!OSFunctions::legacyShellAvailable()) { + return HttpResponse::plaintext('Legacy maintenance is unavailable in Windows/portable mode.')->withStatus(HttpResponse::STATUS_SERVICE_UNAVAILABLE); + } OSFunctions::execInBackground('git reset --hard origin/main'); $message = 'Forcefully moving the HEAD back to origin/main...'; if (isset($this->civ13->channel_ids['staff_bot']) && $channel = $this->discord->getChannel($this->civ13->channel_ids['staff_bot'])) { @@ -444,6 +447,9 @@ function (ServerRequestInterface $request, string $endpoint, bool $whitelisted): ->offsetSet( '/githubupdated', function (ServerRequestInterface $request, string $endpoint, bool $whitelisted): HttpResponse { + if (!OSFunctions::legacyShellAvailable()) { + return HttpResponse::plaintext('Legacy maintenance is unavailable in Windows/portable mode.')->withStatus(HttpResponse::STATUS_SERVICE_UNAVAILABLE); + } $secret = (string) getenv('github_secret'); $body = strval($request->getBody()); // Prefer the SHA-256 signature GitHub sends; fall back to SHA-1. @@ -499,6 +505,9 @@ function (ServerRequestInterface $request, string $endpoint, bool $whitelisted): ->offsetSet( '/cancelupdaterestart', function (ServerRequestInterface $request, string $endpoint, bool $whitelisted): HttpResponse { + if (!OSFunctions::legacyShellAvailable()) { + return HttpResponse::plaintext('Legacy maintenance is unavailable in Windows/portable mode.')->withStatus(HttpResponse::STATUS_SERVICE_UNAVAILABLE); + } if (isset($this->civ13->timers['restart_pending']) && $this->civ13->timers['restart_pending'] instanceof TimerInterface) { $this->discord->getLoop()->cancelTimer($this->civ13->timers['restart_pending']); unset($this->civ13->timers['restart_pending']); @@ -515,6 +524,9 @@ function (ServerRequestInterface $request, string $endpoint, bool $whitelisted): ->offsetSet( '/pull', function (ServerRequestInterface $request, string $endpoint, bool $whitelisted): HttpResponse { + if (!OSFunctions::legacyShellAvailable()) { + return HttpResponse::plaintext('Legacy maintenance is unavailable in Windows/portable mode.')->withStatus(HttpResponse::STATUS_SERVICE_UNAVAILABLE); + } OSFunctions::execInBackground('git pull'); $message = 'Updating code from GitHub...'; if (isset($this->civ13->channel_ids['staff_bot']) && $channel = $this->discord->getChannel($this->civ13->channel_ids['staff_bot'])) { @@ -528,6 +540,9 @@ function (ServerRequestInterface $request, string $endpoint, bool $whitelisted): ->offsetSet( '/update', function (ServerRequestInterface $request, string $endpoint, bool $whitelisted): HttpResponse { + if (!OSFunctions::legacyShellAvailable()) { + return HttpResponse::plaintext('Legacy maintenance is unavailable in Windows/portable mode.')->withStatus(HttpResponse::STATUS_SERVICE_UNAVAILABLE); + } OSFunctions::execInBackground('composer update'); $message = 'Updating dependencies...'; if (isset($this->civ13->channel_ids['staff_bot']) && $channel = $this->discord->getChannel($this->civ13->channel_ids['staff_bot'])) { @@ -541,6 +556,9 @@ function (ServerRequestInterface $request, string $endpoint, bool $whitelisted): ->offsetSet( '/restart', function (ServerRequestInterface $request, string $endpoint, bool $whitelisted): HttpResponse { + if (!OSFunctions::legacyShellAvailable()) { + return HttpResponse::plaintext('Legacy maintenance is unavailable in Windows/portable mode.')->withStatus(HttpResponse::STATUS_SERVICE_UNAVAILABLE); + } $message = 'Manually Restarting...'; if (isset($this->civ13->restart_message) && $this->civ13->restart_message instanceof Message) { $this->civ13->restart_message->edit(Civ13::createBuilder()->setContent('Manually Restarting...'))->then(fn () => $this->civ13->restart()); @@ -558,6 +576,9 @@ function (ServerRequestInterface $request, string $endpoint, bool $whitelisted): ->offsetSet( '/kill', function (ServerRequestInterface $request, string $endpoint, bool $whitelisted): HttpResponse { + if (!OSFunctions::legacyShellAvailable()) { + return HttpResponse::plaintext('Legacy maintenance is unavailable in Windows/portable mode.')->withStatus(HttpResponse::STATUS_SERVICE_UNAVAILABLE); + } $message = 'Shutting down...'; $this->discord->getLoop()->addTimer(3, fn () => $this->civ13->restart()); @@ -568,6 +589,9 @@ function (ServerRequestInterface $request, string $endpoint, bool $whitelisted): ->offsetSet( '/clearenv', function (ServerRequestInterface $request, string $endpoint, bool $whitelisted): HttpResponse { + if (!OSFunctions::legacyShellAvailable()) { + return HttpResponse::plaintext('Legacy maintenance is unavailable in Windows/portable mode.')->withStatus(HttpResponse::STATUS_SERVICE_UNAVAILABLE); + } $message = 'Clearing .env...'; if (file_exists($env = getcwd().'/.env')) { file_put_contents($env, ''); @@ -888,7 +912,7 @@ function sendGetRequest(endpoint) { ) ->offsetSet( '/ss14verified', - fn (ServerRequestInterface $request, string $endpoint, bool $whitelisted): HttpResponse => HttpResponse::json($this->civ13->ss14verifier->toArray()), + fn (ServerRequestInterface $request, string $endpoint, bool $whitelisted): HttpResponse => ($this->civ13->ss14verifier === null ? HttpResponse::json(['error' => 'SS14 verification unavailable'])->withStatus(503) : HttpResponse::json($this->civ13->ss14verifier->toArray())), false ) ->offsetSet( diff --git a/src/Civ13/MessageCommand/Commands/Civ14Host.php b/src/Civ13/MessageCommand/Commands/Civ14Host.php index 3b362604..a1c4dd91 100644 --- a/src/Civ13/MessageCommand/Commands/Civ14Host.php +++ b/src/Civ13/MessageCommand/Commands/Civ14Host.php @@ -28,6 +28,9 @@ class Civ14Host extends Civ13MessageCommand */ public function __invoke(Message $message, string $command, array $message_filtered): PromiseInterface { + if (!OSFunctions::legacyShellAvailable()) { + return $message->reply('This maintenance command is unavailable in Windows/portable mode.'); + } if (! $state = trim(substr($message_filtered['message_content_lower'], strlen($command)))) { return $this->civ13->reply($message, 'Wrong format. Please try `ss14 on` or `ss14 off`.'); } diff --git a/src/Civ13/MessageCommand/Commands/PullCivRepository.php b/src/Civ13/MessageCommand/Commands/PullCivRepository.php index 4e8113d5..e0ad9c4f 100644 --- a/src/Civ13/MessageCommand/Commands/PullCivRepository.php +++ b/src/Civ13/MessageCommand/Commands/PullCivRepository.php @@ -28,6 +28,9 @@ class PullCivRepository extends Civ13MessageCommand */ public function __invoke(Message $message, string $command, array $message_filtered): PromiseInterface { + if (!OSFunctions::legacyShellAvailable()) { + return $message->reply('This maintenance command is unavailable in Windows/portable mode.'); + } return $message->react( (is_dir($fp = $this->civ13->gitdir) && OSFunctions::execInBackground("git -C {$fp} pull")) ? '👍' diff --git a/src/Civ13/MessageCommand/Commands/TypeSpess.php b/src/Civ13/MessageCommand/Commands/TypeSpess.php index 60d6d014..724b7562 100644 --- a/src/Civ13/MessageCommand/Commands/TypeSpess.php +++ b/src/Civ13/MessageCommand/Commands/TypeSpess.php @@ -28,6 +28,9 @@ class TypeSpess extends Civ13MessageCommand */ public function __invoke(Message $message, string $command, array $message_filtered): PromiseInterface { + if (!OSFunctions::legacyShellAvailable()) { + return $message->reply('This maintenance command is unavailable in Windows/portable mode.'); + } if (! $state = trim(substr($message_filtered['message_content_lower'], strlen($command)))) { return $this->civ13->reply($message, 'Wrong format. Please try `ts on` or `ts off`.'); } diff --git a/src/Civ13/MessageCommand/Commands/UpdateDependencies.php b/src/Civ13/MessageCommand/Commands/UpdateDependencies.php index f7cdd43a..985cd6bb 100644 --- a/src/Civ13/MessageCommand/Commands/UpdateDependencies.php +++ b/src/Civ13/MessageCommand/Commands/UpdateDependencies.php @@ -28,6 +28,9 @@ class UpdateDependencies extends Civ13MessageCommand */ public function __invoke(Message $message, string $command, array $message_filtered): PromiseInterface { + if (!OSFunctions::legacyShellAvailable()) { + return $message->reply('This maintenance command is unavailable in Windows/portable mode.'); + } return $message->react( OSFunctions::execInBackground('composer update') ? '👍' diff --git a/src/Civ13/MessageServiceManager.php b/src/Civ13/MessageServiceManager.php index d0493cb2..de7758c5 100644 --- a/src/Civ13/MessageServiceManager.php +++ b/src/Civ13/MessageServiceManager.php @@ -132,9 +132,7 @@ private function __generateGlobalMessageCommands(): void ->offsetSet('cleanupgamelogs', new Commands\Civ13CleanupGameLogs($this->civ13), ['Chief Technical Officer']) ->offsetSet('playerlist', new Commands\Civ13PlayerList($this->civ13), ['Chief Technical Officer']) ->offsetSet('civ13register', new Commands\Civ13Register($this->civ13), ['Chief Technical Officer']) - ->offsetSet('civ14register', new Commands\Civ14Register($this->civ13), ['Chief Technical Officer']) ->offsetSet('civ13unverify', new Commands\Civ13UnVerify($this->civ13), ['Chief Technical Officer']) - ->offsetSet('civ14unverify', new Commands\Civ14UnVerify($this->civ13), ['Chief Technical Officer']) ->offsetSet('dumpappcommands', new Commands\DumpAppCommands($this->civ13), ['Chief Technical Officer']) ->offsetSet('ages', new Commands\Ages($this->civ13), ['Chief Technical Officer']) ->offsetSet('cv2poll', new Commands\CV2Poll($this->civ13), ['Ambassador']) @@ -227,6 +225,10 @@ function (Message $message, string $command, array $message_filtered): PromiseIn ['Admin'] ); } + if ($this->civ13->ss14verifier !== null) { + $this->messageHandler->offsetSet('civ14register', new Commands\Civ14Register($this->civ13), ['Chief Technical Officer']); + $this->messageHandler->offsetSet('civ14unverify', new Commands\Civ14UnVerify($this->civ13), ['Chief Technical Officer']); + } if (isset($this->civ13->ss14verifier, $this->civ13->role_ids['SS14 Verified'])) { $this->messageHandler->offsetSet('verifyme', new Commands\Civ14Verify($this->civ13)); } diff --git a/src/Civ13/OSFunctions.php b/src/Civ13/OSFunctions.php index d514ecf8..5e281601 100644 --- a/src/Civ13/OSFunctions.php +++ b/src/Civ13/OSFunctions.php @@ -27,6 +27,12 @@ */ class OSFunctions { + /** Legacy shell strings cannot establish ownership of Windows/portable children. */ + public static function legacyShellAvailable(): bool + { + return PHP_OS_FAMILY !== 'Windows' && !getenv('CIVILIZATIONBOT_PROFILE'); + } + public const array DEFAULT_PIPES = [ 0 => ['pipe', 'r'], 1 => ['pipe', 'w'], @@ -55,17 +61,17 @@ private static function readPipes(array &$pipes) * Spawns a child process to execute the given command. * * This function attempts to create a new process to run the specified command. - * On Windows systems, it returns null as the POSIX compatibility layer is insufficient. + * Windows and portable profiles reject legacy shell commands before any process is created. * On other systems, it uses `sudo` and `nohup` to run the command and sets up event listeners * to log the process's stdout data, end, error, and close events, as well as the exit event. * * @param string $cmd The command to be executed in the child process. - * @return PromiseInterface The created Process object, or null if running on Windows. + * @return PromiseInterface The created Process object, or a rejected promise in Windows/portable mode. */ public static function spawnChildProcess(string $cmd): PromiseInterface { - if (PHP_OS_FAMILY == 'Windows') { - return reject(new \Exception('Windows does not support POSIX compatibility layer')); + if (!self::legacyShellAvailable()) { + return reject(new \RuntimeException('Legacy shell operations are disabled in Windows/portable mode')); } $process = new Process("sudo nohup $cmd"); $process->stdout->on('end', static fn () => error_log('ended'.PHP_EOL)); @@ -80,9 +86,8 @@ public static function spawnChildProcess(string $cmd): PromiseInterface /** * Executes a command in the background. * - * This function executes a given command in the background, either on Windows or Unix-based systems. - * On Windows, it uses `popen` with the `start` command. - * On Unix-based systems, it uses `proc_open` with `sudo nohup`. + * This function retains the legacy Unix shell path. Windows and portable profiles + * reject it; use an explicitly owned argv-based process adapter instead. * * @param string $cmd The command to execute. * @return PromiseInterface A promise that resolves with the process resource on success, or rejects with an exception on failure. @@ -90,12 +95,8 @@ public static function spawnChildProcess(string $cmd): PromiseInterface */ public static function execInBackground(string $cmd): PromiseInterface { - if (PHP_OS_FAMILY == 'Windows') { - if (($p = popen("start {$cmd}", 'r')) === false) { - return reject(new MissingSystemPermissionException('popen() failed')); - } - - return resolve($p); + if (!self::legacyShellAvailable()) { + return reject(new \RuntimeException('Legacy shell operations are disabled in Windows/portable mode')); } if (! $proc = proc_open($cmd, self::DEFAULT_PIPES, $pipes)) { return reject(new MissingSystemPermissionException('proc_open() failed')); @@ -124,8 +125,8 @@ public static function execInBackground(string $cmd): PromiseInterface /** * Restarts the application by executing an external shell command. * - * On Windows, it runs a batch file (`run.bat`) located in the current working directory. - * On other operating systems, it runs a PHP script (`bot.php`) in the background using `nohup`. + * Windows and portable profiles reject self-spawning shell restarts. On other + * operating systems, the legacy PHP/nohup restart path is preserved. * * @return PromiseInterface A promise that resolves with the process resource on success, or rejects with a MissingSystemPermissionException on failure. * @@ -133,12 +134,8 @@ public static function execInBackground(string $cmd): PromiseInterface */ public static function restart(string $file = 'bot.php'): PromiseInterface { - if (PHP_OS_FAMILY == 'Windows') { - if (($p = popen('cmd /c "'.getcwd().'\run.bat"', 'r')) === false) { - return reject(new MissingSystemPermissionException('popen() failed')); - } - - return resolve($p); + if (!self::legacyShellAvailable()) { + return reject(new \RuntimeException('Legacy shell operations are disabled in Windows/portable mode')); } if (! $proc = proc_open($output = "sudo nohup php \"$file\" &", self::DEFAULT_PIPES, $pipes)) { return reject(new MissingSystemPermissionException('proc_open() failed')); @@ -176,6 +173,9 @@ public static function restart(string $file = 'bot.php'): PromiseInterface */ public static function disown($proc): void { + if (!self::legacyShellAvailable()) { + throw new \RuntimeException('Legacy shell operations are disabled in Windows/portable mode'); + } $proc_details = proc_get_status($proc); error_log("Disowning process {$proc_details['pid']}..."); if (isset($proc_details['pid']) && $proc_details['running']) { @@ -210,24 +210,15 @@ public static function portIsAvailable(int $port = 1714): PromiseInterface * @throws \RuntimeException If the command execution fails or no processes are found. * * Notes: - * - On Windows, it uses `netstat` to find processes and `taskkill` to terminate them. + * - Windows and portable profiles reject port-based process termination. * - On Unix-based systems, it uses `lsof` to find processes and `kill` to terminate them. * - Requires appropriate permissions to execute system commands. * - Logs the termination of each process using `error_log`. */ public static function killProcessOnPort(int $port): void { - if (PHP_OS_FAMILY == 'Windows') { - exec("netstat -ano | findstr :$port", $output); - foreach ($output as $line) { - if (preg_match('/\s+(\d+)$/', $line, $matches)) { - $pid = $matches[1]; - exec("taskkill /F /PID $pid"); - error_log("Terminated process with PID $pid on port $port"); - } - } - - return; + if (!self::legacyShellAvailable()) { + throw new \RuntimeException('Legacy shell operations are disabled in Windows/portable mode'); } exec("lsof -i :$port -t", $output); foreach ($output as $pid) { diff --git a/src/Civ13/Panels.php b/src/Civ13/Panels.php index f9780b03..cef5d2b8 100644 --- a/src/Civ13/Panels.php +++ b/src/Civ13/Panels.php @@ -158,10 +158,12 @@ private function staffPanel(): MessageBuilder }, 'Log cleanup queued.')), $this->staffButton(Button::primary(), '↻ Refresh', 'sp_refresh', fn (Interaction $i) => $i->updateMessage($this->staffPanel())), ]); - $components[] = ActionRow::new()->addComponents([ - $this->staffButton(Button::secondary(), '⬇️ Git Pull', 'sp_pull', fn (Interaction $i) => $this->bg($i, fn () => OSFunctions::execInBackground('git pull'), 'Pulling latest code…')), - $this->staffButton(Button::secondary(), '📦 Composer Update', 'sp_composer', fn (Interaction $i) => $this->bg($i, fn () => OSFunctions::execInBackground('composer update'), 'Updating dependencies…')), - ]); + if (OSFunctions::legacyShellAvailable()) { + $components[] = ActionRow::new()->addComponents([ + $this->staffButton(Button::secondary(), '⬇️ Git Pull', 'sp_pull', fn (Interaction $i) => $this->bg($i, fn () => OSFunctions::execInBackground('git pull'), 'Pulling latest code…')), + $this->staffButton(Button::secondary(), '📦 Composer Update', 'sp_composer', fn (Interaction $i) => $this->bg($i, fn () => OSFunctions::execInBackground('composer update'), 'Updating dependencies…')), + ]); + } $components[] = Separator::new(); $components[] = TextDisplay::new('-# Destructive actions run immediately. Every button re-checks your permission.'); diff --git a/src/Civ13/PortableProcess.php b/src/Civ13/PortableProcess.php new file mode 100644 index 00000000..90fee1be --- /dev/null +++ b/src/Civ13/PortableProcess.php @@ -0,0 +1,37 @@ +['pipe','r'],1=>['file',$state.'/stdout.log','a'],2=>['file',$state.'/stderr.log','a']], $state, ['bypass_shell'=>true]); + } + + /** Hold the returned handle for the lifetime of the child. */ + public static function lock(string $state): mixed + { + $handle = fopen($state.'/bot.lock', 'c+'); + if (!$handle || !flock($handle, LOCK_EX | LOCK_NB)) { + if (is_resource($handle)) { fclose($handle); } + throw new \RuntimeException('Portable instance already running'); + } + return $handle; + } + + public static function requestStop(string $state): void + { + $stream = @fopen($state.'/stop.request', 'x'); + if ($stream === false) { throw new \RuntimeException('Stop already requested or state unavailable'); } + fclose($stream); + } +} diff --git a/src/Civ13/PortableProfile.php b/src/Civ13/PortableProfile.php new file mode 100644 index 00000000..647cbf14 --- /dev/null +++ b/src/Civ13/PortableProfile.php @@ -0,0 +1,92 @@ + 65536) { throw new \RuntimeException('Profile unavailable or oversized'); } + $profile = json_decode($bytes, true, 16, JSON_THROW_ON_ERROR); + if (!is_array($profile) || ($profile['schema'] ?? null) !== 'civilizationbot.portable.v1') { throw new \InvalidArgumentException('Unsupported portable profile'); } + foreach (['root', 'env_file', 'state_dir', 'gitdir'] as $field) { + if (!is_string($profile[$field] ?? null) || !self::absolute($profile[$field]) || str_contains($profile[$field], "\0")) { throw new \InvalidArgumentException('Absolute configured path required: '.$field); } + } + if (!is_dir($profile['root']) || !is_dir($profile['state_dir']) || !is_dir($profile['gitdir'])) { throw new \InvalidArgumentException('Configured directories must exist'); } + if (!is_array($profile['servers'] ?? null) || count($profile['servers']) !== 1) { throw new \InvalidArgumentException('Portable profile requires exactly one explicitly configured test server'); } + foreach ($profile['servers'] as $key => &$server) { + if (!is_string($key) || !preg_match('/^[a-z0-9_]{1,32}$/D', $key) || !is_array($server) || !is_string($server['basedir'] ?? null) || !self::absolute($server['basedir']) || !is_dir($server['basedir']) || ($server['ip'] ?? null) !== '127.0.0.1' || ($server['port'] ?? null) !== 1714) { throw new \InvalidArgumentException('Expected explicit loopback TEST1714 server'); } + foreach (['name', 'host'] as $field) { + if (!is_string($server[$field] ?? null) || $server[$field] === '') { throw new \InvalidArgumentException('Explicit server identity required'); } + } + if (!is_bool($server['enabled'] ?? null)) { throw new \InvalidArgumentException('Explicit enabled flag required'); } + foreach (['discussion','playercount','ooc','lobby','asay','ic','transit','adminlog','debug','garbage','runtime','attack'] as $field) { + if (!is_string($server[$field] ?? null) || ($server[$field] !== '' && !preg_match('/^[0-9]{17,20}$/D', $server[$field]))) { throw new \InvalidArgumentException('Explicit channel string required'); } + } + // Stable GameServer stores its port as a strict string. + $server['port'] = (string) $server['port']; + $server['key'] = $key; + } + unset($server); + if (!is_int($profile['http_port'] ?? null) || $profile['http_port'] < 1024 || $profile['http_port'] > 65535 || $profile['http_port'] === 1714) { throw new \InvalidArgumentException('Distinct explicit local HTTP port required'); } + if (!is_array($profile['options'] ?? null)) { throw new \InvalidArgumentException('Explicit portable options required'); } + foreach (['channel_ids', 'role_ids'] as $field) { + if (!is_array($profile['options'][$field] ?? null)) { throw new \InvalidArgumentException('Explicit destination map required: '.$field); } + } + self::runtimeOptions($profile); + return $profile; + } + + /** Replace privileged production defaults; omitted local identities stay disabled. */ + public static function runtimeOptions(array $profile): array + { + $options = $profile['options']; + foreach (['owner_id', 'technician_id', 'civ13_guild_id'] as $field) { + $value = $options[$field] ?? ''; + if (!is_string($value) || ($value !== '' && preg_match('/^[0-9]{17,20}$/D', $value) !== 1)) { + throw new \InvalidArgumentException('Expected explicit Discord identity: '.$field); + } + $options[$field] = $value; + } + $options['gitdir'] = $profile['gitdir']; + $options['filecache_path'] = $profile['state_dir'].'/json/'; + $options['discover_external_ip'] = false; + $options['verify_url'] = ''; + $options['webserver_url'] = '127.0.0.1'; + return $options; + } + + /** An inherited process environment is not an explicit credential source. */ + public static function environmentValue(string $name, array $loadedNames): string + { + return in_array($name, $loadedNames, true) ? (getenv($name) ?: '') : ''; + } + + public static function assertRoot(array $profile, string $root): void + { + if (realpath($profile['root']) !== realpath($root)) { throw new \InvalidArgumentException('Profile belongs to another checkout'); } + } + + public static function absolute(string $path): bool + { + return str_starts_with($path, '/') || preg_match('~^[A-Za-z]:[\\\\/]~D', $path) === 1; + } + + /** No bootstrap, environment load, sockets or external effects. */ + public static function preflight(array $profile, callable $extension, string $version = PHP_VERSION): array + { + $errors = []; + if (version_compare($version, '8.3', '<')) { $errors[] = 'PHP 8.3 or newer required (typed class constants)'; } + foreach (['json', 'mbstring', 'openssl', 'filter', 'zlib', 'curl'] as $name) { + if (!$extension($name)) { $errors[] = 'Missing extension: '.$name; } + } + if (!is_file($profile['root'].'/vendor/autoload.php')) { $errors[] = 'Composer dependencies missing'; } + if (!is_file($profile['env_file'])) { $errors[] = 'Operator-provisioned separate environment file missing'; } + return $errors; + } +} diff --git a/src/Civ13/Slash.php b/src/Civ13/Slash.php index 0840c90b..c8a49392 100644 --- a/src/Civ13/Slash.php +++ b/src/Civ13/Slash.php @@ -398,7 +398,11 @@ private function __updateCommands(): void /** Freshens the Civ13 guild command list and (re)declares the guild-scoped interaction commands. */ private function __updateGuildCommands(): void { - $this->discord->guilds->get('id', $this->civ13->civ13_guild_id)->commands->freshen()->then(function (GuildCommandRepository $commands): void { + if (!$guild = $this->discord->guilds->get('id', $this->civ13->civ13_guild_id)) { + $this->logger->warning('Configured guild unavailable; guild command registration skipped'); + return; + } + $guild->commands->freshen()->then(function (GuildCommandRepository $commands): void { if ($names = array_map(fn (Command $command) => $command->name, iterator_to_array($commands))) { $this->logger->debug(sprintf('[GUILD APPLICATION COMMAND LIST] `%s`', implode('`, `', $names))); } @@ -566,6 +570,9 @@ private function __updateGuildCommands(): void private function __declareListeners(): void { $this->discord->listenCommand('pull', function (Interaction $interaction): PromiseInterface { + if (!OSFunctions::legacyShellAvailable()) { + return $this->respondWithMessage($interaction, 'This maintenance command is unavailable in Windows/portable mode.', true); + } $this->logger->info('[GIT PULL]'); OSFunctions::execInBackground('git pull'); $this->civ13->loop->addTimer(5, function () { @@ -585,6 +592,9 @@ private function __declareListeners(): void }); $this->discord->listenCommand('update', function (Interaction $interaction): PromiseInterface { + if (!OSFunctions::legacyShellAvailable()) { + return $this->respondWithMessage($interaction, 'This maintenance command is unavailable in Windows/portable mode.', true); + } $this->logger->info('[COMPOSER UPDATE]'); OSFunctions::execInBackground('composer update'); @@ -635,6 +645,7 @@ private function __declareListeners(): void : $this->respondWithMessage($interaction, Civ13::createBuilder()->setContent("<@{$interaction->data->target_id}> is not currently verified with a byond username or it does not exist in the cache yet"), true) ); + if ($this->civ13->ss14verifier !== null) { $this->discord->listenCommand( 'ss14', fn (Interaction $interaction): PromiseInterface => ($item = $this->civ13->ss14verifier->get('discord', $interaction->data->target_id)) @@ -642,6 +653,8 @@ private function __declareListeners(): void : $this->respondWithMessage($interaction, Civ13::createBuilder()->setContent("<@{$interaction->data->target_id}> is not currently verified with an SS14 account or it does not exist in the cache yet"), true) ); + } + $this->discord->listenCommand('bancheck', function (Interaction $interaction): PromiseInterface { if (! $item = $this->civ13->verifier->get('discord', $interaction->data->target_id)) { return $this->respondWithMessage($interaction, Civ13::createBuilder()->setContent("<@{$interaction->data->target_id}> is not currently verified with a byond username or it does not exist in the cache yet"), true); diff --git a/src/Civ13/Verifier.php b/src/Civ13/Verifier.php index aac6c524..2d86ee71 100644 --- a/src/Civ13/Verifier.php +++ b/src/Civ13/Verifier.php @@ -244,7 +244,7 @@ public function setup() /** Renames the `verifier-status` channel to `{name}-online` / `{name}-offline` to match the current verifier state, announcing the change. */ public function verifierStatusChannelUpdate(bool $status): ?PromiseInterface { - if (! $channel = $this->civ13->discord->getChannel($this->civ13->channel_ids['verifier-status'])) { + if (!isset($this->civ13->channel_ids['verifier-status']) || !($channel = $this->civ13->discord->getChannel($this->civ13->channel_ids['verifier-status']))) { return null; } [$verifier_name, $reported_status] = explode('-', $channel->name); @@ -970,6 +970,9 @@ public function fetchVerifiedData(): array ]; } + if ($this->verify_url === '') { + return ['json' => '', 'http_status' => 0]; + } $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, $this->verify_url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); diff --git a/tests/local/library.php b/tests/local/library.php index ec4bae54..63c25069 100644 --- a/tests/local/library.php +++ b/tests/local/library.php @@ -34,3 +34,20 @@ function assertLocal(bool $ok): void { if (!$ok) { throw new RuntimeException('R $options = ['restart_mode' => 'arbitrary'] + $adapters; try { $resolver->invokeArgs($instance, [&$options]); throw new RuntimeException('Invalid mode accepted'); } catch (Symfony\Component\OptionsResolver\Exception\InvalidOptionsException) {} echo "PASS injected restart policy: graceful-only, duplicate suppression, legacy delegation, failed-stop retry\n"; + +// Exercise actual merged Civ13 restart dispatch without initializing Discord or IO. +class PortableRestartProbe extends Civ13\Civ13 { + public int $stops = 0; + public function stop(bool $closeLoop = true): void { $this->stops++; } +} +$portable = (new ReflectionClass(PortableRestartProbe::class))->newInstanceWithoutConstructor(); +$oldProfile = getenv('CIVILIZATIONBOT_PROFILE'); +putenv('CIVILIZATIONBOT_PROFILE=synthetic-local-profile'); +try { + // restartPolicy remains deliberately uninitialized: portable must never reach it or OS spawn. + $portable->restart()->then(static fn ($result) => assertLocal($result === null)); + assertLocal($portable->stops === 1); +} finally { + $oldProfile === false ? putenv('CIVILIZATIONBOT_PROFILE') : putenv('CIVILIZATIONBOT_PROFILE='.$oldProfile); +} +echo "PASS actual merged portable restart stops without legacy/policy spawn\n"; diff --git a/tests/windows/bootstrap.php b/tests/windows/bootstrap.php new file mode 100644 index 00000000..8de7c28e --- /dev/null +++ b/tests/windows/bootstrap.php @@ -0,0 +1,71 @@ +$root,'ip'=>'127.0.0.1','port'=>1714,'name'=>'fixture','host'=>'fixture','enabled'=>false]; + foreach(['discussion','playercount','ooc','lobby','asay','ic','transit','adminlog','debug','garbage','runtime','attack'] as $key){$server[$key]='';} + $profile=['schema'=>'civilizationbot.portable.v1','root'=>$root,'state_dir'=>$root,'env_file'=>$root.'/separate.env','gitdir'=>$root,'http_port'=>25565,'options'=>['channel_ids'=>[],'role_ids'=>[]],'servers'=>['test'=>$server]]; + file_put_contents($root.'/profile.json',json_encode($profile,JSON_THROW_ON_ERROR)); + putenv('CIVILIZATIONBOT_PROFILE='.$root.'/profile.json'); + // This fresh CLI uses only a synthetic environment; no real credential file is read. + putenv('TOKEN');putenv('CIV_TOKEN');putenv('WEBAPI_TOKEN'); + $source=file_get_contents(__DIR__.'/../../bot.php'); + if($negative){ + $source=str_replace('if ($portable === null) { // Production server defaults only.','if (true) { // Original unconditional production defaults.',$source,$count); + check($count===1); + } + $boundary='$options = array_merge($options, $hidden_options);'; + $source=str_replace($boundary,$boundary.' $GLOBALS["bootstrap_options"]=$options; $GLOBALS["bootstrap_civ14"]=$civ14_server_settings; throw new \\BootstrapReached();',$source,$count); + check($count===1);file_put_contents($root.'/bot.php',$source); + set_error_handler(static function(int $severity,string $message,string $file,int $line):never{throw new ErrorException($message,0,$severity,$file,$line);}); + try { require $root.'/bot.php'; throw new RuntimeException('Bootstrap boundary not reached'); } + catch(BootstrapReached){ + check(!$negative);$options=$GLOBALS['bootstrap_options']; + check($options['http_whitelist']===['127.0.0.1'] && $options['http_port']===25565); + check($options['channel_ids']===[] && $options['role_ids']===[] && $options['functions']===[]); + check($options['http_key']==='' && $options['civ_token']==='' && $options['verify_url']===''); + check(array_keys($options['civ13_server_settings'])===['test'] && $GLOBALS['bootstrap_civ14']===[]); + check(!isset($civ13_ip,$vzg_ip,$val_ip)); + echo "PASS real portable bootstrap options with warnings fatal, inert dependencies, no production defaults/DNS/process/socket or live environment\n"; + } catch(ErrorException $error){ + if(!$negative || !str_contains($error->getMessage(),'Undefined variable $civ13_ip')){throw $error;} + echo "PASS negative control detects original portable production-IP warning before options replacement\n"; + } + } finally { + restore_error_handler();chdir($previous); + putenv($previousProfile===false?'CIVILIZATIONBOT_PROFILE':'CIVILIZATIONBOT_PROFILE='.$previousProfile); + if(isset($portableLock)&&is_resource($portableLock)){fclose($portableLock);} + $resolved=str_replace('\\','/',realpath($root)); + $remove=static function(string $path) use (&$remove,$resolved):void{ + $actual=str_replace('\\','/',realpath($path)); + if($actual!==$resolved&&!str_starts_with($actual,$resolved.'/')){throw new RuntimeException('Fixture cleanup path escaped');} + foreach(new DirectoryIterator($path) as $entry){if($entry->isDot())continue;$entry->isDir()?$remove($entry->getPathname()):unlink($entry->getPathname());}rmdir($path); + };$remove($root); + } +} diff --git a/tests/windows/constructor.php b/tests/windows/constructor.php new file mode 100644 index 00000000..689276ce --- /dev/null +++ b/tests/windows/constructor.php @@ -0,0 +1,77 @@ +addPsr4('Civ13\\', __DIR__.'/../../src/Civ13/', true); +$loader->addPsr4('Civ14\\', __DIR__.'/../../src/Civ14/', true); +final class OfflineDiscord extends Discord\Discord { + public function commands(): array { return $this->application_commands ?? []; } + public function __construct(private React\EventLoop\LoopInterface $offlineLoop) {} + public function getLoop(): React\EventLoop\LoopInterface { return $this->offlineLoop; } +} +$cacheNegative=($argv[2] ?? null)==='--cache-negative-control'; +if ($cacheNegative && PHP_OS_FAMILY === 'Windows') { echo "SKIP POSIX permission mutation on Windows\n"; return; } +$dir=sys_get_temp_dir().'/portable-constructor-'.bin2hex(random_bytes(8));mkdir($dir); +$previous=getcwd();chdir($dir); +// Disallow PHP stream HTTP too: disabling React DNS alone does not prevent it. +$blockedWrappers=[]; +foreach (['http','https'] as $scheme) { if (in_array($scheme,stream_get_wrappers(),true)) { stream_wrapper_unregister($scheme);$blockedWrappers[]=$scheme; } } +// Runtime warnings must fail this contract instead of passing CI with failed cache writes. +set_error_handler(static function(int $severity, string $message, string $file, int $line): bool { + if ($severity === E_WARNING) { throw new ErrorException($message, 0, $severity, $file, $line); } + return false; +}); +$negative=($argv[2] ?? null)==='--negative-control'; +if ($negative || $cacheNegative) { + $source=file_get_contents(__DIR__.'/../../src/Civ13/Civ13.php'); + $source=$cacheNegative ? str_replace('mkdir($this->filecache_path, 0755, true);','mkdir($this->filecache_path, 0664, true);',$source,$mutations) : str_replace('if ($this->verifier_server !== null) { new SS14Verifier($this); }','new SS14Verifier($this);',$source,$mutations); + if ($mutations!==1) { throw new RuntimeException('Negative control not applied'); } + file_put_contents($dir.'/Civ13.php',$source); + $loader->addClassMap(['Civ13\\Civ13'=>$dir.'/Civ13.php']); +} +try { + $loop=new React\EventLoop\StreamSelectLoop(); + $discord=new OfflineDiscord($loop); + $logger=new Monolog\Logger('offline',[new Monolog\Handler\NullHandler()]); + $options=['discord'=>$discord,'loop'=>$loop,'logger'=>$logger,'stats'=>(new ReflectionClass(Discord\Stats::class))->newInstanceWithoutConstructor(),'browser'=>new React\Http\Browser(new React\Socket\Connector(['dns'=>false],$loop)),'filesystem'=>new React\Filesystem\Fallback\Adapter(),'verify_url'=>'','functions'=>[],'channel_ids'=>[],'role_ids'=>[]]; + require_once __DIR__.'/../../src/Civ13/PortableProfile.php'; + $sample=json_decode(file_get_contents(__DIR__.'/../../config/examples/windows-test1714.json'), true, 16, JSON_THROW_ON_ERROR); + $profileOptions=($negative || $cacheNegative) ? [] : $sample['options']; + $options=array_merge($options, Civ13\PortableProfile::runtimeOptions(['options'=>$profileOptions, 'gitdir'=>$dir, 'state_dir'=>$dir])); + try { $bot=new Civ13\Civ13($options,[],[],null); } + catch (ErrorException $error) { + if ($cacheNegative && str_contains($error->getMessage(),'Permission denied')) { echo "PASS POSIX cache mutation rejects failed production writes\n"; return; } + throw $error; + } + catch (RuntimeException $error) { + if ($negative && $error->getMessage()==='No SS14 verifier server set.') { echo "PASS negative control detects original unconditional service crash\n"; return; } + throw $error; + } + if ($negative) { throw new RuntimeException('Original service crash was not detected'); } + if ($bot->owner_id!=='' || $bot->technician_id!=='' || $bot->civ13_guild_id!=='' || $bot->filecache_path!==$dir.'/json/') { throw new RuntimeException('Portable privileged identity or state isolation failed'); } + if ($bot->ss14verifier!==null || !isset($bot->httpServiceManager,$bot->messageServiceManager)) { throw new RuntimeException('Incomplete no-service construction'); } + if (PHP_OS_FAMILY !== 'Windows' && (fileperms($bot->filecache_path) & 0100) === 0) { throw new RuntimeException('Cache lacks owner search permission'); } + $cachePayload=['portable'=>true,'value'=>'cache round trip']; + if (!Civ13\OSFunctions::VarSave($bot->filecache_path,'portable-contract.json',$cachePayload,$logger) || Civ13\OSFunctions::VarLoad($bot->filecache_path,'portable-contract.json',$logger)!==$cachePayload) { throw new RuntimeException('Actual cache write/read failed'); } + if ($cacheNegative) { throw new RuntimeException('Broken POSIX directory permissions were not detected'); } + new Civ13\Slash($bot); + if (isset($discord->commands()['ss14']) || !isset($discord->commands()['ckey'])) { throw new RuntimeException('Dependent registration unavailable-mode mismatch'); } + (new ReflectionMethod(Civ13\MessageServiceManager::class,'__generateGlobalMessageCommands'))->invoke($bot->messageServiceManager); + if ($bot->messageServiceManager->messageHandler->offsetGet('civ13register','handlers')===null) { throw new RuntimeException('Control command was not registered'); } + foreach (['civ14register','civ14unverify','verifyme'] as $command) { + if ($bot->messageServiceManager->messageHandler->offsetGet($command,'handlers')!==null) { throw new RuntimeException('Unavailable SS14 message command registered'); } + } + (new ReflectionMethod(Civ13\HttpServiceManager::class,'__generateEndpoints'))->invoke($bot->httpServiceManager); + $callback=$bot->httpServiceManager->httpHandler->offsetGet('/ss14verified','handlers'); + $response=$callback(new React\Http\Message\ServerRequest('GET','http://127.0.0.1/ss14verified'),'/ss14verified',false); + if ($response->getStatusCode()!==503 || str_contains((string)$response->getBody(),'[]')) { throw new RuntimeException('Unavailable service reported successful empty data'); } + echo "PASS actual Civ13 afterConstruct and slash registration without SS14 service; no loop run\n"; +} finally { + restore_error_handler(); + foreach ($blockedWrappers as $scheme) { stream_wrapper_restore($scheme); } + chdir($previous); + $remove=function($path) use (&$remove) { foreach(new DirectoryIterator($path) as $entry) { if($entry->isDot())continue;$entry->isDir()?$remove($entry->getPathname()):unlink($entry->getPathname()); }rmdir($path); }; + $remove($dir); +} + diff --git a/tests/windows/portable.php b/tests/windows/portable.php new file mode 100644 index 00000000..16ccfa79 --- /dev/null +++ b/tests/windows/portable.php @@ -0,0 +1,43 @@ +25565,'schema'=>'civilizationbot.portable.v1','root'=>$dir,'state_dir'=>$dir,'env_file'=>$dir.'/separate.env','gitdir'=>$dir,'options'=>['channel_ids'=>[],'role_ids'=>[]],'servers'=>['test'=>['basedir'=>$dir,'ip'=>'127.0.0.1','port'=>1714,'name'=>'TEST1714','host'=>'fixture','enabled'=>false,'discussion'=>'','playercount'=>'','ooc'=>'','lobby'=>'','asay'=>'','ic'=>'','transit'=>'','adminlog'=>'','debug'=>'','garbage'=>'','runtime'=>'','attack'=>'']]]; + $file=$dir.'/profile.json';file_put_contents($file,json_encode($profile));$loaded=PortableProfile::load($file); + check($loaded['servers']['test']['port']==='1714'); + $options=PortableProfile::runtimeOptions($loaded); + foreach (['owner_id','technician_id','civ13_guild_id','verify_url'] as $field) { check($options[$field]===''); } + check($options['filecache_path']===$dir.'/json/'); + $name='CIVBOT_SYNTHETIC_TEST';$previous=getenv($name);putenv($name.'=fixture-only'); + try { check(PortableProfile::environmentValue($name,[])==='');check(PortableProfile::environmentValue($name,[$name])==='fixture-only'); } + finally { $previous===false ? putenv($name) : putenv($name.'='.$previous); } + $bad=$profile;$bad['options']['owner_id']='invalid';file_put_contents($file,json_encode($bad));rejects(fn()=>PortableProfile::load($file)); + + check(PortableProfile::preflight($loaded,fn($name)=>true,'8.3.0')===[]); + PortableProfile::assertRoot($loaded,$dir);rejects(fn()=>PortableProfile::assertRoot($loaded,dirname($dir))); + rejects(fn()=>PortableProfile::load('relative.json')); + $bad=$profile;$bad['http_port']=1714;file_put_contents($file,json_encode($bad));rejects(fn()=>PortableProfile::load($file)); + check(count(PortableProfile::preflight($loaded,fn($name)=>false,'8.2.0'))===7); + foreach (['ip'=>'civ13.com','port'=>1715,'basedir'=>'relative'] as $field=>$value) { $bad=$profile;$bad['servers']['test'][$field]=$value;file_put_contents($file,json_encode($bad));rejects(fn()=>PortableProfile::load($file)); } + $bad=$profile;unset($bad['options']['role_ids']);file_put_contents($file,json_encode($bad));rejects(fn()=>PortableProfile::load($file)); + $lock=PortableProcess::lock($dir);rejects(fn()=>PortableProcess::lock($dir));fclose($lock); + $lock=PortableProcess::lock($dir);fclose($lock); + $calls=[];$fake=PortableProcess::start(PortableProcess::argv(PHP_BINARY,$dir),$dir,function(...$args) use (&$calls) { $calls[]=$args;return 'FakeProcess'; }); + check($fake==='FakeProcess' && $calls[0][0]===[PHP_BINARY,$dir.'/bot.php'] && $calls[0][3]===['bypass_shell'=>true]); + PortableProcess::requestStop($dir);check(is_file($dir.'/stop.request'));rejects(fn()=>PortableProcess::requestStop($dir)); + require __DIR__.'/../../src/Civ13/GameServer.php'; + $server=(new ReflectionClass(\Civ13\GameServer::class))->newInstanceWithoutConstructor(); + $old=getenv('CIVILIZATIONBOT_PROFILE');putenv('CIVILIZATIONBOT_PROFILE=synthetic-profile'); + try { foreach (['Host','Kill','Restart'] as $method) { + try { $server->$method();throw new RuntimeException('Expected native control rejection'); } + catch (RuntimeException $error) { check($error->getMessage()==='Native lifecycle controls are unavailable on Windows'); } + } } finally { $old===false ? putenv('CIVILIZATIONBOT_PROFILE') : putenv('CIVILIZATIONBOT_PROFILE='.$old); } + echo "PASS portable profile isolation, PHP/extensions, wrong destination/port/path, shell-free FakeProcess and stop ownership\n"; +} finally { foreach (glob($dir.'/*') as $path) { if (is_dir($path)) { foreach (glob($path.'/*') as $child) { unlink($child); } rmdir($path); } else { unlink($path); } } rmdir($dir); } diff --git a/tests/windows/process.php b/tests/windows/process.php new file mode 100644 index 00000000..cf16326e --- /dev/null +++ b/tests/windows/process.php @@ -0,0 +1,57 @@ + $deadline) { exit(99); } + usleep(10000); +} +unlink("stop.request"); +fclose($lock); +exit(23); +'); + $literal = 'spaces & | %PATH% "quoted"'; + $argv = [...PortableProcess::argv(PHP_BINARY, $dir), $literal]; + $process = PortableProcess::start($argv, $dir, static function ($args, $descriptors, $cwd, $options) use (&$pipes) { + // A minimal explicit environment prevents accidental inherited bot credentials. + $environment = array_filter(['SystemRoot'=>getenv('SystemRoot'), 'LD_LIBRARY_PATH'=>getenv('LD_LIBRARY_PATH'), 'PHPRC'=>getenv('PHPRC'), 'PHP_INI_SCAN_DIR'=>getenv('PHP_INI_SCAN_DIR'), 'TEMP'=>sys_get_temp_dir(), 'TMP'=>sys_get_temp_dir()], static fn ($v) => $v !== false); + return proc_open($args, $descriptors, $pipes, $cwd, $environment, $options); + }); + if (!is_resource($process)) { throw new RuntimeException('Child failed to launch'); } + fclose($pipes[0]); + $deadline = microtime(true) + 10; + while (!is_file($dir.'/ready.json') && microtime(true) < $deadline) { usleep(10000); } + if (!is_file($dir.'/ready.json')) { throw new RuntimeException('Child readiness timed out'); } + [$cwd, $received] = json_decode(file_get_contents($dir.'/ready.json'), true, 8, JSON_THROW_ON_ERROR); + if (realpath($cwd) !== realpath($dir) || $received !== $literal) { throw new RuntimeException('Child path or literal argv corrupted'); } + try { $unexpected = PortableProcess::lock($dir); fclose($unexpected); throw new LogicException('Duplicate process lock accepted'); } + catch (RuntimeException $error) { if ($error->getMessage() !== 'Portable instance already running') { throw $error; } } + PortableProcess::requestStop($dir); + $exit = proc_close($process); $process = null; + if ($exit !== 23 || is_file($dir.'/stop.request')) { throw new RuntimeException('Child exit/stop marker mismatch'); } + if (!str_contains(file_get_contents($dir.'/stdout.log'), 'stdout fixture') || !str_contains(file_get_contents($dir.'/stderr.log'), 'stderr fixture')) { throw new RuntimeException('Child log redirection failed'); } + $lock = PortableProcess::lock($dir); fclose($lock); + echo "PASS real PHP child: literal argv, spaced path, log redirection, cross-process locking, stop and exit status\n"; +} finally { + // Only the exact child handle created by this test can be terminated on failure. + if (is_resource($process)) { proc_terminate($process); proc_close($process); } + foreach (glob($dir.'/*') as $path) { unlink($path); } + rmdir($dir); +} diff --git a/tests/windows/ready.php b/tests/windows/ready.php new file mode 100644 index 00000000..bf872bc6 --- /dev/null +++ b/tests/windows/ready.php @@ -0,0 +1,218 @@ +addPsr4('Civ13\\', __DIR__.'/../../src/Civ13/', true); + $loader->addPsr4('Civ14\\', __DIR__.'/../../src/Civ14/', true); + + final class ReadyConnector implements React\Socket\ConnectorInterface + { + public function connect($uri) { throw new RuntimeException('Offline test attempted a connection: '.$uri); } + } + final class ReadyInteraction extends Discord\Parts\Interactions\Interaction + { + public array $responses = []; + public function __construct(Discord\Discord $discord, array $attributes = [], bool $created = false) {} + public function respondWithMessage(Discord\Builders\MessageBuilder|string $builder, bool $ephemeral = false): React\Promise\PromiseInterface { + $this->responses[] = [is_string($builder) ? $builder : $builder->getContent(), $ephemeral]; + return React\Promise\resolve(null); + } + } + final class ReadyLoop implements React\EventLoop\LoopInterface + { + public array $timers = []; + public function addReadStream($stream, $listener) { throw new RuntimeException('Unexpected read stream'); } + public function addWriteStream($stream, $listener) { throw new RuntimeException('Unexpected write stream'); } + public function removeReadStream($stream) {} + public function removeWriteStream($stream) {} + public function addTimer($interval, $callback) { + $timer = new React\EventLoop\Timer\Timer($interval, $callback, false); + $this->timers[spl_object_id($timer)] = $timer; + return $timer; + } + public function addPeriodicTimer($interval, $callback) { + $timer = new React\EventLoop\Timer\Timer($interval, $callback, true); + $this->timers[spl_object_id($timer)] = $timer; + return $timer; + } + public function cancelTimer(React\EventLoop\TimerInterface $timer) { unset($this->timers[spl_object_id($timer)]); } + public function futureTick($listener) { throw new RuntimeException('Unexpected asynchronous IO'); } + public function addSignal($signal, $listener) { throw new RuntimeException('Unexpected signal'); } + public function removeSignal($signal, $listener) {} + public function run() { throw new RuntimeException('The offline loop must not run'); } + public function stop() {} + public function fireBootstrap(): void { + $matching = array_filter($this->timers, static fn ($timer) => !$timer->isPeriodic() && $timer->getInterval() === 5.0); + check(count($matching) === 1, 'Expected exactly one delayed bootstrap timer'); + $timer = reset($matching); + $this->cancelTimer($timer); + ($timer->getCallback())($timer); + } + } + + trait ReadyCommands + { + public array $saved = []; + public int $freshens = 0; + public function __construct($discord = null, array $vars = []) {} + public function freshen(array $queryparams = []): React\Promise\PromiseInterface { + ++$this->freshens; + return React\Promise\resolve($this); + } + public function save(Discord\Parts\Part $part, ?string $reason = null): React\Promise\PromiseInterface { + $this->saved[$part->name] = $part; + return React\Promise\resolve($part); + } + public function get(string $discrim, $key) { return $discrim === 'name' ? ($this->saved[$key] ?? null) : null; } + public function &getIterator(): Traversable { + foreach ($this->saved as $key => &$part) { yield $key => $part; } + } + public function delete($part, ?string $reason = null): React\Promise\PromiseInterface { + unset($this->saved[$part->name]); + return React\Promise\resolve($part); + } + } + final class ReadyGlobalCommands extends Discord\Repository\Interaction\GlobalCommandRepository { use ReadyCommands; } + final class ReadyGuildCommands extends Discord\Repository\Guild\GuildCommandRepository { use ReadyCommands; } + final class ReadyDiscord extends Discord\Discord + { + public ReadyGlobalCommands $globalCommands; + public ReadyGuildCommands $guildCommands; + private array $offline; + public int $channelLookups = 0; + public function __construct(private ReadyLoop $offlineLoop) { + $this->globalCommands = new ReadyGlobalCommands(); + $this->guildCommands = new ReadyGuildCommands(); + $guild = (object) ['id' => '100000000000000001', 'commands' => $this->guildCommands, 'members' => []]; + $this->offline = [ + 'application' => (object) ['commands' => $this->globalCommands], + 'guilds' => new Discord\Helpers\Collection([$guild], 'id'), + 'username' => 'offline-ready', 'discriminator' => '0000', 'user' => 'offline-user', + ]; + // No Discord constructor, credentials, HTTP transport or gateway connection. + $this->http = (new ReflectionClass(Discord\Http\Http::class))->newInstanceWithoutConstructor(); + $this->factory = new Discord\Factory\Factory($this); + } + public function __get(string $name) { return $this->offline[$name] ?? parent::__get($name); } + public function getLoop(): React\EventLoop\LoopInterface { return $this->offlineLoop; } + public function getChannel($channel_id): ?Discord\Parts\Channel\Channel { ++$this->channelLookups; return null; } + public function commands(): array { return $this->application_commands ?? []; } + } + function check(bool $condition, string $message): void { if (!$condition) { throw new RuntimeException($message); } } + + $unconfigured = ($argv[2] ?? null) === '--unconfigured'; + $dir = sys_get_temp_dir().'/portable-ready-'.bin2hex(random_bytes(8)); + mkdir($dir); + $previous = getcwd(); + chdir($dir); + $blockedWrappers = []; + foreach (['http', 'https'] as $scheme) { + if (in_array($scheme, stream_get_wrappers(), true)) { stream_wrapper_unregister($scheme); $blockedWrappers[] = $scheme; } + } + $oldProfile = getenv('CIVILIZATIONBOT_PROFILE'); + putenv('CIVILIZATIONBOT_PROFILE=offline-ready-test'); + $rejections = []; + React\Promise\set_rejection_handler(static function (Throwable $error) use (&$rejections): void { $rejections[] = $error; }); + set_error_handler(static function (int $severity, string $message, string $file, int $line): bool { + if ($severity === E_WARNING) { throw new ErrorException($message, 0, $severity, $file, $line); } + return false; + }); + try { + $loop = new ReadyLoop(); + $discord = new ReadyDiscord($loop); + $logger = new Monolog\Logger('offline-ready', [new Monolog\Handler\NullHandler()]); + $initCalls = $slashCalls = 0; + $sample = json_decode(file_get_contents(__DIR__.'/../../config/examples/windows-test1714.json'), true, 16, JSON_THROW_ON_ERROR); + $options = array_merge([ + 'discord' => $discord, 'loop' => $loop, 'logger' => $logger, + 'stats' => (new ReflectionClass(Discord\Stats::class))->newInstanceWithoutConstructor(), + 'browser' => new React\Http\Browser(new ReadyConnector()), + 'filesystem' => new React\Filesystem\Fallback\Adapter(), 'verify_url' => '', + 'functions' => [ + 'init' => [static function ($bot) use (&$initCalls): void { check($bot->ready, 'Init callback ran before readiness'); ++$initCalls; }], + 'ready_slash' => [static function ($slash, $commands) use (&$slashCalls): void { ++$slashCalls; }], + ], + 'channel_ids' => ['verifier-status' => '100000000000000002'], 'role_ids' => [], + ], Civ13\PortableProfile::runtimeOptions(['options' => $sample['options'], 'gitdir' => $dir, 'state_dir' => $dir])); + $options['channel_ids'] = ['verifier-status' => '100000000000000002']; + $options['civ13_guild_id'] = '100000000000000001'; + $options['owner_id'] = '100000000000000003'; + $options['technician_id'] = '100000000000000004'; + if ($unconfigured) { + $options['channel_ids'] = []; + $options['civ13_guild_id'] = $options['owner_id'] = $options['technician_id'] = ''; + } + $bot = new Civ13\Civ13($options, [], [], null); + check(!$bot->ready && !isset($bot->slash, $bot->panels), 'Bootstrap ran before init'); + $discord->emit('init', [$discord]); + check($bot->ready && $bot->verifier->ready && $bot->moderator->ready && $initCalls === 1, 'Real init path did not finish'); + check(!isset($bot->slash, $bot->panels), 'Delayed bootstrap ran early'); + $loop->fireBootstrap(); + check($bot->slash instanceof Civ13\Slash && $bot->panels instanceof Civ13\Panels, 'Delayed Slash/Panels bootstrap did not finish'); + check($slashCalls === 1 && $discord->globalCommands->freshens === 2 && $discord->guildCommands->freshens === ($unconfigured ? 0 : 1), 'Command repositories were not refreshed through real setup'); + foreach (['ping', 'help', 'staffpanel', 'playerpanel'] as $name) { + check(isset($discord->globalCommands->saved[$name], $discord->commands()[$name]), 'Missing global command or listener: '.$name); + } + foreach (['unban', 'parole', 'permit', 'revoke'] as $name) { + check(isset($discord->guildCommands->saved[$name]) === !$unconfigured, 'Missing guild command: '.$name); + } + check($bot->ss14verifier === null && !isset($discord->commands()['ss14']), 'Unavailable SS14 service registered'); + check(str_contains($bot->embed_footer, 'offline-ready#0000'), 'Ready Discord variables were not initialized'); + check(count($discord->listeners('GUILD_CREATE')) > 0, 'Real guild listener was not installed'); + foreach (['pull', 'update'] as $name) { + $interaction = new ReadyInteraction($discord); + $timerCount = count($loop->timers); + $callback = (new ReflectionProperty(Discord\Helpers\RegisteredCommand::class, 'callback'))->getValue($discord->commands()[$name]); + $callback($interaction); + check(count($interaction->responses) === 1 && str_contains($interaction->responses[0][0], 'unavailable') && $interaction->responses[0][1], 'Maintenance command did not fail closed: '.$name); + check(count($loop->timers) === $timerCount, 'Maintenance command scheduled background work: '.$name); + } + (new ReflectionMethod(Civ13\HttpServiceManager::class, '__generateEndpoints'))->invoke($bot->httpServiceManager); + // Synthetic local sentinel only: no environment file is read or loaded. + $sentinel = "OFFLINE_TEST_SENTINEL=keep-me\n"; + file_put_contents($dir.'/.env', $sentinel); + $bot->timers['restart_pending'] = $loop->addTimer(99, static function (): void { throw new RuntimeException('Unexpected restart'); }); + foreach (['/reset', '/githubupdated', '/pull', '/update', '/restart', '/kill', '/clearenv', '/cancelupdaterestart'] as $endpoint) { + $timersBefore = $loop->timers; + $botTimersBefore = $bot->timers; + $lookupsBefore = $discord->channelLookups; + $handler = $bot->httpServiceManager->httpHandler->offsetGet($endpoint, 'handlers'); + check(is_callable($handler), 'Missing maintenance endpoint: '.$endpoint); + $response = $handler(new React\Http\Message\ServerRequest('POST', 'http://127.0.0.1'.$endpoint), $endpoint, true); + check($response->getStatusCode() === 503 && str_contains((string) $response->getBody(), 'unavailable'), 'Maintenance endpoint reported success: '.$endpoint); + check($loop->timers === $timersBefore && $bot->timers === $botTimersBefore, 'Maintenance endpoint changed timers: '.$endpoint); + check($discord->channelLookups === $lookupsBefore, 'Maintenance endpoint attempted Discord notification: '.$endpoint); + check(file_get_contents($dir.'/.env') === $sentinel, 'Maintenance endpoint changed synthetic environment file: '.$endpoint); + } + check(!$rejections, 'Bootstrap promise rejected: '.($rejections ? (string) $rejections[0] : '')); + echo ($unconfigured ? "UNCONFIGURED " : "CONFIGURED ")."PASS real init and delayed Slash/Panels bootstrap; global/guild commands and listeners; eight HTTP maintenance guards; no network or loop run\n"; + } finally { + putenv($oldProfile === false ? 'CIVILIZATIONBOT_PROFILE' : 'CIVILIZATIONBOT_PROFILE='.$oldProfile); + restore_error_handler(); + React\Promise\set_rejection_handler(null); + foreach ($blockedWrappers as $scheme) { stream_wrapper_restore($scheme); } + chdir($previous); + $remove = function (string $path) use (&$remove): void { + foreach (new DirectoryIterator($path) as $entry) { + if ($entry->isDot()) { continue; } + $entry->isDir() ? $remove($entry->getPathname()) : unlink($entry->getPathname()); + } + rmdir($path); + }; + $remove($dir); + } +} diff --git a/tests/windows/shell-guard.php b/tests/windows/shell-guard.php new file mode 100644 index 00000000..bf19f4ba --- /dev/null +++ b/tests/windows/shell-guard.php @@ -0,0 +1,52 @@ +replies[] = $message; return React\Promise\resolve(null); } + public function react($emoticon): React\Promise\PromiseInterface { throw new LogicException('Maintenance reported success with a reaction'); } + } + $previous = getenv('CIVILIZATIONBOT_PROFILE'); + $expected = 'Legacy shell operations are disabled in Windows/portable mode'; + try { + // Native Windows is protected even without a profile; Unix default remains enabled. + putenv('CIVILIZATIONBOT_PROFILE'); + if (OSFunctions::legacyShellAvailable() !== (PHP_OS_FAMILY !== 'Windows')) { throw new RuntimeException('Default platform policy changed'); } + foreach (PHP_OS_FAMILY === 'Windows' ? ['', 'synthetic-profile'] : ['synthetic-profile'] as $profile) { + putenv('CIVILIZATIONBOT_PROFILE='.$profile); + foreach (['spawnChildProcess', 'execInBackground', 'restart'] as $method) { + $rejection = null; + OSFunctions::$method('never execute this')->then( + static function (): void { throw new LogicException('Legacy operation unexpectedly resolved'); }, + static function (Throwable $error) use (&$rejection): void { $rejection = $error; } + ); + if (!$rejection instanceof RuntimeException || $rejection->getMessage() !== $expected) { throw new RuntimeException('Missing legacy promise rejection: '.$method); } + } + $bot = (new ReflectionClass(Civ13\Civ13::class))->newInstanceWithoutConstructor(); + foreach (['UpdateDependencies', 'PullCivRepository', 'TypeSpess', 'Civ14Host'] as $name) { + $class = 'Civ13\\MessageCommand\\Commands\\'.$name; + $message = new OfflineMaintenanceMessage(); + (new $class($bot))($message, 'fixture', []); + if ($message->replies !== ['This maintenance command is unavailable in Windows/portable mode.']) { throw new RuntimeException('Maintenance did not report unavailable: '.$name); } + } + foreach ([['killProcessOnPort', 1714], ['disown', null]] as [$method, $argument]) { + try { OSFunctions::$method($argument); throw new LogicException('Legacy operation unexpectedly returned'); } + catch (RuntimeException $error) { if ($error->getMessage() !== $expected) { throw $error; } } + } + } + echo "PASS Windows/portable shell guards reject before spawn, restart, process inspection or port-based termination\n"; + } finally { + $previous === false ? putenv('CIVILIZATIONBOT_PROFILE') : putenv('CIVILIZATIONBOT_PROFILE='.$previous); + } +}