diff --git a/.github/workflows/portable.yml b/.github/workflows/portable.yml index 74a43752..a67f831f 100644 --- a/.github/workflows/portable.yml +++ b/.github/workflows/portable.yml @@ -20,6 +20,7 @@ jobs: - 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/default-branch.php - run: php tests/windows/hosted-verifier.php - run: php tests/windows/native-lifecycle.php - run: php tests/windows/bootstrap.php diff --git a/bot.php b/bot.php index cb2f6294..42396ca3 100644 --- a/bot.php +++ b/bot.php @@ -97,6 +97,7 @@ function loadEnv(string $filePath, bool $explicit = false): array } require_once __DIR__.'/src/Civ13/PortableProfile.php'; require_once __DIR__.'/src/Civ13/PortableProcess.php'; +require_once __DIR__.'/src/Civ13/DefaultBranchGuard.php'; $portable = null; $portableLock = null; $portablePath = getenv('CIVILIZATIONBOT_PROFILE'); @@ -104,6 +105,7 @@ function loadEnv(string $filePath, bool $explicit = false): array throw new \RuntimeException('Windows requires an explicit portable profile; default production configuration is not permitted'); } if ($portablePath) { + \Civ13\DefaultBranchGuard::verify(__DIR__); // Before profile credentials, Composer, sockets or Discord. $portable = PortableProfile::load($portablePath); PortableProfile::assertRoot($portable, __DIR__); $baseDir = __DIR__; diff --git a/docs/windows-test1714.md b/docs/windows-test1714.md index 0c1d7ff8..d7ee5c4c 100644 --- a/docs/windows-test1714.md +++ b/docs/windows-test1714.md @@ -32,6 +32,8 @@ From PowerShell, with explicit executable and profile paths: ```powershell C:/php/php.exe scripts/windows/control.php check D:/Civilizationbot-local/profile.json +# While stopped, explicitly fetch and fast-forward clean default-branch source: +C:/php/php.exe scripts/windows/control.php update 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: @@ -48,6 +50,23 @@ Only WEBAPI_TOKEN and CIV_TOKEN explicitly present in the separate environment f 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. +Portable start and direct portable bot.php startup verify source before loading +credentials, Composer or Discord. The checkout must be clean, attached to main, +track origin/main, and use https://github.com/Valgorithms/Civilizationbot.git as +its sole origin URL. Both cached and freshly advertised remote defaults must be +main. Each verification fetches main and compares its exact advertised commit; +offline or inconsistent evidence refuses startup. Detached/feature branches, +untracked changes, hidden index entries, sparse checkouts, ahead or diverged +local commits and stale source all refuse without changing working files. + +The explicit update command takes the bot's exclusive state lock and permits +only a clean fast-forward to freshly verified origin/main. It never resets, +stashes, cleans, switches branches, installs dependencies or restarts the bot. +Required unmerged local capabilities must land on main before startup can pass; +attaching an ahead checkout to main alone does not establish remote alignment. +check remains an offline profile/dependency check, and stop remains available +to request an owned instance's graceful shutdown regardless of source state. + 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 @@ -102,6 +121,12 @@ checks on Windows and Linux with PHP 8.3, 8.4 and 8.5. synthetic messages to prove unsupported maintenance rejects before any shell, process inspection, timer-based restart or misleading success reaction. +`php tests/windows/default-branch.php` uses real temporary local Git repositories +to verify named/default/upstream identity, safe fast-forward, preservation of +dirty/ahead/diverged source, and fail-closed offline/detached/feature controls. +It also exercises actual control start/update and direct portable bootstrap +refusal before environment contents, Composer or a bot process can be loaded. + `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. diff --git a/scripts/windows/control.php b/scripts/windows/control.php index be824082..3c6b8aad 100644 --- a/scripts/windows/control.php +++ b/scripts/windows/control.php @@ -2,16 +2,24 @@ declare(strict_types=1); require __DIR__.'/../../src/Civ13/PortableProfile.php'; require __DIR__.'/../../src/Civ13/PortableProcess.php'; +require __DIR__.'/../../src/Civ13/DefaultBranchGuard.php'; use Civ13\PortableProfile; use Civ13\PortableProcess; try { - if (count($argv)!==3 || !in_array($argv[1],['check','start','stop'],true)) { throw new RuntimeException('Usage: php control.php check|start|stop ABSOLUTE_PROFILE'); } + if (count($argv)!==3 || !in_array($argv[1],['check','start','stop','update'],true)) { throw new RuntimeException('Usage: php control.php check|start|stop|update ABSOLUTE_PROFILE'); } $profile=PortableProfile::load($argv[2]); PortableProfile::assertRoot($profile, dirname(__DIR__, 2)); if ($argv[1]==='stop') { PortableProcess::requestStop($profile['state_dir']); exit(0); } $errors=PortableProfile::preflight($profile,extension_loaded(...)); if ($errors!==[]) { throw new RuntimeException(implode('; ',$errors)); } if ($argv[1]==='check') { echo "Preflight passed; not started\n"; exit(0); } + if ($argv[1]==='update') { + $lock=PortableProcess::lock($profile['state_dir']); + try { echo json_encode(\Civ13\DefaultBranchGuard::verify($profile['root'],true),JSON_THROW_ON_ERROR)."\n"; } + finally { fclose($lock); } + exit(0); + } + \Civ13\DefaultBranchGuard::verify($profile['root']); if (is_file($profile['state_dir'].'/stop.request')) { throw new RuntimeException('Unresolved stop request; inspect previous instance before starting'); } putenv('CIVILIZATIONBOT_PROFILE='.$argv[2]); $pipes=[]; diff --git a/src/Civ13/DefaultBranchGuard.php b/src/Civ13/DefaultBranchGuard.php new file mode 100644 index 00000000..51445557 --- /dev/null +++ b/src/Civ13/DefaultBranchGuard.php @@ -0,0 +1,73 @@ + self::command($git, $root, $args); + $require = static function (bool $ok, string $reason): void { if (!$ok) { throw new \RuntimeException('Default branch guard refused: '.$reason); } }; + $require($command(['symbolic-ref','--quiet','--short','HEAD']) === self::BRANCH, 'named branch main required'); + $require($command(['rev-parse','--abbrev-ref','--symbolic-full-name','@{upstream}']) === 'origin/main', 'upstream origin/main required'); + $require($command(['symbolic-ref','--quiet','refs/remotes/origin/HEAD']) === 'refs/remotes/origin/main', 'local origin default main required'); + $require($command(['status','--porcelain=v1','--untracked-files=all']) === '', 'local source edits retained'); + $require($command(['remote','get-url','--all','origin']) === $origin, 'unexpected origin retained'); + foreach (explode("\n", $command(['ls-files','-v'])) as $line) { + $require(!preg_match('/^[a-z]|^S /', $line), 'hidden index state retained'); + } + $require(self::command($git,$root,['config','--bool','--get','core.sparseCheckout'],[0,1]) !== 'true', 'sparse checkout unsupported'); + $before = $command(['rev-parse','--verify','HEAD^{commit}']); + $command(['fetch','--no-tags','origin','refs/heads/main:refs/remotes/origin/main']); + $target = $command(['rev-parse','--verify','refs/remotes/origin/main^{commit}']); + $advertised = preg_split('/\r?\n/', $command(['ls-remote','--symref','--exit-code','origin','HEAD','refs/heads/main'])); + $symbolic = false; $identities = []; + foreach ($advertised as $line) { + $parts = preg_split('/\s+/', $line); + if ($parts === ['ref:','refs/heads/main','HEAD']) { $symbolic = true; } + elseif (count($parts) === 2) { $identities[$parts[1]] = $parts[0]; } + } + $require($symbolic, 'remote default branch is not main'); + $require(preg_match('/^[a-f0-9]{40}$/D',$target) === 1 && count($identities) === 2 + && ($identities['HEAD'] ?? null) === $target && ($identities['refs/heads/main'] ?? null) === $target, 'fetched and advertised main disagree'); + if ($before !== $target) { + self::command($git,$root,['merge-base','--is-ancestor',$before,$target],[0,1],$code); + $require($code === 0, 'local main ahead or diverged; unmerged commits retained'); + $require($update, 'main behind; explicit safe update required'); + $require($command(['symbolic-ref','--quiet','--short','HEAD']) === 'main' && $command(['status','--porcelain=v1','--untracked-files=all']) === '', 'source changed during verification'); + $command(['merge','--ff-only',$target]); + } + $require($command(['symbolic-ref','--quiet','--short','HEAD']) === 'main' + && $command(['rev-parse','HEAD']) === $target && $command(['status','--porcelain=v1','--untracked-files=all']) === '', 'final source identity changed'); + return ['branch'=>'main','upstream'=>'origin/main','commit'=>$target,'before'=>$before,'fresh_remote_verified'=>true]; + } + + private static function command(string $git, string $root, array $arguments, array $allowed = [0], ?int &$code = null): string + { + $environment = getenv(); + $environment['GIT_TERMINAL_PROMPT'] = '0'; + $environment['GCM_INTERACTIVE'] = 'Never'; + $pipes = []; $process = proc_open([$git,'-C',$root,...$arguments], [0=>['pipe','r'],1=>['pipe','w'],2=>['pipe','w']], $pipes, $root, $environment, ['bypass_shell'=>true]); + if (!is_resource($process)) { throw new \RuntimeException('Default branch guard refused: Git unavailable'); } + fclose($pipes[0]);stream_set_blocking($pipes[1],false);stream_set_blocking($pipes[2],false); + $started = hrtime(true); $output = ''; $stderrBytes = 0; $failed = false; + try { + do { + $output .= stream_get_contents($pipes[1]); $stderrBytes += strlen(stream_get_contents($pipes[2])); + $status = proc_get_status($process); + if (strlen($output)+$stderrBytes > 1048576 || (hrtime(true)-$started)/1e9 > 60) { $failed = true; proc_terminate($process); break; } + if ($status['running']) { usleep(10000); } + } while ($status['running']); + $output .= stream_get_contents($pipes[1]); + $code = $status['exitcode']; + } finally { fclose($pipes[1]);fclose($pipes[2]);proc_close($process); } + if ($failed || !in_array($code,$allowed,true)) { throw new \RuntimeException('Default branch guard refused: Git '.$arguments[0].' failed; source retained'); } + return trim($output); + } +} diff --git a/tests/windows/bootstrap.php b/tests/windows/bootstrap.php index 8de7c28e..9d55798a 100644 --- a/tests/windows/bootstrap.php +++ b/tests/windows/bootstrap.php @@ -26,6 +26,9 @@ function check(bool $ok): void { if (!$ok) { throw new RuntimeException('Bootstr try { file_put_contents($root.'/vendor/autoload.php','$root,'ip'=>'127.0.0.1','port'=>1714,'name'=>'fixture','host'=>'fixture','enabled'=>false]; @@ -46,7 +49,7 @@ function check(bool $ok): void { if (!$ok) { throw new RuntimeException('Bootstr 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(!$negative && ($GLOBALS['bootstrap_guard_seen'] ?? false));$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']===''); diff --git a/tests/windows/default-branch.php b/tests/windows/default-branch.php new file mode 100644 index 00000000..b5c9a892 --- /dev/null +++ b/tests/windows/default-branch.php @@ -0,0 +1,66 @@ +['pipe','r'],1=>['pipe','w'],2=>['pipe','w']],$pipes,null,null,['bypass_shell'=>true]); + check(is_resource($p));fclose($pipes[0]);$out=stream_get_contents($pipes[1]);$err=stream_get_contents($pipes[2]);fclose($pipes[1]);fclose($pipes[2]);$code=proc_close($p); + if($success&&$code!==0){throw new RuntimeException('Fixture Git/process operation failed');}return [$code,trim($out),$err]; +} +function git(string $root,...$args):string { return command(['git','-c','user.name=Fixture','-c','user.email=fixture@example.invalid','-C',$root,...$args])[1]; } +function refused(callable $call):void { try{$call();}catch(RuntimeException $error){check(str_contains($error->getMessage(),'Default branch guard refused'));return;}throw new RuntimeException('Expected guard refusal'); } +$root=sys_get_temp_dir().'/default-branch-'.bin2hex(random_bytes(8));mkdir($root); +$previousProfile=getenv('CIVILIZATIONBOT_PROFILE'); +try { + $origin=$root.'/origin.git';$seed=$root.'/seed';$repo=$root.'/runtime'; + command(['git','init','--bare','--initial-branch=main',$origin]);command(['git','clone',$origin,$seed]); + file_put_contents($seed.'/source.txt','first');git($seed,'add','source.txt');git($seed,'commit','-m','first');git($seed,'push','origin','main');command(['git','clone',$origin,$repo]); + $verify=static fn(bool $update=false)=>DefaultBranchGuard::verify($repo,$update,'git',$origin); + $first=git($repo,'rev-parse','HEAD');check($verify()['commit']===$first); + file_put_contents($repo.'/source.txt','user edit');refused(fn()=>$verify(true));check(file_get_contents($repo.'/source.txt')==='user edit');file_put_contents($repo.'/source.txt','first'); + file_put_contents($repo.'/notes.txt','unrelated');refused(fn()=>$verify(true));check(file_get_contents($repo.'/notes.txt')==='unrelated');unlink($repo.'/notes.txt'); + git($repo,'update-index','--assume-unchanged','source.txt');refused(fn()=>$verify());git($repo,'update-index','--no-assume-unchanged','source.txt'); + git($repo,'update-index','--skip-worktree','source.txt');refused(fn()=>$verify());git($repo,'update-index','--no-skip-worktree','source.txt'); + git($repo,'config','core.sparseCheckout','true');refused(fn()=>$verify());git($repo,'config','core.sparseCheckout','false'); + git($repo,'switch','-c','pr');refused(fn()=>$verify());git($repo,'switch','main'); + git($repo,'switch','--detach','HEAD');refused(fn()=>$verify());git($repo,'switch','main'); + git($repo,'branch','--unset-upstream');refused(fn()=>$verify());git($repo,'branch','--set-upstream-to=origin/main'); + git($seed,'push','origin','main:pr');git($repo,'fetch','origin','refs/heads/pr:refs/remotes/origin/pr'); + git($repo,'branch','--set-upstream-to=origin/pr');refused(fn()=>$verify());git($repo,'branch','--set-upstream-to=origin/main'); + git($repo,'symbolic-ref','refs/remotes/origin/HEAD','refs/remotes/origin/pr');refused(fn()=>$verify());git($repo,'remote','set-head','origin','main'); + git($origin,'symbolic-ref','HEAD','refs/heads/pr');refused(fn()=>$verify());git($origin,'symbolic-ref','HEAD','refs/heads/main'); + git($repo,'remote','set-url','origin',$root.'/unexpected.git');refused(fn()=>$verify());git($repo,'remote','set-url','origin',$origin); + file_put_contents($seed.'/source.txt','second');git($seed,'add','source.txt');git($seed,'commit','-m','second');git($seed,'push','origin','main'); + refused(fn()=>$verify());check(git($repo,'rev-parse','HEAD')===$first); + $second=git($seed,'rev-parse','HEAD');check($verify(true)['commit']===$second&&git($repo,'symbolic-ref','--short','HEAD')==='main'); + file_put_contents($repo.'/source.txt','reviewed local code');git($repo,'add','source.txt');git($repo,'commit','-m','preserve local');$ahead=git($repo,'rev-parse','HEAD'); + refused(fn()=>$verify(true));check(git($repo,'rev-parse','HEAD')===$ahead&&file_get_contents($repo.'/source.txt')==='reviewed local code'); + file_put_contents($seed.'/source.txt','remote third');git($seed,'add','source.txt');git($seed,'commit','-m','remote third');git($seed,'push','origin','main'); + refused(fn()=>$verify(true));check(git($repo,'rev-parse','HEAD')===$ahead); + check(str_starts_with(str_replace('\\','/',realpath($origin)),str_replace('\\','/',realpath($root)).'/'));rename($origin,$root.'/offline.git'); + refused(fn()=>$verify());check(git($repo,'rev-parse','HEAD')===$ahead); + + // Real control/direct bootstrap refuses detached source before credentials, Composer, spawn or network. + $app=$root.'/app';$state=$root.'/state';mkdir($app);mkdir($state);mkdir($app.'/vendor');mkdir($app.'/scripts');mkdir($app.'/scripts/windows');mkdir($app.'/src');mkdir($app.'/src/Civ13'); + foreach(['DefaultBranchGuard','PortableProfile','PortableProcess'] as $class){copy(__DIR__.'/../../src/Civ13/'.$class.'.php',$app.'/src/Civ13/'.$class.'.php');} + copy(__DIR__.'/../../scripts/windows/control.php',$app.'/scripts/windows/control.php');copy(__DIR__.'/../../bot.php',$app.'/bot.php');file_put_contents($app.'/vendor/autoload.php','$app,'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'=>$app,'state_dir'=>$state,'env_file'=>$root.'/separate.env','gitdir'=>$repo,'http_port'=>25565,'options'=>['channel_ids'=>[],'role_ids'=>[]],'servers'=>['test'=>$server]]; + file_put_contents($root.'/profile.json',json_encode($profile,JSON_THROW_ON_ERROR));git($app,'init','--initial-branch=main');git($app,'add','.');git($app,'commit','-m','fixture application');git($app,'switch','--detach','HEAD'); + foreach(['start','update']as$action){[$code,$out,$err]=command([PHP_BINARY,$app.'/scripts/windows/control.php',$action,$root.'/profile.json'],false);check($code!==0&&str_contains($err,'Default branch guard refused'));} + require_once __DIR__.'/../../src/Civ13/PortableProcess.php'; + $lock=\Civ13\PortableProcess::lock($state); + try { [$code,$out,$err]=command([PHP_BINARY,$app.'/scripts/windows/control.php','update',$root.'/profile.json'],false);check($code!==0&&str_contains($err,'Portable instance already running')); } + finally { fclose($lock); } + putenv('CIVILIZATIONBOT_PROFILE='.$root.'/profile.json');[$code,$out,$err]=command([PHP_BINARY,$app.'/bot.php'],false);check($code!==0&&str_contains($err,'Default branch guard refused')&&!str_contains($err,'Composer must not load')); + echo "PASS real Git default/main/upstream/origin, detached/PR/dirty/hidden/ahead/diverged/offline controls, safe FF and actual start/update/bootstrap precredential denial\n"; +} finally { + putenv($previousProfile===false?'CIVILIZATIONBOT_PROFILE':'CIVILIZATIONBOT_PROFILE='.$previousProfile); + $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 escaped');} + foreach(new DirectoryIterator($path)as$entry){if($entry->isDot())continue;if($entry->isDir()){$remove($entry->getPathname());}else{@chmod($entry->getPathname(),0600);unlink($entry->getPathname());}}rmdir($path); + };$remove($root); +}