Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
ecd57f0
feat(updater): refuse an update that will not fit, and say why a copy…
fabiodalez-dev Sep 9, 2026
c07747b
fix(updater): prove the whole space requirement, and name the cause o…
fabiodalez-dev Sep 10, 2026
d362ef0
fix(updater): describe a write failure before anything else can overw…
fabiodalez-dev Sep 10, 2026
50d0d72
fix(updater): gate before the first write, size the extraction, recon…
fabiodalez-dev Sep 10, 2026
e52cf32
perf(updater): stop paying update-sized costs on every render of the …
fabiodalez-dev Sep 10, 2026
504cc44
feat(updater): disable the in-app update on the official Docker image
fabiodalez-dev Sep 10, 2026
efc78c1
fix(updater): verify cumulative capacity on actual write destinations
fabiodalez-dev Sep 10, 2026
54a85c9
Merge remote-tracking branch 'origin/main' into fix/update-space-pref…
fabiodalez-dev Oct 3, 2026
c848896
Keep updater internals out of the response, and log every failure to …
fabiodalez-dev Oct 3, 2026
05311ef
Merge branch 'ci/waive-dev-only-braces-advisory' into fix/update-spac…
fabiodalez-dev Oct 3, 2026
1a5a152
Report every unwritable update destination and keep the CLI upgrade u…
fabiodalez-dev Oct 3, 2026
25fc7e3
Merge remote-tracking branch 'origin/ci/waive-dev-only-braces-advisor…
fabiodalez-dev Oct 3, 2026
3e66c0e
Keep a dense write probe when the operator waives an unmeasurable spa…
fabiodalez-dev Oct 4, 2026
19ae889
Merge remote-tracking branch 'origin/main' into fix/update-space-pref…
fabiodalez-dev Oct 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 189 additions & 8 deletions app/Support/Updater.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,21 @@ class Updater
*/
private array $releaseByVersionCache = [];

/**
* Directories copied aside for rollback before an update — and therefore
* the directories whose size the space preflight has to account for. One
* list for both, so an addition here can never make the estimate lie.
*/
private const APP_BACKUP_DIRS = ['app', 'config', 'locale', 'public/assets', 'installer', 'vendor'];

/**
* Upper bound for the write probe of the space preflight. Writing the full
* requirement would double the cost of every update; this only has to prove
* the account can still write at all, which is what an exhausted quota
* prevents.
*/
private const SPACE_PROBE_BYTES = 16 * 1024 * 1024;

/** @var array<string> Files/directories to preserve during update */
private array $preservePaths = [
'.env',
Expand Down Expand Up @@ -2303,6 +2318,18 @@ public function installUpdate(string $sourcePath, string $targetVersion): array
));
}

// PRE-FLIGHT (issue #422): free space. The next step copies the whole
// application aside for rollback, so an account that is nearly full
// fails MID-COPY on whatever file the loop happens to reach — an
// error naming a file in vendor/ that says nothing about the real
// cause, which is exactly how a healthy release once looked broken.
// Refuse here, with the numbers, instead of failing halfway.
$spaceError = $this->checkFreeSpaceForUpdate();
if ($spaceError !== null) {
$this->debugLog('ERROR', 'Preflight: spazio insufficiente', ['detail' => $spaceError]);
throw new Exception($spaceError);
}

// Log update start
$logId = $this->logUpdateStart($currentVersion, $targetVersion, null);

Expand Down Expand Up @@ -2419,6 +2446,157 @@ public function installUpdate(string $sourcePath, string $targetVersion): array
/**
* Backup application files for atomic rollback
*/
/**
* Space needed by an update, in bytes: the rollback copy of the directories
* backupAppFiles() duplicates, plus a margin for the new files landing
* alongside the old ones during the copy.
*/
private function estimateUpdateSpace(): int
{
$total = 0;
foreach (self::APP_BACKUP_DIRS as $dir) {
$total += $this->directorySize($this->rootPath . '/' . $dir);
}
// 30% margin: the copy writes new files before the old ones are gone,
// and a partially-fitting update is the failure mode being prevented.
return (int) ($total * 1.3);
}

/** Size of a directory in bytes; 0 when it does not exist or cannot be read. */
private function directorySize(string $path): int
{
if (!is_dir($path)) {
return 0;
}
$bytes = 0;
try {
$it = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ($it as $file) {
if ($file instanceof \SplFileInfo && $file->isFile()) {
$bytes += (int) $file->getSize();
}
}
} catch (\Throwable $e) {
$this->debugLog('DEBUG', 'Impossibile misurare directory', ['path' => $path, 'error' => $e->getMessage()]);
}
return $bytes;
}

