PluginProbe ʕ •ᴥ•ʔ
WP STAGING – WordPress Backups, Restore, Migration & Clone / 4.11.0
WP STAGING – WordPress Backups, Restore, Migration & Clone v4.11.0
4.11.0 4.10.0 4.9.5 4.9.4 4.9.3 4.9.2 4.9.1 4.9.0 4.8.1 trunk 3.0.0 3.0.1 3.0.2 3.0.3 3.0.4 3.0.5 3.0.6 3.1.0 3.1.1 3.1.2 3.1.3 3.1.4 3.10.0 3.2.0 3.3.1 3.3.2 3.3.3 3.4.1 3.4.3 3.5.0 3.6.0 3.7.1 3.8.0 3.8.1 3.8.2 3.8.3 3.8.4 3.8.5 3.8.6 3.8.7 3.9.0 3.9.1 3.9.2 3.9.3 3.9.4 4.0.0 4.1.0 4.1.1 4.1.2 4.1.3 4.1.4 4.2.0 4.2.1 4.3.0 4.3.1 4.3.2 4.4.0 4.5.0 4.6.0 4.7.0 4.7.1 4.7.2 4.7.3 4.8.0
wp-staging / Backup / Service / AbstractExtractor.php
wp-staging / Backup / Service Last commit date
Compression 1 day ago Database 1 day ago DirectoryExplorer 1 day ago AbstractBackgroundBackupRequest.php 1 day ago AbstractBackupsFinder.php 1 day ago AbstractExtractor.php 1 day ago AbstractServiceProvider.php 1 day ago Archiver.php 1 day ago BackupAssets.php 1 day ago BackupContent.php 1 day ago BackupMetadataEditor.php 1 day ago BackupMetadataReader.php 1 day ago BackupSigner.php 1 day ago BackupsDirectoryResolver.php 1 day ago BackupsFinder.php 1 day ago BeforeUpdateBackupRequest.php 1 day ago BeforeUpdateBackupsService.php 1 day ago Extractor.php 1 day ago FileBackupService.php 1 day ago FileBackupServiceProvider.php 1 day ago ServiceInterface.php 1 day ago TmpBackupCleaner.php 1 day ago UpdateProtectionHealth.php 1 day ago UpdateProtectionSettings.php 1 day ago ZlibCompressor.php 1 day ago
AbstractExtractor.php
869 lines
1 <?php
2
3 namespace WPStaging\Backup\Service;
4
5 use WPStaging\Backup\BackupHeader;
6 use WPStaging\Backup\Dto\File\ExtractorDto;
7 use WPStaging\Backup\Entity\BackupMetadata;
8 use WPStaging\Backup\Entity\FileBeingExtracted;
9 use WPStaging\Backup\FileHeader;
10 use WPStaging\Backup\Interfaces\IndexLineInterface;
11 use WPStaging\Framework\Adapter\DirectoryInterface;
12 use WPStaging\Framework\Job\Exception\FileValidationException;
13 use WPStaging\Framework\Filesystem\FileObject;
14 use WPStaging\Framework\Filesystem\PathIdentifier;
15 use WPStaging\Framework\Filesystem\PartIdentifier;
16 use WPStaging\Framework\Filesystem\Permissions;
17 use WPStaging\Framework\Traits\DebugLogTrait;
18 use WPStaging\Framework\Traits\FormatTrait;
19
20
21
22
23
24
25
26 abstract class AbstractExtractor
27 {
28 use FormatTrait;
29 use DebugLogTrait;
30
31
32 const VALIDATE_DIRECTORY = 'validate';
33
34
35 const ITEM_SKIP_EXCEPTION_CODE = 4001;
36
37
38 const FINISHED_QUEUE_EXCEPTION_CODE = 4002;
39
40
41 const FILE_FILTERED_EXCEPTION_CODE = 4003;
42
43
44
45
46
47 protected $extractingFile;
48
49
50 protected $wpstgFile;
51
52
53 protected $dirRestore;
54
55
56 protected $wpstgIndexOffsetForCurrentFile;
57
58
59 protected $wpstgIndexOffsetForNextFile;
60
61
62 protected $extractorDto;
63
64
65 protected $bytesWrittenThisRequest = 0;
66
67
68 protected $isBackupFormatV1 = false;
69
70
71 protected $pathIdentifier;
72
73
74 protected $directory;
75
76
77 protected $backupHeader;
78
79
80 protected $indexLineDto;
81
82
83 protected $backupMetadata;
84
85
86 protected $extractIdentifier = '';
87
88
89 protected $isValidateOnly = false;
90
91
92 protected $excludedIdentifier = [];
93
94
95 protected $databaseBackupFile;
96
97
98 protected $defaultDirectoryOctal = 0755;
99
100
101 protected $currentIdentifier;
102
103
104 protected $throwExceptionOnValidationFailure = false;
105
106
107 protected $lastIdentifiablePath;
108
109 public function __construct(
110 PathIdentifier $pathIdentifier,
111 DirectoryInterface $directory,
112 BackupHeader $backupHeader,
113 Permissions $permissions
114 ) {
115 $this->pathIdentifier = $pathIdentifier;
116 $this->directory = $directory;
117 $this->backupHeader = $backupHeader;
118
119 $this->defaultDirectoryOctal = $permissions->getDirectoryOctal();
120 $this->excludedIdentifier = [];
121 }
122
123
124
125
126
127 public function setExcludedIdentifiers(array $excludedIdentifier)
128 {
129 $this->excludedIdentifier = $excludedIdentifier;
130 }
131
132 public function setExtractOnlyPart(string $partToExtract)
133 {
134
135 $this->excludedIdentifier = [];
136
137 if (empty($partToExtract)) {
138 return;
139 }
140
141 $parts = [
142 PartIdentifier::DROPIN_PART_IDENTIFIER,
143 PartIdentifier::DATABASE_PART_IDENTIFIER,
144 PartIdentifier::MU_PLUGIN_PART_IDENTIFIER,
145 PartIdentifier::PLUGIN_PART_IDENTIFIER,
146 PartIdentifier::THEME_PART_IDENTIFIER,
147 PartIdentifier::UPLOAD_PART_IDENTIFIER,
148 PartIdentifier::LANGUAGE_PART_IDENTIFIER,
149 PartIdentifier::WP_CONTENT_PART_IDENTIFIER,
150 PartIdentifier::WP_ROOT_PART_IDENTIFIER,
151 ];
152
153 foreach ($parts as $part) {
154 if ($part === $partToExtract) {
155 continue;
156 }
157
158 if ($part === PartIdentifier::DROPIN_PART_IDENTIFIER) {
159 $this->excludedIdentifier[] = PartIdentifier::DROPIN_PART_IDENTIFIER;
160 continue;
161 }
162
163
164 if ($part === PartIdentifier::DATABASE_PART_IDENTIFIER) {
165 $this->excludedIdentifier[] = PartIdentifier::DATABASE_PART_IDENTIFIER;
166 continue;
167 }
168
169 $this->excludedIdentifier[] = $this->pathIdentifier->getIdentifierByPartName($part);
170 }
171 }
172
173
174
175
176
177 public function setIndexLineDto(IndexLineInterface $indexLineDto)
178 {
179 $this->indexLineDto = $indexLineDto;
180 }
181
182
183
184
185
186 public function setIsBackupFormatV1(bool $isBackupFormatV1)
187 {
188 $this->isBackupFormatV1 = $isBackupFormatV1;
189 }
190
191 public function setThrowExceptionOnValidationFailure(bool $throwExceptionOnValidationFailure)
192 {
193 $this->throwExceptionOnValidationFailure = $throwExceptionOnValidationFailure;
194 }
195
196
197
198
199 public function getBytesWrittenInThisRequest(): int
200 {
201 return $this->bytesWrittenThisRequest;
202 }
203
204 public function getExtractorDto(): ExtractorDto
205 {
206 return $this->extractorDto;
207 }
208
209
210
211
212
213
214
215 public function setup(ExtractorDto $extractorDto, string $backupFilePath, string $tmpPath = '')
216 {
217 $this->dirRestore = $tmpPath;
218 $this->extractorDto = $extractorDto;
219 $this->setFileToExtract($backupFilePath);
220
221 if (empty($this->dirRestore)) {
222 $this->dirRestore = $this->directory->getTmpDirectory();
223 }
224
225 $this->dirRestore = rtrim($this->dirRestore, '/') . '/';
226 }
227
228
229
230
231
232 public function setFileToExtract(string $filePath)
233 {
234 try {
235 $this->wpstgFile = new FileObject($filePath);
236 $this->backupMetadata = new BackupMetadata();
237 $this->backupMetadata = $this->backupMetadata->hydrateByFile($this->wpstgFile);
238 $this->databaseBackupFile = $this->backupMetadata->getDatabaseFile();
239 $this->extractorDto->setIndexStartOffset($this->backupMetadata->getHeaderStart());
240 $this->extractorDto->setTotalChunks($this->backupMetadata->getTotalChunks());
241 } catch (\Exception $ex) {
242 $this->throwMissingFileException($ex, $filePath);
243 }
244 }
245
246
247
248
249
250 public function findFileToExtract(int $fileToExtractOffset = 0)
251 {
252 if ($fileToExtractOffset > 0) {
253 $this->extractorDto->setCurrentIndexOffset($fileToExtractOffset);
254 }
255
256 if ($this->extractorDto->getCurrentIndexOffset() === 0) {
257 $this->extractorDto->setCurrentIndexOffset($this->extractorDto->getIndexStartOffset());
258 }
259
260 $this->setWpstgFileOffset();
261
262
263 $this->wpstgIndexOffsetForCurrentFile = $this->wpstgFile->ftell();
264
265 $rawIndexFile = $this->wpstgFile->readAndMoveNext();
266
267
268 $this->wpstgIndexOffsetForNextFile = $this->wpstgFile->ftell();
269
270 if (!$this->indexLineDto->isIndexLine($rawIndexFile)) {
271 throw new \Exception("", self::FINISHED_QUEUE_EXCEPTION_CODE);
272 }
273
274
275 $backupFileIndex = $this->indexLineDto->readIndexLine($rawIndexFile);
276 $identifiablePath = $backupFileIndex->getIdentifiablePath();
277 if (empty($identifiablePath)) {
278 $this->extractorDto->incrementTotalFilesSkipped();
279 $this->extractorDto->setCurrentIndexOffset($this->wpstgIndexOffsetForNextFile);
280 $this->debugLog('Identifier not found during extraction. Raw Index is logged: ' . rtrim($rawIndexFile, "\n"));
281 throw new \Exception('Skipping file: Identifier not found. Raw Index is logged', self::ITEM_SKIP_EXCEPTION_CODE);
282 }
283
284 if ($this->pathIdentifier->hasPathTraversal($identifiablePath)) {
285 $this->extractorDto->incrementTotalFilesSkipped();
286 $this->extractorDto->setCurrentIndexOffset($this->wpstgIndexOffsetForNextFile);
287 $this->debugLog('Skipping file with unsafe path (possible path traversal): ' . rtrim($identifiablePath, "\n"));
288 throw new \Exception('Skipping file: unsafe path detected', self::ITEM_SKIP_EXCEPTION_CODE);
289 }
290
291 $isSegmentedEntry = $backupFileIndex instanceof FileHeader && $this->isSegmentedFileHeader($backupFileIndex);
292 if ($identifiablePath === $this->lastIdentifiablePath && !$isSegmentedEntry) {
293 $this->extractorDto->incrementTotalFilesSkipped();
294 $this->extractorDto->setCurrentIndexOffset($this->wpstgIndexOffsetForNextFile);
295 $this->debugLog('File already extracted: ' . rtrim($identifiablePath, "\n"));
296 throw new \Exception('Skipping file: ' . $identifiablePath, self::ITEM_SKIP_EXCEPTION_CODE);
297 }
298
299 $identifier = $this->pathIdentifier->getIdentifierFromPath($identifiablePath);
300 $this->currentIdentifier = $identifier;
301 if ($this->isFileSkipped($identifiablePath, $identifier)) {
302 $this->extractorDto->incrementTotalFilesSkipped();
303 $this->extractorDto->setCurrentIndexOffset($this->wpstgIndexOffsetForNextFile);
304 $this->debugLog('Skipping File By Rule: ' . rtrim($identifiablePath, "\n"));
305 throw new \Exception('Skipping file: ' . $identifiablePath, self::ITEM_SKIP_EXCEPTION_CODE);
306 }
307
308 $extractFolder = $this->getExtractFolder($identifier);
309
310 if (!$this->createDirectory($extractFolder)) {
311 throw new \RuntimeException("Could not create folder to extract backup file: $extractFolder. Please check folder permissions.");
312 }
313
314 $this->setupExtractingFile($backupFileIndex, $extractFolder);
315
316 if (!$this->pathIdentifier->isPathWithinRoot($this->extractingFile->getBackupPath(), $extractFolder)) {
317 $this->extractorDto->incrementTotalFilesSkipped();
318 $this->extractorDto->setCurrentIndexOffset($this->wpstgIndexOffsetForNextFile);
319 $this->debugLog('Skipping file outside restore root (possible path traversal): ' . rtrim($identifiablePath, "\n"));
320 throw new \Exception('Skipping file: unsafe path detected', self::ITEM_SKIP_EXCEPTION_CODE);
321 }
322
323 if ($this->isFileExtracted($backupFileIndex, $this->extractingFile->getBackupPath())) {
324 $this->extractorDto->incrementTotalFilesSkipped();
325 $this->extractorDto->setCurrentIndexOffset($this->wpstgIndexOffsetForNextFile);
326 $this->debugLog('Skipping already extracted file: ' . rtrim($identifiablePath, "\n"));
327 throw new \Exception('File already extracted: ' . $identifiablePath, self::ITEM_SKIP_EXCEPTION_CODE);
328 }
329
330 $this->cleanExistingFile($identifier);
331
332 $this->wpstgFile->fseek($this->extractingFile->getCurrentOffset());
333 $this->indexLineDto = $backupFileIndex;
334 }
335
336
337
338
339
340
341
342
343 public function createEmptyFile(string $filePath): bool
344 {
345
346 if (file_exists($filePath)) {
347 return true;
348 }
349
350
351
352
353 return $this->filePutContents($filePath, '') !== false;
354 }
355
356 public function isExtractingFileExtracted(callable $logInfo): bool
357 {
358 $this->bytesWrittenThisRequest += $this->extractingFile->getWrittenBytes();
359
360 if ($this->extractingFile->isFinished()) {
361 return true;
362 }
363
364 if ($this->extractingFile->getWrittenBytes() > 0 && $this->isBigFile()) {
365 $percentProcessed = ceil(($this->extractingFile->getWrittenBytes() / $this->extractingFile->getTotalBytes()) * 100);
366 $logInfo(sprintf('Extracting big file: %s - %s/%s (%s%%)', $this->extractingFile->getRelativePath(), $this->formatSize($this->extractingFile->getWrittenBytes(), 2), $this->formatSize($this->extractingFile->getTotalBytes(), 2), $percentProcessed));
367 }
368
369 $this->extractorDto->setCurrentIndexOffset($this->wpstgIndexOffsetForCurrentFile);
370 $this->extractorDto->setExtractorFileWrittenBytes($this->extractingFile->getWrittenBytes());
371 $this->extractorDto->setExtractorFileReadBytes($this->extractingFile->getReadBytes());
372
373 return false;
374 }
375
376 public function validateExtractedFileAndMoveNext()
377 {
378 $destinationFilePath = $this->extractingFile->getBackupPath();
379 $pathForErrorLogging = $this->pathIdentifier->transformIdentifiableToPath($this->indexLineDto->getIdentifiablePath());
380 if (file_exists($destinationFilePath) && filesize($destinationFilePath) === 0 && $this->extractingFile->getTotalBytes() !== 0) {
381 throw new \RuntimeException(sprintf('File %s is empty', $pathForErrorLogging));
382 }
383
384 if ($this->isBackupFormatV1) {
385 $this->maybeRemoveLastAccidentalCharFromLastExtractedFile();
386 }
387
388 $isValidated = true;
389 $exception = null;
390 clearstatcache();
391
392
393 if ($this->extractingFile->areHeaderBytesRemoved()) {
394 $this->debugLog('Skipping validation for file because duplicate file headers were removed: ' . $pathForErrorLogging);
395 } elseif ($this->indexLineDto instanceof FileHeader && $this->isSegmentedFileHeader($this->indexLineDto)) {
396 $fileHeader = $this->indexLineDto;
397
398
399
400
401
402
403
404 try {
405 $this->validateMultipartSegment($destinationFilePath, $pathForErrorLogging);
406 if (!$fileHeader->getIsNextPartRequired()) {
407 $this->validateMultipartReassembledFile($destinationFilePath, $pathForErrorLogging);
408 } else {
409 $this->debugLog('Deferring whole-file validation until terminal segment for multipart-split file: ' . $pathForErrorLogging);
410 }
411 } catch (FileValidationException $e) {
412 $isValidated = false;
413 $exception = $e;
414 }
415 } else {
416 try {
417 $this->indexLineDto->validateFile($destinationFilePath, $pathForErrorLogging);
418 } catch (FileValidationException $e) {
419 $isValidated = false;
420 $exception = $e;
421 }
422 }
423
424 $this->moveToNextFile($destinationFilePath);
425 if (!$isValidated) {
426 throw $exception;
427 }
428 }
429
430
431
432
433 protected function moveToNextFile(string $destinationFilePath = '')
434 {
435 $this->lastIdentifiablePath = $this->indexLineDto->getIdentifiablePath();
436
437 $this->extractorDto->setCurrentIndexOffset($this->wpstgIndexOffsetForNextFile);
438 $this->extractorDto->incrementTotalFilesExtracted();
439
440
441 $this->extractorDto->setHeaderBytesRemoved(0);
442 $this->extractorDto->setExtractorFileWrittenBytes(0);
443 $this->extractorDto->setExtractorFileReadBytes(0);
444
445 $this->extractorDto->setExtractorFileBaseBytes(0);
446
447 if (!empty($destinationFilePath) && $this->shouldDeleteValidationFileAfterMove()) {
448 $this->deleteValidationFile($destinationFilePath);
449 }
450 }
451
452 public function finishExtractingFile()
453 {
454 $this->extractingFile->setWrittenBytes($this->extractingFile->getTotalBytes());
455 }
456
457 public function getExtractingFile(): FileBeingExtracted
458 {
459 return $this->extractingFile;
460 }
461
462 public function getBackupFileOffset(): int
463 {
464 return $this->wpstgFile->ftell();
465 }
466
467 public function readBackup(int $dataLengthToRead): string
468 {
469 return $this->wpstgFile->fread($dataLengthToRead);
470 }
471
472 protected function isBigFile(): bool
473 {
474 return $this->extractingFile->getTotalBytes() > 10 * MB_IN_BYTES;
475 }
476
477
478
479
480
481 protected function maybeRemoveLastAccidentalCharFromLastExtractedFile()
482 {
483 if ($this->isValidateOnly) {
484 return;
485 }
486
487 if ($this->backupMetadata->getTotalFiles() !== $this->extractorDto->getTotalFilesExtracted()) {
488 return;
489 }
490
491 $this->removeLastCharInExtractedFile();
492 }
493
494 protected function setupExtractingFile(IndexLineInterface $backupFileIndex, string $extractFolder)
495 {
496 $this->extractingFile = new FileBeingExtracted($backupFileIndex->getIdentifiablePath(), $extractFolder, $this->pathIdentifier, $backupFileIndex);
497 $this->maybeSetSegmentBaseBytes($backupFileIndex);
498 $this->extractingFile->setWrittenBytes($this->extractorDto->getExtractorFileWrittenBytes());
499 $this->extractingFile->setReadBytes($this->extractorDto->getExtractorFileReadBytes());
500 $this->extractingFile->setHeaderBytesRemoved($this->extractorDto->getHeaderBytesRemoved());
501 }
502
503 protected function setWpstgFileOffset()
504 {
505 $this->wpstgFile->fseek($this->extractorDto->getCurrentIndexOffset());
506 }
507
508 protected function updateExtractorDto()
509 {
510 $this->extractorDto->setHeaderBytesRemoved($this->extractingFile->getHeaderBytesRemoved());
511 $this->extractorDto->setExtractorFileWrittenBytes($this->extractingFile->getWrittenBytes());
512 $this->extractorDto->setExtractorFileReadBytes($this->extractingFile->getReadBytes());
513 }
514
515
516
517
518
519
520
521
522
523 protected function maybeSetSegmentBaseBytes(IndexLineInterface $backupFileIndex)
524 {
525 if (!$backupFileIndex instanceof FileHeader || !$backupFileIndex->getIsPreviousPartRequired()) {
526 $this->extractorDto->setExtractorFileBaseBytes(0);
527 return;
528 }
529
530 if ($this->extractorDto->getExtractorFileBaseBytes() > 0 || $this->extractorDto->getExtractorFileWrittenBytes() > 0 || $this->extractorDto->getExtractorFileReadBytes() > 0) {
531 return;
532 }
533
534 $destinationFilePath = $this->extractingFile->getBackupPath();
535 if (!file_exists($destinationFilePath)) {
536 return;
537 }
538
539 clearstatcache();
540 $baseBytes = filesize($destinationFilePath);
541 $this->extractorDto->setExtractorFileBaseBytes($baseBytes === false ? 0 : (int) $baseBytes);
542 }
543
544
545
546
547
548
549 protected function throwMissingFileException(\Exception $ex, string $filePath)
550 {
551 throw new \Exception(sprintf("Following backup part missing: %s", $filePath), 0, $ex);
552 }
553
554
555
556
557
558
559
560 protected function removeLastCharInExtractedFile()
561 {
562 $destinationFilePath = $this->extractingFile->getBackupPath();
563 $fileContent = file_get_contents($destinationFilePath);
564
565 if (empty($fileContent)) {
566 return;
567 }
568
569 if (substr($fileContent, -1) !== 'w') {
570 return;
571 }
572
573 $fileContent = substr($fileContent, 0, -1);
574 file_put_contents($destinationFilePath, $fileContent);
575 }
576
577 protected function getExtractFolder(string $identifier): string
578 {
579 return $this->dirRestore . $this->pathIdentifier->getRelativePath($identifier);
580 }
581
582 protected function cleanExistingFile(string $identifier)
583 {
584 if ($this->isValidateOnly) {
585 return;
586 }
587
588 if ($this->extractingFile->getWrittenBytes() > 0) {
589 return;
590 }
591
592
593
594 if ($this->indexLineDto instanceof FileHeader && $this->indexLineDto->getIsPreviousPartRequired()) {
595 return;
596 }
597
598 if (file_exists($this->extractingFile->getBackupPath())) {
599
600 if (!unlink($this->extractingFile->getBackupPath())) {
601 throw new \RuntimeException(sprintf(__('Could not delete original file %s. Skipping restore of it...', 'wp-staging'), $this->extractingFile->getRelativePath()));
602 }
603 }
604 }
605
606 protected function deleteValidationFile(string $filePath)
607 {
608 if (!$this->isValidateOnly) {
609 return;
610 }
611
612 if (file_exists($filePath)) {
613 @unlink($filePath);
614 }
615 }
616
617 protected function isFileSkipped(string $identifiablePath, string $identifier): bool
618 {
619 if ($identifiablePath === $this->databaseBackupFile) {
620 return in_array(PartIdentifier::DATABASE_PART_IDENTIFIER, $this->excludedIdentifier);
621 }
622
623 if ($identifier === PathIdentifier::IDENTIFIER_WP_CONTENT && !in_array(PartIdentifier::DROPIN_PART_IDENTIFIER, $this->excludedIdentifier) && $this->pathIdentifier->hasDropinsFile($identifiablePath)) {
624 return false;
625 }
626
627 return in_array($identifier, $this->excludedIdentifier);
628 }
629
630 protected function isFileExtracted(IndexLineInterface $backupFileIndex, string $extractPath): bool
631 {
632 if (!file_exists($extractPath)) {
633 return false;
634 }
635
636
637
638
639
640
641
642 if ($backupFileIndex instanceof FileHeader && $this->isSegmentedFileHeader($backupFileIndex)) {
643 return false;
644 }
645
646 return $backupFileIndex->getUncompressedSize() === filesize($extractPath);
647 }
648
649
650
651
652
653
654
655
656 protected function isSegmentedFileHeader(FileHeader $fileHeader): bool
657 {
658 return $fileHeader->getIsPreviousPartRequired() || $fileHeader->getIsNextPartRequired();
659 }
660
661 protected function isCurrentSegmentedFileHeader(): bool
662 {
663 return $this->indexLineDto instanceof FileHeader && $this->isSegmentedFileHeader($this->indexLineDto);
664 }
665
666 protected function shouldDeleteValidationFileAfterMove(): bool
667 {
668 if (!$this->isValidateOnly || !$this->isCurrentSegmentedFileHeader()) {
669 return true;
670 }
671
672
673 $fileHeader = $this->indexLineDto;
674 return !$fileHeader->getIsNextPartRequired();
675 }
676
677
678
679
680
681
682
683
684
685
686 private function validateMultipartReassembledFile(string $filePath, string $pathForErrorLogging)
687 {
688 if (!$this->indexLineDto instanceof FileHeader) {
689 return;
690 }
691
692 $tailMeta = $this->indexLineDto->getMultipartTailMetadata();
693 if ($tailMeta === null) {
694
695
696
697
698 $this->debugLog('Multipart-split file missing whole-file integrity metadata; skipping verification: ' . $pathForErrorLogging);
699 return;
700 }
701
702 if (!file_exists($filePath)) {
703 throw new FileValidationException(sprintf('Reassembled file not found: %s.', $pathForErrorLogging));
704 }
705
706 clearstatcache();
707 $diskSize = filesize($filePath);
708 if ($tailMeta['wholeFileSize'] !== $diskSize) {
709 throw new FileValidationException(sprintf(
710 'Reassembled multipart file size mismatch for %s. Expected: %s. Actual: %s',
711 $pathForErrorLogging,
712 $this->formatSize($tailMeta['wholeFileSize'], 2),
713 $this->formatSize((int) $diskSize, 2)
714 ));
715 }
716
717 $diskCrc = hash_file(FileHeader::CRC32_CHECKSUM_ALGO, $filePath);
718 if ($diskCrc === false || $tailMeta['wholeFileCRC'] !== $diskCrc) {
719 throw new FileValidationException(sprintf(
720 'Reassembled multipart file CRC32 mismatch for %s. Expected: %s. Actual: %s',
721 $pathForErrorLogging,
722 $tailMeta['wholeFileCRC'],
723 $diskCrc === false ? '<unreadable>' : $diskCrc
724 ));
725 }
726 }
727
728
729
730
731
732
733
734
735
736
737
738 private function validateMultipartSegment(string $filePath, string $pathForErrorLogging)
739 {
740 if (!$this->indexLineDto instanceof FileHeader) {
741 return;
742 }
743
744 if (!file_exists($filePath)) {
745 throw new FileValidationException(sprintf('Multipart segment destination file not found: %s.', $pathForErrorLogging));
746 }
747
748 $segmentOffset = $this->extractorDto->getExtractorFileBaseBytes();
749 $segmentSize = $this->indexLineDto->getUncompressedSize();
750
751 clearstatcache();
752 $diskSize = filesize($filePath);
753 if ($diskSize === false || $diskSize < $segmentOffset + $segmentSize) {
754 throw new FileValidationException(sprintf(
755 'Multipart segment size mismatch for %s. Expected at least: %s. Actual: %s',
756 $pathForErrorLogging,
757 $this->formatSize($segmentOffset + $segmentSize, 2),
758 $diskSize === false ? '<unreadable>' : $this->formatSize((int) $diskSize, 2)
759 ));
760 }
761
762 $handle = fopen($filePath, 'rb');
763 if ($handle === false) {
764 throw new FileValidationException(sprintf('Could not read multipart segment for %s.', $pathForErrorLogging));
765 }
766
767 if (fseek($handle, $segmentOffset) !== 0) {
768 fclose($handle);
769 throw new FileValidationException(sprintf('Could not seek to multipart segment for %s.', $pathForErrorLogging));
770 }
771
772 $ctx = hash_init(FileHeader::CRC32_CHECKSUM_ALGO);
773 $remaining = $segmentSize;
774 while ($remaining > 0) {
775 $chunk = fread($handle, (int) min(512 * KB_IN_BYTES, $remaining));
776 if ($chunk === false || $chunk === '') {
777 fclose($handle);
778 throw new FileValidationException(sprintf('Could not read complete multipart segment for %s.', $pathForErrorLogging));
779 }
780
781 hash_update($ctx, $chunk);
782 $remaining -= strlen($chunk);
783 }
784
785 fclose($handle);
786
787 $segmentCrc = hash_final($ctx);
788 if ($segmentCrc !== $this->indexLineDto->getCrc32Checksum()) {
789 throw new FileValidationException(sprintf(
790 'Multipart segment CRC32 mismatch for %s. Expected: %s. Actual: %s',
791 $pathForErrorLogging,
792 $this->indexLineDto->getCrc32Checksum(),
793 $segmentCrc
794 ));
795 }
796 }
797
798
799
800
801
802
803
804 public function maybeRepairMultipleHeadersIssue(string $dataToFix): string
805 {
806
807
808
809 $fileHeader = $this->indexLineDto;
810
811 if (strpos($dataToFix, $fileHeader->getFileHeader()) !== false) {
812 $count = substr_count($dataToFix, $fileHeader->getFileHeader());
813 $this->extractingFile->addHeaderBytesRemoved($count * ($fileHeader->getDynamicHeaderLength() + 1));
814
815 return str_replace($fileHeader->getFileHeader() . "\n", '', $dataToFix);
816 }
817
818
819 if (!$fileHeader->getIsCompressed()) {
820 return $dataToFix;
821 }
822
823
824
825
826
827 if (strpos($dataToFix, $fileHeader->getUncompressedFileHeader()) !== false) {
828 $count = substr_count($dataToFix, $fileHeader->getUncompressedFileHeader());
829 $this->extractingFile->addHeaderBytesRemoved($count * ($fileHeader->getDynamicHeaderLength() + 1));
830
831 return str_replace($fileHeader->getUncompressedFileHeader() . "\n", '', $dataToFix);
832 }
833
834 return $dataToFix;
835 }
836
837
838
839
840
841
842
843
844 private function filePutContents(string $filePath, string $content): bool
845 {
846 if ($fp = fopen($filePath, 'wb')) {
847 $bytes = fwrite($fp, $content);
848 fclose($fp);
849 $fp = null;
850 return $bytes;
851 }
852
853 return false;
854 }
855
856 private function createDirectory(string $directory): bool
857 {
858 if (file_exists($directory)) {
859 return @is_dir($directory);
860 }
861
862 if (!is_dir($directory) && !mkdir($directory, $this->defaultDirectoryOctal, true)) {
863 return false;
864 }
865
866 return true;
867 }
868 }
869