getgrav/grav · error · RuntimeException
Bad package file: %s
Error message
Bad package file: %s
What it means
Installer::install() extracts the package zip, then reads the first entry name via $zip->getNameIndex(0) (line 268) to learn the package's root folder. When that returns false the archive contains no entries at all — an empty or truncated zip — so Grav throws rather than guess a destination path. Everything before this point (open, extract) succeeded, which is exactly the signature of an empty archive.
Source
Thrown at system/src/Grav/Common/GPM/Installer.php:268
if ($maxSize > 0) {
// Enforce the uncompressed-size cap against bytes actually written,
// so a forged-small declared size cannot smuggle a bomb past the
// advisory pre-pass above (GHSA-8h9x-89f2-m7x3). On failure the
// helper sets self::$error and removes the destination.
if (!self::extractStreamed($zip, $destination, $numFiles, $maxSize)) {
$zip->close();
return false;
}
} elseif (!$zip->extractTo($destination)) {
self::$error = self::ZIP_EXTRACT_ERROR;
Folder::delete($destination);
$zip->close();
return false;
}
$package_folder_name = $zip->getNameIndex(0);
if ($package_folder_name === false) {
throw new \RuntimeException('Bad package file: ' . Utils::basename($zip_file));
}
$package_folder_name = preg_replace('#\./$#', '', $package_folder_name);
$zip->close();
self::$error = self::OK;
return $destination . '/' . $package_folder_name;
}
self::$error = self::ZIP_EXTRACT_ERROR;
self::$error_zip = $archive;
return false;
}
/**
* Reject Zip Slip primitives in archive entry names: empty names, NUL
* bytes, absolute paths, or any path segment that is `..`. Forward andView on GitHub (pinned to 6040efed04)
Solutions
- Re-download the package from the source and retry — a truncated download is the most common cause.
- Pre-validate the archive before installing: open it with ZipArchive and require numFiles >= 1 so you can fail with a clearer message than the installer's.
- If you build the package yourself, ensure the zip actually contains the expected top-level folder (e.g. 'my-plugin/...').
Example fix
// before
$result = Installer::install($zipPath, $installPath);
// after
$zip = new \ZipArchive();
if (true !== $zip->open($zipPath) || $zip->numFiles < 1) {
throw new \RuntimeException('The downloaded package is empty or not a valid zip archive.');
}
$zip->close();
$result = Installer::install($zipPath, $installPath); Defensive patterns
Strategy: validation
Validate before calling
$zip = new \ZipArchive();
$ok = true === $zip->open($zipPath) && $zip->numFiles >= 1;
if ($ok) { $zip->close(); }
if (!$ok) {
throw new \RuntimeException('Package archive is empty or corrupt — re-download it.');
}
Installer::install($zipPath, $installPath); Type guard
function isNonEmptyZip(string $path): bool
{
$z = new \ZipArchive();
$ok = true === $z->open($path) && $z->numFiles >= 1;
if ($ok) { $z->close(); }
return $ok;
} Try / catch
try {
Installer::install($zipPath, $installPath);
} catch (\RuntimeException $e) {
if (str_starts_with($e->getMessage(), 'Bad package file')) {
// delete the downloaded file, re-fetch once, then retry install
}
} Prevention
- Verify Content-Length and content-type when saving downloaded packages.
- Check zip entry counts before handing archives to Installer.
- Keep packaging steps in CI fail-loud so empty zips never ship.
When it happens
Trigger: Installing a package whose zip has zero central-directory entries: an empty file renamed to .zip, a partially downloaded/corrupted archive, or a zip produced by a failed packaging step; direct Installer::install() calls with such a file.
Common situations: Install-from-URL where the server returned an HTML error page or empty 200 response saved as a .zip; interrupted downloads on slow connections; CI-built plugin/theme zips whose packaging command failed silently.
Related errors
- Malformed GPM URL: {$package_file}
- ZipArchiver: refused to extract {archive_file}. Archive exce
- ZipArchiver: refused to extract {archive_file}. Entry "{name
- ZipArchiver: refused to extract {archive_file}. Entry "{name
- ZipArchiver: refused to extract {archive_file}. Archive exce
AI-assisted analysis of getgrav/grav@6040efed04 (2026-08-17).
Data as JSON: /api/errors/1169bc6cc6fee539.
Report an issue: GitHub.