first commit

This commit is contained in:
2024-07-15 11:28:08 +02:00
commit f52d538ea5
21891 changed files with 6161164 additions and 0 deletions

View File

@@ -0,0 +1,823 @@
<?php
/**
* Akeeba Engine
*
* @package akeebaengine
* @copyright Copyright (c)2006-2022 Nicholas K. Dionysopoulos / Akeeba Ltd
* @license GNU General Public License version 3, or later
*/
namespace Akeeba\Engine\Archiver;
defined('AKEEBAENGINE') || die();
use Akeeba\Engine\Base\Exceptions\ErrorException;
use Akeeba\Engine\Base\Exceptions\WarningException;
use Akeeba\Engine\Factory;
use Akeeba\Engine\Platform;
use Akeeba\Engine\Util\FileCloseAware;
use Akeeba\Engine\Util\FileSystem;
use Exception;
use RuntimeException;
/**
* Abstract parent class of all archiver engines
*/
abstract class Base
{
use FileCloseAware;
/** @var string The archive's comment. It's currently used ONLY in the ZIP file format */
protected $_comment;
/** @var Filesystem Filesystem utilities object */
protected $fsUtils = null;
/** @var resource JPA transformation source handle */
private $_xform_fp;
/** @var int The total size of the source JPA file */
private $totalSourceJPASize = 0;
/**
* Public constructor
*
* @codeCoverageIgnore
*
* @return void
*/
public function __construct()
{
$this->__bootstrap_code();
}
/**
* Wakeup (unserialization) function
*
* @codeCoverageIgnore
*
* @return void
*/
public function __wakeup()
{
$this->__bootstrap_code();
}
/**
* Adds a single file in the archive
*
* @param string $file The absolute path to the file to add
* @param string $removePath Path to remove from $file
* @param string $addPath Path to prepend to $file
*
* @return void
*
* @throws Exception
*/
public final function addFile($file, $removePath = '', $addPath = '')
{
$storedName = $this->addRemovePaths($file, $removePath, $addPath);
$this->addFileRenamed($file, $storedName);
}
/**
* Adds a list of files into the archive, removing $removePath from the
* file names and adding $addPath to them.
*
* @param array $fileList A simple string array of filepaths to include
* @param string $removePath Paths to remove from the filepaths
* @param string $addPath Paths to add in front of the filepaths
*
* @return void
*
* @throws Exception
*/
public final function addFileList(&$fileList, $removePath = '', $addPath = '')
{
if (!is_array($fileList))
{
Factory::getLog()->warning('addFileList called without a file list array');
return;
}
foreach ($fileList as $file)
{
$this->addFile($file, $removePath, $addPath);
}
}
/**
* Adds a file to the archive, with a name that's different from the source
* filename
*
* @param string $sourceFile Absolute path to the source file
* @param string $targetFile Relative filename to store in archive
*
* @return void
*
* @throws Exception
*/
public function addFileRenamed($sourceFile, $targetFile)
{
$mb_encoding = '8bit';
if (function_exists('mb_internal_encoding'))
{
$mb_encoding = mb_internal_encoding();
mb_internal_encoding('ISO-8859-1');
}
try
{
$this->_addFile(false, $sourceFile, $targetFile);
}
catch (WarningException $e)
{
Factory::getLog()->warning($e->getMessage());
}
finally
{
if (function_exists('mb_internal_encoding'))
{
mb_internal_encoding($mb_encoding);
}
}
}
/**
* Adds a file to the archive, given the stored name and its contents
*
* @param string $fileName The base file name
* @param string $addPath The relative path to prepend to file name
* @param string $virtualContent The contents of the file to be archived
*
* @return void
*/
public final function addFileVirtual($fileName, $addPath, &$virtualContent)
{
$storedName = $this->addRemovePaths($fileName, '', $addPath);
$mb_encoding = '8bit';
if (function_exists('mb_internal_encoding'))
{
$mb_encoding = mb_internal_encoding();
mb_internal_encoding('ISO-8859-1');
}
try
{
$this->_addFile(true, $virtualContent, $storedName);
}
catch (WarningException $e)
{
Factory::getLog()->warning($e->getMessage());
}
finally
{
if (function_exists('mb_internal_encoding'))
{
mb_internal_encoding($mb_encoding);
}
}
}
/**
* Adds a file to the archive, given the stored name and its contents
*
* @param string $fileName The base file name
* @param string $addPath The relative path to prepend to file name
* @param string $virtualContent The contents of the file to be archived
*
* @return void
*
* @deprecated 7.0.0
*/
public final function addVirtualFile($fileName, $addPath, &$virtualContent)
{
Factory::getLog()->debug('DEPRECATED: addVirtualFile() has been renamed to addFileVirtual().');
$this->addFileVirtual($fileName, $addPath, $virtualContent);
}
/**
* Makes whatever finalization is needed for the archive to be considered
* complete and useful (or, generally, clean up)
*
* @return void
*/
abstract public function finalize();
/**
* Returns a string with the extension (including the dot) of the files produced
* by this class.
*
* @return string
*/
abstract public function getExtension();
/**
* Initialises the archiver class, creating the archive from an existent
* installer's JPA archive. MUST BE OVERRIDEN BY CHILDREN CLASSES.
*
* @param string $targetArchivePath Absolute path to the generated archive
* @param array $options A named key array of options (optional)
*
* @return void
*/
abstract public function initialize($targetArchivePath, $options = []);
/**
* Notifies the engine on the backup comment and converts it to plain text for
* inclusion in the archive file, if applicable.
*
* @param string $comment The archive's comment
*
* @return void
*/
public function setComment($comment)
{
// First, sanitize the comment in a text-only format
$comment = str_replace("\n", " ", $comment); // Replace newlines with spaces
$comment = str_replace("<br>", "\n", $comment); // Replace HTML4 <br> with single newlines
$comment = str_replace("<br/>", "\n", $comment); // Replace HTML4 <br> with single newlines
$comment = str_replace("<br />", "\n", $comment); // Replace HTML <br /> with single newlines
$comment = str_replace("</p>", "\n\n", $comment); // Replace paragraph endings with double newlines
$comment = str_replace("<b>", "*", $comment); // Replace bold with star notation
$comment = str_replace("</b>", "*", $comment); // Replace bold with star notation
$comment = str_replace("<i>", "_", $comment); // Replace italics with underline notation
$comment = str_replace("</i>", "_", $comment); // Replace italics with underline notation
$this->_comment = strip_tags($comment, '');
}
/**
* Transforms a JPA archive (containing an installer) to the native archive format
* of the class. It actually extracts the source JPA in memory and instructs the
* class to include each extracted file.
*
* @codeCoverageIgnore
*
* @param integer $index The index in the source JPA archive's list currently in use
* @param integer $offset The source JPA archive's offset to use
*
* @return array|bool False if an error occurred, return array otherwise
*/
public function transformJPA($index, $offset)
{
$xform_source = null;
// Do we have to open the file?
if (!$this->_xform_fp)
{
// Get the source path
$registry = Factory::getConfiguration();
$embedded_installer = $registry->get('akeeba.advanced.embedded_installer');
// Fetch the name of the installer image
$installerDescriptors = Factory::getEngineParamsProvider()->getInstallerList();
$xform_source = Platform::getInstance()->get_installer_images_path() .
'/foobar.jpa'; // We need this as a "safe fallback"
// Try to find a sane default if we are not given a valid embedded installer
if (!array_key_exists($embedded_installer, $installerDescriptors))
{
$embedded_installer = 'angie';
if (!array_key_exists($embedded_installer, $installerDescriptors))
{
$allInstallers = array_keys($installerDescriptors);
foreach ($allInstallers as $anInstaller)
{
if ($anInstaller == 'none')
{
continue;
}
$embedded_installer = $anInstaller;
break;
}
}
}
if (array_key_exists($embedded_installer, $installerDescriptors))
{
$packages = $installerDescriptors[$embedded_installer]['package'] ?? '';
$langPacks = $installerDescriptors[$embedded_installer]['language'] ?? '';
if (empty($packages))
{
// No installer package specified. Pretend we are done!
$retArray = [
"filename" => '', // File name extracted
"data" => '', // File data
"index" => 0, // How many source JPA files I have
"offset" => 0, // Offset in JPA file
"skip" => false, // Skip this?
"done" => true, // Are we done yet?
"filesize" => 0,
];
return $retArray;
}
$packages = explode(',', $packages);
$langPacks = explode(',', $langPacks);
$this->totalSourceJPASize = 0;
$pathPrefix = Platform::getInstance()->get_installer_images_path() . '/';
foreach ($packages as $package)
{
$filePath = $pathPrefix . $package;
$this->totalSourceJPASize += (int) @filesize($filePath);
}
foreach ($langPacks as $langPack)
{
$filePath = $pathPrefix . $langPack;
if (!is_file($filePath))
{
continue;
}
$packages[] = $langPack;
$this->totalSourceJPASize += (int) @filesize($filePath);
}
if (count($packages) < $index)
{
throw new RuntimeException(__CLASS__ . ":: Installer package index $index not found for embedded installer $embedded_installer");
}
$package = $packages[$index];
// A package is specified, use it!
$xform_source = $pathPrefix . $package;
}
// 2.3: Try to use sane default if the indicated installer doesn't exist
if (!is_null($xform_source) && !file_exists($xform_source) && (basename($xform_source) != 'angie.jpa'))
{
throw new RuntimeException(__CLASS__ . ":: Installer package $xform_source of embedded installer $embedded_installer not found. Please go to the configuration page, select an Embedded Installer, save the configuration and try backing up again.");
}
// Try opening the file
if (!is_null($xform_source) && file_exists($xform_source))
{
$this->_xform_fp = @fopen($xform_source, 'r');
if ($this->_xform_fp === false)
{
throw new RuntimeException(__CLASS__ . ":: Can't seed archive with installer package " . $xform_source);
}
}
else
{
throw new RuntimeException(__CLASS__ . ":: Installer package " . $xform_source . " does not exist!");
}
}
$headerDataLength = 0;
if (!$offset)
{
// First run detected!
Factory::getLog()->debug('Initializing with JPA package ' . $xform_source);
// Skip over the header and check no problem exists
$offset = $this->_xformReadHeader();
if ($offset === false)
{
throw new RuntimeException('JPA package file was not read');
}
$headerDataLength = $offset;
}
$ret = $this->_xformExtract($offset);
$ret['index'] = $index;
if (is_array($ret))
{
$ret['chunkProcessed'] = $headerDataLength + $ret['offset'] - $offset;
$offset = $ret['offset'];
if (!$ret['skip'] && !$ret['done'])
{
Factory::getLog()->debug(' Adding ' . $ret['filename'] . '; Next offset:' . $offset);
$this->addFileVirtual($ret['filename'], '', $ret['data']);
}
elseif ($ret['done'])
{
$registry = Factory::getConfiguration();
$embedded_installer = $registry->get('akeeba.advanced.embedded_installer');
$installerDescriptors = Factory::getEngineParamsProvider()->getInstallerList();
$packages = $installerDescriptors[$embedded_installer]['package'];
$packages = explode(',', $packages);
$pathPrefix = Platform::getInstance()->get_installer_images_path() . '/';
$langPacks = $installerDescriptors[$embedded_installer]['language'];
$langPacks = explode(',', $langPacks);
foreach ($langPacks as $langPack)
{
$filePath = $pathPrefix . $langPack;
if (!is_file($filePath))
{
continue;
}
$packages[] = $langPack;
}
Factory::getLog()->debug(' Done with package ' . $packages[$index]);
if (count($packages) > ($index + 1))
{
$ret['done'] = false;
$ret['index'] = $index + 1;
$ret['offset'] = 0;
$this->_xform_fp = null;
}
else
{
Factory::getLog()->debug(' Done with installer seeding.');
}
}
else
{
$reason = ' Skipping ' . $ret['filename'];
Factory::getLog()->debug($reason);
}
}
else
{
throw new RuntimeException('JPA extraction returned FALSE. The installer image is corrupt.');
}
if ($ret['done'])
{
// We are finished! Close the file
$this->conditionalFileClose($this->_xform_fp);
Factory::getLog()->debug('Initializing with JPA package has finished');
}
$ret['filesize'] = $this->totalSourceJPASize;
return $ret;
}
/**
* Common code which gets called on instance creation or wake-up (unserialization)
*
* @codeCoverageIgnore
*
* @return void
*/
protected function __bootstrap_code()
{
$this->fsUtils = Factory::getFilesystemTools();
}
/**
* The most basic file transaction: add a single entry (file or directory) to
* the archive.
*
* @param boolean $isVirtual If true, the next parameter contains file data instead of a file name
* @param string $sourceNameOrData Absolute file name to read data from or the file data itself is $isVirtual
* is true
* @param string $targetName The (relative) file name under which to store the file in the archive
*
* @return boolean True on success, false otherwise. DEPRECATED: Use exceptions instead.
*
* @throws WarningException When there's a warning (the backup integrity is NOT compromised)
* @throws ErrorException When there's an error (the backup integrity is compromised backup dead)
*/
abstract protected function _addFile($isVirtual, &$sourceNameOrData, $targetName);
/**
* This function indicates if the path $p_path is under the $p_dir tree. Or,
* said in an other way, if the file or sub-dir $p_path is inside the dir
* $p_dir.
* The function indicates also if the path is exactly the same as the dir.
* This function supports path with duplicated '/' like '//', but does not
* support '.' or '..' statements.
*
* Copied verbatim from pclZip library
*
* @codeCoverageIgnore
*
* @param string $p_dir Source tree
* @param string $p_path Check if this is part of $p_dir
*
* @return integer 0 if $p_path is not inside directory $p_dir,
* 1 if $p_path is inside directory $p_dir
* 2 if $p_path is exactly the same as $p_dir
*/
private function _PathInclusion($p_dir, $p_path)
{
$v_result = 1;
// ----- Explode dir and path by directory separator
$v_list_dir = explode("/", $p_dir);
$v_list_dir_size = count($v_list_dir);
$v_list_path = explode("/", $p_path);
$v_list_path_size = count($v_list_path);
// ----- Study directories paths
$i = 0;
$j = 0;
while (($i < $v_list_dir_size) && ($j < $v_list_path_size) && ($v_result))
{
// ----- Look for empty dir (path reduction)
if ($v_list_dir[$i] == '')
{
$i++;
continue;
}
if ($v_list_path[$j] == '')
{
$j++;
continue;
}
// ----- Compare the items
if (($v_list_dir[$i] != $v_list_path[$j]) && ($v_list_dir[$i] != '') && ($v_list_path[$j] != ''))
{
$v_result = 0;
}
// ----- Next items
$i++;
$j++;
}
// ----- Look if everything seems to be the same
if ($v_result)
{
// ----- Skip all the empty items
while (($j < $v_list_path_size) && ($v_list_path[$j] == ''))
{
$j++;
}
while (($i < $v_list_dir_size) && ($v_list_dir[$i] == ''))
{
$i++;
}
if (($i >= $v_list_dir_size) && ($j >= $v_list_path_size))
{
// ----- There are exactly the same
$v_result = 2;
}
else if ($i < $v_list_dir_size)
{
// ----- The path is shorter than the dir
$v_result = 0;
}
}
// ----- Return
return $v_result;
}
/**
* Extracts a file from the JPA archive and returns an in-memory array containing it
* and its file data. The data returned is an array, consisting of the following keys:
* "filename" => relative file path stored in the archive
* "data" => file data
* "offset" => next offset to use
* "skip" => if this is not a file, just skip it...
* "done" => No more files left in archive
*
* @codeCoverageIgnore
*
* @param integer $offset The absolute data offset from archive's header
*
* @return array|bool See description for more information
*/
private function &_xformExtract($offset)
{
$false = false; // Used to return false values in case an error occurs
// Generate a return array
$retArray = [
"filename" => '', // File name extracted
"data" => '', // File data
"offset" => 0, // Offset in ZIP file
"skip" => false, // Skip this?
"done" => false // Are we done yet?
];
// If we can't open the file, return an error condition
if ($this->_xform_fp === false)
{
return $false;
}
// Go to the offset specified
if (!fseek($this->_xform_fp, $offset) == 0)
{
return $false;
}
// Get and decode Entity Description Block
$signature = fread($this->_xform_fp, 3);
// Check signature
if ($signature == 'JPF')
{
// This a JPA Entity Block. Process the header.
// Read length of EDB and of the Entity Path Data
$length_array = unpack('vblocksize/vpathsize', fread($this->_xform_fp, 4));
// Read the path data
$file = fread($this->_xform_fp, $length_array['pathsize']);
// Read and parse the known data portion
$bin_data = fread($this->_xform_fp, 14);
$header_data = unpack('Ctype/Ccompression/Vcompsize/Vuncompsize/Vperms', $bin_data);
// Read any unknwon data
$restBytes = $length_array['blocksize'] - (21 + $length_array['pathsize']);
if ($restBytes > 0)
{
$junk = fread($this->_xform_fp, $restBytes);
}
$compressionType = $header_data['compression'];
// Populate the return array
$retArray['filename'] = $file;
$retArray['skip'] = ($header_data['compsize'] == 0); // Skip over directories
switch ($header_data['type'])
{
case 0:
// directory
break;
case 1:
// file
switch ($compressionType)
{
case 0: // No compression
if ($header_data['compsize'] > 0) // 0 byte files do not have data to be read
{
$retArray['data'] = fread($this->_xform_fp, $header_data['compsize']);
}
break;
case 1: // GZip compression
$zipData = fread($this->_xform_fp, $header_data['compsize']);
$retArray['data'] = gzinflate($zipData);
break;
case 2: // BZip2 compression
$zipData = fread($this->_xform_fp, $header_data['compsize']);
$retArray['data'] = bzdecompress($zipData);
break;
}
break;
}
}
else
{
// This is not a file header. This means we are done.
$retArray['done'] = true;
}
$retArray['offset'] = ftell($this->_xform_fp);
return $retArray;
}
/**
* Skips over the JPA header entry and returns the offset file data starts from
*
* @codeCoverageIgnore
*
* @return boolean|integer False on failure, offset otherwise
*/
private function _xformReadHeader()
{
// Fail for unreadable files
if ($this->_xform_fp === false)
{
return false;
}
// Go to the beggining of the file
rewind($this->_xform_fp);
// Read the signature
$sig = fread($this->_xform_fp, 3);
// Not a JPA Archive?
if ($sig != 'JPA')
{
return false;
}
// Read and parse header length
$header_length_array = unpack('v', fread($this->_xform_fp, 2));
$header_length = $header_length_array[1];
// Read and parse the known portion of header data (14 bytes)
$bin_data = fread($this->_xform_fp, 14);
$header_data = unpack('Cmajor/Cminor/Vcount/Vuncsize/Vcsize', $bin_data);
// Load any remaining header data (forward compatibility)
$rest_length = $header_length - 19;
if ($rest_length > 0)
{
$junk = fread($this->_xform_fp, $rest_length);
}
return ftell($this->_xform_fp);
}
/**
* Removes the $p_remove_dir from $p_filename, while prepending it with $p_add_dir.
* Largely based on code from the pclZip library.
*
* @param string $p_filename The absolute file name to treat
* @param string $p_remove_dir The path to remove
* @param string $p_add_dir The path to prefix the treated file name with
*
* @return string The treated file name
*/
private function addRemovePaths($p_filename, $p_remove_dir, $p_add_dir)
{
$p_filename = $this->fsUtils->TranslateWinPath($p_filename);
$p_remove_dir = ($p_remove_dir == '') ? '' :
$this->fsUtils->TranslateWinPath($p_remove_dir); //should fix corrupt backups, fix by nicholas
$v_stored_filename = $p_filename;
if (!($p_remove_dir == ""))
{
if (substr($p_remove_dir, -1) != '/')
{
$p_remove_dir .= "/";
}
if ((substr($p_filename, 0, 2) == "./") || (substr($p_remove_dir, 0, 2) == "./"))
{
if ((substr($p_filename, 0, 2) == "./") && (substr($p_remove_dir, 0, 2) != "./"))
{
$p_remove_dir = "./" . $p_remove_dir;
}
if ((substr($p_filename, 0, 2) != "./") && (substr($p_remove_dir, 0, 2) == "./"))
{
$p_remove_dir = substr($p_remove_dir, 2);
}
}
$v_compare = $this->_PathInclusion($p_remove_dir, $p_filename);
if ($v_compare > 0)
{
if ($v_compare == 2)
{
$v_stored_filename = "";
}
else
{
$v_stored_filename =
substr($p_filename, (function_exists('mb_strlen') ? mb_strlen($p_remove_dir, '8bit') :
strlen($p_remove_dir)));
}
}
}
else
{
$v_stored_filename = $p_filename;
}
if (!($p_add_dir == ""))
{
if (substr($p_add_dir, -1) == "/")
{
$v_stored_filename = $p_add_dir . $v_stored_filename;
}
else
{
$v_stored_filename = $p_add_dir . "/" . $v_stored_filename;
}
}
return $v_stored_filename;
}
}

