diff --git a/core/src/Console/SiteUpdateCommand.php b/core/src/Console/SiteUpdateCommand.php index b9a57dddfc..81873a367b 100644 --- a/core/src/Console/SiteUpdateCommand.php +++ b/core/src/Console/SiteUpdateCommand.php @@ -11,6 +11,11 @@ use Illuminate\Console\Command; /** + * Check and apply a core archive using the site's configured Manager and Composer environment. + * + * Preflight runs before the overlay; dependency repair, migrations and cache cleanup + * then bring the installed files and application state onto the requested version. + * * @see: https://github.com/laravel-zero/foundation/blob/9.x/src/Illuminate/Foundation/Console/ClearCompiledCommand.php */ class SiteUpdateCommand extends Command @@ -95,6 +100,11 @@ class SiteUpdateCommand extends Command with 1 when an error left the site unchanged (unknown branch, GitHub unreachable or rate limited, custom packages that do not fit). +Manager files follow EVO_MANAGER_PATH, including a renamed manager directory. +Existing Manager configuration and local access-control files are preserved. +Composer keeps the operator's authentication environment; core/composer/auth.json +remains supported, with a site-local home fallback for headless environments. + HELP; /** @@ -258,7 +268,7 @@ public function startUpdate() return false; } - putenv('COMPOSER_HOME=' . EVO_CORE_PATH . 'composer'); + $this->configureComposerHome(EVO_CORE_PATH . 'composer'); $customPackageUpdate = $this->preflightComposer( $temp_dir . '/' . $dir . '/core', $customPackageNames, @@ -270,7 +280,7 @@ public function startUpdate() return false; } - self::moveFiles($temp_dir . '/' . $dir, EVO_BASE_PATH); + self::moveFiles($temp_dir . '/' . $dir, EVO_BASE_PATH, EVO_MANAGER_PATH); self::rmdirs($temp_dir); // Only a release carries a version number of its own. Any other ref (a @@ -1491,28 +1501,83 @@ protected function isCommitHash(string $ref): bool } /** - * Recursively move files from the extracted update archive into the site root. + * Keep Composer's normal authentication lookup unless a legacy site auth file is needed. * - * Destination directories are created on demand. Existing writable files are replaced. + * An explicit home or inline authentication belongs to the operator. Without + * an explicit home, an existing site-local auth.json remains supported unless + * inline authentication was supplied. Otherwise Composer resolves its own home, + * including XDG_CONFIG_HOME, instead of losing global auth. A worker without HOME + * (APPDATA on Windows) still needs the old site-local fallback, even with inline + * authentication. No credentials are read, copied or printed by the updater. + * + * @since 3.6.1 + * @param string $legacyHome Site-local Composer home used by earlier updaters. + * @return void + */ + protected function configureComposerHome(string $legacyHome): void + { + if (getenv('COMPOSER_HOME') !== false) { + return; + } + + $authFile = rtrim($legacyHome, '/\\') . '/auth.json'; + $globalHome = getenv(windows_os() ? 'APPDATA' : 'HOME'); + if ((getenv('COMPOSER_AUTH') === false && is_file($authFile) && is_readable($authFile)) + || $globalHome === false || $globalHome === '') { + putenv('COMPOSER_HOME=' . $legacyHome); + } + } + + /** + * Overlay an update archive, routing only its top-level manager tree to the active Manager. + * + * The optional Manager path preserves the two-argument file-copy contract. Site + * updates pass EVO_MANAGER_PATH so renamed or externally located Managers receive + * the new forms and assets without creating a second default login directory. + * Existing Manager configuration, .htaccess and .user.ini stay operator-owned; + * unrelated destination files are never deleted. Invalid roots fail before writes. * * @param string $src Absolute path to the extracted source directory. * @param string $dest Absolute path to the destination root. + * @param string|null $managerPath Existing configured Manager directory, or null for an unchanged archive layout. * @return void */ - static public function moveFiles($src, $dest) + static public function moveFiles($src, $dest, ?string $managerPath = null) { $path = realpath($src); $dest = realpath($dest); - $objects = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path), \RecursiveIteratorIterator::SELF_FIRST); + $manager = $managerPath === null ? null : realpath($managerPath); + if ($path === false || !is_dir($path) || $dest === false || !is_dir($dest) + || ($managerPath !== null && ($manager === false || !is_dir($manager)))) { + throw new \RuntimeException('The update source, site root and configured Manager must be existing directories.'); + } + + $objects = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST + ); foreach ($objects as $name => $object) { - $startsAt = substr(dirname($name), strlen($path)); - self::mmkDir($dest . $startsAt); + $relative = str_replace('\\', '/', substr($name, strlen($path) + 1)); + $target = $dest . '/' . $relative; + if ($manager !== null && ($relative === 'manager' || str_starts_with($relative, 'manager/'))) { + $managerRelative = substr($relative, strlen('manager/')); + $target = $manager . ($managerRelative === '' ? '' : '/' . $managerRelative); + if (in_array($managerRelative, ['includes/config.inc.php', '.htaccess', '.user.ini'], true) + && (file_exists($target) || is_link($target))) { + continue; + } + } + if ($object->isDir()) { - self::mmkDir($dest . substr($name, strlen($path))); + self::mmkDir($target); + continue; } - if (is_writable($dest . $startsAt) && $object->isFile()) { - rename((string)$name, $dest . $startsAt . '/' . basename($name)); + if ($object->isFile()) { + self::mmkDir(dirname($target)); + if (!is_writable(dirname($target)) || !rename((string) $name, $target)) { + throw new \RuntimeException('Could not replace an update file: ' . $relative); + } } } } diff --git a/core/tests/Unit/Console/SiteUpdateLayoutTest.php b/core/tests/Unit/Console/SiteUpdateLayoutTest.php new file mode 100644 index 0000000000..99332a8282 --- /dev/null +++ b/core/tests/Unit/Console/SiteUpdateLayoutTest.php @@ -0,0 +1,186 @@ +workspace = sys_get_temp_dir() . '/evo-update-layout-' . bin2hex(random_bytes(8)); + $this->archive = $this->workspace . '/unpacked'; + $this->site = $this->workspace . '/site'; + mkdir($this->archive . '/manager/views/page', 0777, true); + mkdir($this->archive . '/manager/includes', 0777, true); + mkdir($this->archive . '/assets/manager', 0777, true); + mkdir($this->site . '/core', 0777, true); + file_put_contents($this->archive . '/index.php', 'new front controller'); + file_put_contents($this->archive . '/manager/index.php', 'new manager controller'); + file_put_contents($this->archive . '/manager/views/page/system_settings.blade.php', + file_get_contents(dirname(__DIR__, 4) . '/manager/views/page/system_settings.blade.php')); + file_put_contents($this->archive . '/assets/manager/example.js', 'ordinary asset'); + + $this->composerEnvironment = []; + foreach (['COMPOSER_HOME', 'COMPOSER_AUTH'] as $name) { + $this->composerEnvironment[$name] = getenv($name); + putenv($name); + } +}); + +afterEach(function () { + foreach ($this->composerEnvironment as $name => $value) { + $value === false ? putenv($name) : putenv($name . '=' . $value); + } + SiteUpdateCommand::rmdirs($this->workspace); +}); + +test('update overlays the configured Manager and delivers the current CSRF form', function (string $directory) { + $manager = $this->site . '/' . $directory; + mkdir($manager . '/views/page', 0777, true); + file_put_contents($manager . '/views/page/system_settings.blade.php', '
'); + file_put_contents($manager . '/local-plugin.php', 'keep local extension'); + + SiteUpdateCommand::moveFiles($this->archive, $this->site, $manager . '/'); + + $template = file_get_contents($manager . '/views/page/system_settings.blade.php'); + $compiler = new BladeCompiler(new Filesystem(), $this->workspace . '/compiled'); + expect($template)->toContain('@csrf') + ->and($compiler->compileString($template))->toContain('csrf_field()') + ->and(file_get_contents($manager . '/index.php'))->toBe('new manager controller') + ->and(file_get_contents($manager . '/local-plugin.php'))->toBe('keep local extension') + ->and(file_get_contents($this->site . '/index.php'))->toBe('new front controller') + ->and(file_get_contents($this->site . '/assets/manager/example.js'))->toBe('ordinary asset'); + if ($directory !== 'manager') { + expect(is_dir($this->site . '/manager'))->toBeFalse(); + } +})->with(['manager', 'points', 'private/control-panel']); + +test('renamed Manager updates do not write to a leftover default Manager', function () { + mkdir($this->site . '/points', 0777, true); + mkdir($this->site . '/manager', 0777, true); + file_put_contents($this->site . '/manager/index.php', 'leftover default manager'); + + SiteUpdateCommand::moveFiles($this->archive, $this->site, $this->site . '/points'); + + expect(file_get_contents($this->site . '/points/index.php'))->toBe('new manager controller') + ->and(file_get_contents($this->site . '/manager/index.php'))->toBe('leftover default manager') + ->and(is_dir($this->site . '/manager/views'))->toBeFalse(); +}); + +test('Manager paths outside the public site root follow EVO_MANAGER_PATH too', function () { + $manager = $this->workspace . '/private-manager'; + mkdir($manager, 0777, true); + + SiteUpdateCommand::moveFiles($this->archive, $this->site, $manager); + + expect(file_get_contents($manager . '/index.php'))->toBe('new manager controller') + ->and(is_dir($this->site . '/manager'))->toBeFalse(); +}); + +test('Manager overlay preserves existing configuration and access-control files', function (string $directory) { + $manager = $this->site . '/' . $directory; + mkdir($manager . '/includes', 0777, true); + foreach (['includes/config.inc.php', '.htaccess', '.user.ini'] as $file) { + file_put_contents($this->archive . '/manager/' . $file, 'archive default'); + file_put_contents($manager . '/' . $file, 'local configuration'); + } + + SiteUpdateCommand::moveFiles($this->archive, $this->site, $manager); + + foreach (['includes/config.inc.php', '.htaccess', '.user.ini'] as $file) { + expect(file_get_contents($manager . '/' . $file))->toBe('local configuration'); + } +})->with(['manager', 'points']); + +test('Manager overlay installs shipped access-control files when they are absent', function () { + $manager = $this->site . '/points'; + mkdir($manager, 0777, true); + file_put_contents($this->archive . '/manager/.htaccess', 'archive access control'); + + SiteUpdateCommand::moveFiles($this->archive, $this->site, $manager); + + expect(file_get_contents($manager . '/.htaccess'))->toBe('archive access control'); +}); + +test('repeated Manager overlays update the same configured directory', function () { + $manager = $this->site . '/points'; + mkdir($manager, 0777, true); + SiteUpdateCommand::moveFiles($this->archive, $this->site, $manager); + file_put_contents($this->archive . '/manager/index.php', 'next manager controller'); + + SiteUpdateCommand::moveFiles($this->archive, $this->site, $manager); + + expect(file_get_contents($manager . '/index.php'))->toBe('next manager controller') + ->and(is_dir($this->site . '/manager'))->toBeFalse(); +}); + +test('a missing configured Manager fails before any site file is overwritten', function () { + file_put_contents($this->site . '/index.php', 'old front controller'); + + expect(fn () => SiteUpdateCommand::moveFiles($this->archive, $this->site, $this->site . '/missing')) + ->toThrow(RuntimeException::class) + ->and(file_get_contents($this->site . '/index.php'))->toBe('old front controller'); +}); + +test('two-argument moveFiles keeps the ordinary archive layout', function () { + SiteUpdateCommand::moveFiles($this->archive, $this->site); + + expect(file_get_contents($this->site . '/manager/index.php'))->toBe('new manager controller'); +}); + +test('updater passes the configured Manager path into the archive overlay', function () { + $source = file_get_contents(dirname(__DIR__, 3) . '/src/Console/SiteUpdateCommand.php'); + + expect($source)->toContain("self::moveFiles(\$temp_dir . '/' . \$dir, EVO_BASE_PATH, EVO_MANAGER_PATH);"); +}); + +test('Composer keeps global and explicit authentication while retaining legacy site auth', function ( + bool $legacyAuth, + string|false $home, + string|false $auth, + string|false $expectedHome +) { + $legacy = $this->workspace . '/legacy-composer'; + mkdir($legacy, 0777, true); + if ($legacyAuth) { + file_put_contents($legacy . '/auth.json', '{}'); + } + if ($home !== false) { + putenv('COMPOSER_HOME=' . $home); + } + if ($auth !== false) { + putenv('COMPOSER_AUTH=' . $auth); + } + $method = new ReflectionMethod(SiteUpdateCommand::class, 'configureComposerHome'); + + $method->invoke(new SiteUpdateCommand(), $legacy); + + expect(getenv('COMPOSER_HOME'))->toBe($expectedHome === 'legacy' ? $legacy : $expectedHome) + ->and(getenv('COMPOSER_AUTH'))->toBe($auth) + ->and(is_file($legacy . '/auth.json'))->toBe($legacyAuth); +})->with([ + 'normal global/XDG lookup' => [false, false, false, false], + 'legacy site auth' => [true, false, false, 'legacy'], + 'explicit home without legacy auth' => [false, '/operator/composer', false, '/operator/composer'], + 'explicit home wins over legacy auth' => [true, '/operator/composer', false, '/operator/composer'], + 'inline auth without legacy auth' => [false, false, '{}', false], + 'inline auth wins over legacy home' => [true, false, '{}', false], +]); + +test('a headless worker still has a Composer home without hiding inline auth', function (bool $inlineAuth) { + $name = windows_os() ? 'APPDATA' : 'HOME'; + $home = getenv($name); + $legacy = $this->workspace . '/worker-composer'; + putenv($name); + if ($inlineAuth) { + putenv('COMPOSER_AUTH={}'); + } + + try { + (new ReflectionMethod(SiteUpdateCommand::class, 'configureComposerHome')) + ->invoke(new SiteUpdateCommand(), $legacy); + + expect(getenv('COMPOSER_HOME'))->toBe($legacy) + ->and(getenv('COMPOSER_AUTH'))->toBe($inlineAuth ? '{}' : false); + } finally { + $home === false ? putenv($name) : putenv($name . '=' . $home); + } +})->with([false, true]);