mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
1788 lines
51 KiB
PHP
1788 lines
51 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Archives;
|
|
|
|
/**
|
|
* Enhanced partial 7z header parser to recover file information from an in-memory buffer.
|
|
* Supports unencoded headers directly; encoded headers are flagged so caller can fallback
|
|
* to external 7z listing. Provides heuristics for encryption and compression detection.
|
|
*
|
|
* Features:
|
|
* - Extracts file names, sizes, attributes, timestamps, and CRC values
|
|
* - Detects compression methods (LZMA, LZMA2, PPMd, BZip2, etc.)
|
|
* - Heuristic encryption detection (AES-256, header encryption)
|
|
* - Fallback scanning for partial/corrupted archives
|
|
* - Solid archive detection
|
|
*/
|
|
class SevenZipPartialParser
|
|
{
|
|
private string $data;
|
|
|
|
private int $len;
|
|
|
|
/**
|
|
* @var array<string, mixed>
|
|
*/
|
|
private array $names = [];
|
|
|
|
/**
|
|
* @var array<string, mixed>
|
|
*/
|
|
private array $files = []; // Extended file info with metadata
|
|
|
|
/**
|
|
* @var array<string, mixed>
|
|
*/
|
|
private array $sizes = []; // Uncompressed sizes
|
|
|
|
/**
|
|
* @var array<string, mixed>
|
|
*/
|
|
private array $packedSizes = []; // Compressed sizes
|
|
|
|
/**
|
|
* @var array<string, mixed>
|
|
*/
|
|
private array $crcs = []; // CRC32 values
|
|
|
|
/**
|
|
* @var array<string, mixed>
|
|
*/
|
|
private array $attributes = []; // File attributes (directory, readonly, etc.)
|
|
|
|
/**
|
|
* @var array<string, mixed>
|
|
*/
|
|
private array $mtimes = []; // Modification times
|
|
|
|
/**
|
|
* @var array<string, mixed>
|
|
*/
|
|
private array $ctimes = []; // Creation times
|
|
|
|
/**
|
|
* @var array<string, mixed>
|
|
*/
|
|
private array $atimes = []; // Access times
|
|
|
|
private bool $parsed = false;
|
|
|
|
private bool $encodedHeader = false; // flag if we encountered kEncodedHeader
|
|
|
|
private bool $encrypted = false; // heuristic flag if AES encryption detected
|
|
|
|
private bool $headerEncrypted = false; // flag if header itself is encrypted
|
|
|
|
private bool $solidArchive = false; // flag for solid archives
|
|
|
|
private int $numFiles = 0; // Total number of files detected
|
|
|
|
/**
|
|
* @var array<string, mixed>
|
|
*/
|
|
private array $compressionMethods = []; // Detected compression methods
|
|
|
|
private int $totalUnpackedSize = 0; // Total unpacked size
|
|
|
|
private int $totalPackedSize = 0; // Total packed size
|
|
|
|
private string $lastError = ''; // Last error message for debugging
|
|
|
|
// 7z Property IDs
|
|
private const K_END = 0x00;
|
|
|
|
private const K_HEADER = 0x01;
|
|
|
|
private const K_ARCHIVE_PROPERTIES = 0x02;
|
|
|
|
private const K_ADDITIONAL_STREAMS_INFO = 0x03;
|
|
|
|
private const K_MAIN_STREAMS_INFO = 0x04;
|
|
|
|
private const K_FILES_INFO = 0x05;
|
|
|
|
private const K_PACK_INFO = 0x06;
|
|
|
|
private const K_UNPACK_INFO = 0x07;
|
|
|
|
private const K_SUBSTREAMS_INFO = 0x08;
|
|
|
|
private const K_SIZE = 0x09;
|
|
|
|
private const K_CRC = 0x0A;
|
|
|
|
private const K_FOLDER = 0x0B;
|
|
|
|
private const K_CODERS_UNPACK_SIZE = 0x0C;
|
|
|
|
private const K_NUM_UNPACK_STREAM = 0x0D;
|
|
|
|
private const K_EMPTY_STREAM = 0x0E;
|
|
|
|
private const K_EMPTY_FILE = 0x0F;
|
|
|
|
private const K_ANTI = 0x10;
|
|
|
|
private const K_NAME = 0x11;
|
|
|
|
private const K_CTIME = 0x12;
|
|
|
|
private const K_ATIME = 0x13;
|
|
|
|
private const K_MTIME = 0x14;
|
|
|
|
private const K_WIN_ATTRIBUTES = 0x15;
|
|
|
|
/** @phpstan-ignore classConstant.unused */
|
|
private const K_COMMENT = 0x16;
|
|
|
|
private const K_ENCODED_HEADER = 0x17;
|
|
|
|
/** @phpstan-ignore classConstant.unused */
|
|
private const K_START_POS = 0x18;
|
|
|
|
/** @phpstan-ignore classConstant.unused */
|
|
private const K_DUMMY = 0x19;
|
|
|
|
// Compression method IDs
|
|
private const METHOD_COPY = "\x00";
|
|
|
|
private const METHOD_LZMA = "\x03\x01\x01";
|
|
|
|
private const METHOD_LZMA2 = "\x21";
|
|
|
|
private const METHOD_PPMD = "\x03\x04\x01";
|
|
|
|
/** @phpstan-ignore classConstant.unused */
|
|
private const METHOD_BCJ = "\x03\x03\x01\x03";
|
|
|
|
/** @phpstan-ignore classConstant.unused */
|
|
private const METHOD_BCJ2 = "\x03\x03\x01\x1B";
|
|
|
|
private const METHOD_DEFLATE = "\x04\x01\x08";
|
|
|
|
private const METHOD_BZIP2 = "\x04\x02\x02";
|
|
|
|
private const METHOD_AES = "\x06\xF1\x07\x01";
|
|
|
|
// Windows file attributes
|
|
/** @phpstan-ignore classConstant.unused */
|
|
private const FILE_ATTRIBUTE_READONLY = 0x01;
|
|
|
|
/** @phpstan-ignore classConstant.unused */
|
|
private const FILE_ATTRIBUTE_HIDDEN = 0x02;
|
|
|
|
/** @phpstan-ignore classConstant.unused */
|
|
private const FILE_ATTRIBUTE_SYSTEM = 0x04;
|
|
|
|
private const FILE_ATTRIBUTE_DIRECTORY = 0x10;
|
|
|
|
/** @phpstan-ignore classConstant.unused */
|
|
private const FILE_ATTRIBUTE_ARCHIVE = 0x20;
|
|
|
|
public function __construct(string $data)
|
|
{
|
|
$this->data = $data;
|
|
$this->len = strlen($data);
|
|
}
|
|
|
|
/**
|
|
* Public accessor: returns recovered filenames (UTF-8) or empty array.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function getFileNames(): array
|
|
{
|
|
if (! $this->parsed) {
|
|
$this->parse();
|
|
}
|
|
|
|
return $this->names;
|
|
}
|
|
|
|
/**
|
|
* Returns detailed file information with all available metadata.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function getFiles(): array
|
|
{
|
|
if (! $this->parsed) {
|
|
$this->parse();
|
|
}
|
|
|
|
return $this->files;
|
|
}
|
|
|
|
/**
|
|
* Returns uncompressed file sizes indexed by file index.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function getSizes(): array
|
|
{
|
|
if (! $this->parsed) {
|
|
$this->parse();
|
|
}
|
|
|
|
return $this->sizes;
|
|
}
|
|
|
|
/**
|
|
* Returns CRC32 values as hex strings indexed by file index.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function getCRCs(): array
|
|
{
|
|
if (! $this->parsed) {
|
|
$this->parse();
|
|
}
|
|
|
|
return $this->crcs;
|
|
}
|
|
|
|
/**
|
|
* Returns file attributes indexed by file index.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function getAttributes(): array
|
|
{
|
|
if (! $this->parsed) {
|
|
$this->parse();
|
|
}
|
|
|
|
return $this->attributes;
|
|
}
|
|
|
|
/**
|
|
* Returns modification times (Unix timestamps) indexed by file index.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function getModificationTimes(): array
|
|
{
|
|
if (! $this->parsed) {
|
|
$this->parse();
|
|
}
|
|
|
|
return $this->mtimes;
|
|
}
|
|
|
|
/**
|
|
* Returns detected compression methods used in the archive.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function getCompressionMethods(): array
|
|
{
|
|
if (! $this->parsed) {
|
|
$this->parse();
|
|
}
|
|
|
|
return array_unique($this->compressionMethods);
|
|
}
|
|
|
|
/**
|
|
* Returns total number of files detected.
|
|
*/
|
|
public function getFileCount(): int
|
|
{
|
|
if (! $this->parsed) {
|
|
$this->parse();
|
|
}
|
|
|
|
return $this->numFiles;
|
|
}
|
|
|
|
/**
|
|
* Returns total unpacked (uncompressed) size of all files.
|
|
*/
|
|
public function getTotalUnpackedSize(): int
|
|
{
|
|
if (! $this->parsed) {
|
|
$this->parse();
|
|
}
|
|
|
|
return $this->totalUnpackedSize;
|
|
}
|
|
|
|
/**
|
|
* Returns total packed (compressed) size.
|
|
*/
|
|
public function getTotalPackedSize(): int
|
|
{
|
|
if (! $this->parsed) {
|
|
$this->parse();
|
|
}
|
|
|
|
return $this->totalPackedSize;
|
|
}
|
|
|
|
/**
|
|
* Public accessor: returns true if AES encryption is detected (heuristic).
|
|
*/
|
|
public function isEncrypted(): bool
|
|
{
|
|
if (! $this->parsed) {
|
|
$this->parse();
|
|
}
|
|
|
|
return $this->encrypted;
|
|
}
|
|
|
|
/**
|
|
* Returns true if the header itself is encrypted.
|
|
*/
|
|
public function isHeaderEncrypted(): bool
|
|
{
|
|
if (! $this->parsed) {
|
|
$this->parse();
|
|
}
|
|
|
|
return $this->headerEncrypted;
|
|
}
|
|
|
|
/**
|
|
* Returns true if this is a solid archive.
|
|
*/
|
|
public function isSolidArchive(): bool
|
|
{
|
|
if (! $this->parsed) {
|
|
$this->parse();
|
|
}
|
|
|
|
return $this->solidArchive;
|
|
}
|
|
|
|
public function hasEncodedHeader(): bool
|
|
{
|
|
if (! $this->parsed) {
|
|
$this->parse();
|
|
}
|
|
|
|
return $this->encodedHeader;
|
|
}
|
|
|
|
/**
|
|
* Returns the last error message for debugging.
|
|
*/
|
|
public function getLastError(): string
|
|
{
|
|
return $this->lastError;
|
|
}
|
|
|
|
/**
|
|
* Check if the data appears to be a valid 7z archive (has correct signature).
|
|
*/
|
|
public function isValid7zSignature(): bool
|
|
{
|
|
return $this->len >= 6 && strncmp($this->data, "\x37\x7A\xBC\xAF\x27\x1C", 6) === 0;
|
|
}
|
|
|
|
/**
|
|
* Returns compression ratio as a float (0.0 to 1.0+), or null if unknown.
|
|
*/
|
|
public function getCompressionRatio(): ?float
|
|
{
|
|
if (! $this->parsed) {
|
|
$this->parse();
|
|
}
|
|
|
|
if ($this->totalUnpackedSize > 0 && $this->totalPackedSize > 0) {
|
|
return $this->totalPackedSize / $this->totalUnpackedSize;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function parse(): void
|
|
{
|
|
$this->parsed = true;
|
|
|
|
if ($this->len < 32) { // need at least fixed header
|
|
$this->lastError = 'Data too short for 7z header (need at least 32 bytes)';
|
|
// Try fallback scanning even for very short data
|
|
$this->fallbackScanForFilenames();
|
|
|
|
return;
|
|
}
|
|
|
|
// Signature check
|
|
if (strncmp($this->data, "\x37\x7A\xBC\xAF\x27\x1C", 6) !== 0) {
|
|
$this->lastError = 'Invalid 7z signature';
|
|
// Try fallback scanning anyway - data might be partial
|
|
$this->fallbackScanForFilenames();
|
|
|
|
return;
|
|
}
|
|
|
|
// Read header version
|
|
$majorVersion = ord($this->data[6]);
|
|
$minorVersion = ord($this->data[7]);
|
|
|
|
// Read next header info
|
|
$nextHeaderOffset = $this->readUInt64LE(12);
|
|
$nextHeaderSize = $this->readUInt64LE(20);
|
|
$nextHeaderCRC = $this->readUInt32LE(28);
|
|
|
|
// Scan the packed data region for compression method signatures
|
|
$this->scanForCompressionMethods(32, min(32 + $nextHeaderOffset, $this->len));
|
|
|
|
// Bounds sanity
|
|
if ($nextHeaderSize <= 0 || $nextHeaderSize > 16 * 1024 * 1024) { // cap 16MB header
|
|
$this->lastError = 'Invalid next header size: '.$nextHeaderSize;
|
|
$this->fallbackScanForFilenames();
|
|
|
|
return;
|
|
}
|
|
|
|
$nextHeaderStart = 32 + $nextHeaderOffset;
|
|
$nextHeaderEnd = $nextHeaderStart + $nextHeaderSize;
|
|
|
|
if ($nextHeaderEnd > $this->len) {
|
|
// Incomplete buffer - still try to parse what we have
|
|
$this->lastError = 'Incomplete archive data (header extends beyond buffer)';
|
|
|
|
// Try partial parsing with available data
|
|
if ($nextHeaderStart < $this->len) {
|
|
$this->parsePartialHeader((int) $nextHeaderStart, $this->len);
|
|
}
|
|
|
|
// Also try fallback scanning
|
|
$this->fallbackScanForFilenames();
|
|
|
|
return;
|
|
}
|
|
|
|
$cursor = (int) $nextHeaderStart;
|
|
|
|
// First byte may be kEncodedHeader (unsupported) or kHeader
|
|
$id = ord($this->data[$cursor]);
|
|
|
|
if ($id === self::K_ENCODED_HEADER) {
|
|
$this->encodedHeader = true;
|
|
|
|
// Parse encoded header for compression info and encryption detection
|
|
$this->parseEncodedHeader($cursor, (int) $nextHeaderEnd);
|
|
|
|
// Fallback scan for any readable filenames
|
|
$this->fallbackScanForFilenames();
|
|
|
|
return;
|
|
}
|
|
|
|
if ($id !== self::K_HEADER) {
|
|
$this->lastError = 'Unexpected header ID: '.$id;
|
|
$this->fallbackScanForFilenames();
|
|
|
|
return;
|
|
}
|
|
|
|
$cursor++;
|
|
|
|
// Loop until K_END looking for various info blocks
|
|
while ($cursor < $nextHeaderEnd) {
|
|
$id = ord($this->data[$cursor]);
|
|
$cursor++;
|
|
|
|
if ($id === self::K_END) {
|
|
break;
|
|
}
|
|
|
|
switch ($id) {
|
|
case self::K_ARCHIVE_PROPERTIES:
|
|
$cursor = $this->skipUntilEnd($cursor, (int) $nextHeaderEnd);
|
|
break;
|
|
|
|
case self::K_ADDITIONAL_STREAMS_INFO:
|
|
$cursor = $this->skipUntilEnd($cursor, (int) $nextHeaderEnd);
|
|
break;
|
|
|
|
case self::K_MAIN_STREAMS_INFO:
|
|
$cursor = $this->parseMainStreamsInfo($cursor, (int) $nextHeaderEnd);
|
|
break;
|
|
|
|
case self::K_FILES_INFO:
|
|
$cursor = $this->parseFilesInfo($cursor, (int) $nextHeaderEnd);
|
|
break;
|
|
|
|
default:
|
|
// Try to skip unknown property
|
|
if ($this->readVIntAt($cursor, $propSize, $cursor, (int) $nextHeaderEnd)) {
|
|
$cursor += $propSize;
|
|
} else {
|
|
$cursor = -1;
|
|
}
|
|
break;
|
|
}
|
|
|
|
if ($cursor === -1) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Build consolidated file info array
|
|
$this->buildFileInfo();
|
|
|
|
// If we didn't find any files, try fallback
|
|
if (empty($this->names)) {
|
|
$this->fallbackScanForFilenames();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse encoded header to extract compression method info and detect encryption.
|
|
*/
|
|
private function parseEncodedHeader(int $cursor, int $limit): void
|
|
{
|
|
$cursor++; // skip K_ENCODED_HEADER byte
|
|
|
|
// Scan for AES encryption signature
|
|
$scan = substr($this->data, $cursor, min(1024, $limit - $cursor));
|
|
if (strpos($scan, self::METHOD_AES) !== false) {
|
|
$this->encrypted = true;
|
|
$this->headerEncrypted = true;
|
|
}
|
|
|
|
// Look for streams info
|
|
while ($cursor < $limit) {
|
|
$id = ord($this->data[$cursor]);
|
|
$cursor++;
|
|
|
|
if ($id === self::K_END) {
|
|
break;
|
|
}
|
|
|
|
if ($id === self::K_PACK_INFO) {
|
|
$cursor = $this->parsePackInfo($cursor, $limit);
|
|
} elseif ($id === self::K_UNPACK_INFO) {
|
|
$cursor = $this->parseUnpackInfo($cursor, $limit);
|
|
} elseif ($id === self::K_SUBSTREAMS_INFO) {
|
|
$cursor = $this->skipUntilEnd($cursor, $limit);
|
|
} else {
|
|
// Skip unknown
|
|
if (! $this->readVIntAt($cursor, $propSize, $cursor, $limit)) {
|
|
break;
|
|
}
|
|
$cursor += $propSize;
|
|
}
|
|
|
|
if ($cursor === -1) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse a partial/incomplete header to extract whatever information is available.
|
|
*/
|
|
private function parsePartialHeader(int $start, int $end): void
|
|
{
|
|
$cursor = $start;
|
|
|
|
// Try to identify what kind of block this might be
|
|
if ($cursor < $end) {
|
|
$id = ord($this->data[$cursor]);
|
|
|
|
if ($id === self::K_ENCODED_HEADER) {
|
|
$this->encodedHeader = true;
|
|
$scan = substr($this->data, $cursor, min(512, $end - $cursor));
|
|
if (strpos($scan, self::METHOD_AES) !== false) {
|
|
$this->encrypted = true;
|
|
$this->headerEncrypted = true;
|
|
}
|
|
} elseif ($id === self::K_HEADER) {
|
|
$cursor++;
|
|
// Try to parse as much as possible
|
|
while ($cursor < $end - 1) {
|
|
$blockId = ord($this->data[$cursor]);
|
|
if ($blockId === self::K_FILES_INFO) {
|
|
$cursor++;
|
|
$this->parseFilesInfo($cursor, $end);
|
|
break;
|
|
}
|
|
$cursor++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse MainStreamsInfo to get packed/unpacked sizes and detect solid archives.
|
|
*/
|
|
private function parseMainStreamsInfo(int $cursor, int $limit): int
|
|
{
|
|
while ($cursor < $limit) {
|
|
$id = ord($this->data[$cursor]);
|
|
$cursor++;
|
|
|
|
if ($id === self::K_END) {
|
|
return $cursor;
|
|
}
|
|
|
|
switch ($id) {
|
|
case self::K_PACK_INFO:
|
|
$cursor = $this->parsePackInfo($cursor, $limit);
|
|
break;
|
|
|
|
case self::K_UNPACK_INFO:
|
|
$cursor = $this->parseUnpackInfo($cursor, $limit);
|
|
break;
|
|
|
|
case self::K_SUBSTREAMS_INFO:
|
|
$cursor = $this->parseSubstreamsInfo($cursor, $limit);
|
|
break;
|
|
|
|
default:
|
|
return $this->skipUntilEnd($cursor - 1, $limit);
|
|
}
|
|
|
|
if ($cursor === -1) {
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
return $cursor;
|
|
}
|
|
|
|
/**
|
|
* Parse PackInfo to get packed sizes.
|
|
*/
|
|
private function parsePackInfo(int $cursor, int $limit): int
|
|
{
|
|
// PackPos (VInt)
|
|
if (! $this->readVIntAt($cursor, $packPos, $cursor, $limit)) {
|
|
return -1;
|
|
}
|
|
|
|
// NumPackStreams (VInt)
|
|
if (! $this->readVIntAt($cursor, $numPackStreams, $cursor, $limit)) {
|
|
return -1;
|
|
}
|
|
|
|
while ($cursor < $limit) {
|
|
$id = ord($this->data[$cursor]);
|
|
$cursor++;
|
|
|
|
if ($id === self::K_END) {
|
|
return $cursor;
|
|
}
|
|
|
|
if ($id === self::K_SIZE) {
|
|
// Read packed sizes
|
|
for ($i = 0; $i < $numPackStreams && $cursor < $limit; $i++) {
|
|
if ($this->readVIntAt($cursor, $size, $cursor, $limit)) {
|
|
$this->packedSizes[] = $size;
|
|
$this->totalPackedSize += $size;
|
|
}
|
|
}
|
|
} elseif ($id === self::K_CRC) {
|
|
// Skip CRC info
|
|
$cursor = $this->skipBitVector($cursor, $limit, $numPackStreams);
|
|
} else {
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
return $cursor;
|
|
}
|
|
|
|
/**
|
|
* Parse UnpackInfo to get unpack sizes and detect compression methods.
|
|
*/
|
|
private function parseUnpackInfo(int $cursor, int $limit): int
|
|
{
|
|
while ($cursor < $limit) {
|
|
$id = ord($this->data[$cursor]);
|
|
$cursor++;
|
|
|
|
if ($id === self::K_END) {
|
|
return $cursor;
|
|
}
|
|
|
|
if ($id === self::K_FOLDER) {
|
|
$cursor = $this->parseFolderInfo($cursor, $limit);
|
|
} elseif ($id === self::K_CODERS_UNPACK_SIZE) {
|
|
$cursor = $this->parseCodersUnpackSize($cursor, $limit);
|
|
} elseif ($id === self::K_CRC) {
|
|
if (! $this->readVIntAt($cursor, $propSize, $cursor, $limit)) {
|
|
return -1;
|
|
}
|
|
$cursor += $propSize;
|
|
} else {
|
|
if (! $this->readVIntAt($cursor, $propSize, $cursor, $limit)) {
|
|
return -1;
|
|
}
|
|
$cursor += $propSize;
|
|
}
|
|
|
|
if ($cursor === -1) {
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
return $cursor;
|
|
}
|
|
|
|
/**
|
|
* Parse FolderInfo to detect compression methods and solid archive structure.
|
|
*/
|
|
private function parseFolderInfo(int $cursor, int $limit): int
|
|
{
|
|
// NumFolders (VInt)
|
|
if (! $this->readVIntAt($cursor, $numFolders, $cursor, $limit)) {
|
|
return -1;
|
|
}
|
|
|
|
// If there's only one folder with multiple files, it's likely solid
|
|
if ($numFolders === 1 && $this->numFiles > 1) {
|
|
$this->solidArchive = true;
|
|
}
|
|
|
|
// External flag
|
|
if ($cursor >= $limit) {
|
|
return -1;
|
|
}
|
|
$external = ord($this->data[$cursor]);
|
|
$cursor++;
|
|
|
|
if ($external !== 0) {
|
|
// External data - skip
|
|
if (! $this->readVIntAt($cursor, $dataIndex, $cursor, $limit)) {
|
|
return -1;
|
|
}
|
|
|
|
return $cursor;
|
|
}
|
|
|
|
// Parse each folder
|
|
for ($i = 0; $i < $numFolders && $cursor < $limit; $i++) {
|
|
$cursor = $this->parseFolder($cursor, $limit);
|
|
if ($cursor === -1) {
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
return $cursor;
|
|
}
|
|
|
|
/**
|
|
* Parse a single Folder structure to extract compression method info.
|
|
*/
|
|
private function parseFolder(int $cursor, int $limit): int
|
|
{
|
|
// NumCoders (VInt)
|
|
if (! $this->readVIntAt($cursor, $numCoders, $cursor, $limit)) {
|
|
return -1;
|
|
}
|
|
|
|
$totalInputStreams = 0;
|
|
$totalOutputStreams = 0;
|
|
|
|
for ($i = 0; $i < $numCoders && $cursor < $limit; $i++) {
|
|
// Coder flags
|
|
$flags = ord($this->data[$cursor]);
|
|
$cursor++;
|
|
|
|
$codecIdSize = $flags & 0x0F;
|
|
$isComplex = ($flags & 0x10) !== 0;
|
|
$hasAttributes = ($flags & 0x20) !== 0;
|
|
|
|
// Codec ID
|
|
if ($cursor + $codecIdSize > $limit) {
|
|
return -1;
|
|
}
|
|
$codecId = substr($this->data, $cursor, $codecIdSize);
|
|
$cursor += $codecIdSize;
|
|
|
|
// Identify compression method
|
|
$method = $this->identifyCompressionMethod($codecId);
|
|
if ($method !== null) {
|
|
$this->compressionMethods[] = $method;
|
|
|
|
// Check for AES encryption
|
|
if ($method === 'AES-256') {
|
|
$this->encrypted = true;
|
|
}
|
|
}
|
|
|
|
if ($isComplex) {
|
|
// NumInStreams (VInt)
|
|
if (! $this->readVIntAt($cursor, $numIn, $cursor, $limit)) {
|
|
return -1;
|
|
}
|
|
// NumOutStreams (VInt)
|
|
if (! $this->readVIntAt($cursor, $numOut, $cursor, $limit)) {
|
|
return -1;
|
|
}
|
|
$totalInputStreams += $numIn;
|
|
$totalOutputStreams += $numOut;
|
|
} else {
|
|
$totalInputStreams++;
|
|
$totalOutputStreams++;
|
|
}
|
|
|
|
if ($hasAttributes) {
|
|
// PropertiesSize (VInt)
|
|
if (! $this->readVIntAt($cursor, $propSize, $cursor, $limit)) {
|
|
return -1;
|
|
}
|
|
$cursor += $propSize;
|
|
}
|
|
}
|
|
|
|
// BindPairs
|
|
$numBindPairs = $totalOutputStreams - 1;
|
|
for ($i = 0; $i < $numBindPairs && $cursor < $limit; $i++) {
|
|
if (! $this->readVIntAt($cursor, $inIndex, $cursor, $limit)) {
|
|
return -1;
|
|
}
|
|
if (! $this->readVIntAt($cursor, $outIndex, $cursor, $limit)) {
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
// PackedStreams
|
|
$numPackedStreams = $totalInputStreams - $numBindPairs;
|
|
if ($numPackedStreams > 1) {
|
|
for ($i = 0; $i < $numPackedStreams && $cursor < $limit; $i++) {
|
|
if (! $this->readVIntAt($cursor, $packedIndex, $cursor, $limit)) {
|
|
return -1;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $cursor;
|
|
}
|
|
|
|
/**
|
|
* Identify compression method from codec ID bytes.
|
|
*/
|
|
private function identifyCompressionMethod(string $codecId): ?string
|
|
{
|
|
if ($codecId === self::METHOD_COPY || $codecId === "\x00") {
|
|
return 'Copy';
|
|
}
|
|
if (str_starts_with($codecId, "\x03\x01\x01")) {
|
|
return 'LZMA';
|
|
}
|
|
if ($codecId === self::METHOD_LZMA2 || str_starts_with($codecId, "\x21")) {
|
|
return 'LZMA2';
|
|
}
|
|
if (str_starts_with($codecId, "\x03\x04\x01")) {
|
|
return 'PPMd';
|
|
}
|
|
if (str_starts_with($codecId, "\x03\x03\x01\x03")) {
|
|
return 'BCJ';
|
|
}
|
|
if (str_starts_with($codecId, "\x03\x03\x01\x1B")) {
|
|
return 'BCJ2';
|
|
}
|
|
if (str_starts_with($codecId, "\x04\x01\x08")) {
|
|
return 'Deflate';
|
|
}
|
|
if (str_starts_with($codecId, "\x04\x02\x02")) {
|
|
return 'BZip2';
|
|
}
|
|
if (str_starts_with($codecId, "\x06\xF1\x07\x01")) {
|
|
return 'AES-256';
|
|
}
|
|
if (str_starts_with($codecId, "\x03\x03\x01\x05")) {
|
|
return 'ARM';
|
|
}
|
|
if (str_starts_with($codecId, "\x03\x03\x01\x08")) {
|
|
return 'SPARC';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Parse CodersUnpackSize.
|
|
*/
|
|
private function parseCodersUnpackSize(int $cursor, int $limit): int
|
|
{
|
|
// This should read sizes for each output stream of each folder
|
|
// For now, just read available VInts as sizes
|
|
while ($cursor < $limit) {
|
|
$b = ord($this->data[$cursor]);
|
|
if ($b === self::K_END || $b === self::K_CRC) {
|
|
break;
|
|
}
|
|
if ($this->readVIntAt($cursor, $size, $newCursor, $limit)) {
|
|
$this->sizes[] = $size;
|
|
$this->totalUnpackedSize += $size;
|
|
$cursor = $newCursor;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return $cursor;
|
|
}
|
|
|
|
/**
|
|
* Parse SubstreamsInfo for individual file sizes within folders.
|
|
*/
|
|
private function parseSubstreamsInfo(int $cursor, int $limit): int
|
|
{
|
|
while ($cursor < $limit) {
|
|
$id = ord($this->data[$cursor]);
|
|
$cursor++;
|
|
|
|
if ($id === self::K_END) {
|
|
return $cursor;
|
|
}
|
|
|
|
switch ($id) {
|
|
case self::K_NUM_UNPACK_STREAM:
|
|
// NumUnpackStreams per folder
|
|
if (! $this->readVIntAt($cursor, $propSize, $cursor, $limit)) {
|
|
return -1;
|
|
}
|
|
$cursor += $propSize;
|
|
break;
|
|
|
|
case self::K_SIZE:
|
|
// Sizes
|
|
if (! $this->readVIntAt($cursor, $propSize, $cursor, $limit)) {
|
|
return -1;
|
|
}
|
|
$cursor += $propSize;
|
|
break;
|
|
|
|
case self::K_CRC:
|
|
// CRCs
|
|
if (! $this->readVIntAt($cursor, $propSize, $cursor, $limit)) {
|
|
return -1;
|
|
}
|
|
$cursor += $propSize;
|
|
break;
|
|
|
|
default:
|
|
if (! $this->readVIntAt($cursor, $propSize, $cursor, $limit)) {
|
|
return -1;
|
|
}
|
|
$cursor += $propSize;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return $cursor;
|
|
}
|
|
|
|
/**
|
|
* Skip a bit vector structure.
|
|
*/
|
|
private function skipBitVector(int $cursor, int $limit, int $numItems): int
|
|
{
|
|
$allDefined = ord($this->data[$cursor]);
|
|
$cursor++;
|
|
|
|
if ($allDefined === 0) {
|
|
// Bit array follows
|
|
$numBytes = (int) ceil($numItems / 8);
|
|
$cursor += $numBytes;
|
|
}
|
|
|
|
// Then comes the actual CRC values
|
|
for ($i = 0; $i < $numItems && $cursor + 4 <= $limit; $i++) {
|
|
$cursor += 4;
|
|
}
|
|
|
|
return $cursor;
|
|
}
|
|
|
|
private function parseFilesInfo(int $cursor, int $limit): int
|
|
{
|
|
// Number of files (VInt)
|
|
$numFiles = $this->readVIntAt($cursor, $value, $newCursor, $limit) ? $value : null;
|
|
if ($numFiles === null || $numFiles < 0 || $numFiles > 100000) { // sanity cap
|
|
$this->lastError = 'Invalid number of files: '.$numFiles;
|
|
|
|
return $limit;
|
|
}
|
|
$cursor = $newCursor;
|
|
$this->numFiles = $numFiles;
|
|
|
|
// Initialize arrays for metadata
|
|
$names = [];
|
|
$emptyStreams = [];
|
|
$emptyFiles = [];
|
|
$antiFiles = [];
|
|
|
|
// Property loop until K_END
|
|
while ($cursor < $limit) {
|
|
$propId = ord($this->data[$cursor]);
|
|
$cursor++;
|
|
|
|
if ($propId === self::K_END) {
|
|
break;
|
|
}
|
|
|
|
// Size of property data (VInt)
|
|
if (! $this->readVIntAt($cursor, $propSize, $cursor, $limit)) {
|
|
break;
|
|
}
|
|
if ($propSize < 0 || $propSize > ($limit - $cursor)) {
|
|
break;
|
|
}
|
|
|
|
$propStart = $cursor;
|
|
|
|
switch ($propId) {
|
|
case self::K_EMPTY_STREAM:
|
|
$emptyStreams = $this->parseBitVector($cursor, $propSize, $numFiles);
|
|
$cursor = $propStart + $propSize;
|
|
break;
|
|
|
|
case self::K_EMPTY_FILE:
|
|
$numEmpty = count(array_filter($emptyStreams));
|
|
$emptyFiles = $this->parseBitVector($cursor, $propSize, $numEmpty);
|
|
$cursor = $propStart + $propSize;
|
|
break;
|
|
|
|
case self::K_ANTI:
|
|
$numEmpty = count(array_filter($emptyStreams));
|
|
$antiFiles = $this->parseBitVector($cursor, $propSize, $numEmpty);
|
|
$cursor = $propStart + $propSize;
|
|
break;
|
|
|
|
case self::K_NAME:
|
|
$names = $this->parseNames($cursor, $propSize, $numFiles);
|
|
$cursor = $propStart + $propSize;
|
|
break;
|
|
|
|
case self::K_MTIME:
|
|
$this->mtimes = $this->parseFileTimes($cursor, $propSize, $numFiles); // @phpstan-ignore assign.propertyType
|
|
$cursor = $propStart + $propSize;
|
|
break;
|
|
|
|
case self::K_CTIME:
|
|
$this->ctimes = $this->parseFileTimes($cursor, $propSize, $numFiles); // @phpstan-ignore assign.propertyType
|
|
$cursor = $propStart + $propSize;
|
|
break;
|
|
|
|
case self::K_ATIME:
|
|
$this->atimes = $this->parseFileTimes($cursor, $propSize, $numFiles); // @phpstan-ignore assign.propertyType
|
|
$cursor = $propStart + $propSize;
|
|
break;
|
|
|
|
case self::K_WIN_ATTRIBUTES:
|
|
$this->attributes = $this->parseAttributes($cursor, $propSize, $numFiles); // @phpstan-ignore assign.propertyType
|
|
$cursor = $propStart + $propSize;
|
|
break;
|
|
|
|
case self::K_CRC:
|
|
$this->crcs = $this->parseCRCs($cursor, $propSize, $numFiles); // @phpstan-ignore assign.propertyType
|
|
$cursor = $propStart + $propSize;
|
|
break;
|
|
|
|
default:
|
|
// Skip unknown property
|
|
$cursor = $propStart + $propSize;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Assign collected names
|
|
if (! empty($names)) {
|
|
$this->names = array_values(array_unique($names)); // @phpstan-ignore assign.propertyType
|
|
}
|
|
|
|
return $cursor;
|
|
}
|
|
|
|
/**
|
|
* Parse file names from K_NAME property.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function parseNames(int $cursor, int $propSize, int $numFiles): array
|
|
{
|
|
$names = [];
|
|
|
|
if ($propSize < 1) {
|
|
return $names;
|
|
}
|
|
|
|
$external = ord($this->data[$cursor]);
|
|
if ($external !== 0) { // External data not supported
|
|
return $names;
|
|
}
|
|
|
|
$nameBytes = $propSize - 1;
|
|
$cursor++;
|
|
|
|
if ($nameBytes <= 0) {
|
|
return $names;
|
|
}
|
|
|
|
$blob = substr($this->data, $cursor, $nameBytes);
|
|
|
|
// Ensure even length for UTF-16LE. Truncate last byte if odd.
|
|
if (($nameBytes & 1) === 1) {
|
|
$blob = substr($blob, 0, -1);
|
|
}
|
|
|
|
// Split on UTF-16LE null terminators (00 00)
|
|
$segments = preg_split('/\x00\x00/', $blob);
|
|
|
|
foreach ($segments as $seg) {
|
|
if ($seg === '') {
|
|
continue;
|
|
}
|
|
|
|
$utf8 = @iconv('UTF-16LE', 'UTF-8//IGNORE', $seg);
|
|
if ($utf8 === false) {
|
|
continue;
|
|
}
|
|
|
|
$utf8 = trim($utf8);
|
|
if ($utf8 === '') {
|
|
continue;
|
|
}
|
|
|
|
// Normalize path separators
|
|
$utf8Clean = str_replace(['\\'], '/', $utf8);
|
|
// Remove leading './'
|
|
$utf8Clean = preg_replace('#^\./#', '', $utf8Clean);
|
|
|
|
if ($utf8Clean === '' || substr_count($utf8Clean, '/') > 16) { // excessive depth -> skip
|
|
continue;
|
|
}
|
|
|
|
$names[] = $utf8Clean;
|
|
|
|
if (count($names) >= $numFiles) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return $names;
|
|
}
|
|
|
|
/**
|
|
* Parse a bit vector from property data.
|
|
*
|
|
* @return list<string|null>
|
|
*/
|
|
private function parseBitVector(int $cursor, int $propSize, int $numItems): array
|
|
{
|
|
$result = array_fill(0, $numItems, false);
|
|
|
|
if ($propSize < 1) {
|
|
return $result;
|
|
}
|
|
|
|
$allDefined = ord($this->data[$cursor]);
|
|
$cursor++;
|
|
|
|
if ($allDefined !== 0) {
|
|
// All items are defined
|
|
return array_fill(0, $numItems, true);
|
|
}
|
|
|
|
// Parse bit array
|
|
$numBytes = (int) ceil($numItems / 8);
|
|
for ($i = 0; $i < $numItems; $i++) {
|
|
$byteIndex = (int) ($i / 8);
|
|
$bitIndex = 7 - ($i % 8);
|
|
if ($cursor + $byteIndex < $this->len) {
|
|
$byte = ord($this->data[$cursor + $byteIndex]);
|
|
$result[$i] = (($byte >> $bitIndex) & 1) === 1;
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Parse file times from property data.
|
|
*
|
|
* @return array<int<0, max>, bool>
|
|
*/
|
|
private function parseFileTimes(int $cursor, int $propSize, int $numFiles): array
|
|
{
|
|
$times = [];
|
|
|
|
if ($propSize < 1) {
|
|
return $times;
|
|
}
|
|
|
|
// Check for AllDefined byte
|
|
$allDefined = ord($this->data[$cursor]);
|
|
$cursor++;
|
|
|
|
$definedBits = [];
|
|
if ($allDefined === 0) {
|
|
// Parse bit vector for which files have times defined
|
|
$numBytes = (int) ceil($numFiles / 8);
|
|
for ($i = 0; $i < $numFiles; $i++) {
|
|
$byteIndex = (int) ($i / 8);
|
|
$bitIndex = 7 - ($i % 8);
|
|
if ($cursor + $byteIndex < $this->len) {
|
|
$byte = ord($this->data[$cursor + $byteIndex]);
|
|
$definedBits[$i] = (($byte >> $bitIndex) & 1) === 1;
|
|
} else {
|
|
$definedBits[$i] = false;
|
|
}
|
|
}
|
|
$cursor += $numBytes;
|
|
} else {
|
|
$definedBits = array_fill(0, $numFiles, true);
|
|
}
|
|
|
|
// External flag
|
|
if ($cursor >= $this->len) {
|
|
return $times;
|
|
}
|
|
$external = ord($this->data[$cursor]);
|
|
$cursor++;
|
|
|
|
if ($external !== 0) {
|
|
return $times; // External data not supported
|
|
}
|
|
|
|
// Read times for defined files (FILETIME format - 8 bytes each)
|
|
for ($i = 0; $i < $numFiles; $i++) {
|
|
if (! empty($definedBits[$i]) && $cursor + 8 <= $this->len) {
|
|
$filetime = $this->readUInt64LE($cursor);
|
|
// Convert FILETIME (100-ns intervals since 1601) to Unix timestamp
|
|
$times[$i] = $this->filetimeToUnix($filetime);
|
|
$cursor += 8;
|
|
} else {
|
|
$times[$i] = null;
|
|
}
|
|
}
|
|
|
|
return $times;
|
|
}
|
|
|
|
/**
|
|
* Parse Windows attributes from property data.
|
|
*
|
|
* @return array<int<0, max>, int|null>
|
|
*/
|
|
private function parseAttributes(int $cursor, int $propSize, int $numFiles): array
|
|
{
|
|
$attrs = [];
|
|
|
|
if ($propSize < 1) {
|
|
return $attrs;
|
|
}
|
|
|
|
// AllDefined byte
|
|
$allDefined = ord($this->data[$cursor]);
|
|
$cursor++;
|
|
|
|
$definedBits = [];
|
|
if ($allDefined === 0) {
|
|
$numBytes = (int) ceil($numFiles / 8);
|
|
for ($i = 0; $i < $numFiles; $i++) {
|
|
$byteIndex = (int) ($i / 8);
|
|
$bitIndex = 7 - ($i % 8);
|
|
if ($cursor + $byteIndex < $this->len) {
|
|
$byte = ord($this->data[$cursor + $byteIndex]);
|
|
$definedBits[$i] = (($byte >> $bitIndex) & 1) === 1;
|
|
} else {
|
|
$definedBits[$i] = false;
|
|
}
|
|
}
|
|
$cursor += $numBytes;
|
|
} else {
|
|
$definedBits = array_fill(0, $numFiles, true);
|
|
}
|
|
|
|
// External flag
|
|
if ($cursor >= $this->len) {
|
|
return $attrs;
|
|
}
|
|
$external = ord($this->data[$cursor]);
|
|
$cursor++;
|
|
|
|
if ($external !== 0) {
|
|
return $attrs;
|
|
}
|
|
|
|
// Read attributes (4 bytes each)
|
|
for ($i = 0; $i < $numFiles; $i++) {
|
|
if (! empty($definedBits[$i]) && $cursor + 4 <= $this->len) {
|
|
$attrs[$i] = $this->readUInt32LE($cursor);
|
|
$cursor += 4;
|
|
} else {
|
|
$attrs[$i] = null;
|
|
}
|
|
}
|
|
|
|
return $attrs;
|
|
}
|
|
|
|
/**
|
|
* Parse CRC values from property data.
|
|
*
|
|
* @return array<int, string|null>
|
|
*/
|
|
private function parseCRCs(int $cursor, int $propSize, int $numFiles): array
|
|
{
|
|
$crcs = [];
|
|
|
|
if ($propSize < 1) {
|
|
return $crcs;
|
|
}
|
|
|
|
// AllDefined byte
|
|
$allDefined = ord($this->data[$cursor]);
|
|
$cursor++;
|
|
|
|
$definedBits = [];
|
|
if ($allDefined === 0) {
|
|
$numBytes = (int) ceil($numFiles / 8);
|
|
for ($i = 0; $i < $numFiles; $i++) {
|
|
$byteIndex = (int) ($i / 8);
|
|
$bitIndex = 7 - ($i % 8);
|
|
if ($cursor + $byteIndex < $this->len) {
|
|
$byte = ord($this->data[$cursor + $byteIndex]);
|
|
$definedBits[$i] = (($byte >> $bitIndex) & 1) === 1;
|
|
} else {
|
|
$definedBits[$i] = false;
|
|
}
|
|
}
|
|
$cursor += $numBytes;
|
|
} else {
|
|
$definedBits = array_fill(0, $numFiles, true);
|
|
}
|
|
|
|
// Read CRC values (4 bytes each)
|
|
for ($i = 0; $i < $numFiles; $i++) {
|
|
if (! empty($definedBits[$i]) && $cursor + 4 <= $this->len) {
|
|
$crc = $this->readUInt32LE($cursor);
|
|
$crcs[$i] = sprintf('%08X', $crc);
|
|
$cursor += 4;
|
|
} else {
|
|
$crcs[$i] = null;
|
|
}
|
|
}
|
|
|
|
return $crcs;
|
|
}
|
|
|
|
/**
|
|
* Build consolidated file info array.
|
|
*/
|
|
private function buildFileInfo(): void
|
|
{
|
|
$numFiles = max(count($this->names), $this->numFiles);
|
|
|
|
for ($i = 0; $i < $numFiles; $i++) {
|
|
$isDir = false;
|
|
if (isset($this->attributes[$i])) {
|
|
$isDir = ($this->attributes[$i] & self::FILE_ATTRIBUTE_DIRECTORY) !== 0;
|
|
}
|
|
|
|
$this->files[$i] = [
|
|
'name' => $this->names[$i] ?? null,
|
|
'size' => $this->sizes[$i] ?? null,
|
|
'packed_size' => $this->packedSizes[$i] ?? null,
|
|
'crc' => $this->crcs[$i] ?? null,
|
|
'attributes' => $this->attributes[$i] ?? null,
|
|
'is_dir' => $isDir,
|
|
'mtime' => $this->mtimes[$i] ?? null,
|
|
'ctime' => $this->ctimes[$i] ?? null,
|
|
'atime' => $this->atimes[$i] ?? null,
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Scan raw data for compression method signatures.
|
|
*/
|
|
private function scanForCompressionMethods(int $start, int $end): void
|
|
{
|
|
$data = substr($this->data, $start, $end - $start);
|
|
|
|
// Look for common method signatures
|
|
if (strpos($data, self::METHOD_AES) !== false) {
|
|
$this->encrypted = true;
|
|
$this->compressionMethods[] = 'AES-256';
|
|
}
|
|
if (strpos($data, self::METHOD_LZMA2) !== false) {
|
|
$this->compressionMethods[] = 'LZMA2';
|
|
}
|
|
if (strpos($data, self::METHOD_LZMA) !== false) {
|
|
$this->compressionMethods[] = 'LZMA';
|
|
}
|
|
if (strpos($data, self::METHOD_PPMD) !== false) {
|
|
$this->compressionMethods[] = 'PPMd';
|
|
}
|
|
if (strpos($data, self::METHOD_BZIP2) !== false) {
|
|
$this->compressionMethods[] = 'BZip2';
|
|
}
|
|
if (strpos($data, self::METHOD_DEFLATE) !== false) {
|
|
$this->compressionMethods[] = 'Deflate';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fallback scanning for filenames when normal parsing fails.
|
|
* Attempts to find UTF-16LE encoded filenames in the raw data.
|
|
*/
|
|
private function fallbackScanForFilenames(): void
|
|
{
|
|
if (! empty($this->names)) {
|
|
return; // Already have names
|
|
}
|
|
|
|
// Look for common file extension patterns in UTF-16LE
|
|
$patterns = [
|
|
'.avi', '.mkv', '.mp4', '.wmv', '.mov',
|
|
'.mp3', '.flac', '.wav', '.ogg',
|
|
'.rar', '.zip', '.exe', '.dll',
|
|
'.nfo', '.txt', '.pdf', '.doc',
|
|
'.jpg', '.png', '.gif', '.bmp',
|
|
'.iso', '.bin', '.img', '.nrg',
|
|
];
|
|
|
|
$found = [];
|
|
|
|
foreach ($patterns as $ext) {
|
|
// Convert extension to UTF-16LE for searching
|
|
$utf16Ext = @iconv('UTF-8', 'UTF-16LE', $ext);
|
|
if ($utf16Ext === false) {
|
|
continue;
|
|
}
|
|
|
|
$pos = 0;
|
|
while (($pos = strpos($this->data, $utf16Ext, $pos)) !== false) {
|
|
// Try to extract the full filename by looking backwards
|
|
$start = $this->findFilenameStart($pos);
|
|
if ($start !== false && $start < $pos) {
|
|
$len = ($pos - $start) + strlen($utf16Ext);
|
|
$nameData = substr($this->data, $start, $len);
|
|
|
|
// Validate it's likely a filename (reasonable length, no control chars)
|
|
if (strlen($nameData) > 2 && strlen($nameData) < 512) {
|
|
$utf8 = @iconv('UTF-16LE', 'UTF-8//IGNORE', $nameData);
|
|
if ($utf8 !== false && $this->isValidFilename($utf8)) {
|
|
$utf8 = str_replace('\\', '/', $utf8);
|
|
$found[] = $utf8;
|
|
}
|
|
}
|
|
}
|
|
$pos += strlen($utf16Ext);
|
|
}
|
|
}
|
|
|
|
// Also try to find NFO files specifically (common in releases)
|
|
$this->scanForNfoFiles($found); // @phpstan-ignore argument.type
|
|
|
|
if (! empty($found)) {
|
|
$this->names = array_values(array_unique($found)); // @phpstan-ignore assign.propertyType
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find the start of a UTF-16LE filename by scanning backwards.
|
|
*/
|
|
private function findFilenameStart(int $extensionPos): int|false
|
|
{
|
|
// Scan backwards looking for a null terminator or invalid char
|
|
$start = $extensionPos;
|
|
$maxLen = 256; // Max filename length to consider
|
|
|
|
for ($i = $extensionPos - 2; $i >= max(0, $extensionPos - $maxLen * 2); $i -= 2) {
|
|
if ($i + 1 >= $this->len) {
|
|
continue;
|
|
}
|
|
|
|
$lo = ord($this->data[$i]);
|
|
$hi = ord($this->data[$i + 1]);
|
|
|
|
// Check for null terminator
|
|
if ($lo === 0 && $hi === 0) {
|
|
$start = $i + 2;
|
|
break;
|
|
}
|
|
|
|
// Check for invalid filename characters
|
|
if ($hi === 0) {
|
|
// ASCII range - check for path separators or invalid chars
|
|
if ($lo === 0 || $lo < 32) {
|
|
$start = $i + 2;
|
|
break;
|
|
}
|
|
}
|
|
|
|
$start = $i;
|
|
}
|
|
|
|
return $start >= 0 ? $start : false;
|
|
}
|
|
|
|
/**
|
|
* Validate if a string looks like a valid filename.
|
|
*/
|
|
private function isValidFilename(string $name): bool
|
|
{
|
|
$name = trim($name);
|
|
|
|
if (empty($name) || strlen($name) > 260) {
|
|
return false;
|
|
}
|
|
|
|
// Must have at least one printable character
|
|
if (! preg_match('/[a-zA-Z0-9]/', $name)) {
|
|
return false;
|
|
}
|
|
|
|
// Should not have too many special characters
|
|
$specialCount = preg_match_all('/[^\w\s.\-_\/\\\\]/', $name);
|
|
if ($specialCount > 5) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Scan for NFO files which are very common in release archives.
|
|
*
|
|
* @param array<string, mixed> $found
|
|
*/
|
|
private function scanForNfoFiles(array &$found): void
|
|
{
|
|
// NFO files are extremely common and usually have simple names
|
|
$nfoMarker = @iconv('UTF-8', 'UTF-16LE', '.nfo');
|
|
if ($nfoMarker === false) {
|
|
return;
|
|
}
|
|
|
|
$pos = 0;
|
|
while (($pos = strpos($this->data, $nfoMarker, $pos)) !== false) {
|
|
$start = $this->findFilenameStart($pos);
|
|
if ($start !== false && $start < $pos) {
|
|
$len = ($pos - $start) + strlen($nfoMarker);
|
|
$nameData = substr($this->data, $start, $len);
|
|
|
|
if (strlen($nameData) > 2 && strlen($nameData) < 256) {
|
|
$utf8 = @iconv('UTF-16LE', 'UTF-8//IGNORE', $nameData);
|
|
if ($utf8 !== false && $this->isValidFilename($utf8)) {
|
|
$found[] = str_replace('\\', '/', $utf8); // @phpstan-ignore parameterByRef.type
|
|
}
|
|
}
|
|
}
|
|
$pos += strlen($nfoMarker);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert Windows FILETIME to Unix timestamp.
|
|
*/
|
|
private function filetimeToUnix(int $filetime): int
|
|
{
|
|
// FILETIME is 100-nanosecond intervals since January 1, 1601
|
|
// Unix epoch is January 1, 1970
|
|
// Difference is 116444736000000000 100-ns intervals
|
|
$unixEpochDiff = 116444736000000000;
|
|
|
|
if ($filetime <= $unixEpochDiff) {
|
|
return 0;
|
|
}
|
|
|
|
return (int) (($filetime - $unixEpochDiff) / 10000000);
|
|
}
|
|
|
|
private function skipUntilEnd(int $cursor, int $limit): int
|
|
{
|
|
while ($cursor < $limit) {
|
|
$id = ord($this->data[$cursor]);
|
|
$cursor++;
|
|
if ($id === self::K_END) {
|
|
return $cursor;
|
|
}
|
|
// Property-like: read size then skip
|
|
if (! $this->readVIntAt($cursor, $propSize, $cursor, $limit)) {
|
|
return -1;
|
|
}
|
|
if ($propSize < 0 || $propSize > ($limit - $cursor)) {
|
|
return -1;
|
|
}
|
|
$cursor += $propSize;
|
|
}
|
|
|
|
return -1;
|
|
}
|
|
|
|
/**
|
|
* Reads a 7z variable-length integer at offset, returns value via reference.
|
|
*
|
|
* @param-out int $value
|
|
*/
|
|
private function readVIntAt(int $offset, ?int &$value, ?int &$newOffset, int $limit): bool
|
|
{
|
|
$value = 0;
|
|
$shift = 0;
|
|
$pos = $offset;
|
|
while ($pos < $limit && $shift <= 63) {
|
|
$b = ord($this->data[$pos]);
|
|
$pos++;
|
|
$value |= ($b & 0x7F) << $shift;
|
|
if (($b & 0x80) === 0) {
|
|
$newOffset = $pos;
|
|
|
|
return true;
|
|
}
|
|
$shift += 7;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function readUInt64LE(int $offset): int
|
|
{
|
|
if ($offset + 8 > $this->len) {
|
|
return 0;
|
|
}
|
|
$v = 0;
|
|
for ($i = 0; $i < 8; $i++) {
|
|
$v |= ord($this->data[$offset + $i]) << ($i * 8);
|
|
}
|
|
|
|
// Constrain to PHP int (on 64-bit fine; on 32-bit may overflow but those environments uncommon here)
|
|
return $v & 0xFFFFFFFFFFFFFFFF;
|
|
}
|
|
|
|
/**
|
|
* Read a 32-bit unsigned little-endian integer.
|
|
*/
|
|
private function readUInt32LE(int $offset): int
|
|
{
|
|
if ($offset + 4 > $this->len) {
|
|
return 0;
|
|
}
|
|
|
|
return ord($this->data[$offset])
|
|
| (ord($this->data[$offset + 1]) << 8)
|
|
| (ord($this->data[$offset + 2]) << 16)
|
|
| (ord($this->data[$offset + 3]) << 24);
|
|
}
|
|
|
|
/**
|
|
* Extract a summary of archive information as an associative array.
|
|
* Useful for quick inspection of archive contents.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function getSummary(): array
|
|
{
|
|
if (! $this->parsed) {
|
|
$this->parse();
|
|
}
|
|
|
|
$dirs = 0;
|
|
$regularFiles = 0;
|
|
|
|
foreach ($this->files as $file) {
|
|
if ($file['is_dir']) {
|
|
$dirs++;
|
|
} else {
|
|
$regularFiles++;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'valid_signature' => $this->isValid7zSignature(),
|
|
'file_count' => $this->numFiles,
|
|
'directory_count' => $dirs,
|
|
'regular_file_count' => $regularFiles,
|
|
'total_unpacked_size' => $this->totalUnpackedSize,
|
|
'total_packed_size' => $this->totalPackedSize,
|
|
'compression_ratio' => $this->getCompressionRatio(),
|
|
'compression_methods' => $this->getCompressionMethods(),
|
|
'encrypted' => $this->encrypted,
|
|
'header_encrypted' => $this->headerEncrypted,
|
|
'encoded_header' => $this->encodedHeader,
|
|
'solid_archive' => $this->solidArchive,
|
|
'last_error' => $this->lastError,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Get files matching a specific extension.
|
|
*
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public function getFilesByExtension(string $extension): array
|
|
{
|
|
if (! $this->parsed) {
|
|
$this->parse();
|
|
}
|
|
|
|
$extension = ltrim(strtolower($extension), '.');
|
|
$matches = [];
|
|
|
|
foreach ($this->files as $index => $file) {
|
|
if ($file['name'] === null) {
|
|
continue;
|
|
}
|
|
|
|
$fileExt = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
|
if ($fileExt === $extension) {
|
|
$matches[$index] = $file;
|
|
}
|
|
}
|
|
|
|
return $matches;
|
|
}
|
|
|
|
/**
|
|
* Check if archive contains any files with given extension.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function hasFileWithExtension(string $extension): bool
|
|
{
|
|
return ! empty($this->getFilesByExtension($extension));
|
|
}
|
|
|
|
/**
|
|
* Get all directory entries from the archive.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function getDirectories(): array
|
|
{
|
|
if (! $this->parsed) {
|
|
$this->parse();
|
|
}
|
|
|
|
return array_filter($this->files, fn ($file) => $file['is_dir']);
|
|
}
|
|
|
|
/**
|
|
* Get the largest file in the archive.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function getLargestFile(): ?array
|
|
{
|
|
if (! $this->parsed) {
|
|
$this->parse();
|
|
}
|
|
|
|
$largest = null;
|
|
$maxSize = -1;
|
|
|
|
foreach ($this->files as $file) {
|
|
if ($file['size'] !== null && $file['size'] > $maxSize && ! $file['is_dir']) {
|
|
$maxSize = $file['size'];
|
|
$largest = $file;
|
|
}
|
|
}
|
|
|
|
return $largest;
|
|
}
|
|
}
|