setEncryptionPassword('open'); $spreadsheet = $reader->load(self::FIXTURE); self::assertSame('agile encryption fixture', $spreadsheet->getActiveSheet()->getCell('A1')->getValue()); self::assertSame(42, $spreadsheet->getActiveSheet()->getCell('B1')->getValue()); $spreadsheet->disconnectWorksheets(); self::assertSame($temporaryFiles, self::spreadsheetTemporaryFiles()); } public function testCanReadEncryptedWorkbook(): void { self::assertTrue((new Xlsx())->canRead(self::FIXTURE)); self::assertFalse((new Xlsx())->canRead('tests/data/Reader/XLS/sample.xls')); } public function testNonEncryptedOleFallsBackToNormalXlsxLoading(): void { $this->expectException(Exception::class); (new Xlsx())->load('tests/data/Reader/XLS/sample.xls'); } public function testListWorksheetMetadataForEncryptedWorkbook(): void { $reader = (new Xlsx())->setEncryptionPassword('open'); self::assertSame(['Sheet1'], $reader->listWorksheetNames(self::FIXTURE)); self::assertSame( [[ 'worksheetName' => 'Sheet1', 'lastColumnLetter' => 'B', 'lastColumnIndex' => 1, 'totalRows' => 1, 'totalColumns' => 2, 'sheetState' => 'visible', ]], $reader->listWorksheetInfo(self::FIXTURE) ); } public function testEncryptedWorkbookRequiresPassword(): void { $this->expectException(Exception::class); $this->expectExceptionMessage('XLSX encryption password required'); (new Xlsx())->load(self::FIXTURE); } public function testEncryptedWorkbookRejectsIncorrectPassword(): void { $temporaryFiles = self::spreadsheetTemporaryFiles(); try { $this->expectException(Exception::class); $this->expectExceptionMessage('XLSX encryption password is incorrect'); (new Xlsx())->setEncryptionPassword('wrong')->load(self::FIXTURE); } finally { self::assertSame($temporaryFiles, self::spreadsheetTemporaryFiles()); } } public function testEncryptedWorkbookRejectsSpinCountAboveConfiguredMaximum(): void { $this->expectException(Exception::class); $this->expectExceptionMessage('Unsupported XLSX encryption profile'); (new Xlsx()) ->setEncryptionPassword('open') ->setMaxEncryptionSpinCount(99999) ->load(self::FIXTURE); } public function testEncryptedWorkbookAcceptsSpinCountAtConfiguredMaximum(): void { $spreadsheet = (new Xlsx()) ->setEncryptionPassword('open') ->setMaxEncryptionSpinCount(100000) ->load(self::FIXTURE); self::assertSame('agile encryption fixture', $spreadsheet->getActiveSheet()->getCell('A1')->getValue()); $spreadsheet->disconnectWorksheets(); } public function testRejectsInvalidMaximumEncryptionSpinCount(): void { $this->expectException(InvalidArgumentException::class); (new Xlsx())->setMaxEncryptionSpinCount(AgileEncryption::MAX_SPIN_COUNT + 1); } public function testRejectsNegativeMaximumEncryptionSpinCount(): void { $this->expectException(InvalidArgumentException::class); (new Xlsx())->setMaxEncryptionSpinCount(-1); } public function testRejectsTamperedEncryptedPackage(): void { $package = AgileEncryption::encrypt("PK\x03\x04test", 'password'); $lastByte = strlen($package['encryptedPackage']) - 1; $package['encryptedPackage'][$lastByte] = chr(ord($package['encryptedPackage'][$lastByte]) ^ 1); $this->expectException(Exception::class); $this->expectExceptionMessage('integrity check failed'); AgileEncryption::decrypt(AgileEncryption::parse($package['encryptionInfo']), $package['encryptedPackage'], 'password'); } public function testRejectsMalformedEncryptedPackage(): void { $package = AgileEncryption::encrypt('package', 'password', 128, 'SHA1', 10); $this->expectException(Exception::class); $this->expectExceptionMessage('Malformed encrypted XLSX package'); AgileEncryption::decrypt(AgileEncryption::parse($package['encryptionInfo']), substr($package['encryptedPackage'], 0, 7), 'password'); } public function testRejectsMalformedDecryptedSecretKey(): void { $package = AgileEncryption::encrypt('package', 'password', 128, 'SHA1', 10); $info = AgileEncryption::parse($package['encryptionInfo']); $info['encryptedKey'] = ''; $this->expectException(Exception::class); $this->expectExceptionMessage('Malformed XLSX encryption information'); AgileEncryption::decrypt($info, $package['encryptedPackage'], 'password'); } public function testRejectsMalformedEncryptionInfo(): void { $this->expectException(Exception::class); $this->expectExceptionMessage('Malformed XLSX encryption information'); AgileEncryption::parse("\x04\x00\x04\x00\x40\x00\x00\x00not XML"); } public function testParserMaximumCannotExceedAbsoluteSpinCountCeiling(): void { $package = AgileEncryption::encrypt('package', 'password', 128, 'SHA1', 10); $encryptionInfo = str_replace('spinCount="10"', 'spinCount="10000001"', $package['encryptionInfo']); $this->expectException(Exception::class); $this->expectExceptionMessage('Unsupported XLSX encryption profile'); AgileEncryption::parse($encryptionInfo, PHP_INT_MAX); } public function testRejectsEncryptionInfoWithUnexpectedNamespace(): void { $package = AgileEncryption::encrypt('package', 'password'); $encryptionInfo = str_replace('xmlns="http://schemas.microsoft.com/office/2006/encryption"', 'xmlns="urn:unexpected"', $package['encryptionInfo']); $this->expectException(Exception::class); $this->expectExceptionMessage('Unsupported XLSX encryption profile'); AgileEncryption::parse($encryptionInfo); } public function testAcceptsEncryptionInfoWithPrefixedEncryptionNamespace(): void { $package = AgileEncryption::encrypt('package', 'password'); $encryptionInfo = str_replace('', '', $encryptionInfo); self::assertSame('package', AgileEncryption::decrypt(AgileEncryption::parse($encryptionInfo), $package['encryptedPackage'], 'password')); } public function testRejectsEncryptionInfoWithUnsupportedCipherChaining(): void { $package = AgileEncryption::encrypt('package', 'password'); $encryptionInfo = str_replace('cipherChaining="ChainingModeCBC"', 'cipherChaining="ChainingModeCFB"', $package['encryptionInfo']); $this->expectException(Exception::class); $this->expectExceptionMessage('Unsupported XLSX encryption profile'); AgileEncryption::parse($encryptionInfo); } public function testRejectsEncryptionInfoWithInvalidBase64Data(): void { $package = AgileEncryption::encrypt('package', 'password'); $encryptionInfo = str_replace('saltValue="', 'saltValue="!', $package['encryptionInfo']); $this->expectException(Exception::class); $this->expectExceptionMessage('Malformed XLSX encryption information'); AgileEncryption::parse($encryptionInfo); } public function testRejectsEncryptionInfoWithDuplicateRequiredElement(): void { $package = AgileEncryption::encrypt('package', 'password'); $encryptionInfo = str_replace('', '', $package['encryptionInfo']); $this->expectException(Exception::class); $this->expectExceptionMessage('Unsupported XLSX encryption profile'); AgileEncryption::parse($encryptionInfo); } public function testRejectsEncryptionInfoWithIncorrectDecodedValueLength(): void { $package = AgileEncryption::encrypt('package', 'password'); $encryptionInfo = preg_replace('/saltValue="[^"]+"/', 'saltValue=""', $package['encryptionInfo'], 1); self::assertIsString($encryptionInfo); $this->expectException(Exception::class); $this->expectExceptionMessage('Malformed XLSX encryption information'); AgileEncryption::parse($encryptionInfo); } public function testRejectsEncryptionInfoWithNonDecimalNumericAttribute(): void { $package = AgileEncryption::encrypt('package', 'password'); $encryptionInfo = str_replace('keyBits="256"', 'keyBits="256bits"', $package['encryptionInfo']); $this->expectException(Exception::class); $this->expectExceptionMessage('Malformed XLSX encryption information'); AgileEncryption::parse($encryptionInfo); } public function testEncryptRequiresSupportedProfileAndPassword(): void { $this->expectException(Exception::class); $this->expectExceptionMessage('XLSX encryption password required'); AgileEncryption::encrypt('package', ''); } public function testDecryptRequiresPassword(): void { $package = AgileEncryption::encrypt('package', 'password', 128, 'SHA1', 10); $this->expectException(Exception::class); $this->expectExceptionMessage('XLSX encryption password required'); AgileEncryption::decrypt(AgileEncryption::parse($package['encryptionInfo']), $package['encryptedPackage'], ''); } public function testDecryptRejectsIncorrectPassword(): void { $package = AgileEncryption::encrypt('package', 'password', 128, 'SHA1', 10); $this->expectException(Exception::class); $this->expectExceptionMessage('XLSX encryption password is incorrect'); AgileEncryption::decrypt(AgileEncryption::parse($package['encryptionInfo']), $package['encryptedPackage'], 'wrong'); } public function testDecryptsUnicodePassword(): void { $password = 'pāssw🔐rd'; $package = AgileEncryption::encrypt('package', $password, 128, 'SHA1', 10); self::assertSame('package', AgileEncryption::decrypt(AgileEncryption::parse($package['encryptionInfo']), $package['encryptedPackage'], $password)); } public function testEncryptRejectsUnsupportedProfile(): void { $this->expectException(Exception::class); $this->expectExceptionMessage('Unsupported XLSX encryption profile'); AgileEncryption::encrypt('package', 'password', 64, 'SHA512', 10); } public function testRejectsUnsupportedEncryptionInfo(): void { $this->expectException(Exception::class); $this->expectExceptionMessage('Unsupported XLSX encryption profile'); AgileEncryption::parse("\x04\x00\x04\x00\x00\x00\x00\x00"); } public function testPasswordDoesNotAffectNormalXlsx(): void { $reader = (new Xlsx())->setEncryptionPassword('unused'); $spreadsheet = $reader->load('tests/data/Reader/XLSX/threesheets.xlsx'); self::assertSame(3, $spreadsheet->getSheetCount()); $spreadsheet->disconnectWorksheets(); } public function testSupportsApprovedAgileProfiles(): void { foreach ([[128, 'SHA1'], [192, 'SHA256'], [256, 'SHA384'], [256, 'SHA512']] as [$keyBits, $hashAlgorithm]) { $package = AgileEncryption::encrypt('profile test', 'password', $keyBits, $hashAlgorithm, 10); $info = AgileEncryption::parse($package['encryptionInfo']); self::assertSame($keyBits, $info['keyBits']); self::assertSame($hashAlgorithm, $info['hashAlgorithm']); self::assertSame('profile test', AgileEncryption::decrypt($info, $package['encryptedPackage'], 'password')); } } public function testDecryptsMultipleEncryptedSegments(): void { $plain = str_repeat('x', 4097); $package = AgileEncryption::encrypt($plain, 'password', 128, 'SHA1', 10); self::assertSame($plain, AgileEncryption::decrypt(AgileEncryption::parse($package['encryptionInfo']), $package['encryptedPackage'], 'password')); } public function testDecryptFileStreamsMultipleEncryptedSegments(): void { $plain = str_repeat('x', 4097); $package = AgileEncryption::encrypt($plain, 'password', 128, 'SHA1', 10); $encryptedFilename = tempnam(sys_get_temp_dir(), 'phpspreadsheet-encrypted-'); $plainFilename = tempnam(sys_get_temp_dir(), 'phpspreadsheet-plain-'); self::assertNotFalse($encryptedFilename); self::assertNotFalse($plainFilename); try { file_put_contents($encryptedFilename, $package['encryptedPackage']); AgileEncryption::decryptFile(AgileEncryption::parse($package['encryptionInfo']), $encryptedFilename, $plainFilename, 'password'); self::assertSame($plain, file_get_contents($plainFilename)); } finally { unlink($encryptedFilename); unlink($plainFilename); } } public function testDecryptFileDoesNotTruncateOutputWhenInputCannotBeOpened(): void { $package = AgileEncryption::encrypt('package', 'password', 128, 'SHA1', 10); $outputFilename = tempnam(sys_get_temp_dir(), 'phpspreadsheet-output-'); self::assertNotFalse($outputFilename); file_put_contents($outputFilename, 'preserve this content'); try { $this->expectException(Exception::class); $this->expectExceptionMessage('Could not open XLSX package for decryption'); AgileEncryption::decryptFile(AgileEncryption::parse($package['encryptionInfo']), 'does-not-exist.xlsx', $outputFilename, 'password'); } finally { self::assertSame('preserve this content', file_get_contents($outputFilename)); unlink($outputFilename); } } public function testDecryptFileDoesNotTruncateOutputWhenPasswordIsIncorrect(): void { $package = AgileEncryption::encrypt('package', 'password', 128, 'SHA1', 10); $inputFilename = tempnam(sys_get_temp_dir(), 'phpspreadsheet-input-'); $outputFilename = tempnam(sys_get_temp_dir(), 'phpspreadsheet-output-'); self::assertNotFalse($inputFilename); self::assertNotFalse($outputFilename); file_put_contents($inputFilename, $package['encryptedPackage']); file_put_contents($outputFilename, 'preserve this content'); try { $this->expectException(Exception::class); $this->expectExceptionMessage('XLSX encryption password is incorrect'); AgileEncryption::decryptFile(AgileEncryption::parse($package['encryptionInfo']), $inputFilename, $outputFilename, 'wrong'); } finally { self::assertSame('preserve this content', file_get_contents($outputFilename)); unlink($inputFilename); unlink($outputFilename); } } public function testDecryptFileClosesInputWhenOutputCannotBeOpened(): void { $package = AgileEncryption::encrypt('package', 'password', 128, 'SHA1', 10); $inputFilename = tempnam(sys_get_temp_dir(), 'phpspreadsheet-input-'); $outputDirectory = sys_get_temp_dir() . '/phpspreadsheet-output-' . uniqid(); self::assertNotFalse($inputFilename); mkdir($outputDirectory); file_put_contents($inputFilename, $package['encryptedPackage']); try { $this->expectException(Exception::class); $this->expectExceptionMessage('Could not open XLSX package for decryption'); AgileEncryption::decryptFile(AgileEncryption::parse($package['encryptionInfo']), $inputFilename, $outputDirectory, 'password'); } finally { unlink($inputFilename); rmdir($outputDirectory); } } public function testDecryptFileRejectsTamperedEncryptedPackage(): void { $package = AgileEncryption::encrypt('package', 'password', 128, 'SHA1', 10); $lastByte = strlen($package['encryptedPackage']) - 1; $package['encryptedPackage'][$lastByte] = chr(ord($package['encryptedPackage'][$lastByte]) ^ 1); $inputFilename = tempnam(sys_get_temp_dir(), 'phpspreadsheet-input-'); $outputFilename = tempnam(sys_get_temp_dir(), 'phpspreadsheet-output-'); self::assertNotFalse($inputFilename); self::assertNotFalse($outputFilename); file_put_contents($inputFilename, $package['encryptedPackage']); file_put_contents($outputFilename, 'preserve this content'); try { $this->expectException(Exception::class); $this->expectExceptionMessage('integrity check failed'); AgileEncryption::decryptFile(AgileEncryption::parse($package['encryptionInfo']), $inputFilename, $outputFilename, 'password'); } finally { self::assertSame('preserve this content', file_get_contents($outputFilename)); unlink($inputFilename); unlink($outputFilename); } } public function testDecryptFileRejectsMalformedDecryptedSecretKey(): void { $package = AgileEncryption::encrypt('package', 'password', 128, 'SHA1', 10); $info = AgileEncryption::parse($package['encryptionInfo']); $info['encryptedKey'] = ''; $inputFilename = tempnam(sys_get_temp_dir(), 'phpspreadsheet-input-'); $outputFilename = tempnam(sys_get_temp_dir(), 'phpspreadsheet-output-'); self::assertNotFalse($inputFilename); self::assertNotFalse($outputFilename); file_put_contents($inputFilename, $package['encryptedPackage']); try { $this->expectException(Exception::class); $this->expectExceptionMessage('Malformed XLSX encryption information'); AgileEncryption::decryptFile($info, $inputFilename, $outputFilename, 'password'); } finally { unlink($inputFilename); unlink($outputFilename); } } public function testRejectsUnrepresentableEncryptedPackageSize(): void { $method = new ReflectionMethod(AgileEncryption::class, 'unpackSize'); $this->expectException(Exception::class); $this->expectExceptionMessage('too large for this platform'); $high = PHP_INT_SIZE < 8 ? 1 : 0x80000000; $method->invoke(null, pack('V2', 0, $high)); } public function testRejectsUnrepresentableEncryptedPackageSizeOn32BitPlatform(): void { $method = new ReflectionMethod(AgileEncryption::class, 'sizeFromWords'); $this->expectException(Exception::class); $this->expectExceptionMessage('too large for this platform'); $method->invoke(null, 0, 1, 4, 2147483647); } public function testAcceptsRepresentableEncryptedPackageSizeOn32BitPlatform(): void { $method = new ReflectionMethod(AgileEncryption::class, 'sizeFromWords'); self::assertSame(123, $method->invoke(null, 123, 0, 4, 2147483647)); } /** @return string[] */ private static function spreadsheetTemporaryFiles(): array { $temporaryFiles = glob(File::sysGetTempDir() . '/phpspreadsheet*') ?: []; sort($temporaryFiles); return $temporaryFiles; } }