This commit is contained in:
2026-11-02 08:06:45 +02:00
parent e4f520092e
commit 2a4169162b
108 changed files with 3793 additions and 88 deletions
+11
View File
@@ -0,0 +1,11 @@
# Distribuição Compras → SGF
Fonte canónica: este directório em Compras. As cópias no SGF são artefactos do mesmo mecanismo, não implementações mantidas em paralelo. O runtime instalado nunca procura a pasta de Compras. PHP ZipArchive e as dependências já instaladas de cada host são reutilizados.
O formato 1 transporta apenas código, metadados e documentação do pacote Purchasing. Exclui `.env`, bases, documentos, contas, dados de teste e concessões. Exige uma referência SHA-256 de manifesto obtida de um canal de confiança; hashes não substituem a autenticidade desse canal. Não existe download de URLs arbitrários, execução de scripts de migração ou substituição de dependências. Distribuição remota assinada e migrações de módulos com dados exigem extensão explícita do contrato.
O editor publica uma versão patch apenas quando os bytes da origem mudam. A publicação oficial executa regressões e uma prova da fotografia imutável antes de actualizar o catálogo. A instalação verifica o pacote inteiro, prova a candidata num processo PHP do host, verifica a integridade da versão anterior e activa uma pasta imutável por ponteiro atómico. Bloqueio de ficheiro serializa instalações concorrentes. Código alterado localmente, versão repetida com outros bytes, downgrade implícito, ficheiros extra/ausentes e caminhos fora do pacote são recusados. A versão anterior não é apagada.
Histórico de activações fica na instalação, separado do código distribuído. Uma falha antes da troca do ponteiro mantém a versão activa; preparações incompletas podem permanecer para diagnóstico. `rollback` regressa explicitamente à versão de código anterior verificada, sem apagar ou recalcular dados. O contrato actual não permite mudanças de schema, tornando essa reversão independente da base.
Actualizar este próprio mecanismo exige distribuir e conferir os seus artefactos de arranque, em separado do pacote de negócio. Não sobrescrever modificações locais por conveniência. Serviços de longa duração devem ser reiniciados pelo procedimento de operação da instalação depois de uma actualização; não são terminados automaticamente pelo instalador.
+295
View File
@@ -0,0 +1,295 @@
<?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);
}
}
}
@@ -0,0 +1,12 @@
<?php
use Tempo\Distribution\Release;
// Installed host loader. No runtime dependency on the source checkout.
require_once dirname(__DIR__).'/module-updates/Release.php';
$purchasingRelease = Release::current(dirname(__DIR__, 2));
if ($purchasingRelease === null) {
throw new RuntimeException('Instale uma versão verificada do módulo de Compras.');
}
require_once $purchasingRelease['path'].'/bootstrap.php';
unset($purchasingRelease);
@@ -0,0 +1,11 @@
{
"schema": 1,
"canonical_source": "Compras/packages/module-updates and Compras/scripts",
"files": {
"packages/module-updates/Release.php": "c64020ad7fb8bd82348d4f1b0d63ff7ac8209ea1005c90d86fe1144309e41161",
"packages/purchasing/bootstrap.php": "f0b2e9f06e29c767609f58ca6fe410af4850bc9066076610fa7fb513d286ecb2",
"scripts/purchasing-release.php": "c1420370252302d3da2d9981ead5a288eec9be35f0797e6df0643f9a8b9844be",
"scripts/purchasing-probe.php": "b4b505624e31f1bed4985e800a6f31f47c05e29dfabb0f296504521b5121c57d",
"scripts/purchasing-client-check.php": "57f1195e1e40bd304164740f4ae0b45424858587f9fd6fcf26eb143c8d62becc"
}
}