Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/portable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions bot.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,15 @@ 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');
if (PHP_OS_FAMILY === 'Windows' && !$portablePath) {
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__;
Expand Down
25 changes: 25 additions & 0 deletions docs/windows-test1714.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
10 changes: 9 additions & 1 deletion scripts/windows/control.php
Original file line number Diff line number Diff line change
Expand Up @@ -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=[];
Expand Down
73 changes: 73 additions & 0 deletions src/Civ13/DefaultBranchGuard.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Civ13;

/** Fail-closed portable bot source policy. No reset, checkout, stash, force or credential loading. */
final class DefaultBranchGuard
{
public const ORIGIN = 'https://github.com/Valgorithms/Civilizationbot.git';
public const BRANCH = 'main';

public static function verify(string $repository, bool $update = false, string $git = 'git', string $origin = self::ORIGIN): array
{
$root = realpath($repository);
if ($root === false) { throw new \RuntimeException('Default branch guard refused: repository unavailable'); }
$command = static fn (array $args): string => 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);
}
}
5 changes: 4 additions & 1 deletion tests/windows/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ function check(bool $ok): void { if (!$ok) { throw new RuntimeException('Bootstr
try {
file_put_contents($root.'/vendor/autoload.php','<?php return 1;');
foreach(['PortableProfile','PortableProcess'] as $class) { copy(__DIR__.'/../../src/Civ13/'.$class.'.php',$root.'/src/Civ13/'.$class.'.php'); }
// Git/network admission is covered by default-branch.php; this fixture
// isolates subsequent bootstrap option assembly with synthetic inputs.
file_put_contents($root.'/src/Civ13/DefaultBranchGuard.php', '<?php namespace Civ13; final class DefaultBranchGuard { public static function verify(string $root): array { $GLOBALS["bootstrap_guard_seen"] = true; return []; } }');
file_put_contents($root.'/variable_functions.php','<?php $status_changer_timer = static function () {}; $status_changer_random = static function () {};');
file_put_contents($root.'/separate.env',"TOKEN=synthetic-bootstrap-only\n");
$server=['basedir'=>$root,'ip'=>'127.0.0.1','port'=>1714,'name'=>'fixture','host'=>'fixture','enabled'=>false];
Expand All @@ -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']==='');
Expand Down
Loading
Loading