View File

@@ -0,0 +1,721 @@
<?php
/**
* Akeeba Engine
*
* @package akeebaengine
* @copyright Copyright (c)2006-2022 Nicholas K. Dionysopoulos / Akeeba Ltd
* @license GNU General Public License version 3, or later
*/
namespace Akeeba\Engine\Archiver;
defined('AKEEBAENGINE') || die();
use Akeeba\Engine\Base\Exceptions\ErrorException;
use Akeeba\Engine\Base\Exceptions\WarningException;
use Akeeba\Engine\Factory;
if (!defined('AKEEBA_CHUNK'))
{
$configuration = Factory::getConfiguration();
$chunksize = $configuration->get('engine.archiver.common.chunk_size', 1048576);
define('AKEEBA_CHUNK', $chunksize);
}
if (!function_exists('aksubstr'))
{
/**
* Attempt to use mbstring for getting parts of strings
*
* @param string $string
* @param int $start
* @param int|null $length
*
* @return string
*/
function aksubstr($string, $start, $length = null)
{
return function_exists('mb_substr') ? mb_substr($string, $start, $length, '8bit') :
substr($string, $start, $length);
}
}
/**
* Abstract class for custom archiver implementations
*/
abstract class BaseArchiver extends BaseFileManagement
{
/** @var array The last part which has been finalized and waits to be post-processed */
public $finishedPart = [];
/** @var resource File pointer to the archive being currently written to */
protected $fp = null;
/** @var resource File pointer to the archive's central directory file (for ZIP) */
protected $cdfp = null;
/** @var string The name of the file holding the archive's data, which becomes the final archive */
protected $_dataFileName;
/** @var string Archive full path without extension */
protected $dataFileNameWithoutExtension = '';
/** @var bool Should I store symlinks as such (no dereferencing?) */
protected $storeSymlinkTarget = false;
/** @var int Part size for split archives, in bytes */
protected $partSize = 0;
/** @var bool Should I use Split ZIP? */
protected $useSplitArchive = false;
/** @var int Permissions for the backup archive part files */
protected $permissions = null;
/**
* Release file pointers when the object is being serialized
*
* @codeCoverageIgnore
*
* @return void
*/
public function _onSerialize()
{
$this->_closeAllFiles();
$this->fp = null;
$this->cdfp = null;
}
/**
* Release file pointers when the object is being destroyed
*
* @codeCoverageIgnore
*
* @return void
*/
public function __destruct()
{
$this->_closeAllFiles();
$this->fp = null;
$this->cdfp = null;
}
/**
* Create a new archive part file (but does NOT open it for writing)
*
* @param bool $finalPart True if this is the final part
*
* @return bool False if creating a new part fails
*/
abstract protected function createNewPartFile($finalPart = false);
/**
* Create a new part file and open it for writing
*
* @param bool $finalPart Is this the final part?
*
* @return void
*/
protected function createAndOpenNewPart($finalPart = false)
{
@$this->fclose($this->fp);
$this->fp = null;
// Not enough space on current part, create new part
if (!$this->createNewPartFile($finalPart))
{
$extension = $this->getExtension();
$extension = ltrim(strtoupper($extension), '.');
throw new ErrorException("Could not create new $extension part file " . basename($this->_dataFileName));
}
$this->openArchiveForOutput(true);
}
/**
* Create a new backup archive
*
* @return void
*
* @throws ErrorException
*/
protected function createNewBackupArchive()
{
Factory::getLog()->debug(__CLASS__ . " :: Killing old archive");
$this->fp = $this->fopen($this->_dataFileName, "w");
if ($this->fp === false)
{
if (file_exists($this->_dataFileName))
{
@unlink($this->_dataFileName);
}
@touch($this->_dataFileName);
@chmod($this->_dataFileName, 0666);
$this->fp = $this->fopen($this->_dataFileName, "w");
if ($this->fp !== false)
{
throw new ErrorException("Could not open archive file '{$this->_dataFileName}' for append!");
}
}
@ftruncate($this->fp, 0);
}
/**
* Opens the backup archive file for output. Returns false if the archive file cannot be opened in binary append
* mode.
*
* @param bool $force Should I forcibly reopen the file? If false, I'll only open the file if the current
* file pointer is null.
*
* @return void
*/
protected function openArchiveForOutput($force = false)
{
if (is_null($this->fp) || $force)
{
$this->fp = $this->fopen($this->_dataFileName, "a");
}
if ($this->fp === false)
{
$this->fp = null;
throw new ErrorException("Could not open archive file '{$this->_dataFileName}' for append!");
}
}
/**
* Converts a human formatted size to integer representation of bytes,
* e.g. 1M to 1024768
*
* @param string $setting The value in human readable format, e.g. "1M"
*
* @return integer The value in bytes
*/
protected function humanToIntegerBytes($setting)
{
$val = trim($setting);
$last = strtolower($val[strlen($val) - 1]);
if (is_numeric($last))
{
return $setting;
}
switch ($last)
{
case 't':
$val *= 1024;
case 'g':
$val *= 1024;
case 'm':
$val *= 1024;
case 'k':
$val *= 1024;
}
return (int) $val;
}
/**
* Get the PHP memory limit in bytes
*
* @return int|null Memory limit in bytes or null if we can't figure it out.
*/
protected function getMemoryLimit()
{
if (!function_exists('ini_get'))
{
return null;
}
$memLimit = ini_get("memory_limit");
if ((is_numeric($memLimit) && ($memLimit < 0)) || !is_numeric($memLimit))
{
$memLimit = 0; // 1.2a3 -- Rare case with memory_limit < 0, e.g. -1Mb!
}
$memLimit = $this->humanToIntegerBytes($memLimit);
return $memLimit;
}
/**
* Enable storing of symlink target if we are not on Windows
*
* @return void
*/
protected function enableSymlinkTargetStorage()
{
$configuration = Factory::getConfiguration();
$dereferenceSymlinks = $configuration->get('engine.archiver.common.dereference_symlinks', true);
if ($dereferenceSymlinks)
{
return;
}
// We are told not to dereference symlinks. Are we on Windows?
$isWindows = (DIRECTORY_SEPARATOR == '\\');
if (function_exists('php_uname'))
{
$isWindows = stristr(php_uname(), 'windows');
}
// If we are not on Windows, enable symlink target storage
$this->storeSymlinkTarget = !$isWindows;
}
/**
* Gets the file size and last modification time (also works on virtual files and symlinks)
*
* @param string $sourceNameOrData File path to the source file or source data (if $isVirtual is true)
* @param bool $isVirtual Is this a virtual file?
* @param bool $isSymlink Is this a symlink?
* @param bool $isDir Is this a directory?
*
* @return array
*/
protected function getFileSizeAndModificationTime(&$sourceNameOrData, $isVirtual, $isSymlink, $isDir)
{
// Get real size before compression
if ($isVirtual)
{
$fileSize = akstrlen($sourceNameOrData);
$fileModTime = time();
return [$fileSize, $fileModTime];
}
if ($isSymlink)
{
$fileSize = akstrlen(@readlink($sourceNameOrData));
$fileModTime = 0;
return [$fileSize, $fileModTime];
}
// Is the file readable?
if (!is_readable($sourceNameOrData) && !$isDir)
{
// Really, REALLY check if it is readable (PHP sometimes lies, dammit!)
$myFP = @$this->fopen($sourceNameOrData, 'r');
if ($myFP === false)
{
// Unreadable file, skip it.
throw new WarningException('Unreadable file ' . $sourceNameOrData . '. Check permissions');
}
@$this->fclose($myFP);
}
// Get the file size
$fileSize = $isDir ? 0 : @filesize($sourceNameOrData);
$fileModTime = $isDir ? 0 : @filemtime($sourceNameOrData);
return [$fileSize, $fileModTime];
}
/**
* Get the preferred compression method for a file
*
* @param int $fileSize File size in bytes
* @param int $memLimit Memory limit in bytes
* @param bool $isDir Is it a directory?
* @param bool $isSymlink Is it a symlink?
*
* @return int Compression method to use: 0 (uncompressed) or 1 (gzip deflate)
*/
protected function getCompressionMethod($fileSize, $memLimit, $isDir, $isSymlink)
{
// If we don't have gzip installed we can't compress anything
if (!function_exists("gzcompress"))
{
return 0;
}
// Don't compress directories or symlinks
if ($isDir || $isSymlink)
{
return 0;
}
// Do not compress files over the compression threshold
if ($fileSize >= _AKEEBA_COMPRESSION_THRESHOLD)
{
return 0;
}
// No memory limit, file smaller than the compression threshold: always compress.
if (is_numeric($memLimit) && ($memLimit == 0))
{
return 1;
}
// Non-zero memory limit, PHP can report memory usage, see if there's enough memory.
if (is_numeric($memLimit) && function_exists("memory_get_usage"))
{
$availableRAM = $memLimit - memory_get_usage();
// Conservative approach: if the file size is over 40% of the available memory we won't compress.
$compressionMethod = (($availableRAM / 2.5) >= $fileSize) ? 1 : 0;
return $compressionMethod;
}
// Non-zero memory limit, PHP can't report memory usage, compress only files up to 512Kb (very conservative)
return ($fileSize <= 524288) ? 1 : 0;
}
/**
* Checks if the file exists and is readable
*
* @param string $sourceNameOrData The path to the file being compressed, or the raw file data for virtual files
* @param bool $isVirtual Is this a virtual file?
* @param bool $isSymlink Is this a symlink?
* @param bool $isDir Is this a directory?
*
* @return void
*
* @throws WarningException
*/
protected function testIfFileExists(&$sourceNameOrData, &$isVirtual, &$isDir, &$isSymlink)
{
if ($isVirtual || $isDir)
{
return;
}
if (!@file_exists($sourceNameOrData))
{
if ($isSymlink)
{
throw new WarningException('The symlink ' . $sourceNameOrData . ' points to a file or folder that no longer exists and will NOT be backed up.');
}
throw new WarningException('The file ' . $sourceNameOrData . ' no longer exists and will NOT be backed up. Are you backing up temporary or cache data?');
}
if (!@is_readable($sourceNameOrData))
{
throw new WarningException('Unreadable file ' . $sourceNameOrData . '. Check permissions.');
}
}
/**
* Try to get the compressed data for a file
*
* @param string $sourceNameOrData
* @param bool $isVirtual
* @param int $compressionMethod
* @param string $zdata
* @param int $unc_len
* @param int $c_len
*
* @return void
*/
protected function getZData(&$sourceNameOrData, &$isVirtual, &$compressionMethod, &$zdata, &$unc_len, &$c_len)
{
// Get uncompressed data
$udata =& $sourceNameOrData;
if (!$isVirtual)
{
$udata = @file_get_contents($sourceNameOrData);
}
// If the compression fails, we will let it behave like no compression was available
$c_len = $unc_len;
$compressionMethod = 0;
// Proceed with compression
$zdata = @gzcompress($udata);
if ($zdata !== false)
{
// The compression succeeded
unset($udata);
$compressionMethod = 1;
$zdata = aksubstr($zdata, 2, -4);
$c_len = akstrlen($zdata);
}
}
/**
* Returns the bytes available for writing data to the current part file (i.e. part size minus current offset)
*
* @return int
*/
protected function getPartFreeSize()
{
clearstatcache();
$current_part_size = @filesize($this->_dataFileName);
return (int) $this->partSize - ($current_part_size === false ? 0 : $current_part_size);
}
/**
* Enable split archive creation where possible
*
* @return void
*/
protected function enableSplitArchives()
{
$configuration = Factory::getConfiguration();
$partSize = $configuration->get('engine.archiver.common.part_size', 0);
// If the part size is less than 64Kb we won't enable split archives
if ($partSize < 65536)
{
return;
}
$extension = $this->getExtension();
$altExtension = substr($extension, 0, 2) . '01';
$archiveTypeUppercase = strtoupper(substr($extension, 1));
Factory::getLog()->info(__CLASS__ . " :: Split $archiveTypeUppercase creation enabled");
$this->useSplitArchive = true;
$this->partSize = $partSize;
$this->dataFileNameWithoutExtension =
dirname($this->_dataFileName) . '/' . basename($this->_dataFileName, $extension);
$this->_dataFileName = $this->dataFileNameWithoutExtension . $altExtension;
// Indicate that we have at least 1 part
$statistics = Factory::getStatistics();
$statistics->updateMultipart(1);
}
/**
* Write a file's GZip compressed data to the archive, taking into account archive splitting
*
* @param string $zdata The compressed data to write to the archive
*
* @return void
*/
protected function putRawDataIntoArchive(&$zdata)
{
// Single part archive. Just dump the compressed data.
if (!$this->useSplitArchive)
{
$this->fwrite($this->fp, $zdata);
return;
}
// Split JPA. Check if we need to split the part in the middle of the data.
$freeSpaceInPart = $this->getPartFreeSize();
// Nope. We have enough space to write all of the data in this part.
if ($freeSpaceInPart >= akstrlen($zdata))
{
$this->fwrite($this->fp, $zdata);
return;
}
$bytesLeftInData = akstrlen($zdata);
while ($bytesLeftInData > 0)
{
// Try to write to the archive. We can only write as much bytes as the free space in the backup archive OR
// the total data bytes left, whichever is lower.
$bytesWritten = $this->fwrite($this->fp, $zdata, min($bytesLeftInData, $freeSpaceInPart));
// Since we may have written fewer bytes than anticipated we use the real bytes written for calculations
$freeSpaceInPart -= $bytesWritten;
$bytesLeftInData -= $bytesWritten;
// If we still have data to write, remove the part already written and keep the rest
if ($bytesLeftInData > 0)
{
$zdata = aksubstr($zdata, -$bytesLeftInData);
}
// If the part file is full create a new one
if ($freeSpaceInPart <= 0)
{
// Create new part
$this->createAndOpenNewPart();
// Get its free space
$freeSpaceInPart = $this->getPartFreeSize();
}
}
// Tell PHP to free up some memory
$zdata = null;
}
/**
* Begin or resume adding an uncompressed file into the archive.
*
* IMPORTANT! Only this case can be spanned across steps: uncompressed, non-virtual data
*
* @param string $sourceNameOrData The path to the file we are reading from.
* @param int $fileLength The file size we are supposed to read, in bytes.
* @param int $resumeOffset Offset in the file to resume reading from
*
* @return bool True to indicate more processing is required in the next step
*/
protected function putUncompressedFileIntoArchive(&$sourceNameOrData, $fileLength = 0, $resumeOffset = null)
{
// Copy the file contents, ignore directories
$sourceFilePointer = @fopen($sourceNameOrData, "r");
if ($sourceFilePointer === false)
{
// If we have already written the file header and can't read the data your archive is busted.
throw new ErrorException('Unreadable file ' . $sourceNameOrData . '. Check permissions. Your archive is corrupt!');
}
// Seek to the resume point if required
if (!is_null($resumeOffset))
{
// Seek to new offset
$seek_result = @fseek($sourceFilePointer, $resumeOffset);
if ($seek_result === -1)
{
// What?! We can't resume!
$this->conditionalFileClose($sourceFilePointer);
throw new ErrorException(sprintf('Could not resume packing of file %s. Your archive is damaged!', $sourceNameOrData));
}
// Change the uncompressed size to reflect the remaining data
$fileLength -= $resumeOffset;
}
$mustBreak = $this->putDataFromFileIntoArchive($sourceFilePointer, $fileLength);
$this->conditionalFileClose($sourceFilePointer);
return $mustBreak;
}
/**
* Return the requested permissions for the backup archive file.
*
* @return int
* @since 8.0.0
*/
protected function getPermissions(): int
{
if (!is_null($this->permissions))
{
return $this->permissions;
}
$configuration = Factory::getConfiguration();
$permissions = $configuration->get('engine.archiver.common.permissions', '0666') ?: '0666';
$this->permissions = octdec($permissions);
return $this->permissions;
}
/**
* Put up to $fileLength bytes of the file pointer $sourceFilePointer into the backup archive. Returns true if we
* ran out of time and need to perform a step break. Returns false when the whole quantity of data has been copied.
* Throws an ErrorException if soemthing really bad happens.
*
* @param resource $sourceFilePointer The pointer to the input file
* @param int $fileLength How many bytes to copy
*
* @return bool True to indicate we need to resume packing the file in the next step
*/
private function putDataFromFileIntoArchive(&$sourceFilePointer, &$fileLength)
{
// Get references to engine objects we're going to be using
$configuration = Factory::getConfiguration();
$timer = Factory::getTimer();
// Quick copy data into the archive, AKEEBA_CHUNK bytes at a time
while (!feof($sourceFilePointer) && ($timer->getTimeLeft() > 0) && ($fileLength > 0))
{
// Normally I read up to AKEEBA_CHUNK bytes at a time
$chunkSize = AKEEBA_CHUNK;
// Do I have a split ZIP?
if ($this->useSplitArchive)
{
// I must only read up to the free space in the part file if it's less than AKEEBA_CHUNK.
$free_space = $this->getPartFreeSize();
$chunkSize = min($free_space, AKEEBA_CHUNK);
// If I ran out of free space I have to create a new part file.
if ($free_space <= 0)
{
$this->createAndOpenNewPart();
// We have created the part. If the user asked for immediate post-proc, break step now.
if ($configuration->get('engine.postproc.common.after_part', 0))
{
$resumeOffset = @ftell($sourceFilePointer);
$this->conditionalFileClose($sourceFilePointer);
$configuration->set('volatile.engine.archiver.resume', $resumeOffset);
$configuration->set('volatile.engine.archiver.processingfile', true);
$configuration->set('volatile.breakflag', true);
// Always close the open part when immediate post-processing is requested
@$this->fclose($this->fp);
$this->fp = null;
return true;
}
// No immediate post-proc. Recalculate the optimal chunk size.
$free_space = $this->getPartFreeSize();
$chunkSize = min($free_space, AKEEBA_CHUNK);
}
}
// Read some data and write it to the backup archive part file
$data = fread($sourceFilePointer, $chunkSize);
$bytesWritten = $this->fwrite($this->fp, $data, akstrlen($data));
// Subtract the written bytes from the bytes left to write
$fileLength -= $bytesWritten;
}
/**
* According to the file size we read when we were writing the file header we have more data to write. However,
* we reached the end of the file. This means the file went away or shrunk. We can't reliably go back and
* change the file header since it may be in a previous part file that's already been post-processed. All we can
* do is try to warn the user.
*/
while (feof($sourceFilePointer) && ($timer->getTimeLeft() > 0) && ($fileLength > 0))
{
throw new ErrorException('The file shrunk or went away while putting it in the backup archive. Your archive is damaged! If this is a temporary or cache file we advise you to exclude the contents of the temporary / cache folder it is contained in.');
}
// WARNING!!! The extra $unc_len != 0 check is necessary as PHP won't reach EOF for 0-byte files.
if (!feof($sourceFilePointer) && ($fileLength != 0))
{
// We have to break, or we'll time out!
$resumeOffset = @ftell($sourceFilePointer);
$this->conditionalFileClose($sourceFilePointer);
$configuration->set('volatile.engine.archiver.resume', $resumeOffset);
$configuration->set('volatile.engine.archiver.processingfile', true);
return true;
}
return false;
}
}

