/ (anything else is ignored): * * bin/gui/zagan-bet-gui---[.exe] * bin/client/zagan-bet-client---[.exe] * bin/server/zagan-bet-server---[.exe] * * is MAJOR.MINOR.PATCH, is linux|windows|macos and * is x86_64|i686|aarch64. * * SHA-256 values are cached in a JSON file keyed on size and mtime, so a file * is only re-read when it changes. The cache lives in the system temp * directory by default (the web root is often not writable by the PHP user); * set ZAGAN_HASH_CACHE to put it somewhere else. */ const KINDS = [ 'gui' => [ 'title' => 'Desktop app', 'hint' => 'Windows 10 or newer, or a Linux desktop. No terminal needed.', ], 'client' => [ 'title' => 'Client', 'hint' => 'Needs a terminal of at least 80×24 cells.', ], 'server' => [ 'title' => 'Server', 'hint' => 'Generates its own TLS identity on first run.', ], ]; const PLATFORMS = [ 'linux' => 'Linux', 'windows' => 'Windows', 'macos' => 'macOS', ]; const ARCHES = [ 'x86_64' => '64-bit', 'i686' => '32-bit', 'aarch64' => 'ARM64', ]; // Newest version first, then 64-bit builds before 32-bit. const PLATFORM_ORDER = ['linux' => 0, 'windows' => 1, 'macos' => 2]; const ARCH_ORDER = ['x86_64' => 0, 'aarch64' => 1, 'i686' => 2]; /** * Hash to use for a published file, reusing the cached value when the file has * not changed since it was hashed. * * A cached hash is valid only when the size and mtime still match and the hash * was taken at or after the file's mtime, so a restored or touched file is * re-read. * * @param array{files:array} $cache */ function cached_hash( string $key, string $path, int $size, int $mtime, array &$cache, bool &$dirty ): ?string { $entry = $cache['files'][$key] ?? null; if ( is_array($entry) && ($entry['size'] ?? null) === $size && ($entry['mtime'] ?? null) === $mtime && (int) ($entry['hashed_at'] ?? 0) >= $mtime && isset($entry['sha256']) && is_string($entry['sha256']) && preg_match('/\A[0-9a-f]{64}\z/', $entry['sha256']) === 1 ) { return $entry['sha256']; } $hash = hash_file('sha256', $path); if ($hash === false) { return null; } $cache['files'][$key] = [ 'size' => $size, 'mtime' => $mtime, 'sha256' => $hash, 'hashed_at' => time(), ]; $dirty = true; return $hash; } /** @return array{files:array} */ function load_hash_cache(string $path): array { $raw = @file_get_contents($path); if ($raw === false || $raw === '') { return ['files' => []]; } $decoded = json_decode($raw, true); if (!is_array($decoded) || !isset($decoded['files']) || !is_array($decoded['files'])) { return ['files' => []]; } return ['files' => $decoded['files']]; } /** * Best effort: a read-only web root, a full disk or a racing writer must never * break the page, so failures are ignored and the next request recomputes. * * @param array{files:array} $cache */ function save_hash_cache(string $path, array $cache): void { $json = json_encode($cache, JSON_UNESCAPED_SLASHES); if ($json === false) { return; } $directory = dirname($path); $temporary = @tempnam($directory, '.zagan-hashes-'); if ($temporary === false) { return; } if (@file_put_contents($temporary, $json, LOCK_EX) === false || !@rename($temporary, $path)) { @unlink($temporary); } } /** * @return array{kind:string,version:string,platform:string,arch:string,file:string,size:int,mtime:int,sha256:string}|null */ function read_release( string $kind, string $directory, string $entry, array &$cache, bool &$dirty ): ?array { $pattern = '/^zagan-bet-' . preg_quote($kind, '/') . '-v?(\d+\.\d+\.\d+)-([a-z]+)-([a-z0-9_]+)(\.exe)?$/i'; if (preg_match($pattern, $entry, $matches) !== 1) { return null; } $realDirectory = realpath($directory); $real = realpath($directory . '/' . $entry); if ($realDirectory === false || $real === false) { return null; } // Never serve anything that resolves outside the published directory. if (!str_starts_with($real, $realDirectory . DIRECTORY_SEPARATOR)) { return null; } if (!is_file($real)) { return null; } $size = filesize($real); $mtime = filemtime($real); if ($size === false || $mtime === false) { return null; } $hash = cached_hash($kind . '/' . $entry, $real, $size, $mtime, $cache, $dirty); if ($hash === null) { return null; } return [ 'kind' => $kind, 'version' => $matches[1], 'platform' => strtolower($matches[2]), 'arch' => strtolower($matches[3]), 'file' => $entry, 'size' => $size, 'mtime' => $mtime, 'sha256' => $hash, ]; } /** * Scan every kind and return its releases, newest version first. * * @return array{releases:array>,newest_mtime:int} */ function scan_releases(): array { $cachePath = getenv('ZAGAN_HASH_CACHE'); if (!is_string($cachePath) || $cachePath === '') { $cachePath = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'zagan-bet-hashes-' . substr(hash('sha256', __DIR__), 0, 16) . '.json'; } $cache = load_hash_cache($cachePath); $dirty = false; $seen = []; $releases = []; $newestMtime = 0; foreach (KINDS as $kind => $meta) { $found = []; $directory = __DIR__ . '/bin/' . $kind; if (is_dir($directory)) { $entries = scandir($directory); if ($entries !== false) { foreach ($entries as $entry) { if ($entry === '.' || $entry === '..') { continue; } $release = read_release($kind, $directory, $entry, $cache, $dirty); if ($release === null) { continue; } $seen[$kind . '/' . $entry] = true; $found[] = $release; $newestMtime = max($newestMtime, $release['mtime']); } } } usort($found, static function (array $a, array $b): int { $byVersion = version_compare($b['version'], $a['version']); if ($byVersion !== 0) { return $byVersion; } $byPlatform = (PLATFORM_ORDER[$a['platform']] ?? 9) <=> (PLATFORM_ORDER[$b['platform']] ?? 9); if ($byPlatform !== 0) { return $byPlatform; } $byArch = (ARCH_ORDER[$a['arch']] ?? 9) <=> (ARCH_ORDER[$b['arch']] ?? 9); if ($byArch !== 0) { return $byArch; } return strcmp($a['file'], $b['file']); }); $releases[$kind] = $found; } // Drop cache entries for files that are no longer published. foreach (array_keys($cache['files']) as $key) { if (!isset($seen[$key])) { unset($cache['files'][$key]); $dirty = true; } } if ($dirty) { save_hash_cache($cachePath, $cache); } return ['releases' => $releases, 'newest_mtime' => $newestMtime]; }