data = $data; $this->len = strlen($data); } /** * Public accessor: returns recovered filenames (UTF-8) or empty array. */ public function getFileNames(): array { if (! $this->parsed) { $this->parse(); } return $this->names; } /** * Returns detailed file information with all available metadata. * * @return array */ public function getFiles(): array { if (! $this->parsed) { $this->parse(); } return $this->files; } /** * Returns uncompressed file sizes indexed by file index. */ public function getSizes(): array { if (! $this->parsed) { $this->parse(); } return $this->sizes; } /** * Returns CRC32 values as hex strings indexed by file index. */ public function getCRCs(): array { if (! $this->parsed) { $this->parse(); } return $this->crcs; } /** * Returns file attributes indexed by file index. */ public function getAttributes(): array { if (! $this->parsed) { $this->parse(); } return $this->attributes; } /** * Returns modification times (Unix timestamps) indexed by file index. */ public function getModificationTimes(): array { if (! $this->parsed) { $this->parse(); } return $this->mtimes; } /** * Returns detected compression methods used in the archive. */ 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); $cursor = $propStart + $propSize; break; case self::K_CTIME: $this->ctimes = $this->parseFileTimes($cursor, $propSize, $numFiles); $cursor = $propStart + $propSize; break; case self::K_ATIME: $this->atimes = $this->parseFileTimes($cursor, $propSize, $numFiles); $cursor = $propStart + $propSize; break; case self::K_WIN_ATTRIBUTES: $this->attributes = $this->parseAttributes($cursor, $propSize, $numFiles); $cursor = $propStart + $propSize; break; case self::K_CRC: $this->crcs = $this->parseCRCs($cursor, $propSize, $numFiles); $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)); } return $cursor; } /** * Parse file names from K_NAME property. */ 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 array */ 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 */ 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 */ 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 */ 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); if (! empty($found)) { $this->names = array_values(array_unique($found)); } } /** * 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. */ 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); } } } $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. */ 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. */ 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 */ 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. */ public function hasFileWithExtension(string $extension): bool { return ! empty($this->getFilesByExtension($extension)); } /** * Get all directory entries from the archive. * * @return array */ 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. */ 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; } }