View File

@@ -0,0 +1,235 @@
<?php
/**
* Akeeba Engine
*
* @package akeebaengine
* @copyright Copyright (c)2006-2022 Nicholas K. Dionysopoulos / Akeeba Ltd
* @license GNU General Public License version 3, or later
*/
namespace Akeeba\Engine\Archiver;
defined('AKEEBAENGINE') || die();
use Akeeba\Engine\Base\Exceptions\ErrorException;
use Akeeba\Engine\Factory;
if (!function_exists('akstrlen'))
{
/**
* Attempt to use mbstring for calculating the binary string length.
*
* @param $string
*
* @return int
*/
function akstrlen($string)
{
return function_exists('mb_strlen') ? mb_strlen($string, '8bit') : strlen($string);
}
}
/**
* Abstract class for an archiver using managed file pointers
*/
abstract class BaseFileManagement extends Base
{
/** @var resource File pointer to the archive's central directory file (for ZIP) */
protected $cdfp = null;
/** @var resource File pointer to the archive being currently written to */
protected $fp = null;
/** @var array An array of the last open files for writing and their last written to offsets */
private $fileOffsets = [];
/** @var array An array of open file pointers */
private $filePointers = [];
/** @var null|string The last filename fwrite() wrote to */
private $lastFileName = null;
/** @var null|resource The last file pointer fwrite() wrote to */
private $lastFilePointer = null;
/**
* Release file pointers when the object is being destroyed
*
* @codeCoverageIgnore
*
* @return void
*/
public function __destruct()
{
$this->_closeAllFiles();
$this->fp = null;
$this->cdfp = null;
}
/**
* Release file pointers when the object is being serialized
*
* @codeCoverageIgnore
*
* @return void
*/
public function _onSerialize()
{
$this->_closeAllFiles();
$this->fp = null;
$this->cdfp = null;
}
/**
* Closes all open files known to this archiver object
*
* @return void
*/
protected function _closeAllFiles()
{
if (!empty($this->filePointers))
{
foreach ($this->filePointers as $file => $fp)
{
$this->conditionalFileClose($fp);
unset($this->filePointers[$file]);
}
}
}
/**
* Closes an already open file
*
* @param resource $fp The file pointer to close
*
* @return boolean
*/
protected function fclose(&$fp)
{
$result = true;
$offset = array_search($fp, $this->filePointers, true);
if (!is_null($fp) && is_resource($fp))
{
$result = $this->conditionalFileClose($fp);
}
if ($offset !== false)
{
unset($this->filePointers[$offset]);
}
$fp = null;
return $result;
}
protected function fcloseByName($file)
{
if (!array_key_exists($file, $this->filePointers))
{
return true;
}
$ret = $this->fclose($this->filePointers[$file]);
if (array_key_exists($file, $this->filePointers))
{
unset($this->filePointers[$file]);
}
return $ret;
}
/**
* Opens a file, if it's not already open, or returns its cached file pointer if it's already open
*
* @param string $file The filename to open
* @param string $mode File open mode, defaults to binary write
*
* @return resource
*/
protected function fopen($file, $mode = 'w')
{
if (!array_key_exists($file, $this->filePointers))
{
//Factory::getLog()->debug("Opening backup archive $file with mode $mode");
$this->filePointers[$file] = @fopen($file, $mode);
// If we open a file for append we have to seek to the correct offset
if (substr($mode, 0, 1) == 'a')
{
if (isset($this->fileOffsets[$file]))
{
Factory::getLog()->debug("Truncating backup archive file $file to " . $this->fileOffsets[$file] . " bytes");
@ftruncate($this->filePointers[$file], $this->fileOffsets[$file]);
}
fseek($this->filePointers[$file], 0, SEEK_END);
}
}
return $this->filePointers[$file];
}
/**
* Write to file, defeating magic_quotes_runtime settings (pure binary write)
*
* @param resource $fp Handle to a file
* @param string $data The data to write to the file
* @param integer $p_len Maximum length of data to write
*
* @return int The number of bytes written
*
* @throws ErrorException When writing to the file is not possible
*/
protected function fwrite($fp, $data, $p_len = null)
{
if ($fp !== $this->lastFilePointer)
{
$this->lastFilePointer = $fp;
$this->lastFileName = array_search($fp, $this->filePointers, true);
}
$len = is_null($p_len) ? (akstrlen($data)) : $p_len;
$ret = fwrite($fp, $data, $len);
if (($ret === false) || (abs(($ret - $len)) >= 1))
{
// Log debug information about the archive file's existence and current size. This helps us figure out if
// there is a server-imposed maximum file size limit.
clearstatcache();
$fileExists = @file_exists($this->lastFileName) ? 'exists' : 'does NOT exist';
$currentSize = @filesize($this->lastFileName);
Factory::getLog()->debug(sprintf("%s::_fwrite() ERROR!! Cannot write to archive file %s. The file %s. File size %s bytes after writing %s of %d bytes. Please check the output directory permissions and make sure you have enough disk space available. If this does not help, please set up a Part Size for Split Archives LOWER than this size and retry backing up.", __CLASS__, $this->lastFileName, $fileExists, $currentSize, $ret, $len));
throw new ErrorException(sprintf("Couldn\'t write to the archive file; check the output directory permissions and make sure you have enough disk space available. [len=%s / %s]", $ret, $len));
}
if ($this->lastFileName !== false)
{
$this->fileOffsets[$this->lastFileName] = @ftell($fp);
}
return $ret;
}
/**
* Removes a file path from the list of resumable offsets
*
* @param $filename
*/
protected function removeFromOffsetsList($filename)
{
if (isset($this->fileOffsets[$filename]))
{
unset($this->fileOffsets[$filename]);
}
}
}