/**
* Refuse the update when the space it needs is not there. Returns the
* message to show, or null when there is room.
*
* Two checks, because neither alone is enough on shared hosting:
* disk_free_space() sees the FILESYSTEM, which on a cPanel account can
* report tens of gigabytes free while the account's own quota is exhausted;
* and a real write proves what the account can actually do right now. The
* probe is capped — it is meant to detect "cannot write at all", not to
* reserve the full amount, which would double the cost of every update.
*/
private function checkFreeSpaceForUpdate(): ?string
{
$needed = $this->estimateUpdateSpace();
if ($needed <= 0) {
return null; // nothing measurable to copy: let the update proceed
}

$free = @disk_free_space($this->rootPath);
if (is_float($free) && $free > 0 && $free < $needed) {
return sprintf(
__('Spazio su disco insufficiente per aggiornare: servono circa %1$s, disponibili %2$s. Libera spazio (i backup automatici in storage/backups sono i primi candidati) e riprova.'),
$this->formatBytes($needed),
$this->formatBytes((int) $free)
);
}

$probeBytes = (int) min($needed, self::SPACE_PROBE_BYTES);
if (!$this->canWriteBytes($probeBytes)) {
return sprintf(
__('Impossibile scrivere %1$s di prova: lo spazio disponibile o la quota dell\'account sono esauriti. L\'aggiornamento richiede circa %2$s. Libera spazio (i backup automatici in storage/backups sono i primi candidati) e riprova.'),
$this->formatBytes($probeBytes),
$this->formatBytes($needed)
);
}

$this->debugLog('INFO', 'Preflight spazio superato', [
'needed_bytes' => $needed,
'probe_bytes' => $probeBytes,
'disk_free_bytes' => is_float($free) ? (int) $free : null,
]);
return null;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Write and remove a real file of the given size under storage/tmp.
* Sparse allocation would not do: a hole consumes no quota, and the quota
* is precisely what this is testing.
*/
private function canWriteBytes(int $bytes): bool
{
$dir = $this->rootPath . '/storage/tmp';
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
return false;
}
$probe = $dir . '/.space_probe_' . bin2hex(random_bytes(4));
$handle = @fopen($probe, 'wb');
if ($handle === false) {
return false;
}
$ok = true;
try {
$chunk = str_repeat('0', 1024 * 1024);
$written = 0;
while ($written < $bytes) {
$slice = $bytes - $written >= strlen($chunk) ? $chunk : substr($chunk, 0, $bytes - $written);
$result = @fwrite($handle, $slice);
if ($result === false || $result === 0) {
$ok = false;
break;
}
$written += $result;
}
if ($ok && !@fflush($handle)) {
$ok = false;
}
} catch (\Throwable) {
$ok = false;
} finally {
@fclose($handle);
// nosemgrep: php.lang.security.unlink-use.unlink-use -- own probe file under storage/tmp, name generated here
@unlink($probe);
}
return $ok;
}

/**
* Why a write to this path failed, in words an operator can act on.
* Checked in order of likelihood, ending with whatever PHP reported.
*/
private function describeWriteFailure(string $targetPath): string
{
$dir = dirname($targetPath);

if (!$this->canWriteBytes(1024 * 1024)) {
return __('spazio su disco o quota dell\'account esauriti');
}
if (is_file($targetPath) && !is_writable($targetPath)) {
return __('il file di destinazione esiste e non è scrivibile');
}
if (!is_dir($dir)) {
return __('la directory di destinazione non esiste');
}
if (!is_writable($dir)) {
return __('la directory di destinazione non è scrivibile');
}

$last = error_get_last();
$message = $last === null ? '' : trim($last['message']);
return $message !== '' ? $message : __('causa sconosciuta');
}

private function backupAppFiles(): string
{
$timestamp = date('Y-m-d_His');
Expand All @@ -2441,9 +2619,7 @@ private function backupAppFiles(): string
throw new Exception(__('Impossibile creare directory di backup applicazione'));
}

$dirsToBackup = ['app', 'config', 'locale', 'public/assets', 'installer', 'vendor'];

