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); } } }