View File

@@ -0,0 +1,540 @@
<?php
/**
* Akeeba Engine
*
* @package akeebaengine
* @copyright Copyright (c)2006-2022 Nicholas K. Dionysopoulos / Akeeba Ltd
* @license GNU General Public License version 3, or later
*/
namespace Akeeba\Engine\Archiver;
defined('AKEEBAENGINE') || die();
use Akeeba\Engine\Base\Exceptions\ErrorException;
use Akeeba\Engine\Factory;
use RuntimeException;
/**
* JPA creation class
*
* JPA Format 1.2 implemented, minus BZip2 compression support
*/
class Jpa extends BaseArchiver
{
/** @var integer How many files are contained in the archive */
private $totalFilesCount = 0;
/** @var integer The total size of files contained in the archive as they are stored */
private $totalCompressedSize = 0;
/** @var integer The total size of files contained in the archive when they are extracted to disk. */
private $totalUncompressedSize = 0;
/** @var string Standard Header signature */
private $archiveSignature = "\x4A\x50\x41";
/** @var string Entity Block signature */
private $fileHeaderSignature = "\x4A\x50\x46";
/** @var string Marks the split archive's extra header */
private $splitArchiveExtraHeader = "\x4A\x50\x01\x01"; //
/** @var int Current part file number */
private $currentPartNumber = 1;
/** @var int Total number of part files */
private $totalParts = 1;
/**
* Initialises the archiver class, creating the archive from an existent
* installer's JPA archive.
*
* @param string $targetArchivePath Absolute path to the generated archive
* @param array $options A named key array of options (optional)
*
* @return void
*/
public function initialize($targetArchivePath, $options = [])
{
Factory::getLog()->debug(__CLASS__ . " :: new instance - archive $targetArchivePath");
$this->_dataFileName = $targetArchivePath;
// Should we enable Split ZIP feature?
$this->enableSplitArchives();
// Should I use Symlink Target Storage?
$this->enableSymlinkTargetStorage();
// Try to kill the archive if it exists
$this->createNewBackupArchive();
// Write the initial instance of the archive header
$this->_writeArchiveHeader();
}
/**
* Updates the Standard Header with current information
*
* @return void
*/
public function finalize()
{
if (is_resource($this->fp))
{
$this->fclose($this->fp);
}
if (is_resource($this->cdfp))
{
$this->fclose($this->cdfp);
}
$this->_closeAllFiles();
// If Spanned JPA and there is no .jpa file, rename the last fragment to .jpa
if ($this->useSplitArchive)
{
$extension = substr($this->_dataFileName, -4);
if ($extension != '.jpa')
{
Factory::getLog()->debug('Renaming last JPA part to .JPA extension');
$newName = $this->dataFileNameWithoutExtension . '.jpa';
if (!@rename($this->_dataFileName, $newName))
{
throw new RuntimeException('Could not rename last JPA part to .JPA extension.');
}
$this->_dataFileName = $newName;
}
// Finally, point to the first part so that we can re-write the correct header information
if ($this->totalParts > 1)
{
$this->_dataFileName = $this->dataFileNameWithoutExtension . '.j01';
}
}
// Re-write the archive header
$this->_writeArchiveHeader();
}
/**
* Returns a string with the extension (including the dot) of the files produced
* by this class.
*
* @return string
*/
public function getExtension()
{
return '.jpa';
}
/**
* Outputs a Standard Header at the top of the file
*
* @return void
*/
protected function _writeArchiveHeader()
{
if (!is_null($this->fp))
{
$this->fclose($this->fp);
$this->fp = null;
}
$this->fp = $this->fopen($this->_dataFileName, 'c');
if ($this->fp === false)
{
throw new ErrorException('Could not open ' . $this->_dataFileName . ' for writing. Check permissions and open_basedir restrictions.');
}
// Calculate total header size
$headerSize = 19; // Standard Header
if ($this->useSplitArchive)
{
// Spanned JPA header
$headerSize += 8;
}
$this->fwrite($this->fp, $this->archiveSignature); // ID string (JPA)
$this->fwrite($this->fp, pack('v', $headerSize)); // Header length; fixed to 19 bytes
$this->fwrite($this->fp, pack('C', _JPA_MAJOR)); // Major version
$this->fwrite($this->fp, pack('C', _JPA_MINOR)); // Minor version
$this->fwrite($this->fp, pack('V', $this->totalFilesCount)); // File count
$this->fwrite($this->fp, pack('V', $this->totalUncompressedSize)); // Size of files when extracted
$this->fwrite($this->fp, pack('V', $this->totalCompressedSize)); // Size of files when stored
// Do I need to add a split archive's header too?
if ($this->useSplitArchive)
{
$this->fwrite($this->fp, $this->splitArchiveExtraHeader); // Signature
$this->fwrite($this->fp, pack('v', 4)); // Extra field length
$this->fwrite($this->fp, pack('v', $this->totalParts)); // Number of parts
}
$this->fclose($this->fp);
@chmod($this->_dataFileName, $this->getPermissions());
}
/**
* Extend the bootstrap code to add some define's used by the JPA format engine
*
* @codeCoverageIgnore
*
* @return void
*/
protected function __bootstrap_code()
{
if (!defined('_AKEEBA_COMPRESSION_THRESHOLD'))
{
$config = Factory::getConfiguration();
define("_AKEEBA_COMPRESSION_THRESHOLD", $config->get('engine.archiver.common.big_file_threshold')); // Don't compress files over this size
/**
* Akeeba Backup and JPA Format version change chart:
* Akeeba Backup 3.0: JPA Format 1.1 is used
* Akeeba Backup 3.1: JPA Format 1.2 with file modification timestamp is used
*/
define('_JPA_MAJOR', 1); // JPA Format major version number
define('_JPA_MINOR', 2); // JPA Format minor version number
}
parent::__bootstrap_code();
}
/**
* The most basic file transaction: add a single entry (file or directory) to
* the archive.
*
* @param bool $isVirtual If true, the next parameter contains file data instead of a file name
* @param string $sourceNameOrData Absolute file name to read data from or the file data itself is $isVirtual is
* true
* @param string $targetName The (relative) file name under which to store the file in the archive
*
* @return boolean True on success, false otherwise
*
* @since 1.2.1
*/
protected function _addFile($isVirtual, &$sourceNameOrData, $targetName)
{
// Get references to engine objects we're going to be using
$configuration = Factory::getConfiguration();
// Is this a virtual file?
$isVirtual = (bool) $isVirtual;
// Open data file for output
$this->openArchiveForOutput();
// Should I continue backing up a file from the previous step?
$continueProcessingFile = $configuration->get('volatile.engine.archiver.processingfile', false);
// Initialize with the default values. Why are *these* values default? If we are continuing file packing, by
// definition we have an uncompressed, non-virtual file. Hence the default values.
$isDir = false;
$isSymlink = false;
$compressionMethod = 0;
$zdata = null;
// If we are continuing file packing we have an uncompressed, non-virtual file.
$isVirtual = $continueProcessingFile ? false : $isVirtual;
$resume = $continueProcessingFile ? 0 : null;
if (!$continueProcessingFile)
{
// Log the file being added
$messageSource = $isVirtual ? '(virtual data)' : "(source: $sourceNameOrData)";
Factory::getLog()->debug("-- Adding $targetName to archive $messageSource");
// Write a file header
$this->writeFileHeader($sourceNameOrData, $targetName, $isVirtual, $isSymlink, $isDir, $compressionMethod, $zdata, $unc_len);
}
else
{
$sourceNameOrData = $configuration->get('volatile.engine.archiver.sourceNameOrData', '');
$unc_len = $configuration->get('volatile.engine.archiver.unc_len', 0);
$resume = $configuration->get('volatile.engine.archiver.resume', 0);
// Log the file we continue packing
Factory::getLog()->debug("-- Resuming adding file $sourceNameOrData to archive from position $resume (total size $unc_len)");
}
/* "File data" segment. */
if ($compressionMethod == 1)
{
// Compressed data. Put into the archive.
$this->putRawDataIntoArchive($zdata);
}
elseif ($isVirtual)
{
// Virtual data. Put into the archive.
$this->putRawDataIntoArchive($sourceNameOrData);
}
elseif ($isSymlink)
{
// Symlink. Just put the link target into the archive.
$this->fwrite($this->fp, @readlink($sourceNameOrData));
}
elseif ((!$isDir) && (!$isSymlink))
{
// Uncompressed file.
if ($this->putUncompressedFileIntoArchive($sourceNameOrData, $unc_len, $resume) === true)
{
// If it returns true we are doing a step break to resume packing in the next step. So we need to return
// true here to avoid running the final bit of code which uncaches the file resume data.
return true;
}
}
// Factory::getLog()->debug("DEBUG -- Added $targetName to archive");
// Uncache data
$configuration->set('volatile.engine.archiver.sourceNameOrData', null);
$configuration->set('volatile.engine.archiver.unc_len', null);
$configuration->set('volatile.engine.archiver.resume', null);
$configuration->set('volatile.engine.archiver.processingfile', false);
// ... and return TRUE = success
return true;
}
/**
* Write the file header to the backup archive.
*
* Only the first three parameters are input. All other are ignored for input and are overwritten.
*
* @param string $sourceNameOrData The path to the file being compressed, or the raw file data for virtual files
* @param string $targetName The target path to be stored inside the archive
* @param bool $isVirtual Is this a virtual file?
* @param bool $isSymlink Is this a symlink?
* @param bool $isDir Is this a directory?
* @param int $compressionMethod The compression method chosen for this file
* @param string $zdata If we have compression method other than 0 this holds the compressed data.
* We return that from this method to avoid having to compress the same data
* twice (once to write the compressed data length in the header and once to
* write the compressed data to the archive).
* @param int $unc_len The uncompressed size of the file / source data
*
* @return void
*/
protected function writeFileHeader(&$sourceNameOrData, &$targetName, &$isVirtual, &$isSymlink, &$isDir, &$compressionMethod, &$zdata, &$unc_len)
{
static $memLimit = null;
if (is_null($memLimit))
{
$memLimit = $this->getMemoryLimit();
}
$configuration = Factory::getConfiguration();
// Uncache data -- WHY DO THAT?!
/**
* $configuration->set('volatile.engine.archiver.sourceNameOrData', null);
* $configuration->set('volatile.engine.archiver.unc_len', null);
* $configuration->set('volatile.engine.archiver.resume', null);
* $configuration->set('volatile.engine.archiver.processingfile',false);
* /**/
// See if it's a directory
$isDir = $isVirtual ? false : is_dir($sourceNameOrData);
// See if it's a symlink (w/out dereference)
$isSymlink = false;
if ($this->storeSymlinkTarget && !$isVirtual)
{
$isSymlink = is_link($sourceNameOrData);
}
// Get real size before compression
[$fileSize, $fileModTime] =
$this->getFileSizeAndModificationTime($sourceNameOrData, $isVirtual, $isSymlink, $isDir);
// Decide if we will compress
$compressionMethod = $this->getCompressionMethod($fileSize, $memLimit, $isDir, $isSymlink);
$storedName = $targetName;
/* "Entity Description Block" segment. */
$unc_len = $fileSize; // File size
$storedName .= ($isDir) ? "/" : "";
/**
* !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
* !!!! WARNING!!! DO NOT MOVE THIS BLOCK OF CODE AFTER THE testIfFileExists OR getZData!!!! !!!!
* !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
*
* PHP 5.6.3 IS BROKEN. Possibly the same applies for all old versions of PHP. If you try to get the file
* permissions after reading its contents PHP segfaults.
*/
// Get file permissions
$perms = 0644;
if (!$isVirtual)
{
$perms = @fileperms($sourceNameOrData);
}
// Test for non-existing or unreadable files
$this->testIfFileExists($sourceNameOrData, $isVirtual, $isDir, $isSymlink);
// Default compressed (archived) length = uncompressed length valid unless we can actually compress the data.
$c_len = $unc_len;
if ($compressionMethod == 1)
{
$this->getZData($sourceNameOrData, $isVirtual, $compressionMethod, $zdata, $unc_len, $c_len);
}
$this->totalCompressedSize += $c_len; // Update global data
$this->totalUncompressedSize += $fileSize; // Update global data
$this->totalFilesCount++;
// Calculate Entity Description Block length
$blockLength = 21 + akstrlen($storedName);
// If we need to store the file mod date
if ($fileModTime > 0)
{
$blockLength += 8;
}
// Get file type
$fileType = 1;
if ($isSymlink)
{
$fileType = 2;
}
elseif ($isDir)
{
$fileType = 0;
}
// If it's a split JPA file, we've got to make sure that the header can fit in the part
if ($this->useSplitArchive)
{
// Compare to free part space
$free_space = $this->getPartFreeSize();
if ($free_space <= $blockLength)
{
// Not enough space on current part, create new part
$this->createAndOpenNewPart();
}
}
$this->fwrite($this->fp, $this->fileHeaderSignature); // Entity Description Block header
$this->fwrite($this->fp, pack('v', $blockLength)); // Entity Description Block header length
$this->fwrite($this->fp, pack('v', akstrlen($storedName))); // Length of entity path
$this->fwrite($this->fp, $storedName); // Entity path
$this->fwrite($this->fp, pack('C', $fileType)); // Entity type
$this->fwrite($this->fp, pack('C', $compressionMethod)); // Compression method
$this->fwrite($this->fp, pack('V', $c_len)); // Compressed size
$this->fwrite($this->fp, pack('V', $unc_len)); // Uncompressed size
$this->fwrite($this->fp, pack('V', $perms)); // Entity permissions
// Timestamp Extra Field, only for files
if ($fileModTime > 0)
{
$this->fwrite($this->fp, "\x00\x01"); // Extra Field Identifier
$this->fwrite($this->fp, pack('v', 8)); // Extra Field Length
$this->fwrite($this->fp, pack('V', $fileModTime)); // Timestamp
}
// Cache useful information about the file
if (!$isDir && !$isSymlink && !$isVirtual)
{
$configuration->set('volatile.engine.archiver.unc_len', $unc_len);
$configuration->set('volatile.engine.archiver.sourceNameOrData', $sourceNameOrData);
}
}
/**
* Creates a new part for the spanned archive
*
* @param bool $finalPart Is this the final archive part?
*
* @return bool True on success
*/
protected function createNewPartFile($finalPart = false)
{
// Close any open file pointers
if (!is_resource($this->fp))
{
$this->fclose($this->fp);
}
if (is_resource($this->cdfp))
{
$this->fclose($this->cdfp);
}
// Remove the just finished part from the list of resumable offsets
$this->removeFromOffsetsList($this->_dataFileName);
// Set the file pointers to null
$this->fp = null;
$this->cdfp = null;
// Push the previous part if we have to post-process it immediately
$configuration = Factory::getConfiguration();
if ($configuration->get('engine.postproc.common.after_part', 0))
{
// The first part needs its header overwritten during archive
// finalization. Skip it from immediate processing.
if ($this->currentPartNumber != 1)
{
$this->finishedPart[] = $this->_dataFileName;
}
}
$this->totalParts++;
$this->currentPartNumber = $this->totalParts;
if ($finalPart)
{
$this->_dataFileName = $this->dataFileNameWithoutExtension . '.jpa';
}
else
{
$this->_dataFileName = $this->dataFileNameWithoutExtension . '.j' . sprintf('%02d', $this->currentPartNumber);
}
Factory::getLog()->info('Creating new JPA part #' . $this->currentPartNumber . ', file ' . $this->_dataFileName);
$statistics = Factory::getStatistics();
$statistics->updateMultipart($this->totalParts);
// Try to remove any existing file
@unlink($this->_dataFileName);
// Touch the new file
$result = @touch($this->_dataFileName);
chmod($this->_dataFileName, $this->getPermissions());
// Try to write 6 bytes to it
if ($result)
{
$result = @file_put_contents($this->_dataFileName, 'AKEEBA') == 6;
}
if ($result)
{
@unlink($this->_dataFileName);
$result = @touch($this->_dataFileName);
@chmod($this->_dataFileName, $this->getPermissions());
}
return $result;
}
}

