more fixes for safe upgrade

Signed-off-by: Andy Miller <rhuk@mac.com>
This commit is contained in:
Andy Miller
2025-10-31 19:08:48 +00:00
parent 16b0b562fb
commit ff0de91bab
3 changed files with 357 additions and 10 deletions

1
.gitignore vendored
View File

@@ -51,3 +51,4 @@ system/templates/testing/*
/system/recovery.window
tmp/*
#needs_fixing.txt
/AGENTS.md

209
bin/build-test-update.php Executable file
View File

@@ -0,0 +1,209 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
use Grav\Common\Filesystem\Folder;
require __DIR__ . '/../vendor/autoload.php';
if (!\defined('GRAV_ROOT')) {
\define('GRAV_ROOT', realpath(__DIR__ . '/..') ?: getcwd());
}
if (!\extension_loaded('zip')) {
fwrite(STDERR, "The PHP zip extension is required.\n");
exit(1);
}
$options = getopt('', [
'version:',
'output::',
'port::',
'base-url::',
'serve',
]);
if (!isset($options['version'])) {
fwrite(
STDERR,
"Usage: php bin/build-test-update.php --version=1.7.999 [--output=tmp/test-gpm] [--port=8043] [--base-url=http://127.0.0.1:8043] [--serve]\n"
);
exit(1);
}
$version = trim((string) $options['version']);
if ($version === '') {
fwrite(STDERR, "A non-empty --version value is required.\n");
exit(1);
}
$root = GRAV_ROOT;
$output = $options['output'] ?? $root . '/tmp/test-gpm';
if (!str_starts_with($output, DIRECTORY_SEPARATOR)) {
$output = $root . '/' . ltrim($output, '/');
}
$output = rtrim($output, DIRECTORY_SEPARATOR);
$defaultPort = isset($options['port']) ? (int) $options['port'] : 8043;
$baseUrl = $options['base-url'] ?? sprintf('http://127.0.0.1:%d', $defaultPort);
$serve = array_key_exists('serve', $options);
Folder::create($output);
$downloadName = sprintf('grav-update-%s.zip', $version);
$zipPath = $output . '/' . $downloadName;
$jsonPath = $output . '/grav.json';
$zipPrefix = 'grav-update/';
$excludeDirs = [
'.build',
'.crush',
'.ddev',
'.git',
'.github',
'.gitlab',
'.circleci',
'.idea',
'.vscode',
'.pytest_cache',
'backup',
'cache',
'images',
'logs',
'node_modules',
'tests',
'tmp',
'user',
];
$excludeFiles = [
'.htaccess',
'.DS_Store',
'robots.txt',
];
$directory = new RecursiveDirectoryIterator($root, RecursiveDirectoryIterator::SKIP_DOTS);
$filtered = new RecursiveCallbackFilterIterator(
$directory,
function (SplFileInfo $current) use ($root, $excludeDirs, $excludeFiles): bool {
$relative = ltrim(str_replace($root, '', $current->getPathname()), DIRECTORY_SEPARATOR);
$relative = str_replace('\\', '/', $relative);
if ($relative === '') {
return true;
}
if (str_contains($relative, '..')) {
return false;
}
foreach ($excludeDirs as $prefix) {
$prefix = trim($prefix, '/');
if ($prefix === '') {
continue;
}
if ($relative === $prefix || str_starts_with($relative, $prefix . '/')) {
return false;
}
}
if (in_array($current->getFilename(), $excludeFiles, true)) {
return false;
}
return true;
}
);
$zip = new ZipArchive();
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
throw new RuntimeException(sprintf('Unable to open archive at %s', $zipPath));
}
$zip->addEmptyDir($zipPrefix);
$iterator = new RecursiveIteratorIterator($filtered, RecursiveIteratorIterator::SELF_FIRST);
/** @var SplFileInfo $fileInfo */
foreach ($iterator as $fileInfo) {
$fullPath = $fileInfo->getPathname();
$relative = ltrim(str_replace($root, '', $fullPath), DIRECTORY_SEPARATOR);
$relative = str_replace('\\', '/', $relative);
$targetPath = $zipPrefix . $relative;
if ($fileInfo->isDir()) {
$zip->addEmptyDir(rtrim($targetPath, '/') . '/');
continue;
}
if ($fileInfo->isLink()) {
$target = readlink($fullPath);
$zip->addFromString($targetPath, $target === false ? '' : $target);
$zip->setExternalAttributesName($targetPath, ZipArchive::OPSYS_UNIX, 0120000 << 16);
continue;
}
$zip->addFile($fullPath, $targetPath);
$perms = @fileperms($fullPath);
if ($perms !== false) {
$zip->setExternalAttributesName($targetPath, ZipArchive::OPSYS_UNIX, ($perms & 0xFFFF) << 16);
}
}
$zip->close();
$size = filesize($zipPath);
$sha256 = hash_file('sha256', $zipPath);
$timestamp = date('c');
$downloadUrl = rtrim($baseUrl, '/') . '/' . rawurlencode($downloadName);
$manifest = [
'version' => $version,
'date' => $timestamp,
'min_php' => '8.3.0',
'assets' => [
'grav-update' => [
'name' => $downloadName,
'slug' => 'grav-update',
'version' => $version,
'date' => $timestamp,
'testing' => false,
'description' => 'Local test update package generated for safe-upgrade validation.',
'download' => $downloadUrl,
'size' => $size,
'checksum' => 'sha256:' . $sha256,
'sha256' => $sha256,
'host' => parse_url($downloadUrl, PHP_URL_HOST),
],
],
'changelog' => [
$version => [
'date' => $timestamp,
'content' => "- Local test update package generated by build-test-update.\n",
],
],
];
file_put_contents($jsonPath, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
$manifestUrl = rtrim($baseUrl, '/') . '/grav.json';
echo "Update package created at: {$zipPath}\n";
echo "Manifest written to: {$jsonPath}\n";
echo "Manifest URL: {$manifestUrl}\n";
echo "Download URL: {$downloadUrl}\n";
echo "Archive size: {$size} bytes\n";
echo "SHA256: {$sha256}\n";
if ($serve) {
$host = parse_url($baseUrl, PHP_URL_HOST) ?: '127.0.0.1';
$port = parse_url($baseUrl, PHP_URL_PORT) ?: $defaultPort;
$command = sprintf('php -S %s:%d -t %s', $host, $port, escapeshellarg($output));
echo "\nServing files using PHP built-in server. Press Ctrl+C to stop.\n";
echo $command . "\n\n";
passthru($command);
}

View File

@@ -23,30 +23,39 @@ use RecursiveIteratorIterator;
use FilesystemIterator;
use function array_key_exists;
use function basename;
use function chgrp;
use function chmod;
use function chown;
use function copy;
use function count;
use function dirname;
use function file_get_contents;
use function file_put_contents;
use function filegroup;
use function fileowner;
use function glob;
use function in_array;
use function is_dir;
use function is_file;
use function json_decode;
use function json_encode;
use function lchgrp;
use function lchown;
use function preg_match;
use function preg_replace;
use function property_exists;
use function rename;
use function rsort;
use function sort;
use function time;
use function rtrim;
use function uniqid;
use function trim;
use function strpos;
use function time;
use function trim;
use function uniqid;
use function unlink;
use function ltrim;
use function preg_replace;
use function posix_getgrgid;
use function posix_getpwuid;
use const GRAV_ROOT;
use const GLOB_ONLYDIR;
use const JSON_PRETTY_PRINT;
@@ -179,21 +188,26 @@ class SafeUpgradeService
$stagingMode = $this->stageExtractedPackage($extractedPath, $packagePath);
$this->reportProgress('installing', 'Preparing staged package...', null, ['mode' => $stagingMode]);
$packageEntries = $this->collectPackageEntries($packagePath);
$this->carryOverRootDotfiles($packagePath);
// Ensure ignored directories are replaced with live copies.
$this->hydrateIgnoredDirectories($packagePath, $ignores);
$this->carryOverRootFiles($packagePath, $ignores);
$entries = $this->collectPackageEntries($packagePath);
if (!$entries) {
$snapshotEntries = $this->collectPackageEntries($packagePath);
if (!$packageEntries) {
throw new RuntimeException('Staged package does not contain any files to promote.');
}
if (!$snapshotEntries) {
$snapshotEntries = $packageEntries;
}
$this->reportProgress('snapshot', 'Creating backup snapshot...', null);
$this->createBackupSnapshot($entries, $backupPath);
$this->createBackupSnapshot($snapshotEntries, $backupPath);
$manifest = $this->buildManifest($stageId, $targetVersion, $packagePath, $backupPath, $entries);
$manifest = $this->buildManifest($stageId, $targetVersion, $packagePath, $backupPath, $snapshotEntries);
$manifestPath = $stagePath . DIRECTORY_SEPARATOR . 'manifest.json';
Folder::create(dirname($manifestPath));
file_put_contents($manifestPath, json_encode($manifest, JSON_PRETTY_PRINT));
@@ -201,9 +215,9 @@ class SafeUpgradeService
$this->reportProgress('installing', 'Copying update files...', null);
try {
$this->copyEntries($entries, $packagePath, $this->rootPath, 'installing', 'Deploying');
$this->copyEntries($packageEntries, $packagePath, $this->rootPath, 'installing', 'Deploying');
} catch (Throwable $e) {
$this->copyEntries($entries, $backupPath, $this->rootPath, 'installing', 'Restoring');
$this->copyEntries($snapshotEntries, $backupPath, $this->rootPath, 'installing', 'Restoring');
throw new RuntimeException('Failed to promote staged Grav release.', 0, $e);
}
@@ -334,6 +348,7 @@ class SafeUpgradeService
}
$destination = $targetBase . DIRECTORY_SEPARATOR . $entry;
$metadata = $this->captureEntryMeta($destination);
$this->removeEntry($destination);
if (is_link($source)) {
@@ -341,9 +356,13 @@ class SafeUpgradeService
if (!@symlink(readlink($source), $destination)) {
throw new RuntimeException(sprintf('Failed to replicate symlink "%s".', $source));
}
$this->applyEntryMeta($destination, $metadata);
continue;
} elseif (is_dir($source)) {
Folder::create(dirname($destination));
Folder::rcopy($source, $destination, true);
$this->applyEntryMeta($destination, $metadata);
continue;
} else {
Folder::create(dirname($destination));
if (!@copy($source, $destination)) {
@@ -358,6 +377,8 @@ class SafeUpgradeService
@touch($destination, $mtime);
}
}
$this->applyEntryMeta($destination, $metadata);
}
}
@@ -370,6 +391,122 @@ class SafeUpgradeService
}
}
/**
* Capture ownership and permission data for an existing filesystem entry.
*
* @param string $path
* @return array<string, mixed>
*/
private function captureEntryMeta(string $path): array
{
if (!file_exists($path) && !is_link($path)) {
return [];
}
$meta = [
'link' => is_link($path),
];
$perms = @fileperms($path);
if ($perms !== false) {
$meta['perms'] = $perms & 0777;
}
$owner = @fileowner($path);
if ($owner !== false) {
$meta['owner'] = $owner;
}
$group = @filegroup($path);
if ($group !== false) {
$meta['group'] = $group;
}
return $meta;
}
/**
* Reapply ownership and permission data to a copied entry when possible.
*
* @param string $path
* @param array<string, mixed> $meta
* @return void
*/
private function applyEntryMeta(string $path, array $meta): void
{
if (!$meta) {
return;
}
if (isset($meta['perms'])) {
@chmod($path, (int) $meta['perms']);
}
$isLink = !empty($meta['link']);
if (isset($meta['owner'])) {
$owner = $this->resolveOwner($meta['owner']);
if ($isLink && function_exists('lchown')) {
@lchown($path, $owner);
} elseif (!$isLink && function_exists('chown')) {
@chown($path, $owner);
}
}
if (isset($meta['group'])) {
$group = $this->resolveGroup($meta['group']);
if ($isLink && function_exists('lchgrp')) {
@lchgrp($path, $group);
} elseif (!$isLink && function_exists('chgrp')) {
@chgrp($path, $group);
}
}
}
/**
* Resolve stored owner identifier to a format accepted by chown/lchown.
*
* @param int|string $owner
* @return int|string
*/
private function resolveOwner($owner)
{
if (is_string($owner)) {
return $owner;
}
if (function_exists('posix_getpwuid')) {
$info = @posix_getpwuid((int) $owner);
if (is_array($info) && isset($info['name'])) {
return $info['name'];
}
}
return (int) $owner;
}
/**
* Resolve stored group identifier to a format accepted by chgrp/lchgrp.
*
* @param int|string $group
* @return int|string
*/
private function resolveGroup($group)
{
if (is_string($group)) {
return $group;
}
if (function_exists('posix_getgrgid')) {
$info = @posix_getgrgid((int) $group);
if (is_array($info) && isset($info['name'])) {
return $info['name'];
}
}
return (int) $group;
}
public function setProgressCallback(?callable $callback): self
{
$this->progressCallback = $callback;