Codex's updates...

This commit is contained in:
2026-10-18 19:27:17 -04:00
parent a1643eb1b7
commit 02223083f6
338 changed files with 14757 additions and 7986 deletions
@@ -78,7 +78,7 @@ class CodePage
* Convert Microsoft Code Page Identifier to Code Page Name which iconv
* and mbstring understands.
*
* @param int $codePage Microsoft Code Page Indentifier
* @param int $codePage Microsoft Code Page Identifier
*
* @return string Code Page Name
*/
@@ -11,6 +11,7 @@ use PhpOffice\PhpSpreadsheet\Cell\Cell;
use PhpOffice\PhpSpreadsheet\Exception;
use PhpOffice\PhpSpreadsheet\Exception as PhpSpreadsheetException;
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
use Throwable;
class Date
{
@@ -159,7 +160,7 @@ class Date
* serialized timestamp.
* See https://en.wikipedia.org/wiki/ISO_8601 for details of the ISO-8601 standard format.
*/
public static function convertIsoDate(mixed $value): float|int
public static function convertIsoDate(mixed $value, ?int $calendar = null): float|int
{
if (!is_string($value)) {
throw new Exception('Non-string value supplied for Iso Date conversion');
@@ -172,7 +173,7 @@ class Date
throw new Exception("Invalid string $value supplied for datatype Date");
}
$newValue = self::dateTimeToExcel($date);
$newValue = self::dateTimeToExcel($date, $calendar);
if (preg_match('/^\s*\d?\d:\d\d(:\d\d([.]\d+)?)?\s*(am|pm)?\s*$/i', $value) == 1) {
$newValue = fmod($newValue, 1.0);
@@ -189,18 +190,21 @@ class Date
* if you don't want to treat it as a UTC value
* Use the default (UTC) unless you absolutely need a conversion
*
* @throws \Exception
*
* @return DateTime PHP date/time object
*/
public static function excelToDateTimeObject(float|int $excelTimestamp, null|DateTimeZone|string $timeZone = null): DateTime
public static function excelToDateTimeObject(float|int $excelTimestamp, null|DateTimeZone|string $timeZone = null, ?int $calendar = null): DateTime
{
$calendar ??= self::$excelCalendar;
$timeZone = ($timeZone === null) ? self::getDefaultTimezone() : self::validateTimeZone($timeZone);
if (Functions::getCompatibilityMode() == Functions::COMPATIBILITY_EXCEL) {
if ($excelTimestamp < 1 && self::$excelCalendar === self::CALENDAR_WINDOWS_1900) {
if ($excelTimestamp < 1 && $calendar === self::CALENDAR_WINDOWS_1900) {
// Unix timestamp base date
$baseDate = new DateTime('1970-01-01', $timeZone);
} else {
// MS Excel calendar base dates
if (self::$excelCalendar == self::CALENDAR_WINDOWS_1900) {
if ($calendar == self::CALENDAR_WINDOWS_1900) {
// Allow adjustment for 1900 Leap Year in MS Excel
$baseDate = ($excelTimestamp < 60) ? new DateTime('1899-12-31', $timeZone) : new DateTime('1899-12-30', $timeZone);
} else {
@@ -213,10 +217,10 @@ class Date
if (is_int($excelTimestamp)) {
if ($excelTimestamp >= 0) {
return $baseDate->modify("+ $excelTimestamp days");
return self::safeModify($baseDate, "+ $excelTimestamp days");
}
return $baseDate->modify("$excelTimestamp days");
return self::safeModify($baseDate, "$excelTimestamp days");
}
$days = floor($excelTimestamp);
$partDay = $excelTimestamp - $days;
@@ -233,7 +237,7 @@ class Date
}
$interval = $days . ' days';
return $baseDate->modify($interval)
return self::safeModify($baseDate, $interval)
->setTime($hours, $minutes, $seconds, $microseconds);
}
@@ -249,9 +253,9 @@ class Date
*
* @return int Unix timetamp for this date/time
*/
public static function excelToTimestamp($excelTimestamp, $timeZone = null): int
public static function excelToTimestamp($excelTimestamp, $timeZone = null, ?int $calendar = null): int
{
$dto = self::excelToDateTimeObject($excelTimestamp, $timeZone);
$dto = self::excelToDateTimeObject($excelTimestamp, $timeZone, $calendar);
self::roundMicroseconds($dto);
return (int) $dto->format('U');
@@ -266,14 +270,16 @@ class Date
* @return false|float Excel date/time value
* or boolean FALSE on failure
*/
public static function PHPToExcel(mixed $dateValue)
public static function PHPToExcel(mixed $dateValue, ?int $calendar = null)
{
if ((is_object($dateValue)) && ($dateValue instanceof DateTimeInterface)) {
return self::dateTimeToExcel($dateValue);
} elseif (is_numeric($dateValue)) {
return self::timestampToExcel($dateValue);
} elseif (is_string($dateValue)) {
return self::stringToExcel($dateValue);
return self::dateTimeToExcel($dateValue, $calendar);
}
if (is_numeric($dateValue)) {
return self::timestampToExcel($dateValue, $calendar);
}
if (is_string($dateValue)) {
return self::stringToExcel($dateValue, $calendar);
}
return false;
@@ -286,7 +292,7 @@ class Date
*
* @return float MS Excel serialized date/time value
*/
public static function dateTimeToExcel(DateTimeInterface $dateValue): float
public static function dateTimeToExcel(DateTimeInterface $dateValue, ?int $calendar = null): float
{
$seconds = (float) sprintf('%d.%06d', $dateValue->format('s'), $dateValue->format('u'));
@@ -296,7 +302,8 @@ class Date
(int) $dateValue->format('d'),
(int) $dateValue->format('H'),
(int) $dateValue->format('i'),
$seconds
$seconds,
$calendar
);
}
@@ -309,13 +316,13 @@ class Date
*
* @return false|float MS Excel serialized date/time value
*/
public static function timestampToExcel($unixTimestamp): bool|float
public static function timestampToExcel($unixTimestamp, ?int $calendar = null): bool|float
{
if (!is_numeric($unixTimestamp)) {
return false;
}
return self::dateTimeToExcel(new DateTime('@' . $unixTimestamp));
return self::dateTimeToExcel(new DateTime('@' . $unixTimestamp), $calendar);
}
/**
@@ -323,9 +330,10 @@ class Date
*
* @return float Excel date/time value
*/
public static function formattedPHPToExcel(int $year, int $month, int $day, int $hours = 0, int $minutes = 0, float|int $seconds = 0): float
public static function formattedPHPToExcel(int $year, int $month, int $day, int $hours = 0, int $minutes = 0, float|int $seconds = 0, ?int $calendar = null): float
{
if (self::$excelCalendar == self::CALENDAR_WINDOWS_1900) {
$calendar ??= self::$excelCalendar;
if ($calendar === self::CALENDAR_WINDOWS_1900) {
//
// Fudge factor for the erroneous fact that the year 1900 is treated as a Leap Year in MS Excel
// This affects every date following 28th February 1900
@@ -376,15 +384,18 @@ class Date
$cell->getCalculatedValue()
);
}
$result = is_numeric($value)
&& self::isDateTimeFormat(
if (is_numeric($value)) {
$result = self::isDateTimeFormat(
$worksheet->getStyle(
$cell->getCoordinate()
)->getNumberFormat(),
$dateWithoutTimeOkay
);
} catch (Exception) {
// Result is already false, so no need to actually do anything here
/** @var float|int $value */
self::excelToDateTimeObject($value);
}
} catch (Throwable) {
$result = false;
}
$worksheet->setSelectedCells($selected);
$spreadsheet->setActiveSheetIndex($index);
@@ -435,7 +446,7 @@ class Date
}
$possibleFormatCharacters = $dateWithoutTimeOkay ? self::POSSIBLE_DATETIME_FORMAT_CHARACTERS : self::POSSIBLE_TIME_FORMAT_CHARACTERS;
// Try checking for any of the date formatting characters that don't appear within square braces
if (preg_match('/(^|\])[^\[]*[' . $possibleFormatCharacters . ']/i', $excelFormatCode)) {
if (preg_match('/(^|])[^\[]*[' . $possibleFormatCharacters . ']/i', $excelFormatCode)) {
// We might also have a format mask containing quoted strings...
// we don't want to test for any of our characters within the quoted blocks
if (str_contains($excelFormatCode, '"')) {
@@ -445,7 +456,7 @@ class Date
$segMatcher = $segMatcher === false;
if (
$segMatcher
&& (preg_match('/(^|\])[^\[]*[' . $possibleFormatCharacters . ']/i', $subVal))
&& (preg_match('/(^|])[^\[]*[' . $possibleFormatCharacters . ']/i', $subVal))
) {
return true;
}
@@ -468,16 +479,16 @@ class Date
*
* @return false|float Excel date/time serial value
*/
public static function stringToExcel(string $dateValue): bool|float
public static function stringToExcel(string $dateValue, ?int $calendar = null): bool|float
{
if (strlen($dateValue) < 2) {
return false;
}
if (!preg_match('/^(\d{1,4}[ \.\/\-][A-Z]{3,9}([ \.\/\-]\d{1,4})?|[A-Z]{3,9}[ \.\/\-]\d{1,4}([ \.\/\-]\d{1,4})?|\d{1,4}[ \.\/\-]\d{1,4}([ \.\/\-]\d{1,4})?)( \d{1,2}:\d{1,2}(:\d{1,2}([.]\d+)?)?)?$/iu', $dateValue)) {
if (!preg_match('/^(\d{1,4}[ .\/\-][A-Z]{3,9}([ .\/\-]\d{1,4})?|[A-Z]{3,9}[ .\/\-]\d{1,4}([ .\/\-]\d{1,4})?|\d{1,4}[ .\/\-]\d{1,4}([ .\/\-]\d{1,4})?)( \d{1,2}:\d{1,2}(:\d{1,2}([.]\d+)?)?)?$/iu', $dateValue)) {
return false;
}
$dateValueNew = DateTimeExcel\DateValue::fromString($dateValue);
$dateValueNew = DateTimeExcel\DateValue::fromString2($dateValue, $calendar);
if (!is_float($dateValueNew)) {
return false;
@@ -555,7 +566,82 @@ class Date
$rounded = (int) round($microseconds, -6);
$modify = $rounded - $microseconds;
if ($modify !== 0) {
$dti->modify(($modify > 0 ? '+' : '') . $modify . ' microseconds');
self::safeModify($dti, ($modify > 0 ? '+' : '') . $modify . ' microseconds');
}
}
/**
* Safely modifies a DateTime object using a specified modification string.
*
* Prior to PHP 8.3, DateTime::modify() would return false on failure but would also
* emit an E_WARNING. Starting with PHP 8.3, DateTime::modify() throws a DateMalformedStringException
* on failure instead of returning false or emitting warnings.
*
* This method provides consistent exception-based error handling across PHP versions:
* - For PHP 8.3+: Uses the native exception-throwing behavior of modify()
* - For PHP < 8.3: Converts warnings to exceptions using a custom error handler
*
* This ensures that calling code can rely on exception handling for all date modification
* failures regardless of the PHP version in use.
*
* @param DateTime $dateTime The DateTime object to be modified.
* @param string $modifier A modification string, such as '+1 day', '-2 hours', 'last Monday', etc.
*
* @throws PhpSpreadsheetException If an error occurs during the date modification process.
*
* @return DateTime The modified DateTime object.
*
* @codeCoverageIgnore
*/
protected static function safeModify(DateTime $dateTime, string $modifier): DateTime
{
/**
* Starting with PHP 8.3, DateTime::modify() throws a DateMalformedStringException on failure instead of
* returning false or emitting warnings, so we don't need the error handler overhead and can just consume the
* DateTime.modify() method.
*/
if (PHP_VERSION_ID >= 80300) {
return $dateTime->modify($modifier);
}
set_error_handler(
/**
* A selective error handler meant to capture E_WARNING such the following:
*
* > Warning: DateTime::modify(): Failed to parse time string (+ 3172011706730017 days)
* > at position 0 (+): Unexpected character in
* > […]/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Date.php on line 220
*
* @param int $severity The severity level of the error.
* @param string $message The error message to process.
*
* @throws PhpSpreadsheetException If the severity is E_WARNING and the message matches the specified
* condition.
*
* @return bool Returns false if the error is not of type E_WARNING or if the message does not match
* the specified condition. Throws PhpSpreadsheetException when conditions are met.
*/
static function (int $severity, string $message): bool {
if ($severity !== E_WARNING) {
return false;
}
if (!str_starts_with($message, 'DateTime::modify()')) {
return false;
}
throw new PhpSpreadsheetException($message);
}
);
try {
$result = $dateTime->modify($modifier);
if ($result === false) {
throw new PhpSpreadsheetException('Failed to modify date with interval: ' . $modifier);
}
return $result;
} finally {
restore_error_handler();
}
}
}
@@ -49,11 +49,12 @@ class Drawing
// Font name and size
$name = $defaultFont->getName();
$size = $defaultFont->getSize();
$sizex = ($size !== null && $size == (int) $size) ? ((int) $size) : "$size";
if (isset(Font::DEFAULT_COLUMN_WIDTHS[$name][$size])) {
if (isset(Font::DEFAULT_COLUMN_WIDTHS[$name][$sizex])) {
// Exact width can be determined
return $pixelValue * Font::DEFAULT_COLUMN_WIDTHS[$name][$size]['width']
/ Font::DEFAULT_COLUMN_WIDTHS[$name][$size]['px'];
return $pixelValue * Font::DEFAULT_COLUMN_WIDTHS[$name][$sizex]['width']
/ Font::DEFAULT_COLUMN_WIDTHS[$name][$sizex]['px'];
}
// We don't have data for this particular font and size, use approximation by
@@ -75,11 +76,12 @@ class Drawing
// Font name and size
$name = $defaultFont->getName();
$size = $defaultFont->getSize();
$sizex = ($size !== null && $size == (int) $size) ? ((int) $size) : "$size";
if (isset(Font::DEFAULT_COLUMN_WIDTHS[$name][$size])) {
if (isset(Font::DEFAULT_COLUMN_WIDTHS[$name][$sizex])) {
// Exact width can be determined
$colWidth = $cellWidth * Font::DEFAULT_COLUMN_WIDTHS[$name][$size]['px']
/ Font::DEFAULT_COLUMN_WIDTHS[$name][$size]['width'];
$colWidth = $cellWidth * Font::DEFAULT_COLUMN_WIDTHS[$name][$sizex]['px']
/ Font::DEFAULT_COLUMN_WIDTHS[$name][$sizex]['width'];
} else {
// We don't have data for this particular font and size, use approximation by
// extrapolating from Calibri 11
@@ -114,11 +116,7 @@ class Drawing
*/
public static function pointsToPixels($pointValue): int
{
if ($pointValue != 0) {
return (int) ceil($pointValue / 0.75);
}
return 0;
return (int) ceil($pointValue / 0.75);
}
/**
@@ -165,11 +165,7 @@ class SpContainer
*/
public function getOPT(int $property): mixed
{
if (isset($this->OPT[$property])) {
return $this->OPT[$property];
}
return null;
return $this->OPT[$property] ?? null;
}
/**
@@ -34,7 +34,7 @@ class DggContainer
private array $OPT = [];
/**
* Array of identifier clusters containg information about the maximum shape identifiers.
* Array of identifier clusters containing information about the maximum shape identifiers.
*
* @var mixed[]
*/
@@ -2,6 +2,7 @@
namespace PhpOffice\PhpSpreadsheet\Shared;
use Composer\Pcre\Preg;
use PhpOffice\PhpSpreadsheet\Exception;
use PhpOffice\PhpSpreadsheet\Reader\Exception as ReaderException;
use ZipArchive;
@@ -130,12 +131,28 @@ class File
public static function temporaryFilename(): string
{
$filename = tempnam(self::sysGetTempDir(), 'phpspreadsheet');
if ($filename === false) {
throw new Exception('Could not create temporary file');
}
return tempnam(self::sysGetTempDir(), 'phpspreadsheet') ?: throw new Exception('Could not create temporary file');
}
return $filename;
/**
* Blocks phar:// and similar RCE-bearing wrappers.
* Note that many protocols, including http and zip, will already
* return false for is_file.
* A whitelist of protocols may be added if needed in future.
* data: is intentionally allowed (see #4823); callers needing strict
* on-disk-only semantics must validate $filename themselves.
*/
public static function prohibitWrappers(string $filename): void
{
if (
Preg::IsMatch('~^phar://~i', $filename)
|| (Preg::isMatch('/^([\w.\s\x00-\x1f]+):/', $filename) && !Preg::isMatch('/^([\w.]+):/', $filename))
|| Preg::isMatch('~^[\w.]+://.*phar:~is', $filename)
) {
throw new Exception(
"Disallowed stream wrapper used for {$filename}"
);
}
}
/**
@@ -143,12 +160,9 @@ class File
*/
public static function assertFile(string $filename, string $zipMember = ''): void
{
if (!is_file($filename)) {
throw new ReaderException('File "' . $filename . '" does not exist.');
}
if (!is_readable($filename)) {
throw new ReaderException('Could not open "' . $filename . '" for reading.');
self::prohibitWrappers($filename);
if (!is_file($filename) || !is_readable($filename)) {
throw new ReaderException('File "' . $filename . '" does not exist or is not readable.');
}
if ($zipMember !== '') {
@@ -165,13 +179,12 @@ class File
/**
* Same as assertFile, except return true/false and don't throw Exception.
* Will nevertheless throw if filename uses invalid protocol, e.g. phar.
*/
public static function testFileNoThrow(string $filename, ?string $zipMember = null): bool
{
if (!is_file($filename)) {
return false;
}
if (!is_readable($filename)) {
self::prohibitWrappers($filename);
if (!is_file($filename) || !is_readable($filename)) {
return false;
}
if ($zipMember === null) {
@@ -631,11 +631,13 @@ class Font
*/
public static function getDefaultColumnWidthByFont(FontStyle $font, bool $returnAsPixels = false): float|int
{
if (isset(self::DEFAULT_COLUMN_WIDTHS[$font->getName()][$font->getSize()])) {
$size = $font->getSize();
$sizex = ($size !== null && $size == (int) $size) ? ((int) $size) : "$size";
if (isset(self::DEFAULT_COLUMN_WIDTHS[$font->getName()][$sizex])) {
// Exact width can be determined
$columnWidth = $returnAsPixels
? self::DEFAULT_COLUMN_WIDTHS[$font->getName()][$font->getSize()]['px']
: self::DEFAULT_COLUMN_WIDTHS[$font->getName()][$font->getSize()]['width'];
? self::DEFAULT_COLUMN_WIDTHS[$font->getName()][$sizex]['px']
: self::DEFAULT_COLUMN_WIDTHS[$font->getName()][$sizex]['width'];
} else {
// We don't have data for this particular font and size, use approximation by
// extrapolating from Calibri 11
@@ -665,8 +667,9 @@ class Font
{
$name = $font->getName();
$size = $font->getSize();
if (isset(self::DEFAULT_COLUMN_WIDTHS[$name][$size])) {
$rowHeight = self::DEFAULT_COLUMN_WIDTHS[$name][$size]['height'];
$sizex = ($size !== null && $size == (int) $size) ? ((int) $size) : "$size";
if (isset(self::DEFAULT_COLUMN_WIDTHS[$name][$sizex])) {
$rowHeight = self::DEFAULT_COLUMN_WIDTHS[$name][$sizex]['height'];
} elseif ($name === 'Arial' || $name === 'Verdana') {
$rowHeight = self::DEFAULT_COLUMN_WIDTHS[$name][10]['height'] * $size / 10.0;
} else {
@@ -221,22 +221,22 @@ class OLE
// Store current instance in global array, so that it can be accessed
// in OLE_ChainedBlockStream::stream_open().
// Object is removed from self::$instances in OLE_Stream::close().
$GLOBALS['_OLE_INSTANCES'][] = $this; //* @phpstan-ignore-line
$keys = array_keys($GLOBALS['_OLE_INSTANCES']); //* @phpstan-ignore-line
$GLOBALS['_OLE_INSTANCES'][] = $this; //* @phpstan-ignore offsetAccess.nonOffsetAccessible (I don't know how to fix this)
$keys = array_keys($GLOBALS['_OLE_INSTANCES']); //* @phpstan-ignore argument.type (I don't know how to fix this)
$instanceId = end($keys);
$path = 'ole-chainedblockstream://oleInstanceId=' . $instanceId;
if ($blockIdOrPps instanceof OLE\PPS) {
$path .= '&blockId=' . $blockIdOrPps->startBlock;
$path .= '&size=' . $blockIdOrPps->Size;
if ($blockIdOrPps instanceof Root) {
$path .= '&isRoot=1';
}
} else {
$path .= '&blockId=' . $blockIdOrPps;
}
$resource = fopen($path, 'rb');
if ($resource === false) {
throw new Exception("Unable to open stream $path");
}
$resource = fopen($path, 'rb') ?: throw new Exception("Unable to open stream $path");
return $resource;
}
@@ -475,6 +475,86 @@ class OLE
return 0;
}
/**
* Get the complete contents of a named file stream.
*
* @throws ReaderException if no file stream has the requested name
*/
public function getDataByName(string $name): string
{
foreach ($this->_list as $index => $pps) {
if ($pps->Type === self::OLE_PPS_TYPE_FILE && $pps->Name === $name) {
return $this->getData($index, 0, $pps->Size);
}
}
throw new ReaderException("OLE stream '$name' was not found.");
}
/**
* Check whether a named file stream exists without reading its contents.
*/
public function hasDataByName(string $name): bool
{
foreach ($this->_list as $pps) {
if ($pps->Type === self::OLE_PPS_TYPE_FILE && $pps->Name === $name) {
return true;
}
}
return false;
}
/**
* Copy a named file stream to an open output handle without loading a
* large stream into memory.
*
* @param resource $output
*
* @throws ReaderException if no file stream has the requested name
*/
public function copyDataByName(string $name, $output): void
{
foreach ($this->_list as $pps) {
if ($pps->Type !== self::OLE_PPS_TYPE_FILE || $pps->Name !== $name) {
continue;
}
if ($pps->Size < $this->bigBlockThreshold) {
$data = $this->getData($pps->No, 0, $pps->Size);
if (fwrite($output, $data) !== strlen($data)) {
throw new ReaderException("Could not write OLE stream '$name'.");
}
return;
}
$remaining = $pps->Size;
$blockId = $pps->startBlock;
$blocks = [];
while ($remaining > 0) {
if (!is_int($blockId) || $blockId === -2 || isset($blocks[$blockId], $this->bbat[$blockId])) {
throw new ReaderException("Invalid OLE stream '$name'.");
}
$blocks[$blockId] = true;
fseek($this->_file_handle, $this->getBlockOffset($blockId));
$length = min($remaining, $this->bigBlockSize);
$data = fread($this->_file_handle, $length);
if ($data === false || strlen($data) !== $length || fwrite($output, $data) !== $length) {
throw new ReaderException("Could not copy OLE stream '$name'.");
}
$remaining -= $length;
$blockId = $this->bbat[$blockId] ?? null;
if (!is_int($blockId)) {
throw new ReaderException("Invalid OLE stream '$name'.");
}
}
return;
}
throw new ReaderException("OLE stream '$name' was not found.");
}
/**
* Utility function to transform ASCII text to Unicode.
*
@@ -509,7 +589,7 @@ class OLE
}
$dateTime = Date::dateTimeFromTimestamp("$date");
// days from 1-1-1601 until the beggining of UNIX era
// days from 1-1-1601 until the beginning of UNIX era
$days = 134774;
// calculate seconds
$big_date = $days * 24 * 3600 + (float) $dateTime->format('U');
@@ -58,42 +58,66 @@ class ChainedBlockStream
// 25 is length of "ole-chainedblockstream://"
parse_str(substr($path, 25), $this->params);
if (!isset($this->params['oleInstanceId'], $this->params['blockId'], $GLOBALS['_OLE_INSTANCES'][$this->params['oleInstanceId']])) { //* @phpstan-ignore-line
if (!isset($this->params['oleInstanceId'], $this->params['blockId'], $GLOBALS['_OLE_INSTANCES'][$this->params['oleInstanceId']])) { //* @phpstan-ignore offsetAccess.nonOffsetAccessible (I don't know how to fix this)
if ($options & STREAM_REPORT_ERRORS) {
trigger_error('OLE stream not found', E_USER_WARNING);
}
return false;
}
$this->ole = $GLOBALS['_OLE_INSTANCES'][$this->params['oleInstanceId']]; //* @phpstan-ignore-line
if (!($this->ole instanceof OLE)) { //* @phpstan-ignore-line
$this->ole = $GLOBALS['_OLE_INSTANCES'][$this->params['oleInstanceId']]; //* @phpstan-ignore assign.propertyType (I don't know how to fix this)
if (!($this->ole instanceof OLE)) { //* @phpstan-ignore instanceof.alwaysTrue (I don't know how to fix this)
throw new Exception('class is not OLE');
}
$blockId = $this->params['blockId'];
$blockId = (int) $this->params['blockId'];
$size = isset($this->params['size']) ? (int) $this->params['size'] : null;
$isRoot = isset($this->params['isRoot']) && $this->params['isRoot'] === '1';
$this->data = '';
if (isset($this->params['size']) && $this->params['size'] < $this->ole->bigBlockThreshold && $blockId != $this->ole->root->startBlock) {
if ($size !== null && $size < $this->ole->bigBlockThreshold && !$isRoot) {
// Block id refers to small blocks
$rootPos = $this->ole->getBlockOffset((int) $this->ole->root->startBlock);
$rootData = '';
if ($this->ole->root->startBlock === null) {
throw new Exception('Invalid OLE root mini-stream chain.');
}
$rootBlockId = (int) $this->ole->root->startBlock;
$rootBlocks = [];
while ($rootBlockId !== -2) {
if (isset($rootBlocks[$rootBlockId], $this->ole->bbat[$rootBlockId])) {
throw new Exception('Invalid OLE root mini-stream chain.');
}
$rootBlocks[$rootBlockId] = true;
fseek($this->ole->_file_handle, $this->ole->getBlockOffset($rootBlockId));
$rootData .= fread($this->ole->_file_handle, $this->ole->bigBlockSize);
$rootBlockId = self::nextBlock($this->ole->bbat, $rootBlockId, 'Invalid OLE root mini-stream chain.');
}
$smallBlocks = [];
while ($blockId != -2) {
/** @var int $blockId */
$pos = $rootPos + $blockId * $this->ole->bigBlockSize;
$blockId = $this->ole->sbat[$blockId];
fseek($this->ole->_file_handle, $pos);
$this->data .= fread($this->ole->_file_handle, $this->ole->bigBlockSize);
if (isset($smallBlocks[$blockId], $this->ole->sbat[$blockId])) {
throw new Exception('Invalid OLE mini-stream chain.');
}
$smallBlocks[$blockId] = true;
$pos = $blockId * $this->ole->smallBlockSize;
$this->data .= substr($rootData, $pos, $this->ole->smallBlockSize);
$blockId = self::nextBlock($this->ole->sbat, $blockId, 'Invalid OLE mini-stream chain.');
}
} else {
// Block id refers to big blocks
$bigBlocks = [];
while ($blockId != -2) {
/** @var int $blockId */
if (isset($bigBlocks[$blockId], $this->ole->bbat[$blockId])) {
throw new Exception('Invalid OLE stream chain.');
}
$bigBlocks[$blockId] = true;
$pos = $this->ole->getBlockOffset($blockId);
fseek($this->ole->_file_handle, $pos);
$this->data .= fread($this->ole->_file_handle, $this->ole->bigBlockSize);
$blockId = $this->ole->bbat[$blockId];
$blockId = self::nextBlock($this->ole->bbat, $blockId, 'Invalid OLE stream chain.');
}
}
if (isset($this->params['size'])) {
$this->data = substr($this->data, 0, $this->params['size']); //* @phpstan-ignore-line
if ($size !== null) {
$this->data = substr($this->data, 0, $size);
}
if ($options & STREAM_USE_PATH) {
@@ -103,6 +127,17 @@ class ChainedBlockStream
return true;
}
/** @param mixed[] $allocationTable */
private static function nextBlock(array $allocationTable, int $blockId, string $message): int
{
$nextBlockId = $allocationTable[$blockId] ?? null;
if (!is_int($nextBlockId)) {
throw new Exception($message);
}
return $nextBlockId;
}
/**
* Implements support for fclose().
*/
@@ -151,6 +186,10 @@ class ChainedBlockStream
/**
* Implements support for fseek().
* Note that the first condition is always true, at least in
* the unit test suite. One consequence is that Phpstan's
* correct flagging of count($this->data) below is never
* executed, and would fail should it be executed.
*
* @param int $offset byte offset
* @param int $whence SEEK_SET, SEEK_CUR or SEEK_END
@@ -161,7 +200,7 @@ class ChainedBlockStream
$this->pos = $offset;
} elseif ($whence == SEEK_CUR && -$offset <= $this->pos) {
$this->pos += $offset;
} elseif ($whence == SEEK_END && -$offset <= count($this->data)) { // @phpstan-ignore-line
} elseif ($whence == SEEK_END && -$offset <= count($this->data)) { // @phpstan-ignore argument.type (phpstan is correct - see docBlock above)
$this->pos = strlen($this->data) + $offset;
} else {
return false;
@@ -20,6 +20,7 @@ namespace PhpOffice\PhpSpreadsheet\Shared\OLE\PPS;
// | Based on OLE::Storage_Lite by Kawai, Takanori |
// +----------------------------------------------------------------------+
//
use PhpOffice\PhpSpreadsheet\Exception;
use PhpOffice\PhpSpreadsheet\Shared\OLE;
use PhpOffice\PhpSpreadsheet\Shared\OLE\PPS;
@@ -30,14 +31,22 @@ use PhpOffice\PhpSpreadsheet\Shared\OLE\PPS;
*/
class Root extends PPS
{
private const BIG_BLOCK_SIZE = 512;
private const SMALL_BLOCK_SIZE = 64;
private const MAX_VERSION_3_STREAM_SIZE = 0x80000000;
private const MAX_REGULAR_SECTOR_COUNT = 0xFFFFFFFB;
/**
* @var resource
*/
private $fileHandle;
private ?int $smallBlockSize = null;
private int $smallBlockSize = self::SMALL_BLOCK_SIZE;
private ?int $bigBlockSize = null;
private int $bigBlockSize = self::BIG_BLOCK_SIZE;
/**
* @param null|float|int $time_1st A timestamp
@@ -64,13 +73,9 @@ class Root extends PPS
{
$this->fileHandle = $fileHandle;
// Initial Setting for saving
$this->bigBlockSize = (int) (2 ** (
(isset($this->bigBlockSize)) ? self::adjust2($this->bigBlockSize) : 9
));
$this->smallBlockSize = (int) (2 ** (
(isset($this->smallBlockSize)) ? self::adjust2($this->smallBlockSize) : 6
));
// This writer implements the version-3 CFB profile only.
$this->bigBlockSize = self::BIG_BLOCK_SIZE;
$this->smallBlockSize = self::SMALL_BLOCK_SIZE;
// Make an array of PPS's (for Save)
$aList = [];
@@ -82,6 +87,7 @@ class Root extends PPS
// Make Small Data string (write SBD)
$this->_data = $this->makeSmallData($aList);
$this->assertVersion3StreamSize(strlen($this->_data));
// Write BB
$this->saveBigData((int) $iSBDcnt, $aList);
@@ -109,6 +115,7 @@ class Root extends PPS
for ($i = 0; $i < $iCount; ++$i) {
if ($raList[$i]->Type == OLE::OLE_PPS_TYPE_FILE) {
$raList[$i]->Size = $raList[$i]->getDataLen();
$this->assertVersion3StreamSize($raList[$i]->Size);
if ($raList[$i]->Size < OLE::OLE_DATA_SIZE_SMALL) {
$iSBcnt += floor($raList[$i]->Size / $this->smallBlockSize)
+ (($raList[$i]->Size % $this->smallBlockSize) ? 1 : 0);
@@ -118,6 +125,7 @@ class Root extends PPS
}
}
}
$this->assertVersion3MiniStreamSize((int) $iSBcnt);
$iSmallLen = $iSBcnt * $this->smallBlockSize;
$iSlCnt = floor($this->bigBlockSize / OLE::OLE_LONG_INT_SIZE);
$iSBDcnt = floor($iSBcnt / $iSlCnt) + (($iSBcnt % $iSlCnt) ? 1 : 0);
@@ -130,18 +138,16 @@ class Root extends PPS
return [$iSBDcnt, $iBBcnt, $iPPScnt];
}
/**
* Helper function for caculating a magic value for block sizes.
*
* @param int $i2 The argument
*
* @see save()
*/
private static function adjust2(int $i2): float
private function assertVersion3StreamSize(int $size): void
{
$iWk = log($i2) / log(2);
if ($size > self::MAX_VERSION_3_STREAM_SIZE) {
throw new Exception('OLE version-3 streams cannot exceed 2 GiB.');
}
}
return ($iWk > floor($iWk)) ? floor($iWk) + 1 : $iWk;
private function assertVersion3MiniStreamSize(int $smallBlockCount): void
{
$this->assertVersion3StreamSize($smallBlockCount * self::SMALL_BLOCK_SIZE);
}
/**
@@ -168,12 +174,16 @@ class Root extends PPS
++$iAllW;
$iBdCntW = floor($iAllW / $iBlCnt) + (($iAllW % $iBlCnt) ? 1 : 0);
$iBdCnt = floor(($iAllW + $iBdCntW) / $iBlCnt) + ((($iAllW + $iBdCntW) % $iBlCnt) ? 1 : 0);
if ($iBdCnt <= ($iBdExL * $iBlCnt + $i1stBdL)) {
if ($iBdCnt <= ($iBdExL * ($iBlCnt - 1) + $i1stBdL)) {
break;
}
}
}
if ($iAllW + $iBdCnt > self::MAX_REGULAR_SECTOR_COUNT) {
throw new Exception('OLE version-3 output exceeds the maximum sector count.');
}
// Save Header
fwrite(
$FILE,
@@ -182,7 +192,7 @@ class Root extends PPS
. "\x00\x00\x00\x00"
. "\x00\x00\x00\x00"
. "\x00\x00\x00\x00"
. pack('v', 0x3B)
. pack('v', 0x3E)
. pack('v', 0x03)
. pack('v', -2)
. pack('v', 9)
@@ -198,7 +208,7 @@ class Root extends PPS
. pack('V', $iSBDcnt)
);
// Extra BDList Start, Count
if ($iBdCnt < $i1stBdL) {
if ($iBdCnt <= $i1stBdL) {
fwrite(
$FILE,
pack('V', -2) // Extra BDList Start
@@ -342,7 +352,7 @@ class Root extends PPS
++$iAllW;
$iBdCntW = floor($iAllW / $iBbCnt) + (($iAllW % $iBbCnt) ? 1 : 0);
$iBdCnt = floor(($iAllW + $iBdCntW) / $iBbCnt) + ((($iAllW + $iBdCntW) % $iBbCnt) ? 1 : 0);
if ($iBdCnt <= ($iBdExL * $iBbCnt + $i1stBdL)) {
if ($iBdCnt <= ($iBdExL * ($iBbCnt - 1) + $i1stBdL)) {
break;
}
}
@@ -61,6 +61,9 @@ class OLERead
/** @var mixed[][] */
private array $props = [];
/** @var int[] */
private array $possibleLoop = [];
/**
* Read the file.
*/
@@ -137,7 +140,9 @@ class OLERead
$sbdBlock = $this->sbdStartBlock;
$this->smallBlockChain = '';
$this->possibleLoop = [];
while ($sbdBlock != -2) {
$this->catchLoop($sbdBlock);
$pos = ($sbdBlock + 1) * self::BIG_BLOCK_SIZE;
$this->smallBlockChain .= substr($this->data, $pos, 4 * $bbs);
@@ -153,6 +158,14 @@ class OLERead
$this->readPropertySets();
}
private function catchLoop(int $sbdBlock): void
{
if (in_array($sbdBlock, $this->possibleLoop, true)) {
throw new ReaderException('Detected loop while iterating blocks');
}
$this->possibleLoop[] = $sbdBlock;
}
/**
* Extract binary stream data.
*/
@@ -172,7 +185,9 @@ class OLERead
/** @var int */
$block = $this->props[$stream]['startBlock'];
$this->possibleLoop = [];
while ($block != -2) {
$this->catchLoop($block);
$pos = $block * self::SMALL_BLOCK_SIZE;
$streamData .= substr($rootdata, $pos, self::SMALL_BLOCK_SIZE);
@@ -195,7 +210,9 @@ class OLERead
/** @var int */
$block = $this->props[$stream]['startBlock'];
$this->possibleLoop = [];
while ($block != -2) {
$this->catchLoop($block);
$pos = ($block + 1) * self::BIG_BLOCK_SIZE;
$streamData .= substr($this->data, $pos, self::BIG_BLOCK_SIZE);
$block = self::getInt4d($this->bigBlockChain, $block * 4);
@@ -215,7 +232,9 @@ class OLERead
{
$data = '';
$this->possibleLoop = [];
while ($block != -2) {
$this->catchLoop($block);
$pos = ($block + 1) * self::BIG_BLOCK_SIZE;
$data .= substr($this->data, $pos, self::BIG_BLOCK_SIZE);
$block = self::getInt4d($this->bigBlockChain, $block * 4);
@@ -231,7 +250,7 @@ class OLERead
{
$offset = 0;
// loop through entires, each entry is 128 bytes
// loop through entries, each entry is 128 bytes
$entryLen = strlen($this->entry);
while ($offset < $entryLen) {
// entry data (128 bytes)
@@ -3,6 +3,8 @@
namespace PhpOffice\PhpSpreadsheet\Shared;
use Composer\Pcre\Preg;
use IntlCalendar;
use NumberFormatter;
use PhpOffice\PhpSpreadsheet\Calculation\Calculation;
use PhpOffice\PhpSpreadsheet\Exception as SpreadsheetException;
use Stringable;
@@ -237,57 +239,76 @@ class StringHelper
/**
* Decimal separator.
*/
private static ?string $decimalSeparator = null;
protected static ?string $decimalSeparator = null;
/**
* Thousands separator.
*/
private static ?string $thousandsSeparator = null;
protected static ?string $thousandsSeparator = null;
/**
* Currency code.
*/
private static ?string $currencyCode = null;
protected static ?string $currencyCode = null;
/**
* Is iconv extension avalable?
* Is iconv extension available?
*/
private static ?bool $isIconvEnabled = null;
protected static ?bool $isIconvEnabled = null;
/**
* iconv options.
*/
private static string $iconvOptions = '//IGNORE//TRANSLIT';
protected static string $iconvOptions = '//IGNORE//TRANSLIT';
/** @var string[] */
protected static array $iconvOptionsArray = ['//IGNORE//TRANSLIT', '//IGNORE'];
/** @internal */
protected static string $iconvName = 'iconv';
/** @internal */
protected static bool $iconvTest2 = false;
/** @internal */
protected static bool $iconvTest3 = false;
/**
* Get whether iconv extension is available.
*/
public static function getIsIconvEnabled(): bool
{
if (isset(self::$isIconvEnabled)) {
return self::$isIconvEnabled;
if (isset(static::$isIconvEnabled)) {
return static::$isIconvEnabled;
}
// Assume no problems with iconv
self::$isIconvEnabled = true;
static::$isIconvEnabled = true;
// Fail if iconv doesn't exist
if (!function_exists('iconv')) {
self::$isIconvEnabled = false;
} elseif (!@iconv('UTF-8', 'UTF-16LE', 'x')) {
if (!function_exists(static::$iconvName)) {
static::$isIconvEnabled = false;
} elseif (static::$iconvTest2 || !@iconv('UTF-8', 'UTF-16LE', 'x')) {
// Sometimes iconv is not working, and e.g. iconv('UTF-8', 'UTF-16LE', 'x') just returns false,
self::$isIconvEnabled = false;
} elseif (defined('PHP_OS') && @stristr(PHP_OS, 'AIX') && defined('ICONV_IMPL') && (@strcasecmp(ICONV_IMPL, 'unknown') == 0) && defined('ICONV_VERSION') && (@strcasecmp(ICONV_VERSION, 'unknown') == 0)) {
static::$isIconvEnabled = false;
} elseif (static::$iconvTest3 || (defined('PHP_OS') && @stristr(PHP_OS, 'AIX') && defined('ICONV_IMPL') && (@strcasecmp(ICONV_IMPL, 'unknown') == 0) && defined('ICONV_VERSION') && (@strcasecmp(ICONV_VERSION, 'unknown') == 0))) {
// CUSTOM: IBM AIX iconv() does not work
self::$isIconvEnabled = false;
static::$isIconvEnabled = false;
}
// Deactivate iconv default options if they fail (as seen on IMB i)
if (self::$isIconvEnabled && !@iconv('UTF-8', 'UTF-16LE' . self::$iconvOptions, 'x')) {
self::$iconvOptions = '';
// Deactivate iconv default options if they fail (as seen on IBM i-series)
if (static::$isIconvEnabled) {
static::$iconvOptions = '';
foreach (static::$iconvOptionsArray as $option) {
if (@iconv('UTF-8', 'UTF-16LE' . $option, 'x') !== false) {
static::$iconvOptions = $option;
break;
}
}
}
return self::$isIconvEnabled;
return static::$isIconvEnabled;
}
/**
@@ -305,7 +326,47 @@ class StringHelper
*/
public static function controlCharacterOOXML2PHP(string $textValue): string
{
return str_replace(self::CONTROL_CHARACTERS_VALUES, self::CONTROL_CHARACTERS_KEYS, $textValue);
return Preg::replaceCallback('/_x[0-9A-F]{4}_(_xD[CDEF][0-9A-F]{2}_)?/', self::toOutChar(...), $textValue);
}
private static function toHexVal(string $char): int
{
if ($char >= '0' && $char <= '9') {
return ord($char) - ord('0');
}
return ord($char) - ord('A') + 10;
}
/** @param array<?string> $match */
private static function toOutChar(array $match): string
{
/** @var string */
$chars = $match[0];
$h = ((self::toHexVal($chars[2]) << 12)
| (self::toHexVal($chars[3]) << 8)
| (self::toHexVal($chars[4]) << 4)
| (self::toHexVal($chars[5])));
if (strlen($chars) === 7) { // no low surrogate
if ($chars[2] === 'D' && in_array($chars[3], ['8', '9', 'A', 'B', 'C', 'D', 'E', 'F'], true)) {
return '�';
}
return mb_chr($h, 'UTF-8');
}
if ($chars[2] === 'D' && in_array($chars[3], ['C', 'D', 'D', 'F'], true)) {
return '�'; // Excel interprets as one substitute, not 2
}
if ($chars[2] !== 'D' || !in_array($chars[3], ['8', '9', 'A', 'B'], true)) {
return mb_chr($h, 'UTF-8') . '�';
}
$l = ((self::toHexVal($chars[9]) << 12)
| (self::toHexVal($chars[10]) << 8)
| (self::toHexVal($chars[11]) << 4)
| (self::toHexVal($chars[12])));
$result = 0x10000 + ($h - 0xD800) * 0x400 + ($l - 0xDC00);
return mb_chr($result, 'UTF-8');
}
/**
@@ -323,6 +384,8 @@ class StringHelper
*/
public static function controlCharacterPHP2OOXML(string $textValue): string
{
$textValue = Preg::replace('/_(x[0-9A-F]{4}_)/', '_x005F_$1', $textValue);
return str_replace(self::CONTROL_CHARACTERS_KEYS, self::CONTROL_CHARACTERS_VALUES, $textValue);
}
@@ -418,10 +481,10 @@ class StringHelper
* @param string $to Encoding to convert to, e.g. 'UTF-8'
* @param string $from Encoding to convert from, e.g. 'UTF-16LE'
*/
public static function convertEncoding(string $textValue, string $to, string $from): string
public static function convertEncoding(string $textValue, string $to, string $from, ?string $options = null): string
{
if (static::getIsIconvEnabled()) {
$result = iconv($from, $to . self::$iconvOptions, $textValue);
$result = iconv($from, $to . ($options ?? static::$iconvOptions), $textValue);
if (false !== $result) {
return $result;
}
@@ -561,11 +624,11 @@ class StringHelper
*/
public static function getDecimalSeparator(): string
{
if (!isset(self::$decimalSeparator)) {
self::$decimalSeparator = self::getLocaleValue('decimal_point', 'mon_decimal_point', '.');
if (!isset(static::$decimalSeparator)) {
static::$decimalSeparator = self::getLocaleValue('decimal_point', 'mon_decimal_point', '.');
}
return self::$decimalSeparator;
return static::$decimalSeparator;
}
/**
@@ -576,7 +639,7 @@ class StringHelper
*/
public static function setDecimalSeparator(?string $separator): void
{
self::$decimalSeparator = $separator;
static::$decimalSeparator = $separator;
}
/**
@@ -585,11 +648,11 @@ class StringHelper
*/
public static function getThousandsSeparator(): string
{
if (!isset(self::$thousandsSeparator)) {
self::$thousandsSeparator = self::getLocaleValue('thousands_sep', 'mon_thousands_sep', ',');
if (!isset(static::$thousandsSeparator)) {
static::$thousandsSeparator = self::getLocaleValue('thousands_sep', 'mon_thousands_sep', ',');
}
return self::$thousandsSeparator;
return static::$thousandsSeparator;
}
/**
@@ -600,7 +663,7 @@ class StringHelper
*/
public static function setThousandsSeparator(?string $separator): void
{
self::$thousandsSeparator = $separator;
static::$thousandsSeparator = $separator;
}
/**
@@ -609,11 +672,11 @@ class StringHelper
*/
public static function getCurrencyCode(bool $trimAlt = false): string
{
if (!isset(self::$currencyCode)) {
self::$currencyCode = self::getLocaleValue('currency_symbol', 'int_curr_symbol', '$', $trimAlt);
if (!isset(static::$currencyCode)) {
static::$currencyCode = self::getLocaleValue('currency_symbol', 'int_curr_symbol', '$', $trimAlt);
}
return self::$currencyCode;
return static::$currencyCode;
}
/**
@@ -624,7 +687,7 @@ class StringHelper
*/
public static function setCurrencyCode(?string $currencyCode): void
{
self::$currencyCode = $currencyCode;
static::$currencyCode = $currencyCode;
}
/**
@@ -724,16 +787,96 @@ class StringHelper
* Php introduced str_increment with Php8.3,
* but didn't issue deprecation notices till 8.5.
*
* @param-out string $str
*
* @codeCoverageIgnore
*/
public static function stringIncrement(string &$str): string
{
if (function_exists('str_increment')) {
$str = str_increment($str); // @phpstan-ignore-line
/** @var non-empty-string $str */
$str2 = str_increment($str);
} else {
++$str; // @phpstan-ignore-line
$str1 = $str;
/**
* This is an outright lie, but I don't know how else to satisfy Phpstan for Php8.5+.
*
* @var numeric-string $str1
*/
++$str1;
$str2 = "$str1";
}
return $str; // @phpstan-ignore-line
/**
* This is demonstrably the case. I don't know why Phpstan thinks $str2 is mixed at this point.
*
* @var string $str2 set above by str_increment or ="..."
*/
$str = $str2;
return $str;
}
/** @internal */
protected static string $testClass = IntlCalendar::class;
/**
* Set all of currencyCode, thousandsSeparator, decimalSeparator,
* and Calculation locale with a single call.
* The main point here is avoid the use of Php setlocale,
* which is not threadsafe. It uses the Intl extension instead,
* which is not a requirement for PhpSpreadsheet.
* Because of that, the function returns a bool which will
* be false if Intl is not available, or the supplied locale
* is not valid according to Intl.
*/
public static function setLocale(?string $locale): bool
{
if ($locale === null) {
self::$currencyCode = null;
self::$thousandsSeparator = null;
self::$decimalSeparator = null;
Calculation::getInstance()->setLocale('en_us');
return true;
}
$localeCalc = $locale;
if (Preg::isMatch('/^([a-z][a-z])_([a-z][a-z])(?:[.]utf-8)?$/i', $locale, $matches)) {
$locale = strtolower($matches[1]) . '_' . strtoupper($matches[2]);
$localeCalc = strtolower($matches[1]) . '_' . strtolower($matches[2]);
}
if (!class_exists(static::$testClass)) {
return false;
}
// NumberFormatter constructor succeeds even with
// bad locale before Php8.4, so try to validate
// the locale beforehand.
$locales = IntlCalendar::getAvailableLocales();
if (!in_array($locale, $locales, true)) {
return false;
}
$formatter = new NumberFormatter(
$locale,
NumberFormatter::CURRENCY
);
$currency = $formatter->getSymbol(
NumberFormatter::CURRENCY_SYMBOL
);
$formatter = new NumberFormatter(
$locale,
NumberFormatter::DECIMAL
);
$thousands = $formatter->getSymbol(
NumberFormatter::GROUPING_SEPARATOR_SYMBOL
);
$decimal = $formatter->getSymbol(
NumberFormatter::DECIMAL_SEPARATOR_SYMBOL
);
self::$currencyCode = $currency;
self::$thousandsSeparator = $thousands;
self::$decimalSeparator = $decimal;
Calculation::getInstance()->setLocale($localeCalc);
return true;
}
}
@@ -111,6 +111,16 @@ abstract class BestFit
return $this->xValues;
}
/**
* Return the original set of Y-Values.
*
* @return float[] Y-Values
*/
public function getYValues(): array
{
return $this->yValues;
}
/**
* Return the Equation of the best-fit line.
*
@@ -416,7 +426,7 @@ abstract class BestFit
// Define X Values if necessary
if ($xValueCount === 0) {
$xValues = range(1, $yValueCount);
$xValues = range(1.0, $yValueCount);
} elseif ($yValueCount !== $xValueCount) {
// Ensure both arrays of points are the same size
$this->error = true;
@@ -69,7 +69,7 @@ class LinearBestFit extends BestFit
parent::__construct($yValues, $xValues);
if (!$this->error) {
$this->linearRegression($yValues, $xValues, (bool) $const);
$this->linearRegression($this->yValues, $this->xValues, (bool) $const);
}
}
}
@@ -24,7 +24,7 @@ class PolynomialBestFit extends BestFit
*/
protected int $order = 0;
private bool $implemented = false;
protected bool $implemented = false;
/**
* Return the order of this polynomial.
@@ -45,9 +45,7 @@ class PolynomialBestFit extends BestFit
{
$retVal = $this->getIntersect();
$slope = $this->getSlope();
// Phpstan and Scrutinizer are both correct - getSlope returns float, not array.
// @phpstan-ignore-next-line
foreach ($slope as $key => $value) {
foreach ($slope as $key => $value) { //* @phpstan-ignore foreach.nonIterable (this whole class is a mess)
/** @var float $value */
if ($value != 0.0) {
/** @var int $key */
@@ -82,8 +80,7 @@ class PolynomialBestFit extends BestFit
$equation = 'Y = ' . $intersect;
// Phpstan and Scrutinizer are both correct - getSlope returns float, not array.
// @phpstan-ignore-next-line
foreach ($slope as $key => $value) {
foreach ($slope as $key => $value) { //* @phpstan-ignore foreach.nonIterable (this whole class is a mess)
/** @var float|int $value */
if ($value != 0.0) {
$equation .= ' + ' . $value . ' * X';
@@ -106,14 +103,12 @@ class PolynomialBestFit extends BestFit
{
if ($dp != 0) {
$coefficients = [];
//* @phpstan-ignore-next-line
foreach ($this->slope as $coefficient) {
foreach ($this->slope as $coefficient) { //* @phpstan-ignore foreach.nonIterable (this whole class is a mess)
/** @var float|int $coefficient */
$coefficients[] = round($coefficient, $dp);
}
// @phpstan-ignore-next-line
return $coefficients;
return $coefficients; //* @phpstan-ignore return.type (this whole class is a mess)
}
return $this->slope;
@@ -122,9 +117,7 @@ class PolynomialBestFit extends BestFit
/** @return array<float|int> */
public function getCoefficients(int $dp = 0): array
{
// Phpstan and Scrutinizer are both correct - getSlope returns float, not array.
// @phpstan-ignore-next-line
return array_merge([$this->getIntersect($dp)], $this->getSlope($dp));
return array_merge([$this->getIntersect($dp)], $this->getSlope($dp)); //* @phpstan-ignore return.type (this whole class is a mess), argument.type (ditto)
}
/**
@@ -179,9 +172,7 @@ class PolynomialBestFit extends BestFit
}
$this->intersect = (float) array_shift($coefficients);
// Phpstan is correct
//* @phpstan-ignore-next-line
$this->slope = $coefficients;
$this->slope = $coefficients; //* @phpstan-ignore assign.propertyType (this whole class is a mess)
$this->calculateGoodnessOfFit($x_sum, $y_sum, $xx_sum, $yy_sum, $xy_sum, 0, 0, 0);
foreach ($this->xValues as $xKey => $xValue) {
@@ -64,7 +64,15 @@ class XMLWriter extends \XMLWriter
}
}
/** @param mixed[] $data */
/**
* Unserialization is not allowed. This needs to be enforced
* in the class before Php8.6, but, with that release,
* this method is no longer needed and will not be executed.
*
* @see https://github.com/php/php-src/pull/21694
*
* @param mixed[] $data
*/
public function __unserialize(array $data): void
{
$this->tempFileName = '';
@@ -202,7 +202,7 @@ class Xls
* @param int $width Width in pixels
* @param int $height Height in pixels
*
* @return array{startCoordinates: string, startOffsetX: int, startOffsetY: int, endCoordinates: string, endOffsetX: int, endOffsetY: int}
* @return ?array{startCoordinates: string, startOffsetX: float|int, startOffsetY: float|int, endCoordinates: string, endOffsetX: float|int, endOffsetY: float|int}
*/
public static function oneAnchor2twoAnchor(Worksheet $worksheet, string $coordinates, int $offsetX, int $offsetY, int $width, int $height): ?array
{
@@ -241,16 +241,12 @@ class Xls
// Bitmap isn't allowed to start or finish in a hidden cell, i.e. a cell
// with zero height or width.
if (self::sizeCol($worksheet, Coordinate::stringFromColumnIndex($col_start)) == 0) {
return null;
}
if (self::sizeCol($worksheet, Coordinate::stringFromColumnIndex($col_end)) == 0) {
return null;
}
if (self::sizeRow($worksheet, $row_start + 1) == 0) {
return null;
}
if (self::sizeRow($worksheet, $row_end + 1) == 0) {
if (
self::sizeCol($worksheet, Coordinate::stringFromColumnIndex($col_start)) == 0
|| self::sizeCol($worksheet, Coordinate::stringFromColumnIndex($col_end)) == 0
|| self::sizeRow($worksheet, $row_start + 1) == 0
|| self::sizeRow($worksheet, $row_end + 1) == 0
) {
return null;
}
@@ -0,0 +1,842 @@
<?php
namespace PhpOffice\PhpSpreadsheet\Shared\Xlsx;
use PhpOffice\PhpSpreadsheet\Reader\Exception;
use PhpOffice\PhpSpreadsheet\Shared\File as SharedFile;
use PhpOffice\PhpSpreadsheet\Shared\OLE;
use SimpleXMLElement;
use Stringable;
use Throwable;
/** ECMA-376 Agile Encryption (AES-256/SHA-512 profile). */
final class AgileEncryption
{
public const MAX_SPIN_COUNT = 10000000;
private const BLOCK_KEY_VERIFIER = "\xFE\xA7\xD2\x76\x3B\x4B\x9E\x79";
private const BLOCK_KEY_VERIFIER_HASH = "\xD7\xAA\x0F\x6D\x30\x61\x34\x4E";
private const BLOCK_KEY_ENCRYPTED_KEY = "\x14\x6E\x0B\xE7\xAB\xAC\xD0\xD6";
private const BLOCK_KEY_HMAC_KEY = "\x5F\xB2\xAD\x01\x0C\xB9\xE1\xF6";
private const BLOCK_KEY_HMAC_VALUE = "\xA0\x67\x7F\x02\xB2\x2C\x84\x33";
private const BLOCK_SIZE = 16;
private const SEGMENT_SIZE = 4096;
private const ENCRYPTION_NAMESPACE = 'http://schemas.microsoft.com/office/2006/encryption';
private const PASSWORD_NAMESPACE = 'http://schemas.microsoft.com/office/2006/keyEncryptor/password';
private const PASSWORD_KEY_ENCRYPTOR_URI = 'http://schemas.microsoft.com/office/2006/keyEncryptor/password';
private const CFB_SECTOR_SIZE = 512;
private const CFB_MINI_SECTOR_SIZE = 64;
private const CFB_MINI_STREAM_CUTOFF = 4096;
private const CFB_FAT_ENTRIES_PER_SECTOR = self::CFB_SECTOR_SIZE >> 2;
private const CFB_DIFAT_ENTRIES_IN_HEADER = 109;
private const CFB_DIFAT_ENTRIES_PER_SECTOR = self::CFB_FAT_ENTRIES_PER_SECTOR - 1;
private const CFB_DIRECTORY_ENTRIES_PER_SECTOR = self::CFB_SECTOR_SIZE >> 7;
private const CFB_MINI_SECTORS_PER_SECTOR = self::CFB_SECTOR_SIZE >> 6;
private const DIRECTORY_ENCRYPTED_PACKAGE = 1;
private const DIRECTORY_DATA_SPACES = 2;
private const DIRECTORY_VERSION = 3;
private const DIRECTORY_DATA_SPACE_MAP = 4;
private const DIRECTORY_DATA_SPACE_INFO = 5;
private const DIRECTORY_STRONG_ENCRYPTION_DATA_SPACE = 6;
private const DIRECTORY_TRANSFORM_INFO = 7;
private const DIRECTORY_STRONG_ENCRYPTION_TRANSFORM = 8;
private const DIRECTORY_PRIMARY = 9;
private const DIRECTORY_ENCRYPTION_INFO = 10;
private const CFB_DIRECTORY_ENTRY_COUNT = self::DIRECTORY_ENCRYPTION_INFO + 1;
private const STREAM_ENCRYPTED_PACKAGE = 'EncryptedPackage';
private const STORAGE_DATA_SPACES = "\x06DataSpaces";
private const STREAM_VERSION = 'Version';
private const STREAM_DATA_SPACE_MAP = 'DataSpaceMap';
private const STORAGE_DATA_SPACE_INFO = 'DataSpaceInfo';
private const STREAM_STRONG_ENCRYPTION_DATA_SPACE = 'StrongEncryptionDataSpace';
private const STORAGE_TRANSFORM_INFO = 'TransformInfo';
private const STORAGE_STRONG_ENCRYPTION_TRANSFORM = 'StrongEncryptionTransform';
private const STREAM_PRIMARY = "\x06Primary";
private const STREAM_ENCRYPTION_INFO = 'EncryptionInfo';
/** @var array<string, array{string, int}> */
private const HASH_ALGORITHMS = [
'SHA1' => ['sha1', 20],
'SHA256' => ['sha256', 32],
'SHA384' => ['sha384', 48],
'SHA512' => ['sha512', 64],
];
/**
* @return array{keyDataSalt: string, passwordSalt: string, encryptedVerifier: string, encryptedVerifierHash: string, encryptedKey: string, encryptedHmacKey: string, encryptedHmacValue: string, spinCount: int, keyBits: int, hashAlgorithm: string, hashSize: int}
*/
public static function parse(string $encryptionInfo, int $maxSpinCount = self::MAX_SPIN_COUNT): array
{
if (substr($encryptionInfo, 0, 8) !== "\x04\x00\x04\x00\x40\x00\x00\x00") {
throw new Exception('Unsupported XLSX encryption profile.');
}
$xml = @simplexml_load_string(substr($encryptionInfo, 8));
if (!$xml instanceof SimpleXMLElement) {
throw new Exception('Malformed XLSX encryption information.');
}
$namespaces = $xml->getDocNamespaces(true) ?: [];
if ($xml->getName() !== 'encryption' || !in_array(self::ENCRYPTION_NAMESPACE, $namespaces, true) || !in_array(self::PASSWORD_NAMESPACE, $namespaces, true)) {
throw new Exception('Unsupported XLSX encryption profile.');
}
$xml->registerXPathNamespace('e', self::ENCRYPTION_NAMESPACE);
$xml->registerXPathNamespace('p', self::PASSWORD_NAMESPACE);
$keyDataNodes = $xml->xpath('/e:encryption/e:keyData') ?: [];
$integrityNodes = $xml->xpath('/e:encryption/e:dataIntegrity') ?: [];
$keyEncryptorNodes = $xml->xpath('/e:encryption/e:keyEncryptors/e:keyEncryptor') ?: [];
$encryptedKeyNodes = $xml->xpath('/e:encryption/e:keyEncryptors/e:keyEncryptor/p:encryptedKey') ?: [];
$keyData = $keyDataNodes[0] ?? null;
$integrity = $integrityNodes[0] ?? null;
$keyEncryptor = $keyEncryptorNodes[0] ?? null;
$encryptedKey = $encryptedKeyNodes[0] ?? null;
if (!$keyData instanceof SimpleXMLElement || !$integrity instanceof SimpleXMLElement || !$keyEncryptor instanceof SimpleXMLElement || !$encryptedKey instanceof SimpleXMLElement || count($keyDataNodes) !== 1 || count($integrityNodes) !== 1 || count($keyEncryptorNodes) !== 1 || count($encryptedKeyNodes) !== 1) {
throw new Exception('Unsupported XLSX encryption profile.');
}
$keyBits = self::decimalAttribute($keyData, 'keyBits');
$hashAlgorithm = (string) $keyData['hashAlgorithm'];
$hashSize = self::decimalAttribute($keyData, 'hashSize');
$spinCount = self::decimalAttribute($encryptedKey, 'spinCount');
$keyDataSaltSize = self::decimalAttribute($keyData, 'saltSize');
$keyDataBlockSize = self::decimalAttribute($keyData, 'blockSize');
$encryptedKeySaltSize = self::decimalAttribute($encryptedKey, 'saltSize');
$encryptedKeyBlockSize = self::decimalAttribute($encryptedKey, 'blockSize');
$encryptedKeyBits = self::decimalAttribute($encryptedKey, 'keyBits');
$encryptedKeyHashSize = self::decimalAttribute($encryptedKey, 'hashSize');
if (
(string) $keyData['cipherAlgorithm'] !== 'AES' || (string) $keyData['cipherChaining'] !== 'ChainingModeCBC'
|| !self::isSupportedProfile($keyBits, $hashAlgorithm, $hashSize) || $keyDataSaltSize !== 16 || $keyDataBlockSize !== 16
|| (string) $encryptedKey['cipherAlgorithm'] !== 'AES'
|| (string) $encryptedKey['cipherChaining'] !== 'ChainingModeCBC'
|| (string) $encryptedKey['hashAlgorithm'] !== $hashAlgorithm || $encryptedKeySaltSize !== 16 || $encryptedKeyBlockSize !== 16 || $encryptedKeyBits !== $keyBits
|| $encryptedKeyHashSize !== $hashSize || $spinCount > min($maxSpinCount, self::MAX_SPIN_COUNT)
|| (string) $keyEncryptor['uri'] !== self::PASSWORD_KEY_ENCRYPTOR_URI
) {
throw new Exception('Unsupported XLSX encryption profile.');
}
$result = [
'keyDataSalt' => self::decode($keyData['saltValue']),
'passwordSalt' => self::decode($encryptedKey['saltValue']),
'encryptedVerifier' => self::decode($encryptedKey['encryptedVerifierHashInput']),
'encryptedVerifierHash' => self::decode($encryptedKey['encryptedVerifierHashValue']),
'encryptedKey' => self::decode($encryptedKey['encryptedKeyValue']),
'encryptedHmacKey' => self::decode($integrity['encryptedHmacKey']),
'encryptedHmacValue' => self::decode($integrity['encryptedHmacValue']),
'spinCount' => $spinCount,
'keyBits' => $keyBits,
'hashAlgorithm' => $hashAlgorithm,
'hashSize' => $hashSize,
];
if (
strlen($result['keyDataSalt']) !== 16 || strlen($result['passwordSalt']) !== 16
|| strlen($result['encryptedVerifier']) !== 16 || strlen($result['encryptedVerifierHash']) !== self::paddedLength($hashSize)
|| strlen($result['encryptedKey']) !== self::paddedLength(intdiv($keyBits, 8)) || strlen($result['encryptedHmacKey']) !== self::paddedLength($hashSize)
|| strlen($result['encryptedHmacValue']) !== self::paddedLength($hashSize)
) {
throw new Exception('Malformed XLSX encryption information.');
}
return $result;
}
/** @param array{keyDataSalt: string, passwordSalt: string, encryptedVerifier: string, encryptedVerifierHash: string, encryptedKey: string, encryptedHmacKey: string, encryptedHmacValue: string, spinCount: int, keyBits: int, hashAlgorithm: string, hashSize: int} $info */
public static function decrypt(array $info, string $encryptedPackage, string $password): string
{
if ($password === '') {
throw new Exception('XLSX encryption password required.');
}
if (strlen($encryptedPackage) < 8) {
throw new Exception('Malformed encrypted XLSX package.');
}
$hash = self::passwordHash($password, $info['passwordSalt'], $info['spinCount'], $info['hashAlgorithm']);
$verifierKey = self::deriveKey($hash, self::BLOCK_KEY_VERIFIER, $info['hashAlgorithm'], $info['keyBits']);
$verifierHashKey = self::deriveKey($hash, self::BLOCK_KEY_VERIFIER_HASH, $info['hashAlgorithm'], $info['keyBits']);
$verifier = self::aesDecrypt($info['encryptedVerifier'], $verifierKey, $info['passwordSalt'], $info['keyBits']);
$expectedHash = self::aesDecrypt($info['encryptedVerifierHash'], $verifierHashKey, $info['passwordSalt'], $info['keyBits']);
if (!hash_equals(self::hash($info['hashAlgorithm'], $verifier), substr($expectedHash, 0, $info['hashSize']))) {
throw new Exception('XLSX encryption password is incorrect.');
}
$secretKey = self::aesDecrypt($info['encryptedKey'], self::deriveKey($hash, self::BLOCK_KEY_ENCRYPTED_KEY, $info['hashAlgorithm'], $info['keyBits']), $info['passwordSalt'], $info['keyBits']);
$secretKey = substr($secretKey, 0, intdiv($info['keyBits'], 8));
if (strlen($secretKey) !== intdiv($info['keyBits'], 8)) {
throw new Exception('Malformed XLSX encryption information.');
}
self::verifyIntegrity($info, $secretKey, $encryptedPackage);
$size = self::unpackSize(substr($encryptedPackage, 0, 8));
$offset = 8;
$plain = '';
for ($block = 0; $size > 0; ++$block) {
$length = min(self::SEGMENT_SIZE, $size);
$cipherLength = (int) (ceil($length / self::BLOCK_SIZE) * self::BLOCK_SIZE);
$cipher = substr($encryptedPackage, $offset, $cipherLength);
if (strlen($cipher) !== $cipherLength) {
throw new Exception('Malformed encrypted XLSX package.');
}
$iv = self::iv($info['keyDataSalt'], pack('V', $block), $info['hashAlgorithm']);
$plain .= substr(self::aesDecrypt($cipher, $secretKey, $iv, $info['keyBits']), 0, $length);
$offset += $cipherLength;
$size -= $length;
}
if ($offset !== strlen($encryptedPackage)) {
throw new Exception('Malformed encrypted XLSX package.');
}
return $plain;
}
/** @param array{keyDataSalt: string, passwordSalt: string, encryptedVerifier: string, encryptedVerifierHash: string, encryptedKey: string, encryptedHmacKey: string, encryptedHmacValue: string, spinCount: int, keyBits: int, hashAlgorithm: string, hashSize: int} $info */
public static function decryptFile(array $info, string $inputFilename, string $outputFilename, string $password): void
{
if ($password === '') {
throw new Exception('XLSX encryption password required.');
}
$input = @fopen($inputFilename, 'rb');
if ($input === false) {
throw new Exception('Could not open XLSX package for decryption.');
}
try {
$hash = self::passwordHash($password, $info['passwordSalt'], $info['spinCount'], $info['hashAlgorithm']);
$verifier = self::aesDecrypt($info['encryptedVerifier'], self::deriveKey($hash, self::BLOCK_KEY_VERIFIER, $info['hashAlgorithm'], $info['keyBits']), $info['passwordSalt'], $info['keyBits']);
$expected = self::aesDecrypt($info['encryptedVerifierHash'], self::deriveKey($hash, self::BLOCK_KEY_VERIFIER_HASH, $info['hashAlgorithm'], $info['keyBits']), $info['passwordSalt'], $info['keyBits']);
if (!hash_equals(self::hash($info['hashAlgorithm'], $verifier), substr($expected, 0, $info['hashSize']))) {
throw new Exception('XLSX encryption password is incorrect.');
}
$secretKey = substr(self::aesDecrypt($info['encryptedKey'], self::deriveKey($hash, self::BLOCK_KEY_ENCRYPTED_KEY, $info['hashAlgorithm'], $info['keyBits']), $info['passwordSalt'], $info['keyBits']), 0, intdiv($info['keyBits'], 8));
if (strlen($secretKey) !== intdiv($info['keyBits'], 8)) {
throw new Exception('Malformed XLSX encryption information.');
}
self::verifyIntegrityFile($info, $secretKey, $input);
$output = @fopen($outputFilename, 'wb');
if ($output === false) {
throw new Exception('Could not open XLSX package for decryption.');
}
rewind($input);
try {
$size = self::unpackSize(self::read($input, 8));
for ($block = 0; $size > 0; ++$block) {
$length = min(self::SEGMENT_SIZE, $size);
$cipher = self::read($input, self::paddedLength($length));
$iv = self::iv($info['keyDataSalt'], pack('V', $block), $info['hashAlgorithm']);
self::write($output, substr(self::aesDecrypt($cipher, $secretKey, $iv, $info['keyBits']), 0, $length));
$size -= $length;
}
if (fread($input, 1) !== '') {
throw new Exception('Malformed encrypted XLSX package.');
}
} finally {
fclose($output);
}
} finally {
fclose($input);
}
}
/**
* Create the EncryptionInfo and EncryptedPackage streams for an Agile-encrypted XLSX.
*
* @return array{encryptionInfo: string, encryptedPackage: string}
*/
public static function encrypt(string $plainPackage, string $password, int $keyBits = 256, string $hashAlgorithm = 'SHA512', int $spinCount = 100000): array
{
if ($password === '') {
throw new Exception('XLSX encryption password required.');
}
if (!self::isSupportedProfile($keyBits, $hashAlgorithm, self::HASH_ALGORITHMS[$hashAlgorithm][1] ?? 0) || $spinCount < 0 || $spinCount > self::MAX_SPIN_COUNT) {
throw new Exception('Unsupported XLSX encryption profile.');
}
$passwordSalt = random_bytes(self::BLOCK_SIZE);
$keyDataSalt = random_bytes(self::BLOCK_SIZE);
$hashSize = self::hashSize($hashAlgorithm);
$passwordHash = self::passwordHash($password, $passwordSalt, $spinCount, $hashAlgorithm);
$verifier = random_bytes(self::BLOCK_SIZE);
$secretKey = random_bytes(self::keyLength($keyBits));
$encryptedVerifier = self::aesEncrypt($verifier, self::deriveKey($passwordHash, self::BLOCK_KEY_VERIFIER, $hashAlgorithm, $keyBits), $passwordSalt, $keyBits);
$encryptedVerifierHash = self::aesEncrypt(str_pad(self::hash($hashAlgorithm, $verifier), self::paddedLength($hashSize), "\x00"), self::deriveKey($passwordHash, self::BLOCK_KEY_VERIFIER_HASH, $hashAlgorithm, $keyBits), $passwordSalt, $keyBits);
$encryptedKey = self::aesEncrypt(str_pad($secretKey, self::paddedLength(strlen($secretKey)), "\x00"), self::deriveKey($passwordHash, self::BLOCK_KEY_ENCRYPTED_KEY, $hashAlgorithm, $keyBits), $passwordSalt, $keyBits);
$encryptedPackage = self::packSize(strlen($plainPackage));
for ($block = 0, $offset = 0; $offset < strlen($plainPackage); ++$block, $offset += self::SEGMENT_SIZE) {
$segment = substr($plainPackage, $offset, self::SEGMENT_SIZE);
$iv = self::iv($keyDataSalt, pack('V', $block), $hashAlgorithm);
$encryptedPackage .= self::aesEncrypt(str_pad($segment, self::paddedLength(strlen($segment)), "\x00"), $secretKey, $iv, $keyBits);
}
$hmacKey = random_bytes(self::hashSize($hashAlgorithm));
$hmacKeyIv = self::iv($keyDataSalt, self::BLOCK_KEY_HMAC_KEY, $hashAlgorithm);
$hmacValueIv = self::iv($keyDataSalt, self::BLOCK_KEY_HMAC_VALUE, $hashAlgorithm);
$encryptedHmacKey = self::aesEncrypt(str_pad($hmacKey, self::paddedLength($hashSize), "\x00"), $secretKey, $hmacKeyIv, $keyBits);
$encryptedHmacValue = self::aesEncrypt(str_pad(hash_hmac(self::HASH_ALGORITHMS[$hashAlgorithm][0], $encryptedPackage, $hmacKey, true), self::paddedLength($hashSize), "\x00"), $secretKey, $hmacValueIv, $keyBits);
$encryptionInfo = "\x04\x00\x04\x00\x40\x00\x00\x00" . self::encryptionInfoXml(
$keyDataSalt,
$passwordSalt,
$encryptedVerifier,
$encryptedVerifierHash,
$encryptedKey,
$encryptedHmacKey,
$encryptedHmacValue,
$keyBits,
$hashAlgorithm,
$hashSize,
$spinCount
);
return ['encryptionInfo' => $encryptionInfo, 'encryptedPackage' => $encryptedPackage];
}
/**
* Encrypt a ZIP package without loading either package into memory.
*
* @return array{encryptionInfo: string, encryptedPackageFilename: string}
*/
public static function encryptFile(string $plainPackageFilename, string $password, int $keyBits = 256, string $hashAlgorithm = 'SHA512', int $spinCount = 100000): array
{
if ($password === '') {
throw new Exception('XLSX encryption password required.');
}
if (!self::isSupportedProfile($keyBits, $hashAlgorithm, self::HASH_ALGORITHMS[$hashAlgorithm][1] ?? 0) || $spinCount < 0 || $spinCount > self::MAX_SPIN_COUNT) {
throw new Exception('Unsupported XLSX encryption profile.');
}
$size = @filesize($plainPackageFilename);
if ($size === false) {
throw new Exception('Could not determine XLSX package size.');
}
$input = fopen($plainPackageFilename, 'rb');
if ($input === false) {
throw new Exception('Could not open XLSX package for encryption.');
}
$encryptedPackageFilename = SharedFile::temporaryFilename();
$output = fopen($encryptedPackageFilename, 'wb');
if ($output === false) {
fclose($input);
@unlink($encryptedPackageFilename);
throw new Exception('Could not create encrypted XLSX package.');
}
try {
$passwordSalt = random_bytes(self::BLOCK_SIZE);
$keyDataSalt = random_bytes(self::BLOCK_SIZE);
$hashSize = self::hashSize($hashAlgorithm);
$passwordHash = self::passwordHash($password, $passwordSalt, $spinCount, $hashAlgorithm);
$verifier = random_bytes(self::BLOCK_SIZE);
$secretKey = random_bytes(self::keyLength($keyBits));
$encryptedVerifier = self::aesEncrypt($verifier, self::deriveKey($passwordHash, self::BLOCK_KEY_VERIFIER, $hashAlgorithm, $keyBits), $passwordSalt, $keyBits);
$encryptedVerifierHash = self::aesEncrypt(str_pad(self::hash($hashAlgorithm, $verifier), self::paddedLength($hashSize), "\x00"), self::deriveKey($passwordHash, self::BLOCK_KEY_VERIFIER_HASH, $hashAlgorithm, $keyBits), $passwordSalt, $keyBits);
$encryptedKey = self::aesEncrypt(str_pad($secretKey, self::paddedLength(strlen($secretKey)), "\x00"), self::deriveKey($passwordHash, self::BLOCK_KEY_ENCRYPTED_KEY, $hashAlgorithm, $keyBits), $passwordSalt, $keyBits);
$hmacKey = random_bytes(self::hashSize($hashAlgorithm));
$hmac = hash_init(self::HASH_ALGORITHMS[$hashAlgorithm][0], HASH_HMAC, $hmacKey);
$header = self::packSize($size);
self::write($output, $header);
hash_update($hmac, $header);
$remaining = $size;
for ($block = 0; $remaining > 0; ++$block) {
$length = min(self::SEGMENT_SIZE, $remaining);
$segment = fread($input, $length);
if ($segment === false || strlen($segment) !== $length) {
throw new Exception('Could not read XLSX package for encryption.');
}
$iv = self::iv($keyDataSalt, pack('V', $block), $hashAlgorithm);
$cipher = self::aesEncrypt(str_pad($segment, self::paddedLength(strlen($segment)), "\x00"), $secretKey, $iv, $keyBits);
self::write($output, $cipher);
hash_update($hmac, $cipher);
$remaining -= $length;
}
$extra = fread($input, 1);
if ($extra === false || $extra !== '') {
throw new Exception('XLSX package changed while being encrypted.');
}
$hmacKeyIv = self::iv($keyDataSalt, self::BLOCK_KEY_HMAC_KEY, $hashAlgorithm);
$hmacValueIv = self::iv($keyDataSalt, self::BLOCK_KEY_HMAC_VALUE, $hashAlgorithm);
$encryptedHmacKey = self::aesEncrypt(str_pad($hmacKey, self::paddedLength($hashSize), "\x00"), $secretKey, $hmacKeyIv, $keyBits);
$encryptedHmacValue = self::aesEncrypt(str_pad(hash_final($hmac, true), self::paddedLength($hashSize), "\x00"), $secretKey, $hmacValueIv, $keyBits);
$encryptionInfo = "\x04\x00\x04\x00\x40\x00\x00\x00" . self::encryptionInfoXml($keyDataSalt, $passwordSalt, $encryptedVerifier, $encryptedVerifierHash, $encryptedKey, $encryptedHmacKey, $encryptedHmacValue, $keyBits, $hashAlgorithm, $hashSize, $spinCount);
} catch (Throwable $e) {
@unlink($encryptedPackageFilename);
throw $e;
} finally {
fclose($input);
fclose($output);
}
return ['encryptionInfo' => $encryptionInfo, 'encryptedPackageFilename' => $encryptedPackageFilename];
}
/**
* Write an Office encrypted package CFB container using a file-backed EncryptedPackage stream.
*
* @param resource $fileHandle
*/
public static function writeContainerFromFile($fileHandle, string $encryptionInfo, string $encryptedPackageFilename): void
{
$containerFilename = SharedFile::temporaryFilename();
$container = fopen($containerFilename, 'w+b');
if ($container === false) {
@unlink($containerFilename);
throw new Exception('Could not create encrypted XLSX container.');
}
try {
self::writeEcma376Container($container, $encryptionInfo, $encryptedPackageFilename);
rewind($container);
while (!feof($container)) {
$data = fread($container, 8192);
if ($data === false) {
throw new Exception('Could not read encrypted XLSX container.');
}
if ($data !== '' && fwrite($fileHandle, $data) !== strlen($data)) {
throw new Exception('Could not write encrypted XLSX container.');
}
}
} finally {
fclose($container);
@unlink($containerFilename);
}
}
/**
* Serialize the ECMA-376 encrypted-package CFB layout. The directory tree
* is prescribed by the Data Spaces structure; sector allocation is dynamic
* so normal workbook size is not artificially constrained.
*
* @param resource $fileHandle
*/
private static function writeEcma376Container($fileHandle, string $encryptionInfo, string $encryptedPackageFilename): void
{
$packageSize = filesize($encryptedPackageFilename);
if ($packageSize === false || $packageSize < self::CFB_MINI_STREAM_CUTOFF || $packageSize > 0xFFFFFFFF) {
throw new Exception('Malformed encrypted XLSX package.');
}
$smallStreams = [
self::DIRECTORY_VERSION => [self::STREAM_VERSION, self::dataSpacesVersion()],
self::DIRECTORY_DATA_SPACE_MAP => [self::STREAM_DATA_SPACE_MAP, self::dataSpaceMap()],
self::DIRECTORY_STRONG_ENCRYPTION_DATA_SPACE => [self::STREAM_STRONG_ENCRYPTION_DATA_SPACE, self::strongEncryptionDataSpace()],
self::DIRECTORY_PRIMARY => [self::STREAM_PRIMARY, self::primaryTransform()],
self::DIRECTORY_ENCRYPTION_INFO => [self::STREAM_ENCRYPTION_INFO, $encryptionInfo],
];
$miniStarts = array_fill_keys(array_keys($smallStreams), 0);
$miniFat = [];
$miniCount = 0;
foreach ($smallStreams as $id => [, $content]) {
if (strlen($content) >= self::CFB_MINI_STREAM_CUTOFF) {
throw new Exception('Malformed XLSX encryption information.');
}
$count = (int) ceil(strlen($content) / self::CFB_MINI_SECTOR_SIZE);
$miniStarts[$id] = $miniCount;
for ($i = 0; $i < $count - 1; ++$i) {
$miniFat[$miniCount + $i] = $miniCount + $i + 1;
}
$miniFat[$miniCount + $count - 1] = 0xFFFFFFFE;
$miniCount += $count;
}
$miniFatSectors = (int) ceil($miniCount / self::CFB_FAT_ENTRIES_PER_SECTOR);
$miniFat = array_pad($miniFat, $miniFatSectors * self::CFB_FAT_ENTRIES_PER_SECTOR, 0xFFFFFFFF);
$miniDataSectors = (int) ceil($miniCount / self::CFB_MINI_SECTORS_PER_SECTOR);
$directorySectors = (int) ceil(self::CFB_DIRECTORY_ENTRY_COUNT / self::CFB_DIRECTORY_ENTRIES_PER_SECTOR);
$packageSectors = (int) ceil($packageSize / self::CFB_SECTOR_SIZE);
$contentSectors = $miniFatSectors + $directorySectors + $miniDataSectors + $packageSectors;
$fatSectors = 1;
$difatSectors = 0;
do {
$requiredFatSectors = (int) ceil(($contentSectors + $fatSectors + $difatSectors) / self::CFB_FAT_ENTRIES_PER_SECTOR);
$requiredDifatSectors = $requiredFatSectors <= self::CFB_DIFAT_ENTRIES_IN_HEADER ? 0 : (int) ceil(($requiredFatSectors - self::CFB_DIFAT_ENTRIES_IN_HEADER) / self::CFB_DIFAT_ENTRIES_PER_SECTOR);
$changed = $requiredFatSectors !== $fatSectors || $requiredDifatSectors !== $difatSectors;
$fatSectors = $requiredFatSectors;
$difatSectors = $requiredDifatSectors;
} while ($changed);
$miniFatSector = $fatSectors;
$directorySector = $miniFatSector + $miniFatSectors;
$miniDataSector = $directorySector + $directorySectors;
$packageSector = $miniDataSector + $miniDataSectors;
$difatSector = $packageSector + $packageSectors;
self::write($fileHandle, self::cfbHeader($fatSectors, $directorySector, $miniFatSector, $miniFatSectors, $difatSector, $difatSectors));
$fat = array_fill(0, $fatSectors * self::CFB_FAT_ENTRIES_PER_SECTOR, 0xFFFFFFFF);
for ($sector = 0; $sector < $fatSectors; ++$sector) {
$fat[$sector] = 0xFFFFFFFD;
}
self::chainFat($fat, $miniFatSector, $miniFatSectors);
self::chainFat($fat, $directorySector, $directorySectors);
self::chainFat($fat, $miniDataSector, $miniDataSectors);
self::chainFat($fat, $packageSector, $packageSectors);
for ($sector = 0; $sector < $difatSectors; ++$sector) {
$fat[$difatSector + $sector] = 0xFFFFFFFC;
}
self::write($fileHandle, self::packSectors($fat));
self::write($fileHandle, self::packSectors($miniFat));
$entries = [
// CFB sibling trees sort by name length, then case-folded UTF-16 code points.
self::directoryEntry('Root Entry', 5, 1, 0xFFFFFFFF, 0xFFFFFFFF, self::DIRECTORY_ENCRYPTION_INFO, $miniDataSector, $miniCount * self::CFB_MINI_SECTOR_SIZE),
self::directoryEntry(self::STREAM_ENCRYPTED_PACKAGE, 2, 0, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, $packageSector, $packageSize),
self::directoryEntry(self::STORAGE_DATA_SPACES, 1, 0, 0xFFFFFFFF, 0xFFFFFFFF, self::DIRECTORY_DATA_SPACE_MAP, 0, 0),
self::directoryEntry(self::STREAM_VERSION, 2, 0, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, $miniStarts[self::DIRECTORY_VERSION], strlen($smallStreams[self::DIRECTORY_VERSION][1])),
self::directoryEntry(self::STREAM_DATA_SPACE_MAP, 2, 1, self::DIRECTORY_VERSION, self::DIRECTORY_DATA_SPACE_INFO, 0xFFFFFFFF, $miniStarts[self::DIRECTORY_DATA_SPACE_MAP], strlen($smallStreams[self::DIRECTORY_DATA_SPACE_MAP][1])),
self::directoryEntry(self::STORAGE_DATA_SPACE_INFO, 1, 0, 0xFFFFFFFF, 0xFFFFFFFF, self::DIRECTORY_TRANSFORM_INFO, 0, 0),
self::directoryEntry(self::STREAM_STRONG_ENCRYPTION_DATA_SPACE, 2, 0, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, $miniStarts[self::DIRECTORY_STRONG_ENCRYPTION_DATA_SPACE], strlen($smallStreams[self::DIRECTORY_STRONG_ENCRYPTION_DATA_SPACE][1])),
self::directoryEntry(self::STORAGE_TRANSFORM_INFO, 1, 1, self::DIRECTORY_STRONG_ENCRYPTION_DATA_SPACE, 0xFFFFFFFF, self::DIRECTORY_STRONG_ENCRYPTION_TRANSFORM, 0, 0),
self::directoryEntry(self::STORAGE_STRONG_ENCRYPTION_TRANSFORM, 1, 1, 0xFFFFFFFF, 0xFFFFFFFF, self::DIRECTORY_PRIMARY, 0, 0),
self::directoryEntry(self::STREAM_PRIMARY, 2, 1, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, $miniStarts[self::DIRECTORY_PRIMARY], strlen($smallStreams[self::DIRECTORY_PRIMARY][1])),
self::directoryEntry(self::STREAM_ENCRYPTION_INFO, 2, 1, self::DIRECTORY_DATA_SPACES, self::DIRECTORY_ENCRYPTED_PACKAGE, 0xFFFFFFFF, $miniStarts[self::DIRECTORY_ENCRYPTION_INFO], strlen($smallStreams[self::DIRECTORY_ENCRYPTION_INFO][1])),
];
self::write($fileHandle, str_pad(implode('', $entries), $directorySectors * self::CFB_SECTOR_SIZE, "\x00"));
$miniData = str_repeat("\x00", $miniDataSectors * self::CFB_SECTOR_SIZE);
foreach ($smallStreams as $id => [, $content]) {
$miniData = substr_replace($miniData, $content, $miniStarts[$id] * self::CFB_MINI_SECTOR_SIZE, strlen($content));
}
self::write($fileHandle, $miniData);
$package = fopen($encryptedPackageFilename, 'rb');
if ($package === false) {
throw new Exception('Could not open encrypted XLSX package.');
}
try {
while (!feof($package)) {
$data = fread($package, 8192);
if ($data === false) {
throw new Exception('Could not read encrypted XLSX package.');
}
if ($data !== '') {
self::write($fileHandle, $data);
}
}
} finally {
fclose($package);
}
$padding = $packageSectors * self::CFB_SECTOR_SIZE - $packageSize;
if ($padding > 0) {
self::write($fileHandle, str_repeat("\x00", $padding));
}
for ($sector = 0; $sector < $difatSectors; ++$sector) {
$firstFatSector = self::CFB_DIFAT_ENTRIES_IN_HEADER + $sector * self::CFB_DIFAT_ENTRIES_PER_SECTOR;
$fatReferences = [];
for ($i = 0; $i < self::CFB_DIFAT_ENTRIES_PER_SECTOR; ++$i) {
$fatReferences[] = $firstFatSector + $i < $fatSectors ? $firstFatSector + $i : 0xFFFFFFFF;
}
$fatReferences[] = $sector + 1 < $difatSectors ? $difatSector + $sector + 1 : 0xFFFFFFFE;
self::write($fileHandle, self::packSectors($fatReferences));
}
}
/** @param int[] $fat */
private static function chainFat(array &$fat, int $start, int $count): void
{
for ($i = 0; $i < $count - 1; ++$i) {
$fat[$start + $i] = $start + $i + 1;
}
$fat[$start + $count - 1] = 0xFFFFFFFE;
}
/** @param int[] $sectors */
private static function packSectors(array $sectors): string
{
return pack('V*', ...$sectors);
}
private static function cfbHeader(int $fatSectors, int $directorySector, int $miniFatSector, int $miniFatSectors, int $difatSector, int $difatSectors): string
{
$headerDifat = [];
for ($sector = 0; $sector < self::CFB_DIFAT_ENTRIES_IN_HEADER; ++$sector) {
$headerDifat[] = $sector < $fatSectors ? $sector : 0xFFFFFFFF;
}
return "\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1" . str_repeat("\x00", 16)
. pack('v8', 0x3E, 3, 0xFFFE, 9, 6, 0, 0, 0)
. pack('V8', 0, $fatSectors, $directorySector, 0, self::CFB_MINI_STREAM_CUTOFF, $miniFatSector, $miniFatSectors, $difatSectors === 0 ? 0xFFFFFFFE : $difatSector)
. pack('V', $difatSectors) . self::packSectors($headerDifat);
}
private static function directoryEntry(string $name, int $type, int $color, int $left, int $right, int $child, int $start, int $size): string
{
$name = OLE::ascToUcs($name);
return str_pad($name, 64, "\x00") . pack('vCCVVV', strlen($name) + 2, $type, $color, $left, $right, $child)
. str_repeat("\x00", 16) . pack('V', 0) . str_repeat("\x00", 16)
. pack('V2', $start, $size % 4294967296) . pack('V', intdiv($size, 4294967296));
}
private static function decode(mixed $value): string
{
if (!is_string($value) && !$value instanceof Stringable) {
throw new Exception('Malformed XLSX encryption information.');
}
$result = base64_decode((string) $value, true);
if ($result === false) {
throw new Exception('Malformed XLSX encryption information.');
}
return $result;
}
private static function decimalAttribute(SimpleXMLElement $element, string $name): int
{
$value = (string) $element[$name];
$maximum = (string) PHP_INT_MAX;
if ($value === '' || !ctype_digit($value) || strlen($value) > strlen($maximum) || (strlen($value) === strlen($maximum) && $value > $maximum)) {
throw new Exception('Malformed XLSX encryption information.');
}
return (int) $value;
}
private static function passwordHash(string $password, string $salt, int $spinCount, string $algorithm = 'SHA512'): string
{
$hash = self::hash($algorithm, $salt . mb_convert_encoding($password, 'UTF-16LE', 'UTF-8'));
for ($i = 0; $i < $spinCount; ++$i) {
$hash = self::hash($algorithm, pack('V', $i) . $hash);
}
return $hash;
}
private static function deriveKey(string $hash, string $blockKey, string $algorithm = 'SHA512', int $keyBits = 256): string
{
return substr(str_pad(self::hash($algorithm, $hash . $blockKey), intdiv($keyBits, 8), "\x36"), 0, intdiv($keyBits, 8));
}
private static function aesDecrypt(string $data, string $key, string $iv, int $keyBits = 256): string
{
self::assertOpenSslAvailable();
$result = openssl_decrypt($data, 'aes-' . $keyBits . '-cbc', $key, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING, $iv);
if ($result === false) {
throw new Exception('Malformed XLSX encrypted data.');
}
return $result;
}
private static function aesEncrypt(string $data, string $key, string $iv, int $keyBits = 256): string
{
self::assertOpenSslAvailable();
$result = openssl_encrypt($data, 'aes-' . $keyBits . '-cbc', $key, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING, $iv);
if ($result === false) {
throw new Exception('Could not encrypt XLSX data.');
}
return $result;
}
private static function isSupportedProfile(int $keyBits, string $hashAlgorithm, int $hashSize): bool
{
return in_array($keyBits, [128, 192, 256], true)
&& isset(self::HASH_ALGORITHMS[$hashAlgorithm])
&& self::HASH_ALGORITHMS[$hashAlgorithm][1] === $hashSize;
}
private static function hash(string $algorithm, string $data): string
{
return hash(self::HASH_ALGORITHMS[$algorithm][0], $data, true);
}
/** @return positive-int */
private static function hashSize(string $algorithm): int
{
return match ($algorithm) {
'SHA1' => 20,
'SHA256' => 32,
'SHA384' => 48,
'SHA512' => 64,
default => throw new Exception('Unsupported XLSX encryption profile.'),
};
}
/** @return positive-int */
private static function keyLength(int $keyBits): int
{
return match ($keyBits) {
128 => 16,
192 => 24,
256 => 32,
default => throw new Exception('Unsupported XLSX encryption profile.'),
};
}
private static function iv(string $salt, string $blockKey, string $algorithm): string
{
return substr(str_pad(self::hash($algorithm, $salt . $blockKey), self::BLOCK_SIZE, "\x36"), 0, self::BLOCK_SIZE);
}
private static function paddedLength(int $length): int
{
return (int) (ceil($length / self::BLOCK_SIZE) * self::BLOCK_SIZE);
}
private static function assertOpenSslAvailable(): void
{
if (!function_exists('openssl_encrypt') || !function_exists('openssl_decrypt')) {
throw new Exception('XLSX encryption requires the OpenSSL extension.');
}
}
private static function packSize(int $size): string
{
if ($size < 0) {
throw new Exception('XLSX package is too large to encrypt.');
}
return pack('V2', $size % 4294967296, intdiv($size, 4294967296));
}
private static function unpackSize(string $data): int
{
$unpackedSize = unpack('Vlow/Vhigh', $data);
if ($unpackedSize === false || !isset($unpackedSize['low'], $unpackedSize['high']) || !is_int($unpackedSize['low']) || !is_int($unpackedSize['high'])) {
throw new Exception('Malformed encrypted XLSX package.');
}
return self::sizeFromWords($unpackedSize['low'], $unpackedSize['high'], PHP_INT_SIZE, PHP_INT_MAX);
}
private static function sizeFromWords(int $low, int $high, int $integerSize, int $integerMax): int
{
if ($integerSize < 8) {
if ($high !== 0 || $low > $integerMax) {
throw new Exception('Encrypted XLSX package is too large for this platform.');
}
return $low;
}
if ($high > 0x7FFFFFFF) {
throw new Exception('Encrypted XLSX package is too large for this platform.');
}
return $low + $high * 4294967296;
}
/** @param resource $fileHandle */
private static function write($fileHandle, string $data): void
{
if (fwrite($fileHandle, $data) !== strlen($data)) {
throw new Exception('Could not write encrypted XLSX package.');
}
}
/** @param resource $fileHandle */
private static function read($fileHandle, int $length): string
{
if ($length < 1) {
throw new Exception('Malformed encrypted XLSX package.');
}
$data = fread($fileHandle, $length);
if ($data === false || strlen($data) !== $length) {
throw new Exception('Malformed encrypted XLSX package.');
}
return $data;
}
private static function encryptionInfoXml(string $keyDataSalt, string $passwordSalt, string $encryptedVerifier, string $encryptedVerifierHash, string $encryptedKey, string $encryptedHmacKey, string $encryptedHmacValue, int $keyBits = 256, string $hashAlgorithm = 'SHA512', int $hashSize = 64, int $spinCount = 100000): string
{
$base64 = static fn (string $value): string => base64_encode($value);
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
. '<encryption xmlns="http://schemas.microsoft.com/office/2006/encryption" xmlns:p="http://schemas.microsoft.com/office/2006/keyEncryptor/password">'
. '<keyData saltSize="16" blockSize="16" keyBits="' . $keyBits . '" hashSize="' . $hashSize . '" cipherAlgorithm="AES" cipherChaining="ChainingModeCBC" hashAlgorithm="' . $hashAlgorithm . '" saltValue="' . $base64($keyDataSalt) . '"/>'
. '<dataIntegrity encryptedHmacKey="' . $base64($encryptedHmacKey) . '" encryptedHmacValue="' . $base64($encryptedHmacValue) . '"/>'
. '<keyEncryptors><keyEncryptor uri="http://schemas.microsoft.com/office/2006/keyEncryptor/password"><p:encryptedKey spinCount="' . $spinCount . '" saltSize="16" blockSize="16" keyBits="' . $keyBits . '" hashSize="' . $hashSize . '" cipherAlgorithm="AES" cipherChaining="ChainingModeCBC" hashAlgorithm="' . $hashAlgorithm . '" saltValue="' . $base64($passwordSalt) . '" encryptedVerifierHashInput="' . $base64($encryptedVerifier) . '" encryptedVerifierHashValue="' . $base64($encryptedVerifierHash) . '" encryptedKeyValue="' . $base64($encryptedKey) . '"/></keyEncryptor></keyEncryptors></encryption>';
}
private static function dataSpacesVersion(): string
{
// Fixed Version stream required by the ECMA-376 Data Spaces construct.
return hex2bin('3c0000004d006900630072006f0073006f00660074002e0043006f006e007400610069006e00650072002e004400610074006100530070006100630065007300010000000100000001000000') ?: throw new Exception('Could not generate XLSX encryption DataSpaces stream.');
}
private static function primaryTransform(): string
{
// Fixed Primary transform descriptor for Microsoft.Container.EncryptionTransform.
return hex2bin('58000000010000004c0000007b00460046003900410033004600300033002d0035003600450046002d0034003600310033002d0042004400440035002d003500410034003100430031004400300037003200340036007d004e0000004d006900630072006f0073006f00660074002e0043006f006e007400610069006e00650072002e0045006e006300720079007000740069006f006e005400720061006e00730066006f0072006d00000001000000010000000100000000000000000000000000000004000000') ?: throw new Exception('Could not generate XLSX encryption DataSpaces stream.');
}
private static function dataSpaceMap(): string
{
// Fixed map binding EncryptedPackage to StrongEncryptionDataSpace.
return hex2bin('08000000010000006800000001000000000000002000000045006e0063007200790070007400650064005000610063006b00610067006500320000005300740072006f006e00670045006e006300720079007000740069006f006e004400610074006100530070006100630065000000') ?: throw new Exception('Could not generate XLSX encryption DataSpaces stream.');
}
private static function strongEncryptionDataSpace(): string
{
// Fixed definition binding StrongEncryptionDataSpace to its transform.
return hex2bin('0800000001000000320000005300740072006f006e00670045006e006300720079007000740069006f006e005400720061006e00730066006f0072006d000000') ?: throw new Exception('Could not generate XLSX encryption DataSpaces stream.');
}
/** @param array{keyDataSalt: string, passwordSalt: string, encryptedVerifier: string, encryptedVerifierHash: string, encryptedKey: string, encryptedHmacKey: string, encryptedHmacValue: string, spinCount: int, keyBits: int, hashAlgorithm: string, hashSize: int} $info */
private static function verifyIntegrity(array $info, string $secretKey, string $encryptedPackage): void
{
$hmacKeyIv = self::iv($info['keyDataSalt'], self::BLOCK_KEY_HMAC_KEY, $info['hashAlgorithm']);
$hmacValueIv = self::iv($info['keyDataSalt'], self::BLOCK_KEY_HMAC_VALUE, $info['hashAlgorithm']);
$hmacKey = substr(self::aesDecrypt($info['encryptedHmacKey'], $secretKey, $hmacKeyIv, $info['keyBits']), 0, $info['hashSize']);
$expected = self::aesDecrypt($info['encryptedHmacValue'], $secretKey, $hmacValueIv, $info['keyBits']);
if (!hash_equals(hash_hmac(self::HASH_ALGORITHMS[$info['hashAlgorithm']][0], $encryptedPackage, $hmacKey, true), substr($expected, 0, $info['hashSize']))) {
throw new Exception('Encrypted XLSX package integrity check failed.');
}
}
/**
* @param array{keyDataSalt: string, passwordSalt: string, encryptedVerifier: string, encryptedVerifierHash: string, encryptedKey: string, encryptedHmacKey: string, encryptedHmacValue: string, spinCount: int, keyBits: int, hashAlgorithm: string, hashSize: int} $info
* @param resource $input
*/
private static function verifyIntegrityFile(array $info, string $secretKey, $input): void
{
$hmacKeyIv = self::iv($info['keyDataSalt'], self::BLOCK_KEY_HMAC_KEY, $info['hashAlgorithm']);
$hmacValueIv = self::iv($info['keyDataSalt'], self::BLOCK_KEY_HMAC_VALUE, $info['hashAlgorithm']);
$hmacKey = substr(self::aesDecrypt($info['encryptedHmacKey'], $secretKey, $hmacKeyIv, $info['keyBits']), 0, $info['hashSize']);
$expected = self::aesDecrypt($info['encryptedHmacValue'], $secretKey, $hmacValueIv, $info['keyBits']);
$hmac = hash_init(self::HASH_ALGORITHMS[$info['hashAlgorithm']][0], HASH_HMAC, $hmacKey);
while (!feof($input)) {
$data = fread($input, 8192);
if ($data === false) {
throw new Exception('Could not read encrypted XLSX package.');
}
if ($data !== '') {
hash_update($hmac, $data);
}
}
if (!hash_equals(hash_final($hmac, true), substr($expected, 0, $info['hashSize']))) {
throw new Exception('Encrypted XLSX package integrity check failed.');
}
}
}