PluginProbe ʕ •ᴥ•ʔ
WP STAGING – WordPress Backup, Restore, Migration & Clone / 4.9.0
WP STAGING – WordPress Backup, Restore, Migration & Clone v4.9.0
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 / FileHeader.php
wp-staging / Backup Last commit date
Ajax 1 month ago BackgroundProcessing 1 year ago Dto 1 week ago Entity 1 week ago Exceptions 1 year ago FileHeader 1 month ago Interfaces 6 months ago Job 1 month ago Request 1 year ago Service 1 week ago Storage 1 month ago Task 1 week ago Traits 10 months ago Utils 3 months ago AfterRestore.php 5 months ago BackupDeleter.php 2 months ago BackupDownload.php 8 months ago BackupFileIndex.php 1 year ago BackupGlitchReason.php 1 year ago BackupHeader.php 1 month ago BackupRepairer.php 6 months ago BackupRetentionHandler.php 1 month ago BackupScheduler.php 1 week ago BackupServiceProvider.php 1 month ago BackupValidator.php 6 months ago FileHeader.php 1 week ago FileHeaderAttribute.php 2 years ago WithBackupIdentifier.php 1 year ago
FileHeader.php
823 lines
1 <?php
2
3 namespace WPStaging\Backup;
4
5 use WPStaging\Backup\FileHeader\ExtraFieldCodec;
6 use WPStaging\Backup\FileHeader\ExtraFieldType;
7 use WPStaging\Backup\Interfaces\IndexLineInterface;
8 use WPStaging\Backup\Traits\EncodingErrorHandler;
9 use WPStaging\Framework\Filesystem\PathIdentifier;
10 use WPStaging\Framework\Job\Exception\FileValidationException;
11 use WPStaging\Framework\Traits\EndOfLinePlaceholderTrait;
12 use WPStaging\Framework\Traits\FormatTrait;
13 use WPStaging\Framework\Utils\DataEncoder;
14
15 class FileHeader implements IndexLineInterface
16 {
17 use EndOfLinePlaceholderTrait;
18 use FormatTrait;
19 use EncodingErrorHandler;
20
21 /**
22 * Packed Hex Code of `WPSTG`
23 * This constant represents an 48bit unsigned integer packed as a hex string.
24 * It is appended as it is to the start of the file header.
25 *
26 * Example:
27 * $hex = '47f6600b0200';
28 * to make it 8 bytes
29 * $hex = $hex . '0000';
30 * $bin = hex2bin($hex);
31 * $int = unpack('P', $bin)[1];
32 * echo $int; //8780838471 the original string
33 * 87 -> W
34 * 80 -> P
35 * 83 -> S
36 * 84 -> T
37 * 71 -> G
38 * @var string
39 */
40 const START_SIGNATURE = '47f6600b0200';
41
42 /** @var int */
43 const FILE_HEADER_FIXED_SIZE = 72;
44
45 /** @var int */
46 const INDEX_HEADER_FIXED_SIZE = 72;
47
48 /**
49 * @var string
50 * The File Header format without the start signature to make it compatible with 32bit PHP.
51 * This is a format string used with the DataEncoder class to encode/decode binary data. The format represents how integer values are packed into a binary header structure for backup files. Each character pair in the
52 * string represents the bit size of each field in the header (4=32bit, 5=40bit, 2=16bit, etc.), used when encoding arrays of integers into hexadecimal format for the file header.
53 *
54 * Example:
55 * $format = '44552424';
56 * $intArray = [123456789, 123456789, 123456789, 123456789];
57 * $hex = $encoder->intArrayToHex($format, $intArray); */
58 const FILE_HEADER_FORMAT = '44552424';
59
60 /** @var string */
61 const INDEX_HEADER_FORMAT = '644552424';
62
63 /** @var string */
64 const CRC32_CHECKSUM_ALGO = 'crc32b';
65
66 /** @var string */
67 private $startSignature;
68
69 /** @var int */
70 private $modifiedTime;
71
72 /** @var string */
73 private $crc32Checksum;
74
75 /** @var int */
76 private $crc32;
77
78 /** @var int */
79 private $compressedSize;
80
81 /** @var int */
82 private $uncompressedSize;
83
84 /** @var int */
85 private $attributes;
86
87 /** @var int */
88 private $extraFieldLength;
89
90 /** @var int */
91 private $fileNameLength;
92
93 /** @var int */
94 private $filePathLength;
95
96 /** @var int */
97 private $startOffset;
98
99 /** @var string */
100 private $filePath;
101
102 /** @var string */
103 private $fileName;
104
105 /** @var string */
106 private $extraField;
107
108 /** @var DataEncoder */
109 private $encoder;
110
111 private $pathIdentifier;
112
113 public function __construct(DataEncoder $encoder, PathIdentifier $pathIdentifier)
114 {
115 $this->encoder = $encoder;
116 $this->pathIdentifier = $pathIdentifier;
117 $this->resetHeader();
118 }
119
120 /**
121 * Log encoding errors with context about the file being processed
122 *
123 * @param string $method The method where the error occurred
124 * @param string $errorMessage The error message from DataEncoder
125 * @return void
126 */
127 private function logEncodingError(string $method, string $errorMessage)
128 {
129 $fileName = $this->getIdentifiablePath();
130 $context = [
131 'file' => $fileName ?: 'unknown',
132 'method' => $method,
133 'modifiedTime' => $this->modifiedTime,
134 'crc32' => $this->crc32,
135 'compressedSize' => $this->compressedSize,
136 'uncompressedSize' => $this->uncompressedSize,
137 'attributes' => $this->attributes,
138 ];
139
140 $logMessageTemplate = 'DataEncoder error in %s for file "' . ($fileName ?: 'unknown') .
141 '": %s. Using fallback values to continue backup.';
142
143 $this->logEncodingErrorWithContext($errorMessage, $context, $logMessageTemplate);
144 }
145
146 /**
147 * Apply fallback values for null properties to allow backup to continue
148 *
149 * @return void
150 */
151 private function applyFallbackValues()
152 {
153 // Apply fallback values for properties that might be null
154 if ($this->modifiedTime === null) {
155 $this->modifiedTime = time(); // Use current time as fallback
156 }
157
158 if ($this->crc32 === null) {
159 $this->crc32 = 0; // Use 0 as fallback for CRC32
160 }
161
162 if ($this->compressedSize === null) {
163 $this->compressedSize = 0; // Use 0 as fallback
164 }
165
166 if ($this->uncompressedSize === null) {
167 $this->uncompressedSize = 0; // Use 0 as fallback
168 }
169
170 if ($this->attributes === null) {
171 $this->attributes = 0; // Use 0 as fallback
172 }
173
174 if ($this->startOffset === null) {
175 $this->startOffset = 0; // Use 0 as fallback
176 }
177
178 // Ensure string lengths are not null
179 if ($this->filePathLength === null) {
180 $this->filePathLength = strlen($this->filePath ?: '');
181 }
182
183 if ($this->fileNameLength === null) {
184 $this->fileNameLength = strlen($this->fileName ?: '');
185 }
186
187 if ($this->extraFieldLength === null) {
188 $this->extraFieldLength = strlen($this->extraField ?: '');
189 }
190 }
191
192 /**
193 * Helper method to safely encode integer array with error handling and fallback values
194 *
195 * @param string $format The format string for encoding
196 * @param array $intArray The array of integers to encode
197 * @param string $method The calling method name for logging
198 * @return string The encoded hex string
199 */
200 private function encodeIntArrayToHex(string $format, array $intArray, string $method): string
201 {
202 try {
203 return $this->encoder->intArrayToHex($format, $intArray);
204 } catch (\InvalidArgumentException $e) {
205 // Log the error with context about which file is causing the issue
206 $this->logEncodingError($method, $e->getMessage());
207
208 // Use fallback values to allow backup to continue
209 $this->applyFallbackValues();
210
211 // Rebuild the array with current property values after fallback application
212 if ($method === 'getFileHeader' || $method === 'getUncompressedFileHeader') {
213 $fallbackArray = [
214 $this->modifiedTime,
215 $this->crc32,
216 $this->compressedSize,
217 $this->uncompressedSize,
218 $this->attributes,
219 $this->filePathLength,
220 $this->fileNameLength,
221 $this->extraFieldLength,
222 ];
223 } elseif ($method === 'getIndexHeader') {
224 $fallbackArray = [
225 $this->startOffset,
226 $this->modifiedTime,
227 $this->crc32,
228 $this->compressedSize,
229 $this->uncompressedSize,
230 $this->attributes,
231 $this->filePathLength,
232 $this->fileNameLength,
233 $this->extraFieldLength,
234 ];
235 } else {
236 // Default fallback - use the original array but replace nulls
237 $fallbackArray = $intArray;
238 foreach ($fallbackArray as $index => $value) {
239 if ($value === null) {
240 $fallbackArray[$index] = 0;
241 }
242 }
243 }
244
245 // Retry with fallback values
246 return $this->encoder->intArrayToHex($format, $fallbackArray);
247 }
248 }
249
250 /**
251 * @param string $filePath
252 * @param string $identifiablePath
253 * @param bool $skipChecksum When true, the whole-file CRC32 is not computed. Callers that
254 * override the checksum (e.g. multipart segmenters writing a
255 * segment-scoped CRC) can skip the expensive hash_file() step,
256 * which otherwise scans the entire source for every segment.
257 * @return void
258 */
259 public function readFile(string $filePath, string $identifiablePath, bool $skipChecksum = false)
260 {
261 $fileInfo = new \SplFileInfo($filePath);
262 $this->setFileName($fileInfo->getFilename());
263
264 $convertedPath = $this->pathIdentifier->transformIdentifiableToPath($identifiablePath);
265 $convertedPathName = basename($convertedPath);
266
267 $path = substr($identifiablePath, 0, -strlen($convertedPathName));
268 $this->setFilePath($path);
269 $this->setExtraField("");
270 $this->setUncompressedSize($fileInfo->getSize());
271 $this->setCompressedSize($fileInfo->getSize());
272 $this->setModifiedTime($fileInfo->getMTime());
273 $this->setAttributes(0);
274
275 if ($skipChecksum) {
276 // Caller will overwrite the checksum with a domain-specific value (e.g. a
277 // segment-scoped CRC); leave the default 0/'' assigned by resetHeader() in place.
278 return;
279 }
280
281 $this->setCrc32Checksum(hash_file(self::CRC32_CHECKSUM_ALGO, $filePath));
282 }
283
284 /**
285 * @param string $index
286 * @return void
287 * @throws \UnexpectedValueException
288 */
289 public function decodeFileHeader(string $index)
290 {
291 $index = $this->trimTrailingLineBreak($index);
292 $fixedHeader = substr($index, 0, self::FILE_HEADER_FIXED_SIZE);
293 $dynamicHeader = substr($index, self::FILE_HEADER_FIXED_SIZE);
294 if (strpos($fixedHeader, self::START_SIGNATURE) !== 0) {
295 throw new \UnexpectedValueException('Invalid file header');
296 }
297
298 $header = $this->encoder->hexToIntArray(self::FILE_HEADER_FORMAT, substr($fixedHeader, 12, self::FILE_HEADER_FIXED_SIZE - 12));
299 $this->setModifiedTime($header[0]);
300 $this->setCrc32($header[1]);
301 $this->setCompressedSize($header[2]);
302 $this->setUncompressedSize($header[3]);
303 $this->setAttributes($header[4]);
304 $this->filePathLength = $header[5];
305 $this->fileNameLength = $header[6];
306 $this->extraFieldLength = $header[7];
307 $this->setFilePath(substr($dynamicHeader, 0, $this->filePathLength));
308 $this->setFileName(substr($dynamicHeader, $this->filePathLength, $this->fileNameLength));
309 $this->setExtraField($this->replacePlaceholdersWithEOLs(substr($dynamicHeader, $this->filePathLength + $this->fileNameLength, $this->extraFieldLength)));
310 }
311
312 /**
313 * @param string $index
314 * @return void
315 */
316 public function decodeIndexHeader(string $index)
317 {
318 $index = $this->trimTrailingLineBreak($index);
319 $fixedHeader = substr($index, 0, self::INDEX_HEADER_FIXED_SIZE);
320 $dynamicHeader = substr($index, self::INDEX_HEADER_FIXED_SIZE);
321 $header = $this->encoder->hexToIntArray(self::INDEX_HEADER_FORMAT, $fixedHeader);
322
323 $this->setStartOffset($header[0]);
324 $this->setModifiedTime($header[1]);
325 $this->setCrc32($header[2]);
326 $this->setCompressedSize($header[3]);
327 $this->setUncompressedSize($header[4]);
328 $this->setAttributes($header[5]);
329 $this->filePathLength = $header[6];
330 $this->fileNameLength = $header[7];
331 $this->extraFieldLength = $header[8];
332 $this->setFilePath(substr($dynamicHeader, 0, $this->filePathLength));
333 $this->setFileName(substr($dynamicHeader, $this->filePathLength, $this->fileNameLength));
334 $this->setExtraField($this->replacePlaceholdersWithEOLs(substr($dynamicHeader, $this->filePathLength + $this->fileNameLength, $this->extraFieldLength)));
335 }
336
337 /**
338 * Remove only line delimiters that may be appended when reading headers line-by-line.
339 *
340 * Binary header data can legally end with bytes that rtrim() would treat as whitespace
341 * (notably "\0"), so we only strip "\n" and an optional preceding "\r".
342 */
343 private function trimTrailingLineBreak(string $line): string
344 {
345 if (substr($line, -2) === "\r\n") {
346 return substr($line, 0, -2);
347 }
348
349 if (substr($line, -1) === "\n") {
350 return substr($line, 0, -1);
351 }
352
353 return $line;
354 }
355
356 /**
357 * For compatibility with IndexLineInterface
358 * @param string $indexLine
359 * @return IndexLineInterface
360 */
361 public function readIndexLine(string $indexLine): IndexLineInterface
362 {
363 $this->decodeIndexHeader($indexLine);
364
365 return $this;
366 }
367
368 /**
369 * For compatibility with IndexLineInterface
370 * @param string $indexLine
371 * @return bool
372 */
373 public function isIndexLine(string $indexLine): bool
374 {
375 if (strlen($indexLine) <= self::INDEX_HEADER_FIXED_SIZE) {
376 return false;
377 }
378
379 return true;
380 }
381
382 public function getFileHeader(): string
383 {
384 $fixedHeader = $this->encodeIntArrayToHex(self::FILE_HEADER_FORMAT, [
385 $this->modifiedTime,
386 $this->crc32,
387 $this->compressedSize,
388 $this->uncompressedSize,
389 $this->attributes,
390 $this->filePathLength,
391 $this->fileNameLength,
392 $this->extraFieldLength,
393 ], 'getFileHeader');
394
395 $fileHeader = self::START_SIGNATURE . $fixedHeader . $this->filePath . $this->fileName . $this->extraField;
396 $fileHeader = $this->replaceEOLsWithPlaceholders($fileHeader);
397
398 return $fileHeader;
399 }
400
401 /**
402 * Used for repairing the file content in compressed file i.e. removing header inside the content. Issue: https://github.com/wp-staging/wp-staging-pro/issues/4241
403 * @return string
404 */
405 public function getUncompressedFileHeader(): string
406 {
407 // Force current attribute to be without compression, preserving them first to restore them later
408 $oldAttributes = $this->attributes;
409 $this->setIsCompressed(false);
410
411 $fixedHeader = $this->encodeIntArrayToHex(self::FILE_HEADER_FORMAT, [
412 $this->modifiedTime,
413 $this->crc32,
414 // Usually, it refers to the compressed size, but we need to set it to the uncompressed size because we initially add the file without compression and perform compression later.
415 // This is done to remove this file header within file content through search replace
416 $this->uncompressedSize,
417 $this->uncompressedSize,
418 $this->attributes,
419 $this->filePathLength,
420 $this->fileNameLength,
421 $this->extraFieldLength,
422 ], 'getUncompressedFileHeader');
423
424 $fileHeader = self::START_SIGNATURE . $fixedHeader . $this->filePath . $this->fileName . $this->extraField;
425 $fileHeader = $this->replaceEOLsWithPlaceholders($fileHeader);
426
427 $this->setAttributes($oldAttributes);
428
429 return $fileHeader;
430 }
431
432 public function getIndexHeader(): string
433 {
434 $fixedHeader = $this->encodeIntArrayToHex(self::INDEX_HEADER_FORMAT, [
435 $this->startOffset,
436 $this->modifiedTime,
437 $this->crc32,
438 $this->compressedSize,
439 $this->uncompressedSize,
440 $this->attributes,
441 $this->filePathLength,
442 $this->fileNameLength,
443 $this->extraFieldLength,
444 ], 'getIndexHeader');
445
446 $fixedHeader = $fixedHeader . $this->filePath . $this->fileName . $this->extraField;
447 $fixedHeader = $this->replaceEOLsWithPlaceholders($fixedHeader);
448
449 return $fixedHeader;
450 }
451
452 /**
453 * @return void
454 */
455 public function resetHeader()
456 {
457 $this->startSignature = '';
458 $this->modifiedTime = 0;
459 $this->crc32 = 0;
460 $this->crc32Checksum = '';
461 $this->compressedSize = 0;
462 $this->uncompressedSize = 0;
463 $this->setAttributes(0);
464 $this->extraFieldLength = 0;
465 $this->fileNameLength = 0;
466 $this->filePathLength = 0;
467 $this->startOffset = 0;
468 $this->filePath = '';
469 $this->fileName = '';
470 $this->extraField = '';
471 }
472
473 public function getStartSignature(): string
474 {
475 return $this->startSignature;
476 }
477
478 /**
479 * @return void
480 */
481 public function setStartSignature(string $startSignature)
482 {
483 $this->startSignature = $startSignature;
484 }
485
486 public function getModifiedTime(): int
487 {
488 return $this->modifiedTime;
489 }
490
491 /**
492 * @return void
493 */
494 public function setModifiedTime(int $modifiedTime)
495 {
496 $this->modifiedTime = $modifiedTime;
497 }
498
499 public function getCrc32(): int
500 {
501 return $this->crc32;
502 }
503
504 /**
505 * @return void
506 */
507 public function setCrc32(int $crc32)
508 {
509 $this->crc32 = $crc32;
510 $this->crc32Checksum = bin2hex(pack('N', $crc32));
511 }
512
513 public function getCrc32Checksum(): string
514 {
515 return $this->crc32Checksum;
516 }
517
518 /**
519 * @return void
520 */
521 public function setCrc32Checksum(string $crc32Checksum)
522 {
523 $this->crc32Checksum = $crc32Checksum;
524 $this->crc32 = unpack('N', pack('H*', $this->crc32Checksum))[1];
525 }
526
527 public function getCompressedSize(): int
528 {
529 return $this->compressedSize;
530 }
531
532 /**
533 * @return void
534 */
535 public function setCompressedSize(int $compressedSize)
536 {
537 $this->compressedSize = $compressedSize;
538 }
539
540 public function getUncompressedSize(): int
541 {
542 return $this->uncompressedSize;
543 }
544
545 /**
546 * @return void
547 */
548 public function setUncompressedSize(int $uncompressedSize)
549 {
550 $this->uncompressedSize = $uncompressedSize;
551 }
552
553 public function getAttributes(): int
554 {
555 return $this->attributes;
556 }
557
558 /**
559 * @return void
560 */
561 public function setAttributes(int $attributes)
562 {
563 $this->attributes = $attributes;
564 }
565
566 public function getIsCompressed(): bool
567 {
568 if ($this->attributes & FileHeaderAttribute::COMPRESSED) {
569 return true;
570 }
571
572 return false;
573 }
574
575 /**
576 * @return void
577 */
578 public function setIsCompressed(bool $isCompressed)
579 {
580 $isCompressed ?
581 $this->attributes |= FileHeaderAttribute::COMPRESSED :
582 $this->attributes &= ~FileHeaderAttribute::COMPRESSED;
583 }
584
585 public function getIsPreviousPartRequired(): bool
586 {
587 if ($this->attributes & FileHeaderAttribute::REQUIRE_PREVIOUS_PART) {
588 return true;
589 }
590
591 return false;
592 }
593
594 /**
595 * @return void
596 */
597 public function setIsPreviousPartRequired(bool $isPreviousPartRequired)
598 {
599 $isPreviousPartRequired ?
600 $this->attributes |= FileHeaderAttribute::REQUIRE_PREVIOUS_PART :
601 $this->attributes &= ~FileHeaderAttribute::REQUIRE_PREVIOUS_PART;
602 }
603
604 public function getIsNextPartRequired(): bool
605 {
606 if ($this->attributes & FileHeaderAttribute::REQUIRE_NEXT_PART) {
607 return true;
608 }
609
610 return false;
611 }
612
613 /**
614 * @return void
615 */
616 public function setIsNextPartRequired(bool $isNextPartRequired)
617 {
618 $isNextPartRequired ?
619 $this->attributes |= FileHeaderAttribute::REQUIRE_NEXT_PART :
620 $this->attributes &= ~FileHeaderAttribute::REQUIRE_NEXT_PART;
621 }
622
623 public function getStartOffset(): int
624 {
625 return $this->startOffset;
626 }
627
628 /**
629 * Stamp this FileHeader's extraField with the metadata needed to verify the reassembled
630 * file at restore time. Only meant for the terminal segment of a multipart-split file.
631 *
632 * @param int $wholeFileSize Bytes in the original (un-split) source file.
633 * @param string $wholeFileCrc hex CRC32 of the whole source file.
634 * @return void
635 */
636 public function setMultipartTailMetadata(int $wholeFileSize, string $wholeFileCrc)
637 {
638 $this->setExtraFieldEntry(ExtraFieldType::TAIL, sprintf('%d:%s', $wholeFileSize, $wholeFileCrc));
639 }
640
641 /**
642 * Decode the extraField as multipart-tail metadata. Returns null when the extraField does
643 * not carry the marker (e.g. non-terminal segments, regular non-segmented entries, or
644 * older backup formats predating this PR).
645 *
646 * @return array|null ['wholeFileSize' => int, 'wholeFileCRC' => string] or null
647 */
648 public function getMultipartTailMetadata()
649 {
650 $payload = $this->getExtraFieldEntry(ExtraFieldType::TAIL);
651 if ($payload === null) {
652 return null;
653 }
654
655 $parts = explode(':', $payload, 2);
656 if (count($parts) !== 2 || $parts[0] === '' || $parts[1] === '') {
657 return null;
658 }
659
660 return [
661 'wholeFileSize' => (int) $parts[0],
662 'wholeFileCRC' => $parts[1],
663 ];
664 }
665
666 /**
667 * @return void
668 */
669 public function setStartOffset(int $startOffset)
670 {
671 $this->startOffset = $startOffset;
672 }
673
674 public function getFilePath(): string
675 {
676 return $this->filePath;
677 }
678
679 /**
680 * @return void
681 */
682 public function setFilePath(string $filePath)
683 {
684 $this->filePath = $filePath;
685 $filePathRenamed = $this->replaceEOLsWithPlaceholders($filePath);
686 $this->filePathLength = strlen($filePathRenamed);
687 }
688
689 public function getFileName(): string
690 {
691 return $this->fileName;
692 }
693
694 /**
695 * @return void
696 */
697 public function setFileName(string $fileName)
698 {
699 $this->fileName = $fileName;
700 $renamedFile = $this->replaceEOLsWithPlaceholders($fileName);
701 $this->fileNameLength = strlen($renamedFile);
702 }
703
704 public function getExtraField(): string
705 {
706 return $this->extraField;
707 }
708
709 /**
710 * @return void
711 */
712 public function setExtraField(string $extraField)
713 {
714 $this->extraField = $extraField;
715 // Length stored on the wire reflects the EOL-placeholder-encoded form, since
716 // getFileHeader/getIndexHeader run replaceEOLsWithPlaceholders over the whole
717 // concatenated header. Mirroring filePathLength/fileNameLength keeps decoders
718 // able to slice the on-disk bytes correctly when extraField contains a newline
719 // byte (e.g. random TLV value bytes).
720 $this->extraFieldLength = strlen($this->replaceEOLsWithPlaceholders($extraField));
721 }
722
723 /**
724 * Set or replace a single TLV entry inside extraField, preserving any other
725 * entries already encoded there.
726 *
727 * @param int $type One of the ExtraFieldType constants. Must not be
728 * LEGACY_RAW, which is a parser-only sentinel.
729 * @param string $value Raw bytes for this entry.
730 * @return void
731 * @throws \UnexpectedValueException When $type is LEGACY_RAW, when the
732 * current extraField holds opaque
733 * pre-TLV bytes (which a TLV write would
734 * destroy), or when the codec rejects
735 * the new entry.
736 */
737 public function setExtraFieldEntry(int $type, string $value)
738 {
739 if ($type === ExtraFieldType::LEGACY_RAW) {
740 throw new \UnexpectedValueException(sprintf('FileHeader::setExtraFieldEntry: type 0x%02X (LEGACY_RAW) is a parser-only sentinel and cannot be written.', ExtraFieldType::LEGACY_RAW));
741 }
742
743 $codec = new ExtraFieldCodec();
744 $entries = $codec->decode($this->extraField);
745 if (isset($entries[ExtraFieldType::LEGACY_RAW])) {
746 throw new \UnexpectedValueException('FileHeader::setExtraFieldEntry: refusing to add a TLV entry to a non-TLV extraField; opaque legacy bytes would be destroyed. Reset the field with setExtraField("") first if this is intentional.');
747 }
748
749 $entries[$type] = $value;
750 $this->setExtraField($codec->encode($entries));
751 }
752
753 /**
754 * Read a single TLV entry from extraField.
755 *
756 * @param int $type One of the ExtraFieldType constants.
757 * @return string|null Raw bytes, or null if the entry is not present.
758 */
759 public function getExtraFieldEntry(int $type)
760 {
761 $entries = (new ExtraFieldCodec())->decode($this->extraField);
762 return isset($entries[$type]) ? $entries[$type] : null;
763 }
764
765 public function getIdentifiablePath(): string
766 {
767 return $this->filePath . $this->fileName;
768 }
769
770 public function getDynamicHeaderLength(): int
771 {
772 return $this->filePathLength + $this->fileNameLength + $this->extraFieldLength;
773 }
774
775 public function getContentStartOffset(): int
776 {
777 return $this->startOffset + self::FILE_HEADER_FIXED_SIZE + $this->getDynamicHeaderLength() + 1;
778 }
779
780 /**
781 * @param string $filePath
782 * @param string $pathForErrorLogging
783 * @return void
784 * @throws FileValidationException
785 */
786 public function validateFile(string $filePath, string $pathForErrorLogging = '')
787 {
788 if (empty($pathForErrorLogging)) {
789 $pathForErrorLogging = $filePath;
790 }
791
792 if (!file_exists($filePath)) {
793 throw new FileValidationException(sprintf('File doesn\'t exist: %s.', $pathForErrorLogging));
794 }
795
796 $fileSize = filesize($filePath);
797 if ($this->getUncompressedSize() !== $fileSize) {
798 throw new FileValidationException(sprintf('Filesize validation failed for file %s. Expected: %s. Actual: %s', $pathForErrorLogging, $this->formatSize($this->getUncompressedSize(), 2), $this->formatSize($fileSize, 2)));
799 }
800
801 $crc32Checksum = hash_file(self::CRC32_CHECKSUM_ALGO, $filePath);
802 if ($this->crc32Checksum !== $crc32Checksum) {
803 throw new FileValidationException(sprintf('CRC32 Checksum validation failed for file %s. Expected: %s. Actual: %s', $pathForErrorLogging, $this->getCrc32Checksum(), $crc32Checksum));
804 }
805 }
806
807 public function toArray(): array
808 {
809 return [
810 'startOffset' => $this->getStartOffset(),
811 'modifiedTime' => $this->getModifiedTime(),
812 'crc32' => $this->getCrc32(),
813 'crc32Checksum' => $this->getCrc32Checksum(),
814 'compressedSize' => $this->getCompressedSize(),
815 'uncompressedSize' => $this->getUncompressedSize(),
816 'filePath' => $this->getFilePath(),
817 'fileName' => $this->getFileName(),
818 'extraField' => $this->getExtraField(),
819 'isCompressed' => $this->getIsCompressed(),
820 ];
821 }
822 }
823