View File

@@ -0,0 +1,913 @@
<?php
/**
* Akeeba Engine
*
* @package akeebaengine
* @copyright Copyright (c)2006-2022 Nicholas K. Dionysopoulos / Akeeba Ltd
* @license GNU General Public License version 3, or later
*/
namespace Akeeba\Engine\Archiver;
defined('AKEEBAENGINE') || die();
use Akeeba\Engine\Base\Exceptions\ErrorException;
use Akeeba\Engine\Base\Exceptions\WarningException;
use Akeeba\Engine\Factory;
use Akeeba\Engine\Util\CRC32;
use RuntimeException;
class Zip extends BaseArchiver
{
/** @var string Beginning of central directory record. */
private $centralDirectoryRecordStartSignature = "\x50\x4b\x01\x02";
/** @var string End of central directory record. */
private $centralDirectoryRecordEndSignature = "\x50\x4b\x05\x06";
/** @var string Beginning of file contents. */
private $fileHeaderSignature = "\x50\x4b\x03\x04";
/** @var string The name of the temporary file holding the ZIP's Central Directory */
private $centralDirectoryFilename;
/** @var integer The total number of files and directories stored in the ZIP archive */
private $totalFilesCount;
/** @var integer The total size of data in the archive. Note: On 32-bit versions of PHP, this will overflow for archives over 2Gb! */
private $totalCompressedSize = 0;
/** @var integer The chunk size for CRC32 calculations */
private $AkeebaPackerZIP_CHUNK_SIZE;
/** @var int Current part file number */
private $currentPartNumber = 1;
/** @var int Total number of part files */
private $totalParts = 1;
/** @var CRC32 The CRC32 calculations object */
private $crcCalculator = null;
/**
* Class constructor - initializes internal operating parameters
*
* @return void
*/
public function __construct()
{
Factory::getLog()->debug(__CLASS__ . " :: New instance");
// Find the optimal chunk size for ZIP archive processing
$this->findOptimalChunkSize();
Factory::getLog()->debug("Chunk size for CRC is now " . $this->AkeebaPackerZIP_CHUNK_SIZE . " bytes");
// Should I use Symlink Target Storage?
$this->enableSymlinkTargetStorage();
parent::__construct();
}
/**
* Initialises the archiver class, creating the archive from an existent
* installer's JPA archive.
*
* @param string $sourceJPAPath Absolute path to an installer's JPA archive
* @param string $targetArchivePath Absolute path to the generated archive
* @param array $options A named key array of options (optional). This is currently not supported
*
* @return void
*/
public function initialize($targetArchivePath, $options = [])
{
Factory::getLog()->debug(__CLASS__ . " :: initialize - archive $targetArchivePath");
// Get names of temporary files
$this->_dataFileName = $targetArchivePath;
// Should we enable split archive feature?
$this->enableSplitArchives();
// Create the Central Directory temporary file
$this->createCentralDirectoryTempFile();
// Try to kill the archive if it exists
$this->createNewBackupArchive();
// On split archives, include the "Split ZIP" header, for PKZIP 2.50+ compatibility
if ($this->useSplitArchive)
{
$this->openArchiveForOutput();
$this->fwrite($this->fp, "\x50\x4b\x07\x08");
}
}
public function finalize()
{
$this->finalizeZIPFile();
}
/**
* Glues the Central Directory of the ZIP file to the archive and takes care about the differences between single
* and multipart archives.
*
* Official ZIP file format: http://www.pkware.com/appnote.txt
*
* @return void
*/
public function finalizeZIPFile()
{
// 1. Get size of central directory
clearstatcache();
$cdOffset = @filesize($this->_dataFileName);
$this->totalCompressedSize += $cdOffset;
$cdSize = @filesize($this->centralDirectoryFilename);
// 2. Append Central Directory to data file and remove the CD temp file afterwards
if (!is_null($this->fp))
{
$this->fclose($this->fp);
}
if (!is_null($this->cdfp))
{
$this->fclose($this->cdfp);
}
$this->openArchiveForOutput(true);
/**
* Do not remove the fcloseByName line! This is required for post-processing multipart archives when for any
* reason $this->filePointers[$this->centralDirectoryFilename] contains null instead of boolean false. In this
* case the while loop would be stuck forever and the backup would fail. This HAS happened and I have been able
* to reproduce it but I did not have enough time to identify the real root cause. This workaround, however,
* works.
*/
$this->fcloseByName($this->centralDirectoryFilename);
$this->cdfp = $this->fopen($this->centralDirectoryFilename, "r");
if ($this->cdfp === false)
{
// Already glued, return
$this->fclose($this->fp);
$this->fp = null;
$this->cdfp = null;
return;
}
// Comment length (I need it before I start gluing the archive)
$comment_length = akstrlen($this->_comment);
// Special consideration for split ZIP files
if ($this->useSplitArchive)
{
// Calculate size of Central Directory + EOCD records
$total_cd_eocd_size = $cdSize + 22 + $comment_length;
// Free space on the part
$free_space = $this->getPartFreeSize();
if (($free_space < $total_cd_eocd_size) && ($total_cd_eocd_size > 65536))
{
// Not enough space on archive for CD + EOCD, will go on separate part
$this->createAndOpenNewPart(true);
}
}
/**
* Write the CD record
*
* Note about is_resource: in some circumstances where multipart ZIP files are generated, the $this->cdfp will
* contain a null value. This seems to happen when $this->fopen returns null, i.e. $this->filePointers has a
* null value instead of a file pointer (resource). Why this happens is unclear but the workaround is to remove
* the null value from $this->filePointers and retry $this->fopen. Normally this should not be required since we
* already to the fcloseByName/fopen dance above. This if-block is our last hope to catch a potential issue
* which would either make the while loop go infinite (not anymore, I've patched it) or the Central Directory
* not get written to the archive, which results in a broken archive.
*/
if (!is_resource($this->cdfp))
{
$this->fcloseByName($this->centralDirectoryFilename);
$this->cdfp = $this->fopen($this->centralDirectoryFilename, "r");
// We tried reopening the central directory file and failed again. Time to report a fatal error.
if (!$this->cdfp)
{
throw new RuntimeException("Cannot open central directory temporary file {$this->centralDirectoryFilename} for reading.");
}
}
while (!feof($this->cdfp) && is_resource($this->cdfp))
{
/**
* Why not split the Central Directory between parts?
*
* APPNOTE.TXT §8.5.2 "The central directory may span segment boundaries, but no single record in the
* central directory should be split across segments."
*
* This would require parsing the CD temp file to prevent any CD record from spanning across two parts.
* But how many bytes is each CD record? It's about 100 bytes per file which gives us about 10,400 files
* per MB. Even a 2MB part size holds more than 20,000 file records. A typical 10Mb part size holds more
* files than the largest backup I've ever seen. Therefore there is no need to waste computational power
* to see if we need to span the Central Directory between parts.
*/
$chunk = fread($this->cdfp, _AKEEBA_DIRECTORY_READ_CHUNK);
$this->fwrite($this->fp, $chunk);
}
unset($chunk);
// Delete the temporary CD file
$this->fclose($this->cdfp);
$this->cdfp = null;
Factory::getTempFiles()->unregisterAndDeleteTempFile($this->centralDirectoryFilename);
// 3. Write the rest of headers to the end of the ZIP file
$this->fwrite($this->fp, $this->centralDirectoryRecordEndSignature);
if ($this->useSplitArchive)
{
// Split ZIP files, enter relevant disk number information
$this->fwrite($this->fp, pack('v', $this->totalParts - 1)); /* Number of this disk. */
$this->fwrite($this->fp, pack('v', $this->totalParts - 1)); /* Disk with central directory start. */
}
else
{
// Non-split ZIP files, the disk number MUST be 0
$this->fwrite($this->fp, pack('V', 0));
}
$this->fwrite($this->fp, pack('v', $this->totalFilesCount)); /* Total # of entries "on this disk". */
$this->fwrite($this->fp, pack('v', $this->totalFilesCount)); /* Total # of entries overall. */
$this->fwrite($this->fp, pack('V', $cdSize)); /* Size of central directory. */
$this->fwrite($this->fp, pack('V', $cdOffset)); /* Offset to start of central dir. */
// 2.0.b2 -- Write a ZIP file comment
$this->fwrite($this->fp, pack('v', $comment_length)); /* ZIP file comment length. */
$this->fwrite($this->fp, $this->_comment);
$this->fclose($this->fp);
// If Split ZIP and there is no .zip file, rename the last fragment to .ZIP
if ($this->useSplitArchive)
{
$extension = substr($this->_dataFileName, -3);
if ($extension != '.zip')
{
Factory::getLog()->debug('Renaming last ZIP part to .ZIP extension');
$newName = $this->dataFileNameWithoutExtension . '.zip';
if (!@rename($this->_dataFileName, $newName))
{
throw new RuntimeException('Could not rename last ZIP part to .ZIP extension.');
}
$this->_dataFileName = $newName;
}
// If Split ZIP and only one fragment, change the signature
if ($this->totalParts == 1)
{
$this->fp = $this->fopen($this->_dataFileName, 'r+');
$this->fwrite($this->fp, "\x50\x4b\x30\x30");
}
}
@chmod($this->_dataFileName, $this->getPermissions());
}
/**
* Returns a string with the extension (including the dot) of the files produced
* by this class.
*
* @return string
*/
public function getExtension()
{
return '.zip';
}
/**
* Extend the bootstrap code to add some define's used by the ZIP format engine
*
* @return void
*/
protected function __bootstrap_code()
{
if (!defined('_AKEEBA_COMPRESSION_THRESHOLD'))
{
$config = Factory::getConfiguration();
define("_AKEEBA_COMPRESSION_THRESHOLD", $config->get('engine.archiver.common.big_file_threshold')); // Don't compress files over this size
define("_AKEEBA_DIRECTORY_READ_CHUNK", $config->get('engine.archiver.zip.cd_glue_chunk_size')); // How much data to read at once when finalizing ZIP archives
}
$this->crcCalculator = Factory::getCRC32Calculator();
parent::__bootstrap_code();
}
/**
* The most basic file transaction: add a single entry (file or directory) to
* the archive.
*
* @param bool $isVirtual If true, the next parameter contains file data instead of a file name
* @param string $sourceNameOrData Absolute file name to read data from or the file data itself is $isVirtual is
* true
* @param string $targetName The (relative) file name under which to store the file in the archive
*
* @return bool True on success, false otherwise
*/
protected function _addFile($isVirtual, &$sourceNameOrData, $targetName)
{
$configuration = Factory::getConfiguration();
// Note down the starting disk number for Split ZIP archives
$starting_disk_number_for_this_file = 0;
if ($this->useSplitArchive)
{
$starting_disk_number_for_this_file = $this->currentPartNumber - 1;
}
// Open data file for output
$this->openArchiveForOutput();
// Should I continue backing up a file from the previous step?
$continueProcessingFile = $configuration->get('volatile.engine.archiver.processingfile', false);
// Initialize with the default values. Why are *these* values default? If we are continuing file packing, by
// definition we have an uncompressed, non-virtual file. Hence the default values.
$isDir = false;
$isSymlink = false;
$compressionMethod = 1;
$zdata = null;
// If we are continuing file packing we have an uncompressed, non-virtual file.
$isVirtual = $continueProcessingFile ? false : $isVirtual;
$resume = $continueProcessingFile ? 0 : null;
if (!$continueProcessingFile)
{
// Log the file being added
$messageSource = $isVirtual ? '(virtual data)' : "(source: $sourceNameOrData)";
Factory::getLog()->debug("-- Adding $targetName to archive $messageSource");
$this->writeFileHeader($sourceNameOrData, $targetName, $isVirtual, $isSymlink, $isDir,
$compressionMethod, $zdata, $unc_len,
$storedName, $crc, $c_len, $hexdtime, $old_offset);
}
else
{
// Since we are continuing archiving, it's an uncompressed regular file. Set up the variables.
$sourceNameOrData = $configuration->get('volatile.engine.archiver.sourceNameOrData', '');
$resume = $configuration->get('volatile.engine.archiver.resume', 0);
$unc_len = $configuration->get('volatile.engine.archiver.unc_len');
$storedName = $configuration->get('volatile.engine.archiver.storedName');
$crc = $configuration->get('volatile.engine.archiver.crc');
$c_len = $configuration->get('volatile.engine.archiver.c_len');
$hexdtime = $configuration->get('volatile.engine.archiver.hexdtime');
$old_offset = $configuration->get('volatile.engine.archiver.old_offset');
// Log the file we continue packing
Factory::getLog()->debug("-- Resuming adding file $sourceNameOrData to archive from position $resume (total size $unc_len)");
}
/* "File data" segment. */
if ($compressionMethod == 8)
{
$this->putRawDataIntoArchive($zdata);
}
elseif ($isVirtual)
{
// Virtual data. Put into the archive.
$this->putRawDataIntoArchive($sourceNameOrData);
}
elseif ($isSymlink)
{
$this->fwrite($this->fp, @readlink($sourceNameOrData));
}
elseif ((!$isDir) && (!$isSymlink))
{
// Uncompressed file.
if ($this->putUncompressedFileIntoArchive($sourceNameOrData, $unc_len, $resume) === true)
{
// If it returns true we are doing a step break to resume packing in the next step. So we need to return
// true here to avoid running the final bit of code which writes the central directory record and
// uncaches the file resume data.
return true;
}
}
// Open the central directory file for append
if (is_null($this->cdfp))
{
$this->cdfp = @$this->fopen($this->centralDirectoryFilename, "a");
}
if ($this->cdfp === false)
{
throw new ErrorException("Could not open Central Directory temporary file for append!");
}
$this->fwrite($this->cdfp, $this->centralDirectoryRecordStartSignature);
if (!$isSymlink)
{
$this->fwrite($this->cdfp, "\x14\x00"); /* Version made by (always set to 2.0). */
$this->fwrite($this->cdfp, "\x14\x00"); /* Version needed to extract */
$this->fwrite($this->cdfp, pack('v', 2048)); /* General purpose bit flag */
$this->fwrite($this->cdfp, ($compressionMethod == 8) ? "\x08\x00" : "\x00\x00"); /* Compression method. */
}
else
{
// Symlinks get special treatment
$this->fwrite($this->cdfp, "\x14\x03"); /* Version made by (version 2.0 with UNIX extensions). */
$this->fwrite($this->cdfp, "\x0a\x03"); /* Version needed to extract */
$this->fwrite($this->cdfp, pack('v', 2048)); /* General purpose bit flag */
$this->fwrite($this->cdfp, "\x00\x00"); /* Compression method. */
}
$this->fwrite($this->cdfp, $hexdtime); /* Last mod time/date. */
$this->fwrite($this->cdfp, pack('V', $crc)); /* CRC 32 information. */
$this->fwrite($this->cdfp, pack('V', $c_len)); /* Compressed filesize. */
if ($compressionMethod == 0)
{
// When we are not compressing, $unc_len is being reduced to 0 while backing up.
// With this trick, we always store the correct length, as in this case the compressed
// and uncompressed length is always the same.
$this->fwrite($this->cdfp, pack('V', $c_len)); /* Uncompressed filesize. */
}
else
{
// When compressing, the uncompressed length differs from compressed length
// and this line writes the correct value.
$this->fwrite($this->cdfp, pack('V', $unc_len)); /* Uncompressed filesize. */
}
$fn_length = akstrlen($storedName);
$this->fwrite($this->cdfp, pack('v', $fn_length)); /* Length of filename. */
$this->fwrite($this->cdfp, pack('v', 0)); /* Extra field length. */
$this->fwrite($this->cdfp, pack('v', 0)); /* File comment length. */
$this->fwrite($this->cdfp, pack('v', $starting_disk_number_for_this_file)); /* Disk number start. */
$this->fwrite($this->cdfp, pack('v', 0)); /* Internal file attributes. */
/* External file attributes */
if (!$isSymlink)
{
// Archive bit set
$this->fwrite($this->cdfp, pack('V', $isDir ? 0x41FF0010 : 0xFE49FFE0));
}
else
{
// For SymLinks we store UNIX file attributes
$this->fwrite($this->cdfp, "\x20\x80\xFF\xA1");
}
$this->fwrite($this->cdfp, pack('V', $old_offset)); /* Relative offset of local header. */
$this->fwrite($this->cdfp, $storedName); /* File name. */
/* Optional extra field, file comment goes here. */
// Finally, increase the file counter by one
$this->totalFilesCount++;
// Uncache data
$configuration->set('volatile.engine.archiver.sourceNameOrData', null);
$configuration->set('volatile.engine.archiver.unc_len', null);
$configuration->set('volatile.engine.archiver.resume', null);
$configuration->set('volatile.engine.archiver.hexdtime', null);
$configuration->set('volatile.engine.archiver.crc', null);
$configuration->set('volatile.engine.archiver.c_len', null);
$configuration->set('volatile.engine.archiver.fn_length', null);
$configuration->set('volatile.engine.archiver.old_offset', null);
$configuration->set('volatile.engine.archiver.storedName', null);
$configuration->set('volatile.engine.archiver.sourceNameOrData', null);
$configuration->set('volatile.engine.archiver.processingfile', false);
// ... and return TRUE = success
return true;
}
/**
* Write the file header before putting the file data into the archive
*
* @param string $sourceNameOrData The path to the file being compressed, or the raw file data for virtual files
* @param string $targetName The target path to be stored inside the archive
* @param bool $isVirtual Is this a virtual file?
* @param bool $isSymlink Is this a symlink?
* @param bool $isDir Is this a directory?
* @param int $compressionMethod The compression method chosen for this file
* @param string $zdata If we have compression method other than 0 this holds the compressed data.
* We return that from this method to avoid having to compress the same data
* twice (once to write the compressed data length in the header and once to
* write the compressed data to the archive).
* @param int $unc_len The uncompressed size of the file / source data
*
* @param string $storedName The file path stored in the archive
* @param string $crc CRC-32 for the file
* @param int $c_len Compressed data length
* @param string $hexdtime ZIP's hexadecimal notation if the file's modification date
* @param int $old_offset Offset of the file header in the part file
*/
protected function writeFileHeader(&$sourceNameOrData, $targetName, &$isVirtual, &$isSymlink, &$isDir,
&$compressionMethod, &$zdata, &$unc_len, &$storedName, &$crc, &$c_len,
&$hexdtime, &$old_offset)
{
static $memLimit = null;
if (is_null($memLimit))
{
$memLimit = $this->getMemoryLimit();
}
$configuration = Factory::getConfiguration();
// See if it's a directory
$isDir = $isVirtual ? false : is_dir($sourceNameOrData);
// See if it's a symlink (w/out dereference)
$isSymlink = false;
if ($this->storeSymlinkTarget && !$isVirtual)
{
$isSymlink = is_link($sourceNameOrData);
}
// Get real size before compression
[$unc_len, $fileModTime] =
$this->getFileSizeAndModificationTime($sourceNameOrData, $isVirtual, $isSymlink, $isDir);
// Decide if we will compress
$compressionMethod = $this->getCompressionMethod($unc_len, $memLimit, $isDir, $isSymlink);
if ($isVirtual)
{
Factory::getLog()->debug(' Virtual add:' . $targetName . ' (' . $unc_len . ') - ' . $compressionMethod);
}
/* "Local file header" segment. */
$crc = $this->getCRCForEntity($sourceNameOrData, $isVirtual, $isDir, $isSymlink);
$storedName = $targetName;
if (!$isSymlink && $isDir)
{
$storedName .= "/";
$unc_len = 0;
}
// Test for non-existing or unreadable files
$this->testIfFileExists($sourceNameOrData, $isVirtual, $isDir, $isSymlink);
// Default compressed (archived) length = uncompressed length valid unless we can actually compress the data.
$c_len = $unc_len;
// If we have to compress, read the data in memory and compress it
if ($compressionMethod == 8)
{
$this->getZData($sourceNameOrData, $isVirtual, $compressionMethod, $zdata, $unc_len, $c_len);
// The method modifies $compressionMethod to 0 (uncompressed) or 1 (Deflate) but the ZIP format needs it
// to be 0 (uncompressed) or 8 (Deflate). So I just multiply by 8.
$compressionMethod *= 8;
}
// Get the hex time.
$dtime = dechex($this->unix2DOSTime($fileModTime));
if (akstrlen($dtime) < 8)
{
$dtime = "00000000";
}
$hexdtime = chr(hexdec($dtime[6] . $dtime[7])) .
chr(hexdec($dtime[4] . $dtime[5])) .
chr(hexdec($dtime[2] . $dtime[3])) .
chr(hexdec($dtime[0] . $dtime[1]));
// If it's a split ZIP file, we've got to make sure that the header can fit in the part
if ($this->useSplitArchive)
{
// Get header size, taking into account any extra header necessary
$header_size = 30 + akstrlen($storedName);
// Compare to free part space
$free_space = $this->getPartFreeSize();
if ($free_space <= $header_size)
{
// Not enough space on current part, create new part
$this->createAndOpenNewPart();
}
}
$old_offset = @ftell($this->fp);
if ($this->useSplitArchive && ($old_offset == 0))
{
// Because in split ZIPs we have the split ZIP marker in the first four bytes.
@fseek($this->fp, 4);
$old_offset = @ftell($this->fp);
}
// Get the file name length in bytes
$fn_length = akstrlen($storedName);
$this->fwrite($this->fp, $this->fileHeaderSignature); /* Begin creating the ZIP data. */
/* Version needed to extract. */
if (!$isSymlink)
{
$this->fwrite($this->fp, "\x14\x00");
}
else
{
$this->fwrite($this->fp, "\x0a\x03");
}
$this->fwrite($this->fp, pack('v', 2048)); /* General purpose bit flag. Bit 11 set = use UTF-8 encoding for filenames & comments */
$this->fwrite($this->fp, ($compressionMethod == 8) ? "\x08\x00" : "\x00\x00"); /* Compression method. */
$this->fwrite($this->fp, $hexdtime); /* Last modification time/date. */
$this->fwrite($this->fp, pack('V', $crc)); /* CRC 32 information. */
$this->fwrite($this->fp, pack('V', $c_len)); /* Compressed filesize. */
$this->fwrite($this->fp, pack('V', $unc_len)); /* Uncompressed filesize. */
$this->fwrite($this->fp, pack('v', $fn_length)); /* Length of filename. */
$this->fwrite($this->fp, pack('v', 0)); /* Extra field length. */
$this->fwrite($this->fp, $storedName); /* File name. */
// Cache useful information about the file
if (!$isDir && !$isSymlink && !$isVirtual)
{
$configuration->set('volatile.engine.archiver.unc_len', $unc_len);
$configuration->set('volatile.engine.archiver.hexdtime', $hexdtime);
$configuration->set('volatile.engine.archiver.crc', $crc);
$configuration->set('volatile.engine.archiver.c_len', $c_len);
$configuration->set('volatile.engine.archiver.fn_length', $fn_length);
$configuration->set('volatile.engine.archiver.old_offset', $old_offset);
$configuration->set('volatile.engine.archiver.storedName', $storedName);
$configuration->set('volatile.engine.archiver.sourceNameOrData', $sourceNameOrData);
}
}
/**
* Get the preferred compression method for a file
*
* @param int $fileSize File size in bytes
* @param int $memLimit Memory limit in bytes
* @param bool $isDir Is it a directory?
* @param bool $isSymlink Is it a symlink?
*
* @return int Compression method to use
*/
protected function getCompressionMethod($fileSize, $memLimit, $isDir, $isSymlink)
{
// ZIP uses 0 for uncompressed and 8 for GZip Deflate whereas the parent method returns 0 and 1 respectively
return 8 * parent::getCompressionMethod($fileSize, $memLimit, $isDir, $isSymlink);
}
/**
* Calculate the CRC-32 checksum
*
* @param string $sourceNameOrData The path to the file being compressed, or the raw file data for virtual files
* @param bool $isVirtual Is this a virtual file?
* @param bool $isSymlink Is this a symlink?
* @param bool $isDir Is this a directory?
*
* @return int The CRC-32
*/
protected function getCRCForEntity(&$sourceNameOrData, &$isVirtual, &$isDir, &$isSymlink)
{
if (!$isSymlink && $isDir)
{
// Dummy CRC for dirs
$crc = 0;
return $crc;
}
if ($isSymlink)
{
$crc = \crc32(@readlink($sourceNameOrData));
return $crc;
}
if ($isVirtual)
{
$crc = \crc32($sourceNameOrData);
return $crc;
}
// This is supposed to be the fast way to calculate CRC32 of a (large) file.
$crc = $this->crcCalculator->crc32_file($sourceNameOrData, $this->AkeebaPackerZIP_CHUNK_SIZE);
// If the file was unreadable, $crc will be false, so we skip the file
if ($crc === false)
{
throw new WarningException('Could not calculate CRC32 for ' . $sourceNameOrData . '. Looks like it is an unreadable file.');
}
return $crc;
}
/**
* Converts a UNIX timestamp to a 4-byte DOS date and time format
* (date in high 2-bytes, time in low 2-bytes allowing magnitude
* comparison).
*
* @param integer $unixtime The current UNIX timestamp.
*
* @return integer The current date in a 4-byte DOS format.
*/
protected function unix2DOSTime($unixtime = null)
{
$timearray = (is_null($unixtime)) ? getdate() : getdate($unixtime);
if ($timearray['year'] < 1980)
{
$timearray['year'] = 1980;
$timearray['mon'] = 1;
$timearray['mday'] = 1;
$timearray['hours'] = 0;
$timearray['minutes'] = 0;
$timearray['seconds'] = 0;
}
return (($timearray['year'] - 1980) << 25) |
($timearray['mon'] << 21) |
($timearray['mday'] << 16) |
($timearray['hours'] << 11) |
($timearray['minutes'] << 5) |
($timearray['seconds'] >> 1);
}
/**
* Creates a new part for the spanned archive
*
* @param bool $finalPart Is this the final archive part?
*
* @return bool True on success
*/
protected function createNewPartFile($finalPart = false)
{
// Close any open file pointers
if (is_resource($this->fp))
{
$this->fclose($this->fp);
}
if (is_resource($this->cdfp))
{
$this->fclose($this->cdfp);
}
// Remove the just finished part from the list of resumable offsets
$this->removeFromOffsetsList($this->_dataFileName);
// Set the file pointers to null
$this->fp = null;
$this->cdfp = null;
// Push the previous part if we have to post-process it immediately
$configuration = Factory::getConfiguration();
if ($configuration->get('engine.postproc.common.after_part', 0))
{
$this->finishedPart[] = $this->_dataFileName;
}
// Add the part's size to our rolling sum
clearstatcache();
$this->totalCompressedSize += filesize($this->_dataFileName);
$this->totalParts++;
$this->currentPartNumber = $this->totalParts;
if ($finalPart)
{
$this->_dataFileName = $this->dataFileNameWithoutExtension . '.zip';
}
else
{
$this->_dataFileName = $this->dataFileNameWithoutExtension . '.z' . sprintf('%02d', $this->currentPartNumber);
}
Factory::getLog()->info('Creating new ZIP part #' . $this->currentPartNumber . ', file ' . $this->_dataFileName);
// Inform the backup engine that we have changed the multipart number
$statistics = Factory::getStatistics();
$statistics->updateMultipart($this->totalParts);
// Try to remove any existing file
@unlink($this->_dataFileName);
// Touch the new file
$result = @touch($this->_dataFileName);
@chmod($this->_dataFileName, $this->getPermissions());
return $result;
}
/**
* Find the optimal chunk size for CRC32 calculations and file processing
*
* @return void
*/
private function findOptimalChunkSize()
{
$configuration = Factory::getConfiguration();
// The user has entered their own preference
if ($configuration->get('engine.archiver.common.chunk_size', 0) > 0)
{
$this->AkeebaPackerZIP_CHUNK_SIZE = AKEEBA_CHUNK;
return;
}
// Get the PHP memory limit
$memLimit = $this->getMemoryLimit();
// Can't get a PHP memory limit? Use 2Mb chunks (fairly large, right?)
if (is_null($memLimit))
{
$this->AkeebaPackerZIP_CHUNK_SIZE = 2097152;
return;
}
if (!function_exists("memory_get_usage"))
{
// PHP can't report memory usage, use a conservative 512Kb
$this->AkeebaPackerZIP_CHUNK_SIZE = 524288;
return;
}
// PHP *can* report memory usage, see if there's enough available memory
$availableRAM = $memLimit - memory_get_usage();
if ($availableRAM > 0)
{
$this->AkeebaPackerZIP_CHUNK_SIZE = $availableRAM * 0.5;
return;
}
// NEGATIVE AVAILABLE MEMORY?!! Some borked PHP implementations also return the size of the httpd footprint.
if (($memLimit - 6291456) > 0)
{
$this->AkeebaPackerZIP_CHUNK_SIZE = $memLimit - 6291456;
return;
}
// If all else fails, use 2Mb and cross your fingers
$this->AkeebaPackerZIP_CHUNK_SIZE = 2097152;
}
/**
* Create a Central Directory temporary file
*
* @return void
*
* @throws ErrorException
*/
private function createCentralDirectoryTempFile()
{
$configuration = Factory::getConfiguration();
$this->centralDirectoryFilename = tempnam($configuration->get('akeeba.basic.output_directory'), 'akzcd');
$this->centralDirectoryFilename = basename($this->centralDirectoryFilename);
$pos = strrpos($this->centralDirectoryFilename, '/');
if ($pos !== false)
{
$this->centralDirectoryFilename = substr($this->centralDirectoryFilename, $pos + 1);
}
$pos = strrpos($this->centralDirectoryFilename, '\\');
if ($pos !== false)
{
$this->centralDirectoryFilename = substr($this->centralDirectoryFilename, $pos + 1);
}
$this->centralDirectoryFilename = Factory::getTempFiles()->registerTempFile($this->centralDirectoryFilename);
Factory::getLog()->debug(__CLASS__ . " :: CntDir Tempfile = " . $this->centralDirectoryFilename);
// Create temporary file
if (!@touch($this->centralDirectoryFilename))
{
throw new ErrorException("Could not open temporary file for ZIP archiver. Please check your temporary directory's permissions!");
}
@chmod($this->centralDirectoryFilename, $this->getPermissions());
}
}

