Release v1.0.4: self-updater hotfixes (wrapper stripping + counter check)

- 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) <noreply@anthropic.com>
This commit is contained in:
Michael 2026-05-19 14:03:12 +00:00
parent 84c68ee35c
commit 87a84eb0ac
2 changed files with 91 additions and 8 deletions

View file

@ -1 +1 @@
1.0.3 1.0.4

View file

@ -170,6 +170,18 @@ class SchneespurUpdater
throw new RuntimeException('Signatur ungültig — MITM oder beschädigt'); 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']; $newCounter = (int) $manifest['counter'];
if ($newCounter <= (int) ($state['last_counter'] ?? 0)) { if ($newCounter <= (int) ($state['last_counter'] ?? 0)) {
throw new RuntimeException( 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); $this->writeLastCheck($state, true, $manifest);
return $manifest; return $manifest;
@ -279,7 +285,44 @@ class SchneespurUpdater
$this->validateZipEntries($zip); $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(); $zip->close();
$this->logPhase('extract', 'complete', ['files' => $this->countFiles($this->stagingDir)]); $this->logPhase('extract', 'complete', ['files' => $this->countFiles($this->stagingDir)]);
@ -287,6 +330,46 @@ class SchneespurUpdater
return $this->stagingDir; 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 private function validateZipEntries(ZipArchive $zip): void
{ {
$resolvedStaging = realpath($this->stagingDir) ?: $this->stagingDir; $resolvedStaging = realpath($this->stagingDir) ?: $this->stagingDir;