Files
2026-11-02 08:06:45 +02:00

296 lines
17 KiB
PHP

<?php
declare(strict_types=1);
namespace Tempo\Distribution;
use RuntimeException;
use ZipArchive;
/** Offline, immutable code releases. Never connects to a database or grants access. */
final class Release
{
private const LIMIT = 33554432;
private static function fail(bool $condition, string $message): void
{
if (! $condition) {
throw new RuntimeException($message);
}
}
private static function path(string $path): bool
{
return preg_match('~^(bootstrap\.php|module\.json|README\.md|src/[A-Za-z0-9_/]+\.php)$~D', $path) === 1
&& ! str_contains($path, '..') && ! str_contains($path, '//');
}
private static function json(string $path): array
{
self::fail(is_file($path) && ! is_link($path) && filesize($path) < self::LIMIT, 'Manifesto inexistente ou inválido.');
$value = json_decode(file_get_contents($path), true, 64, JSON_THROW_ON_ERROR);
self::fail(is_array($value), 'Manifesto inválido.');
return $value;
}
private static function write(string $path, string $content): void
{
self::fail(! is_link($path), 'Destino de gravação não pode ser um link.');
self::fail(file_put_contents($path, $content, LOCK_EX) === strlen($content), 'Não foi possível gravar o artefacto.');
}
private static function encode(array $value): string
{
return json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)."\n";
}
private static function version(string $version): void
{
self::fail(preg_match('/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/D', $version) === 1, 'Versão inválida.');
}
/** Publish a patch version only when canonical package contents have changed. */
public static function publish(string $source, string $channel): array
{
$source = realpath($source);
self::fail($source !== false && is_dir($source), 'Pacote de origem inexistente.');
$module = self::json($source.'/module.json');
self::fail(($module['package'] ?? '') === 'tempo/purchasing' && ($module['host_contract'] ?? null) === 1
&& ($module['database_changes'] ?? null) === false && ($module['commercial_permissions'] ?? null) === false, 'Contrato de distribuição incompatível.');
$contents = [];
$files = [];
$size = 0;
foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($source, \FilesystemIterator::SKIP_DOTS)) as $file) {
self::fail(! $file->isLink(), 'Links não são permitidos no pacote.');
if (! $file->isFile()) {
continue;
}
$path = str_replace('\\', '/', substr($file->getPathname(), strlen($source) + 1));
self::fail(self::path($path), 'Ficheiro fora do contrato do pacote: '.$path);
$size += $file->getSize();
self::fail($size <= self::LIMIT, 'Pacote demasiado grande.');
$contents[$path] = file_get_contents($file->getPathname());
$files[$path] = hash('sha256', $contents[$path]);
}
ksort($files);
self::fail(isset($files['bootstrap.php'], $files['module.json']) && count($files) <= 20000, 'Pacote incompleto.');
$digest = hash('sha256', self::encode($files));
self::fail(is_dir($channel) || mkdir($channel, 0700, true), 'Canal de distribuição indisponível.');
$lock = fopen($channel.'/.publish.lock', 'c');
self::fail($lock !== false && flock($lock, LOCK_EX), 'Publicação ocupada.');
try {
$catalog = is_file($channel.'/catalog.json') ? self::json($channel.'/catalog.json') : ['schema' => 1, 'releases' => []];
self::fail(($catalog['schema'] ?? null) === 1 && is_array($catalog['releases'] ?? null), 'Canal incompatível.');
$last = $catalog['releases'] === [] ? null : $catalog['releases'][array_key_last($catalog['releases'])];
if ($last !== null && ($last['source_sha256'] ?? null) === $digest) {
self::inspect($channel.'/'.$last['archive'], $last['manifest_sha256']);
return $last + ['changed' => false];
}
$version = $last === null ? '0.1.0' : implode('.', [...array_slice(explode('.', $last['version']), 0, 2), ((int) explode('.', $last['version'])[2]) + 1]);
self::version($version);
$manifest = ['schema' => 1, 'package' => 'tempo/purchasing', 'version' => $version,
'source_sha256' => $digest, 'module' => $module, 'files' => $files];
$manifestText = self::encode($manifest);
$filename = 'purchasing-'.$version.'.zip';
self::fail(! file_exists($channel.'/'.$filename), 'Versão já existe; não será sobrescrita.');
$temporary = $channel.'/.publish-'.bin2hex(random_bytes(8)).'.zip';
$zip = new ZipArchive;
self::fail($zip->open($temporary, ZipArchive::CREATE | ZipArchive::EXCL) === true, 'Não foi possível criar o pacote.');
foreach ($files as $path => $hash) {
self::fail($zip->addFromString('payload/'.$path, $contents[$path]), 'Falha ao empacotar código.');
}
$zip->addFromString('manifest.json', $manifestText);
self::fail($zip->close(), 'Falha ao concluir o pacote.');
$entry = ['version' => $version, 'archive' => $filename, 'source_sha256' => $digest, 'manifest_sha256' => hash('sha256', $manifestText)];
self::inspect($temporary, $entry['manifest_sha256']);
self::fail(rename($temporary, $channel.'/'.$filename), 'Falha ao publicar versão.');
$catalog['releases'][] = $entry;
self::write($channel.'/catalog.next.json', self::encode($catalog));
self::fail(rename($channel.'/catalog.next.json', $channel.'/catalog.json'), 'Falha ao publicar catálogo.');
return $entry + ['changed' => true];
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
/** Validate every archive byte and reject undeclared files before writing anywhere. */
public static function inspect(string $archive, string $expectedManifest): array
{
self::fail(preg_match('/^[a-f0-9]{64}$/D', $expectedManifest) === 1 && is_file($archive) && filesize($archive) <= self::LIMIT, 'Pacote ou referência de confiança inválidos.');
$zip = new ZipArchive;
self::fail($zip->open($archive, ZipArchive::RDONLY) === true, 'Pacote ilegível.');
try {
$metadata = $zip->statName('manifest.json');
self::fail(is_array($metadata) && $metadata['size'] < 1048576 && $zip->numFiles < 20001, 'Manifesto ou arquivo demasiado grande.');
$raw = $zip->getFromName('manifest.json');
self::fail(is_string($raw) && strlen($raw) < 1048576 && hash_equals($expectedManifest, hash('sha256', $raw)), 'Manifesto diferente da referência aprovada.');
$manifest = json_decode($raw, true, 64, JSON_THROW_ON_ERROR);
self::fail(($manifest['schema'] ?? null) === 1 && ($manifest['package'] ?? '') === 'tempo/purchasing'
&& ($manifest['module']['host_contract'] ?? null) === 1 && ($manifest['module']['database_changes'] ?? null) === false
&& ($manifest['module']['commercial_permissions'] ?? null) === false, 'Contrato do pacote incompatível.');
self::version($manifest['version']);
self::fail(version_compare(PHP_VERSION, $manifest['module']['minimum_php'], '>='), 'Versão PHP incompatível.');
$files = $manifest['files'] ?? [];
self::fail(is_array($files) && isset($files['bootstrap.php'], $files['module.json']) && count($files) < 20000, 'Pacote incompleto.');
$seen = [];
$contents = [];
$size = 0;
for ($index = 0; $index < $zip->numFiles; $index++) {
$stat = $zip->statIndex($index);
$name = $stat['name'];
self::fail(! isset($seen[$name]), 'Entrada duplicada no pacote.');
$seen[$name] = true;
$size += $stat['size'];
self::fail($size <= self::LIMIT, 'Pacote expandido demasiado grande.');
if ($name === 'manifest.json') {
continue;
}
self::fail(str_starts_with($name, 'payload/'), 'Entrada inesperada no pacote.');
$path = substr($name, 8);
self::fail(self::path($path) && isset($files[$path]), 'Caminho fora do pacote.');
$value = $zip->getFromIndex($index);
self::fail(is_string($value) && hash_equals($files[$path], hash('sha256', $value)), 'Código divergente do manifesto.');
$contents[$path] = $value;
}
self::fail(count($contents) === count($files), 'Faltam ficheiros no pacote.');
$sorted = $files;
ksort($sorted);
self::fail(hash_equals($manifest['source_sha256'], hash('sha256', self::encode($sorted))), 'Identidade da origem inválida.');
self::fail(json_decode($contents['module.json'], true, 32, JSON_THROW_ON_ERROR) === $manifest['module'], 'Metadados do módulo divergentes.');
return ['manifest' => $manifest, 'manifest_text' => $raw, 'contents' => $contents];
} finally {
$zip->close();
}
}
/** Read the installed pointer and verify its immutable source files. */
public static function current(string $host): ?array
{
$base = rtrim($host, '/\\').'/packages/purchasing';
if (! is_file($base.'/current.json')) {
return null;
}
return self::verifiedPointer($host, self::json($base.'/current.json'));
}
private static function verifiedPointer(string $host, array $pointer): array
{
$base = rtrim($host, '/\\').'/packages/purchasing';
self::version($pointer['version']);
$release = $base.'/releases/'.$pointer['version'];
$realHost = str_replace('\\', '/', realpath($host) ?: '');
$realRelease = str_replace('\\', '/', realpath($release) ?: '');
self::fail($realHost !== '' && str_starts_with(strtolower($realRelease), strtolower($realHost).'/packages/purchasing/releases/'), 'Caminho de versão fora da instalação.');
$manifest = self::json($release.'/manifest.json');
self::fail(($manifest['version'] ?? '') === $pointer['version'] && ($manifest['package'] ?? '') === 'tempo/purchasing', 'Identidade da versão instalada inválida.');
self::fail(hash_equals($pointer['manifest_sha256'], hash_file('sha256', $release.'/manifest.json')), 'Versão instalada diverge da referência.');
foreach ($manifest['files'] as $path => $hash) {
$realFile = str_replace('\\', '/', realpath($release.'/'.$path) ?: '');
self::fail(self::path($path) && ! is_link($release.'/'.$path) && is_file($release.'/'.$path)
&& str_starts_with(strtolower($realFile), strtolower($realRelease).'/')
&& hash_equals($hash, hash_file('sha256', $release.'/'.$path)), 'Versão instalada foi alterada: '.$path);
}
foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($release, \FilesystemIterator::SKIP_DOTS)) as $file) {
self::fail(! $file->isLink(), 'Link inesperado na versão instalada.');
if ($file->isFile()) {
$relative = str_replace('\\', '/', substr($file->getPathname(), strlen($release) + 1));
self::fail($relative === 'manifest.json' || isset($manifest['files'][$relative]), 'Ficheiro não declarado na versão instalada.');
}
}
return $pointer + ['path' => $release, 'capabilities' => $manifest['module']['capabilities']];
}
private static function activate(string $base, ?array $before, array $pointer, string $action): void
{
$pointer['audit_id'] = bin2hex(random_bytes(12));
self::fail(! is_link($base.'/history') && (is_dir($base.'/history') || mkdir($base.'/history', 0700)), 'Histórico indisponível.');
self::write($base.'/history/'.$pointer['audit_id'].'.json', self::encode(['action' => $action, 'at' => gmdate('c'), 'before' => $before, 'after' => $pointer, 'database_changed' => false]));
self::write($base.'/current.next.json', self::encode($pointer));
self::fail(rename($base.'/current.next.json', $base.'/current.json'), 'Falha ao activar a versão; versão anterior preservada.');
}
/** Explicit rollback only to the previous verified code release; never rolls back data. */
public static function rollback(string $host): array
{
$base = rtrim($host, '/\\').'/packages/purchasing';
self::fail(is_dir($base) && ! is_link($base) && ! is_link(rtrim($host, '/\\').'/packages'), 'Módulo não instalado ou caminho inválido.');
$lock = fopen($base.'/.update.lock', 'c');
self::fail($lock !== false && flock($lock, LOCK_EX), 'Actualização ocupada.');
try {
$current = self::current($host);
self::fail($current !== null && preg_match('/^[a-f0-9]{24}$/D', $current['audit_id']) === 1, 'Sem versão anterior.');
$history = self::json($base.'/history/'.$current['audit_id'].'.json');
$previous = $history['before'] ?? null;
self::fail(is_array($previous), 'Sem versão anterior.');
$verified = self::verifiedPointer($host, $previous);
$pointer = array_intersect_key($verified, array_flip(['version', 'manifest_sha256', 'source_sha256']));
$pointer['previous'] = $current['version'];
self::activate($base, $current, $pointer, 'rollback_code_release');
return self::current($host);
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
/** Immutable version installation + atomic pointer. No migrations, seeds or permissions. */
public static function install(string $host, string $archive, string $expectedManifest): array
{
$checked = self::inspect($archive, $expectedManifest);
self::fail(is_dir($host) && ! is_link($host), 'Instalação de destino inexistente.');
$host = realpath($host);
self::fail((self::json($host.'/composer.json')['name'] ?? '') === 'tempo/sgf-tempo', 'Destino não é uma instalação SGF.');
self::fail(! is_link($host.'/packages'), 'O directório de pacotes não pode redireccionar a instalação.');
$base = $host.'/packages/purchasing';
self::fail(! is_link($base) && (is_dir($base) || mkdir($base, 0700, true)), 'Directório de módulo inválido.');
$lock = fopen($base.'/.update.lock', 'c');
self::fail($lock !== false && flock($lock, LOCK_EX), 'Actualização ocupada.');
try {
$current = self::current($host);
$version = $checked['manifest']['version'];
if ($current !== null && $current['version'] === $version) {
self::fail(hash_equals($current['manifest_sha256'], $expectedManifest), 'Tentativa de substituir uma versão imutável.');
return $current + ['changed' => false];
}
self::fail($current === null || version_compare($version, $current['version'], '>'), 'Actualização não permite downgrade implícito.');
$releases = $base.'/releases';
self::fail(! is_link($releases) && (is_dir($releases) || mkdir($releases, 0700)), 'Directório de versões inválido.');
$destination = $releases.'/'.$version;
if (is_dir($destination)) {
$pointer = ['version' => $version, 'manifest_sha256' => $expectedManifest, 'source_sha256' => $checked['manifest']['source_sha256'], 'previous' => $current['version'] ?? null];
self::verifiedPointer($host, $pointer);
self::activate($base, $current, $pointer, 'reactivate_verified_code_release');
return self::current($host) + ['changed' => true];
}
self::fail(! file_exists($destination), 'Destino de versão inválido.');
$stage = $releases.'/.stage-'.bin2hex(random_bytes(8));
self::fail(mkdir($stage, 0700), 'Preparação da versão falhou.');
foreach ($checked['contents'] as $path => $content) {
$directory = dirname($stage.'/'.$path);
self::fail(is_dir($directory) || mkdir($directory, 0700, true), 'Preparação do código falhou.');
self::write($stage.'/'.$path, $content);
}
self::write($stage.'/manifest.json', $checked['manifest_text']);
self::fail(rename($stage, $destination), 'Falha ao fixar a versão preparada.');
$pointer = ['version' => $version, 'manifest_sha256' => $expectedManifest, 'source_sha256' => $checked['manifest']['source_sha256'], 'previous' => $current['version'] ?? null];
self::verifiedPointer($host, $pointer);
self::activate($base, $current, $pointer, 'activate_code_release');
return self::current($host) + ['changed' => true];
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
}