View File

@@ -0,0 +1,53 @@
{
"_information": {
"title": "COM_AKEEBA_CONFIG_ENGINE_ARCHIVER_JPA_TITLE",
"description": "COM_AKEEBA_CONFIG_ENGINE_ARCHIVER_JPA_DESCRIPTION"
},
"engine.archiver.common.dereference_symlinks": {
"default": "0",
"type": "bool",
"title": "COM_AKEEBA_CONFIG_DEREFERENCESYMLINKS_TITLE",
"description": "COM_AKEEBA_CONFIG_DEREFERENCESYMLINKS_DESCRIPTION"
},
"engine.archiver.common.part_size": {
"default": "0",
"type": "integer",
"min": "0",
"max": "2147483648",
"shortcuts": "0|131072|262144|524288|1048576|2097152|5242880|10485760|20971520|52428800|104857600|268435456|536870912|1073741824|1610612736|2097152000",
"scale": "1048576",
"uom": "MB",
"title": "COM_AKEEBA_CONFIG_PARTSIZE_TITLE",
"description": "COM_AKEEBA_CONFIG_PARTSIZE_DESCRIPTION"
},
"engine.archiver.common.permissions": {
"default": "0666",
"type": "enum",
"enumkeys": "COM_AKEEBA_CONFIG_PERMISSIONS_0600|COM_AKEEBA_CONFIG_PERMISSIONS_0644|COM_AKEEBA_CONFIG_PERMISSIONS_0666",
"enumvalues": "0600|0644|0666",
"title": "COM_AKEEBA_CONFIG_PERMISSIONS_TITLE",
"description": "COM_AKEEBA_CONFIG_PERMISSIONS_DESCRIPTION"
},
"engine.archiver.common.chunk_size": {
"default": "1048576",
"type": "integer",
"min": "65536",
"max": "10485760",
"shortcuts": "65536|131072|262144|524288|1048576|2097152|5242880|10485760",
"scale": "1048576",
"uom": "MB",
"title": "COM_AKEEBA_CONFIG_CHUNKSIZE_TITLE",
"description": "COM_AKEEBA_CONFIG_CHUNKSIZE_DESCRIPTION"
},
"engine.archiver.common.big_file_threshold": {
"default": "1048576",
"type": "integer",
"min": "65536",
"max": "10485760",
"shortcuts": "65536|131072|262144|524288|1048576|2097152|5242880|10485760",
"scale": "1048576",
"uom": "MB",
"title": "COM_AKEEBA_CONFIG_BIGFILETHRESHOLD_TITLE",
"description": "COM_AKEEBA_CONFIG_BIGFILETHRESHOLD_DESCRIPTION"
}
}

