From ff0de91bab390eb3aacc0e5335159252fe4bd693 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Fri, 31 Oct 2025 19:08:48 +0000 Subject: [PATCH 1/2] more fixes for safe upgrade Signed-off-by: Andy Miller --- .gitignore | 1 + bin/build-test-update.php | 209 ++++++++++++++++++ .../Common/Upgrade/SafeUpgradeService.php | 157 ++++++++++++- 3 files changed, 357 insertions(+), 10 deletions(-) create mode 100755 bin/build-test-update.php diff --git a/.gitignore b/.gitignore index 3661e2375..184a77609 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,4 @@ system/templates/testing/* /system/recovery.window tmp/* #needs_fixing.txt +/AGENTS.md diff --git a/bin/build-test-update.php b/bin/build-test-update.php new file mode 100755 index 000000000..f5eea67f8 --- /dev/null +++ b/bin/build-test-update.php @@ -0,0 +1,209 @@ +#!/usr/bin/env php +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); +} diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php index 95fa2cb44..bdfe7f926 100644 --- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php +++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php @@ -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 + */ + 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 $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; From 0afbce518d28a5babc50ceb3f90fd1c91b8baff9 Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Fri, 31 Oct 2025 19:11:50 +0000 Subject: [PATCH 2/2] prepare for release Signed-off-by: Andy Miller --- CHANGELOG.md | 6 ++++++ system/defines.php | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ca82cd09..f6ea2da7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# v1.7.50.4 +## 10/31/2025 + +1. [](#improved) + * More fixes and improvements for safe-uprade process + # v1.7.50.3 ## 10/21/2025 diff --git a/system/defines.php b/system/defines.php index bd56e74d3..cb0c154ba 100644 --- a/system/defines.php +++ b/system/defines.php @@ -9,7 +9,7 @@ // Some standard defines define('GRAV', true); -define('GRAV_VERSION', '1.7.50.3'); +define('GRAV_VERSION', '1.7.50.4'); define('GRAV_SCHEMA', '1.7.0_2020-11-20_1'); define('GRAV_TESTING', false);