foreach ($dirsToBackup as $dir) {
foreach (self::APP_BACKUP_DIRS as $dir) {
$sourcePath = $this->rootPath . '/' . $dir;
$destPath = $backupPath . '/' . $dir;

Expand Down Expand Up @@ -2473,9 +2649,7 @@ private function backupAppFiles(): string
*/
private function restoreAppFiles(string $backupPath): void
{
$dirsToRestore = ['app', 'config', 'locale', 'public/assets', 'installer', 'vendor'];

foreach ($dirsToRestore as $dir) {
foreach (self::APP_BACKUP_DIRS as $dir) {
$sourcePath = $backupPath . '/' . $dir;
$destPath = $this->rootPath . '/' . $dir;

Expand Down Expand Up @@ -2568,8 +2742,15 @@ private function copyDirectoryRecursive(string $source, string $dest): void
throw new Exception(sprintf(__('Impossibile creare directory: %s'), dirname($relativePath)));
}
}
if (!copy(str_replace('\\', '/', $item->getPathname()), $targetPath)) {
throw new Exception(sprintf(__('Errore nella copia del file: %s'), $relativePath));
if (!@copy(str_replace('\\', '/', $item->getPathname()), $targetPath)) {
// The file name alone is misleading: the copy stops at
// whatever entry it had reached when the real problem (a
// full disk, a read-only target) occurred. Say why.
throw new Exception(sprintf(
__('Errore nella copia del file: %s — %s'),
$relativePath,
$this->describeWriteFailure($targetPath)
));
}
}
}
Expand Down
10 changes: 9 additions & 1 deletion locale/da_DK.json
Original file line number Diff line number Diff line change
Expand Up @@ -7505,5 +7505,13 @@
"Abbonamento eliminato": "Abonnement slettet",
"Eventi plugin": "Pluginhændelser",
"Annata %1$d conservata separatamente come volume «%2$s» per mantenere i dati descrittivi.": "Årgang %1$d bevaret separat som bind »%2$s« for at bevare de beskrivende oplysninger.",
"Le annate con dati descrittivi discordanti restano separate con un volume distinto.": "Årgange med modstridende beskrivende oplysninger bevares separat med en særskilt bindbetegnelse."
"Le annate con dati descrittivi discordanti restano separate con un volume distinto.": "Årgange med modstridende beskrivende oplysninger bevares separat med en særskilt bindbetegnelse.",
"Spazio su disco insufficiente per aggiornare: servono circa %1$s, disponibili %2$s. Libera spazio (i backup automatici in storage/backups sono i primi candidati) e riprova.": "Ikke nok diskplads til opdateringen: der kræves ca. %1$s, %2$s er tilgængelige. Frigør plads (de automatiske sikkerhedskopier i storage/backups er de første kandidater), og prøv igen.",
"Impossibile scrivere %1$s di prova: lo spazio disponibile o la quota dell'account sono esauriti. L'aggiornamento richiede circa %2$s. Libera spazio (i backup automatici in storage/backups sono i primi candidati) e riprova.": "Kunne ikke skrive en testfil på %1$s: den tilgængelige plads eller kontoens kvote er opbrugt. Opdateringen kræver ca. %2$s. Frigør plads (de automatiske sikkerhedskopier i storage/backups er de første kandidater), og prøv igen.",
"spazio su disco o quota dell'account esauriti": "diskplads eller kontokvote opbrugt",
"il file di destinazione esiste e non è scrivibile": "destinationsfilen findes og kan ikke skrives",
"la directory di destinazione non esiste": "destinationsmappen findes ikke",
"la directory di destinazione non è scrivibile": "destinationsmappen kan ikke skrives",
"causa sconosciuta": "ukendt årsag",
"Errore nella copia del file: %s — %s": "Fejl ved kopiering af filen: %s — %s"
}
10 changes: 9 additions & 1 deletion locale/de_DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -7505,5 +7505,13 @@
"Abbonamento eliminato": "Abonnement gelöscht",
"Eventi plugin": "Plugin-Ereignisse",
"Annata %1$d conservata separatamente come volume «%2$s» per mantenere i dati descrittivi.": "Jahrgang %1$d separat als Band „%2$s“ beibehalten, um die beschreibenden Daten zu erhalten.",
"Le annate con dati descrittivi discordanti restano separate con un volume distinto.": "Jahrgänge mit widersprüchlichen Beschreibungsdaten bleiben unter einer eigenen Bandbezeichnung getrennt."
"Le annate con dati descrittivi discordanti restano separate con un volume distinto.": "Jahrgänge mit widersprüchlichen Beschreibungsdaten bleiben unter einer eigenen Bandbezeichnung getrennt.",
"Spazio su disco insufficiente per aggiornare: servono circa %1$s, disponibili %2$s. Libera spazio (i backup automatici in storage/backups sono i primi candidati) e riprova.": "Nicht genügend Speicherplatz für das Update: benötigt werden etwa %1$s, verfügbar sind %2$s. Geben Sie Speicherplatz frei (die automatischen Sicherungen in storage/backups sind die ersten Kandidaten) und versuchen Sie es erneut.",
"Impossibile scrivere %1$s di prova: lo spazio disponibile o la quota dell'account sono esauriti. L'aggiornamento richiede circa %2$s. Libera spazio (i backup automatici in storage/backups sono i primi candidati) e riprova.": "Eine Testdatei von %1$s konnte nicht geschrieben werden: der verfügbare Speicher oder das Kontingent des Kontos ist erschöpft. Das Update benötigt etwa %2$s. Geben Sie Speicherplatz frei (die automatischen Sicherungen in storage/backups sind die ersten Kandidaten) und versuchen Sie es erneut.",
"spazio su disco o quota dell'account esauriti": "Speicherplatz oder Kontingent des Kontos erschöpft",
"il file di destinazione esiste e non è scrivibile": "die Zieldatei existiert und ist nicht beschreibbar",
"la directory di destinazione non esiste": "das Zielverzeichnis existiert nicht",
"la directory di destinazione non è scrivibile": "das Zielverzeichnis ist nicht beschreibbar",
"causa sconosciuta": "unbekannte Ursache",
"Errore nella copia del file: %s — %s": "Fehler beim Kopieren der Datei: %s — %s"
}
10 changes: 9 additions & 1 deletion locale/en_US.json
Original file line number Diff line number Diff line change
Expand Up @@ -7505,5 +7505,13 @@
"Abbonamento eliminato": "Subscription deleted",
"Eventi plugin": "Plugin events",
"Annata %1$d conservata separatamente come volume «%2$s» per mantenere i dati descrittivi.": "Year %1$d retained separately as volume “%2$s” to preserve its descriptive data.",
"Le annate con dati descrittivi discordanti restano separate con un volume distinto.": "Years with conflicting descriptive data remain separate under a distinct volume."
"Le annate con dati descrittivi discordanti restano separate con un volume distinto.": "Years with conflicting descriptive data remain separate under a distinct volume.",
"Spazio su disco insufficiente per aggiornare: servono circa %1$s, disponibili %2$s. Libera spazio (i backup automatici in storage/backups sono i primi candidati) e riprova.": "Not enough disk space to update: about %1$s is needed, %2$s available. Free some space (the automatic backups in storage/backups are the first candidates) and try again.",
"Impossibile scrivere %1$s di prova: lo spazio disponibile o la quota dell'account sono esauriti. L'aggiornamento richiede circa %2$s. Libera spazio (i backup automatici in storage/backups sono i primi candidati) e riprova.": "Could not write a %1$s test file: the available space or the account quota is exhausted. The update needs about %2$s. Free some space (the automatic backups in storage/backups are the first candidates) and try again.",
"spazio su disco o quota dell'account esauriti": "disk space or account quota exhausted",
"il file di destinazione esiste e non è scrivibile": "the destination file exists and is not writable",
"la directory di destinazione non esiste": "the destination directory does not exist",
"la directory di destinazione non è scrivibile": "the destination directory is not writable",
"causa sconosciuta": "unknown cause",
"Errore nella copia del file: %s — %s": "Error copying file: %s — %s"
}
10 changes: 9 additions & 1 deletion locale/fr_FR.json
Original file line number Diff line number Diff line change
Expand Up @@ -7505,5 +7505,13 @@
"Abbonamento eliminato": "Abonnement supprimé",
"Eventi plugin": "Événements des extensions",
"Annata %1$d conservata separatamente come volume «%2$s» per mantenere i dati descrittivi.": "Année %1$d conservée séparément comme volume « %2$s » pour préserver les données descriptives.",
"Le annate con dati descrittivi discordanti restano separate con un volume distinto.": "Les années dont les données descriptives sont contradictoires restent séparées sous un volume distinct."
"Le annate con dati descrittivi discordanti restano separate con un volume distinto.": "Les années dont les données descriptives sont contradictoires restent séparées sous un volume distinct.",
"Spazio su disco insufficiente per aggiornare: servono circa %1$s, disponibili %2$s. Libera spazio (i backup automatici in storage/backups sono i primi candidati) e riprova.": "Espace disque insuffisant pour la mise à jour : environ %1$s sont nécessaires, %2$s disponibles. Libérez de l'espace (les sauvegardes automatiques dans storage/backups sont les premières candidates) et réessayez.",
"Impossibile scrivere %1$s di prova: lo spazio disponibile o la quota dell'account sono esauriti. L'aggiornamento richiede circa %2$s. Libera spazio (i backup automatici in storage/backups sono i primi candidati) e riprova.": "Impossible d'écrire un fichier de test de %1$s : l'espace disponible ou le quota du compte est épuisé. La mise à jour nécessite environ %2$s. Libérez de l'espace (les sauvegardes automatiques dans storage/backups sont les premières candidates) et réessayez.",
"spazio su disco o quota dell'account esauriti": "espace disque ou quota du compte épuisé",
"il file di destinazione esiste e non è scrivibile": "le fichier de destination existe et n'est pas accessible en écriture",
"la directory di destinazione non esiste": "le répertoire de destination n'existe pas",
"la directory di destinazione non è scrivibile": "le répertoire de destination n'est pas accessible en écriture",
"causa sconosciuta": "cause inconnue",
"Errore nella copia del file: %s — %s": "Erreur lors de la copie du fichier : %s — %s"
}
Loading