From 87a84eb0acf94757362e3ae7e18d774cc5fee76b Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 19 May 2026 14:03:12 +0000 Subject: [PATCH] Release v1.0.4: self-updater hotfixes (wrapper stripping + counter check) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Self-updater extractZip: detect and strip a common top-level prefix folder so that release ZIPs built with a versioned wrapper (the build.sh convention) overlay the live install correctly instead of creating a schneespur-X.Y.Z/ subdirectory at the install root. The v1.0.3 update on the test environment surfaced this — files were copied, state.json was committed to 1.0.3, but the wrapper folder meant the live config/app.php and VERSION were never actually overwritten. Same defensive prefix-stripping pattern as the module installer. - Counter check ordering: the same-version short-circuit now runs BEFORE the strict counter check. Previously, after a successful install, the next "prüfen" fetched the same manifest (same counter) and the counter check would throw "Rollback-Versuch", masking the intended "you're up to date" response. The counter check is still enforced for genuinely older manifests — it just doesn't misfire when the server keeps serving the same release. Co-Authored-By: Claude Opus 4.7 (1M context) --- schneespur/VERSION | 2 +- schneespur/app/Services/SchneespurUpdater.php | 97 +++++++++++++++++-- 2 files changed, 91 insertions(+), 8 deletions(-) diff --git a/schneespur/VERSION b/schneespur/VERSION index 21e8796..ee90284 100644 --- a/schneespur/VERSION +++ b/schneespur/VERSION @@ -1 +1 @@ -1.0.3 +1.0.4 diff --git a/schneespur/app/Services/SchneespurUpdater.php b/schneespur/app/Services/SchneespurUpdater.php index 24fac1a..e269635 100644 --- a/schneespur/app/Services/SchneespurUpdater.php +++ b/schneespur/app/Services/SchneespurUpdater.php @@ -170,6 +170,18 @@ class SchneespurUpdater throw new RuntimeException('Signatur ungültig — MITM oder beschädigt'); } + // Same-version short-circuit first: if the server still serves the + // currently installed release (same counter, same version), this is + // not a rollback — just "you're up to date". The counter check below + // would otherwise misfire after every successful install, because + // the next manifest fetch carries the exact same counter that was + // committed during install. + if ($manifest['version'] === ($state['current_version'] ?? '')) { + $this->writeLastCheck($state, false); + + return null; + } + $newCounter = (int) $manifest['counter']; if ($newCounter <= (int) ($state['last_counter'] ?? 0)) { throw new RuntimeException( @@ -177,12 +189,6 @@ class SchneespurUpdater ); } - if ($manifest['version'] === ($state['current_version'] ?? '')) { - $this->writeLastCheck($state, false); - - return null; - } - $this->writeLastCheck($state, true, $manifest); return $manifest; @@ -279,7 +285,44 @@ class SchneespurUpdater $this->validateZipEntries($zip); - $zip->extractTo($this->stagingDir); + $prefix = $this->detectCommonPrefix($zip); + + if ($prefix === null) { + $zip->extractTo($this->stagingDir); + } else { + $this->logPhase('extract', 'stripping_prefix', ['prefix' => $prefix]); + + for ($i = 0; $i < $zip->numFiles; $i++) { + $entry = $zip->getNameIndex($i); + if ($entry === false || $entry === '' || $entry === $prefix) { + continue; + } + if (str_starts_with($entry, '__MACOSX/')) { + continue; + } + + $relative = substr($entry, strlen($prefix)); + if ($relative === '') { + continue; + } + + $dest = $this->stagingDir . '/' . $relative; + + if (str_ends_with($entry, '/')) { + $this->ensureDirectory($dest); + continue; + } + + $this->ensureDirectory(dirname($dest)); + + $contents = $zip->getFromIndex($i); + if ($contents === false || file_put_contents($dest, $contents) === false) { + $zip->close(); + throw new RuntimeException("ZIP-Extraktion fehlgeschlagen: {$entry}"); + } + } + } + $zip->close(); $this->logPhase('extract', 'complete', ['files' => $this->countFiles($this->stagingDir)]); @@ -287,6 +330,46 @@ class SchneespurUpdater return $this->stagingDir; } + /** + * Detect whether all (non-metadata) ZIP entries share one common + * top-level folder. Returns the prefix (incl. trailing slash) when so, + * null when the ZIP is flat or has mixed top-level entries. + * + * Mirrors the logic in SchneespurModuleInstaller so update ZIPs that + * wrap their content in a versioned folder (the build.sh convention) + * are unwrapped during extraction instead of leaving a stray + * schneespur-X.Y.Z/ subdirectory in the live install. + */ + private function detectCommonPrefix(ZipArchive $zip): ?string + { + $prefix = null; + + for ($i = 0; $i < $zip->numFiles; $i++) { + $entry = $zip->getNameIndex($i); + if ($entry === false || $entry === '') { + continue; + } + if (str_starts_with($entry, '__MACOSX/')) { + continue; + } + + $slash = strpos($entry, '/'); + if ($slash === false) { + return null; + } + + $top = substr($entry, 0, $slash + 1); + + if ($prefix === null) { + $prefix = $top; + } elseif ($prefix !== $top) { + return null; + } + } + + return $prefix; + } + private function validateZipEntries(ZipArchive $zip): void { $resolvedStaging = realpath($this->stagingDir) ?: $this->stagingDir;