* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GNU General Public License version 3 or later; see LICENSE * * JPA (Joomla Pack Archive) unarchiver for importing Akeeba Backup files. * * JPA Format Structure: * - Header: signature (3 bytes "JPA"), header length, major/minor version * - Entity headers: signature (3 bytes), header length, path length, path, * compression type (0=none, 1=gzip), compressed size, uncompressed size, * permissions, then compressed data * * Read-only: extracts JPA archives to a staging directory. * The RestoreEngine can then restore from the extracted files. */ namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine; defined('_JEXEC') or die; class JpaUnarchiver { private const JPA_SIGNATURE = "\x4a\x50\x41"; // "JPA" private const ENTITY_SIGNATURE = "\x4a\x50\x46"; // "JPF" — file entity private string $archivePath; private string $outputDir; private int $filesExtracted = 0; public function __construct(string $archivePath, string $outputDir) { $this->archivePath = $archivePath; $this->outputDir = rtrim($outputDir, '/\\'); } /** * Extract a JPA archive to the output directory. * * @return int Number of files extracted * * @throws \RuntimeException On format errors or extraction failure */ public function extract(): int { if (!is_file($this->archivePath) || !is_readable($this->archivePath)) { throw new \RuntimeException('JPA file not readable: ' . $this->archivePath); } $handle = fopen($this->archivePath, 'rb'); if ($handle === false) { throw new \RuntimeException('Cannot open JPA file: ' . $this->archivePath); } try { // Read and validate archive header $this->readArchiveHeader($handle); // Read entities until EOF while (!feof($handle)) { $pos = ftell($handle); // Try to read entity signature $sig = fread($handle, 3); if ($sig === false || strlen($sig) < 3) { break; // End of archive } if ($sig === self::ENTITY_SIGNATURE) { $this->readFileEntity($handle); } else { // Unknown entity — try to skip by reading header length fseek($handle, $pos + 3); $headerLenData = fread($handle, 2); if ($headerLenData === false || strlen($headerLenData) < 2) { break; } $headerLen = unpack('v', $headerLenData)[1]; // Skip remaining header + data fseek($handle, $pos + 3 + $headerLen); } } return $this->filesExtracted; } finally { fclose($handle); } } /** * Read and validate the JPA archive header. */ private function readArchiveHeader($handle): void { $signature = fread($handle, 3); if ($signature !== self::JPA_SIGNATURE) { throw new \RuntimeException('Not a valid JPA archive — invalid signature'); } // Header length (2 bytes, little-endian) $headerLenData = fread($handle, 2); if ($headerLenData === false || strlen($headerLenData) < 2) { throw new \RuntimeException('Truncated JPA header'); } $headerLen = unpack('v', $headerLenData)[1]; // Version: major (1 byte), minor (1 byte) $versionData = fread($handle, 2); if ($versionData === false || strlen($versionData) < 2) { throw new \RuntimeException('Cannot read JPA version'); } // File count (4 bytes, little-endian) $countData = fread($handle, 4); if ($countData === false || strlen($countData) < 4) { throw new \RuntimeException('Cannot read file count'); } // Skip any remaining header bytes $bytesRead = 3 + 2 + 2 + 4; // sig + headerLen + version + count if ($headerLen > ($bytesRead - 3)) { $remaining = $headerLen - ($bytesRead - 3); if ($remaining > 0) { fseek($handle, ftell($handle) + $remaining); } } } /** * Read a single file entity and extract it. */ private function readFileEntity($handle): void { // Entity header length (2 bytes) $headerLenData = fread($handle, 2); if ($headerLenData === false || strlen($headerLenData) < 2) { return; } $headerLen = unpack('v', $headerLenData)[1]; // Path length (2 bytes) $pathLenData = fread($handle, 2); if ($pathLenData === false || strlen($pathLenData) < 2) { return; } $pathLen = unpack('v', $pathLenData)[1]; // Path (variable) $path = fread($handle, $pathLen); if ($path === false || strlen($path) < $pathLen) { return; } // Compression type (1 byte): 0 = none, 1 = gzip $compTypeData = fread($handle, 1); $compType = ord($compTypeData); // Compressed size (4 bytes) $compSizeData = fread($handle, 4); $compSize = unpack('V', $compSizeData)[1]; // Uncompressed size (4 bytes) $uncompSizeData = fread($handle, 4); $uncompSize = unpack('V', $uncompSizeData)[1]; // Permissions (4 bytes) $permsData = fread($handle, 4); $perms = unpack('V', $permsData)[1]; // Skip any remaining header bytes $entityHeaderRead = 2 + 2 + $pathLen + 1 + 4 + 4 + 4; $entityHeaderTotal = $headerLen; if ($entityHeaderTotal > $entityHeaderRead) { fseek($handle, ftell($handle) + ($entityHeaderTotal - $entityHeaderRead)); } // Read compressed data $data = ''; if ($compSize > 0) { $data = fread($handle, $compSize); if ($data === false || strlen($data) < $compSize) { return; } } // Path traversal protection: reject absolute paths and directory traversal if (str_starts_with($path, '/') || str_starts_with($path, '\\') || str_contains($path, '..')) { return; // skip malicious entry } // Is this a directory? if (substr($path, -1) === '/' || $uncompSize === 0 && $compSize === 0) { $dirPath = $this->outputDir . '/' . $path; if (!is_dir($dirPath)) { mkdir($dirPath, 0755, true); } return; } // Decompress if needed if ($compType === 1 && !empty($data)) { $data = @gzinflate($data); if ($data === false) { throw new \RuntimeException('Failed to decompress file: ' . $path); } } // Write file $fullPath = $this->outputDir . '/' . $path; // Verify resolved path stays within output directory $realOutput = realpath($this->outputDir); if ($realOutput !== false) { $parentDir = dirname($fullPath); if (!is_dir($parentDir)) { mkdir($parentDir, 0755, true); } $realDest = realpath($parentDir); if ($realDest === false || !str_starts_with($realDest, $realOutput)) { return; // path escapes staging directory } } $parentDir = dirname($fullPath); if (!is_dir($parentDir)) { mkdir($parentDir, 0755, true); } file_put_contents($fullPath, $data); // Set permissions (only if reasonable) if ($perms > 0 && $perms <= 0777) { @chmod($fullPath, $perms); } $this->filesExtracted++; } /** * Check if a file appears to be a JPA archive. */ public static function isJpaFile(string $path): bool { if (!is_file($path) || !is_readable($path)) { return false; } $handle = fopen($path, 'rb'); if ($handle === false) { return false; } $sig = fread($handle, 3); fclose($handle); return $sig === self::JPA_SIGNATURE; } }