View File

@@ -0,0 +1,64 @@
{
"_information": {
"title": "COM_AKEEBA_CONFIG_ENGINE_ARCHIVER_ZIP_TITLE",
"description": "COM_AKEEBA_CONFIG_ENGINE_ARCHIVER_ZIP_DESCRIPTION"
},
"engine.archiver.common.dereference_symlinks": {
"default": "0",
"type": "bool",
"title": "COM_AKEEBA_CONFIG_DEREFERENCESYMLINKS_TITLE",
"description": "COM_AKEEBA_CONFIG_DEREFERENCESYMLINKS_DESCRIPTION"
},
"engine.archiver.common.part_size": {
"default": "0",
"type": "integer",
"min": "0",
"max": "2147483648",
"shortcuts": "0|131072|262144|524288|1048576|2097152|5242880|10485760|20971520|52428800|104857600|268435456|536870912|1073741824|1610612736|2097152000",
"scale": "1048576",
"uom": "MB",
"title": "COM_AKEEBA_CONFIG_PARTSIZE_TITLE",
"description": "COM_AKEEBA_CONFIG_PARTSIZE_DESCRIPTION"
},
"engine.archiver.common.chunk_size": {
"default": "1048576",
"type": "integer",
"min": "65536",
"max": "10485760",
"shortcuts": "65536|131072|262144|524288|1048576|2097152|5242880|10485760",
"scale": "1048576",
"uom": "MB",
"title": "COM_AKEEBA_CONFIG_CHUNKSIZE_TITLE",
"description": "COM_AKEEBA_CONFIG_CHUNKSIZE_DESCRIPTION"
},
"engine.archiver.common.permissions": {
"default": "0666",
"type": "enum",
"enumkeys": "COM_AKEEBA_CONFIG_PERMISSIONS_0600|COM_AKEEBA_CONFIG_PERMISSIONS_0644|COM_AKEEBA_CONFIG_PERMISSIONS_0666",
"enumvalues": "0600|0644|0666",
"title": "COM_AKEEBA_CONFIG_PERMISSIONS_TITLE",
"description": "COM_AKEEBA_CONFIG_PERMISSIONS_DESCRIPTION"
},
"engine.archiver.common.big_file_threshold": {
"default": "1048576",
"type": "integer",
"min": "65536",
"max": "10485760",
"shortcuts": "65536|131072|262144|524288|1048576|2097152|5242880|10485760",
"scale": "1048576",
"uom": "MB",
"title": "COM_AKEEBA_CONFIG_BIGFILETHRESHOLD_TITLE",
"description": "COM_AKEEBA_CONFIG_BIGFILETHRESHOLD_DESCRIPTION"
},
"engine.archiver.zip.cd_glue_chunk_size": {
"default": "1048576",
"type": "integer",
"min": "65536",
"max": "10485760",
"shortcuts": "65536|131072|262144|524288|1048576|2097152|5242880|10485760",
"scale": "1048576",
"uom": "MB",
"title": "COM_AKEEBA_CONFIG_ZIPCDGLUECHUNKSIZE_TITLE",
"description": "COM_AKEEBA_CONFIG_ZIPCDGLUECHUNKSIZE_DESCRIPTION"
}
}