├── .gitignore ├── README.md ├── archiveinfo.php ├── archivereader.php ├── composer.json ├── par2info.php ├── phpunit.xml.dist ├── pipereader.php ├── rarinfo.php ├── sfvinfo.php ├── srrinfo.php ├── szipinfo.php ├── tests ├── ArchiveInfoTest.php ├── ArchiveReaderTest.php ├── Par2InfoTest.php ├── PipeReaderTest.php ├── RarInfoTest.php ├── SfvInfoTest.php ├── SrrInfoTest.php ├── SzipInfoTest.php ├── ZipInfoTest.php ├── bin │ └── README.md └── fixtures │ ├── generate.php │ ├── misc │ ├── foo.txt │ ├── misc_comp_in_rar.rar │ ├── misc_in_rar.rar │ ├── misc_in_zip.zip │ ├── rar_comp_in_szip.7z │ ├── rar_comp_in_zip.zip │ ├── rar_in_szip.7z │ ├── rar_in_zip.zip │ ├── szip_in_rar.rar │ ├── zip_comp_in_rar.rar │ └── zip_in_rar.rar │ ├── par2 │ ├── .gitignore │ ├── test-3.data │ ├── testdata.par2 │ ├── testdata.vol00+01.par2 │ ├── testdata.vol01+02.par2 │ ├── testdata.vol03+04.par2 │ ├── testdata.vol07+08.par2 │ ├── testdata.vol15+16.par2 │ └── testdata.vol31+29.par2 │ ├── rar │ ├── .gitignore │ ├── 4mb.rar │ ├── 4mb_no_marker.rar │ ├── 4mb_padded_end.rar │ ├── 4mb_padded_start.rar │ ├── commented.rar │ ├── corrupted.rar │ ├── directories.rar │ ├── dirlink_unix.rar │ ├── dirs_and_extra_headers.rar │ ├── embedded_rar50.rar │ ├── embedded_rars.rar │ ├── empty_file.rar │ ├── encrypted_headers.rar │ ├── encrypted_only_files.rar │ ├── garbage.part03.rar │ ├── latest_winrar.rar │ ├── linux_rar.rar │ ├── multi.part1.rar │ ├── multi.part2.rar │ ├── multi.part3.rar │ ├── multi_broken.part1.rar │ ├── rar50_bad_extra_size.rar │ ├── rar50_embedded_rar.rar │ ├── rar50_embedded_rar15.rar │ ├── rar50_encrypted_files.rar │ ├── rar50_encrypted_headers.rar │ ├── rar50_quickopen.rar │ ├── rar_notrar.rar │ ├── rar_unicode.rar │ ├── repeated_name.rar │ ├── secret-crypted-none.rar │ ├── secret-none.rar │ ├── solid.rar │ ├── sparsefiles_rar.rar │ └── store_method.rar │ ├── sfv │ ├── test001.sfv │ ├── test002.sfv │ └── test002.zip │ ├── srr │ ├── .gitignore │ ├── added_empty_file.srr │ ├── store_empty.srr │ ├── store_little.srr │ ├── store_little_srrfile_with_path.srr │ ├── store_little_srrfile_with_path_backslash.srr │ ├── store_rr_solid_auth.part1.srr │ ├── store_split_folder.srr │ ├── store_utf8_comment.srr │ └── utf8_filename_added.srr │ ├── szip │ ├── .gitignore │ ├── empty_file.7z │ ├── encrypted_files.7z │ ├── encrypted_headers.7z │ ├── multi_substreams_encrypted.7z │ ├── multi_volume.7z.001 │ ├── multi_volume.7z.002 │ ├── solid_lzma_multi.7z │ ├── solid_lzma_single.7z │ ├── store_method.7z │ ├── store_method_enc_headers.7z │ ├── store_with_directories.7z │ └── store_with_empty.7z │ └── zip │ ├── .gitignore │ ├── encrypted_file.zip │ ├── encrypted_file_aes.zip │ ├── large_file_end.zip │ ├── large_file_start.zip │ ├── little_file.zip │ ├── pecl_12414.zip │ ├── pecl_binarynull.zip │ ├── pecl_bug49072.zip │ ├── pecl_bug8009.zip │ ├── pecl_test.zip │ ├── pecl_test_procedural.zip │ ├── pecl_test_with_comment.zip │ └── unicode_filename.zip └── zipinfo.php /.gitignore: -------------------------------------------------------------------------------- 1 | phpunit.xml 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A set of basic utility classes for working with RAR archives and related parity 2 | and verification files in pure PHP (no external dependencies). See the [Releases] 3 | (https://github.com/zeebinz/rarinfo/releases) page for versioned releases of the 4 | whole library, which contains: 5 | 6 | ArchiveReader 7 | ------------------------------- 8 | Abstract base class for the various file inspectors that defines the basic API 9 | and implements common methods for file/data handling. 10 | 11 | - 3.1 Fixed compatability with Synology NAS 12 | - 3.0 Fixed compatability with OSX 13 | - 2.9 Added clearstatcache() to default reset 14 | - 2.8 Changed getFileList() to return empty array on error 15 | - 2.7 Added strposall() static method 16 | - 2.6 Added methods for creating temporary files 17 | - 2.5 Added support for ArchiveInfo 18 | - 2.4 Fixed getting file sizes on Windows without com_dotnet loaded 19 | - 2.3 Cleaned up code, added convert2hex() method 20 | - 2.2 Added support for Windows timestamps and some tweaks 21 | - 2.1 Code cleanup, made file property protected 22 | - 2.0 Made get/save range methods protected 23 | - 1.9 Improved range checks 24 | - 1.8 Added methods for getting & saving file data by range 25 | - 1.7 Added support for setting analysis byte ranges, improved handling of 26 | archive file fragments, added default constructor, and misc fixes. 27 | - 1.6 Improved property initialization 28 | - 1.5 Improved method for unpacking unsigned longs 29 | - 1.4 Seeking in files beyond PHP_INT_MAX now throws exception 30 | - 1.3 Improved filesize calculation for large files 31 | - 1.2 Added dos2unixtime() from RarInfo 32 | - 1.1 Added int64() method for handling 64-bit integers 33 | - 1.0 Initial release (derived from RarInfo v2.8, with bugfixes) 34 | 35 | ArchiveInfo (extends ArchiveReader) 36 | ----------------------------------- 37 | Example class that provides a facade for all the readers in the library, and also 38 | allows recursive inspection of archives packed within archives. 39 | 40 | - 2.3 Fixed empty() and isset() calls on reader properties 41 | - 2.2 Added option to override extensions filter via setArchiveExtensions() 42 | - 2.1 Added support for SzipInfo (7-zip archives) 43 | - 2.0 Improved error reporting for recursive extraction 44 | - 1.9 Added support for recursive extraction using external clients 45 | - 1.8 Restricted stored archives to supported types only 46 | - 1.7 Added ability to reset the readers list dynamically 47 | - 1.6 Added method for setting the readers list per instance 48 | - 1.5 Restricted next_offset values to main archive files only 49 | - 1.4 Added option for getArchiveFileList() to merge all archive file lists 50 | - 1.3 Improved performance and error reporting 51 | - 1.2 Fixed allowsRecursion() method to return only booleans 52 | - 1.1 Fixed backward-compatibility with PHP < 5.3.0 53 | - 1.0 Initial release 54 | 55 | RarInfo (extends ArchiveReader) 56 | ------------------------------- 57 | Class for inspecting the contents of RAR archives. 58 | 59 | - 5.7 Fixes out of memory error with corrupt RAR50 files, issue #9 60 | - 5.6 Improved handling of corrupt RAR 5.0 archives 61 | - 5.5 Fixed conversion of external client file paths 62 | - 5.4 Added 'split_after' field to file listing to assist CRC32 checks 63 | - 5.3 Added CRC32 checksums to the file list output 64 | - 5.2 Changed getFileList() to return empty array on error 65 | - 5.1 Speeded up findFileHeader() method quite a bit 66 | - 5.0 Improved handling of external clients, including Windows fix 67 | - 4.9 Added option for extracting files with external clients 68 | - 4.8 Tweaked File header sanity check 69 | - 4.7 Added support for ArchiveInfo 70 | - 4.6 Fixed handling of archives with encrypted headers 71 | - 4.5 Improved handling of some corrupt sources 72 | - 4.4 Improved analysis performance, cleaned up code, fixed b/c 73 | - 4.3 Added handling of RAR 5.0 Quick Open data 74 | - 4.2 Added basic support for the RAR 5.0 archive format 75 | - 4.1 Added handling of zero-padded RAR files 76 | - 4.0 Tweaked handing of file block headers and summaries 77 | - 3.9 Improved methods for extracting file contents 78 | - 3.8 Added support for byte ranges, better handling of RAR fragments 79 | - 3.7 Added more info to Archive block output 80 | - 3.6 Improved handling of Marker blocks, with new test files 81 | - 3.5 Improved Marker block searching 82 | - 3.4 Improved property initialization 83 | - 3.3 Moved dos2unixtime() to ArchiveReader 84 | - 3.2 Added RarInfo::getFileData() and RarInfo::saveFileData(); 85 | Data & open file handles now persist until closed manually 86 | - 3.1 Now uses ArchiveReader::int64() for handling 64-bit integers 87 | - 3.0 Improved speed when handling RAR file fragments 88 | - 2.9 Refactored quite a lot to allow easier extension 89 | - 2.8 Added support for files larger than PHP_INT_MAX bytes 90 | - 2.7 Fixed read & seek issues 91 | - 2.6 Improved input error checking, fixed reset bug 92 | - 2.5 Code cleanup & optimization, added fileCount 93 | - 2.4 Better method for unpacking unsigned longs 94 | - 2.3 Added skipping of directory entries, unicode fixes 95 | - 2.2 Fixed some seeking issues, added more Archive End info 96 | - 2.1 Better support for analyzing large files from disk via open() 97 | - 2.0 Proper unicode support with ported UnicodeFilename class 98 | - 1.9 Basic unicode support, fixed password & salt info 99 | - 1.8 Better info for multipart files, added PACK_SIZE properly 100 | - 1.7 Improved support for RAR file fragments 101 | - 1.6 Added extra error checking to read method 102 | - 1.5 Improved getSummary method output 103 | - 1.4 Added filename sanity checks & maxFilenameLength variable 104 | - 1.3 Fixed issues with some file headers lacking LONG_BLOCK flag 105 | - 1.2 Tweaked seeking method 106 | - 1.1 Fixed issues with PHP not handling unsigned longs properly (pfft) 107 | - 1.0 Initial release 108 | 109 | RarUnicodeFilename (in rarinfo.php) 110 | ----------------------------------- 111 | Class for handling unicode filenames in RAR archive listings. 112 | 113 | - 1.2 Fixed issues with byte processing 114 | - 1.1 Renamed class to avoid collisions 115 | - 1.0 Initial release 116 | 117 | SfvInfo (extends ArchiveReader) 118 | ------------------------------- 119 | Class for inspecting the contents of SFV verification files. 120 | 121 | - 2.1 Added extra check for correct file length 122 | - 2.0 Changed getFileList() to return empty array on error 123 | - 1.9 Added use_range value to getSummary() output 124 | - 1.8 Added support for ArchiveInfo 125 | - 1.7 Added support for byte ranges 126 | - 1.6 Fixed regex greediness with extra whitespaces 127 | - 1.5 File comments are now stored 128 | - 1.4 Now supports all line ending types 129 | - 1.3 Improved check for valid SFV data only 130 | - 1.2 Fixed last byte being discarded when analyzing 131 | - 1.1 Results of getFileList() made consistent with other inspectors 132 | - 1.0 Initial release 133 | 134 | SrrInfo (extends RarInfo) 135 | ------------------------------- 136 | Class for inspecting the contents of SRR files and reporting on the RAR files 137 | that they cover, as well as allowing extraction of any stored files that they 138 | might contain. 139 | 140 | - 2.3 Changed getFileList() to return empty array on error 141 | - 2.2 Tweak to support RarInfo 4.9 142 | - 2.1 Added stricter check for SRR Marker block 143 | - 2.0 Added support for ArchiveInfo 144 | - 1.9 Improved analysis performance, cleaned up code 145 | - 1.8 Added handling of OSO hash blocks 146 | - 1.7 Improved handling of invalid extraction requests 147 | - 1.6 Added support for byte ranges 148 | - 1.5 Improved handling of Marker blocks 149 | - 1.4 Improved Marker block searching 150 | - 1.3 Improved property initialization 151 | - 1.2 Data & open file handles now persist until closed manually 152 | - 1.1 Added easier reporting of client info 153 | - 1.0 Initial release 154 | 155 | Par2Info (extends ArchiveReader) 156 | -------------------------------- 157 | Class for inspecting the contents of PAR2 parity files and reporting on the 158 | archives that they cover. 159 | 160 | - 1.7 Changed getFileList() to return empty array on error 161 | - 1.6 Added support for ArchiveInfo 162 | - 1.5 Improved analysis performance 163 | - 1.4 Added support for byte ranges 164 | - 1.3 Added block count to file list entries 165 | - 1.2 Improved property initialization 166 | - 1.1 Fixed unpacking of unsigned longs 167 | - 1.0 Initial release 168 | 169 | ZipInfo (extends ArchiveReader) 170 | -------------------------------- 171 | Class for inspecting the contents of ZIP archives. 172 | 173 | - 2.1 Added CRC32 checksums to the file list output 174 | - 2.0 Changed getFileList() to return empty array on error 175 | - 1.9 Improved handling of external clients, including Windows fix 176 | - 1.8 Added option for extracting files with external clients 177 | - 1.7 Fixed finding the marker signature 178 | - 1.6 Added support for ArchiveInfo 179 | - 1.5 Improved analysis performance, cleaned up code 180 | - 1.4 Improved methods for extracting file contents 181 | - 1.3 Added support for byte ranges 182 | - 1.2 Improved property initialization 183 | - 1.1 Fixed filecount when CDR is missing 184 | - 1.0 Initial release 185 | 186 | SzipInfo (extends ArchiveReader) 187 | -------------------------------- 188 | Class for inspecting the contents of 7-zip (.7z) archives. 189 | 190 | - 1.4 Improved processing of substreams info 191 | - 1.3 Fixed getPackedRanges() output 192 | - 1.2 Added CRC32 checksums to the file list output 193 | - 1.1 Fixed parsing of substream digests 194 | - 1.0 Initial release 195 | 196 | PipeReader 197 | ------------------------------- 198 | A utility class for handling the piped output of an external command. 199 | 200 | - 1.2 Added readLine() method 201 | - 1.1 Fixed visibility of pipe handle 202 | - 1.0 Initial release 203 | 204 | 205 | Testing 206 | ------------------------------- 207 | Some basic unit tests using [PHPUnit](http://phpunit.de/manual/current/en/installation.html) 208 | are in `/tests`, with sample files in `/tests/fixtures` (run `generate.php` from there first 209 | and on each pull), more coverage and any Github-friendly samples are always welcome. Some 210 | optional tests require external binaries (see `/tests/bin/README.md`). Enjoy :) 211 | -------------------------------------------------------------------------------- /archivereader.php: -------------------------------------------------------------------------------- 1 | $value) { 34 | $code = array_shift($codes); 35 | if (strpos($longs, $code[0]) !== false && $value < 0) { 36 | $unpacked[$key] = $value + 0x100000000; // converts to float 37 | } 38 | } 39 | } 40 | 41 | return $unpacked; 42 | } 43 | 44 | /** 45 | * Converts two longs to a float to represent a 64-bit integer on 32-bit 46 | * systems, otherwise returns the integer. 47 | * 48 | * If more precision is needed, the bcmath functions should be used. 49 | * 50 | * @param integer $low the low 32 bits 51 | * @param integer $high the high 32 bits 52 | * @return float|integer 53 | */ 54 | public static function int64($low, $high) 55 | { 56 | return ($low + ($high * 0x100000000)); 57 | } 58 | 59 | /** 60 | * Converts DOS standard timestamps to UNIX timestamps. 61 | * 62 | * @param integer $dostime DOS timestamp 63 | * @return integer UNIX timestamp 64 | */ 65 | public static function dos2unixtime($dostime) 66 | { 67 | $sec = 2 * ($dostime & 0x1f); 68 | $min = ($dostime >> 5) & 0x3f; 69 | $hrs = ($dostime >> 11) & 0x1f; 70 | $day = ($dostime >> 16) & 0x1f; 71 | $mon = ($dostime >> 21) & 0x0f; 72 | $year = (($dostime >> 25) & 0x7f) + 1980; 73 | 74 | return mktime($hrs, $min, $sec, $mon, $day, $year); 75 | } 76 | 77 | /** 78 | * Converts Windows FILETIME format timestamps to UNIX timestamps. 79 | * 80 | * @param integer $low the low 32 bits 81 | * @param integer $high the high 32 bits 82 | * @return integer UNIX timestamp 83 | */ 84 | public static function win2unixtime($low, $high) 85 | { 86 | $ushift = 116444736000000000; 87 | $ftime = self::int64($low, $high); 88 | 89 | return (int) floor(($ftime - $ushift) / 10000000); 90 | } 91 | 92 | /** 93 | * Converts a numeric value passed by reference to a hexadecimal string. 94 | * 95 | * @param mixed $value the numeric value to convert 96 | * @return void 97 | */ 98 | public static function convert2hex(&$value) 99 | { 100 | if (is_numeric($value)) { 101 | $value = base_convert($value, 10, 16); 102 | } 103 | } 104 | 105 | /** 106 | * Calculates the size of the given file. 107 | * 108 | * This is fiddly on 32-bit systems for sizes larger than 2GB due to internal 109 | * limitations - filesize() returns a signed long - and so needs hackery. 110 | * 111 | * @param string $file full path to the file 112 | * @return integer|float the file size in bytes 113 | */ 114 | public static function getFileSize($file) 115 | { 116 | // 64-bit systems should be OK 117 | if (PHP_INT_SIZE > 4) 118 | return filesize($file); 119 | 120 | // Hack for Windows 121 | if (DIRECTORY_SEPARATOR === '\\') { 122 | if (! extension_loaded('com_dotnet')) { 123 | return trim(shell_exec('for %f in ('.escapeshellarg($file).') do @echo %~zf')) + 0; 124 | } 125 | $com = new COM('Scripting.FileSystemObject'); 126 | $f = $com->GetFile($file); 127 | return $f->Size + 0; 128 | } 129 | 130 | // Hack for *nix 131 | $os = php_uname(); 132 | if (stripos($os, 'Darwin') !== false) { 133 | $command = 'stat -f %z '.escapeshellarg($file); 134 | } elseif (stripos($os, 'DiskStation') !== false) { 135 | $command = 'ls -l '.escapeshellarg($file).' | awk \'{print $5}\''; 136 | } else { 137 | $command = 'stat -c %s '.escapeshellarg($file); 138 | } 139 | return trim(shell_exec($command)) + 0; 140 | } 141 | 142 | /** 143 | * Returns human-readable byte sizes as formatted strings. 144 | * 145 | * @param integer $bytes the size to format 146 | * @param integer $round decimal places limit 147 | * @return string human-readable size 148 | */ 149 | public static function formatSize($bytes, $round=1) 150 | { 151 | $suffix = array('B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'); 152 | for ($i = 0; $bytes > 1024 && isset($suffix[$i+1]); $i++) {$bytes /= 1024;} 153 | return round($bytes, $round).' '.$suffix[$i]; 154 | } 155 | 156 | /** 157 | * Creates a directory if it doesn't already exist. 158 | * 159 | * @param string $dir the directory path 160 | * @return boolean false if the directory already exists 161 | */ 162 | public static function makeDirectory($dir) 163 | { 164 | if (file_exists($dir)) 165 | return false; 166 | 167 | mkdir($dir, 0777, TRUE); 168 | chmod($dir, 0777); 169 | 170 | return true; 171 | } 172 | 173 | /** 174 | * Returns all the positions of a case-sensitive needle in a haystack string. 175 | * With an array of needles, the result will be a sorted list with the positions 176 | * as keys and a list of all matching needle keys as the values. 177 | * 178 | * @param string $haystack the string to search 179 | * @param string|array $needle the string or list of strings to find 180 | * @return array|boolean the needle positions, or false if none found 181 | */ 182 | public static function strposall($haystack, $needle, $offset=0) 183 | { 184 | $start = $offset; 185 | $hlen = strlen($haystack); 186 | $isArray = is_array($needle); 187 | $results = array(); 188 | 189 | foreach ((array) $needle as $key => $value) { 190 | if (($vlen = strlen($value)) == 0) 191 | continue; 192 | while ($offset < $hlen && ($pos = strpos($haystack, $value, $offset)) !== false) { 193 | $offset = $pos + $vlen; 194 | if ($isArray) { 195 | $results[$pos][] = $key; 196 | } else { 197 | $results[$pos] = $pos; 198 | } 199 | } 200 | $offset = $start; 201 | } 202 | if (!empty($results)) { 203 | ksort($results); 204 | return $isArray ? $results : array_values($results); 205 | } 206 | 207 | return false; 208 | } 209 | 210 | // ------ Instance variables and methods --------------------------------------- 211 | 212 | /** 213 | * The last error message. 214 | * @var string 215 | */ 216 | public $error = ''; 217 | 218 | /** 219 | * The number of files in the archive file/data. 220 | * @var integer 221 | */ 222 | public $fileCount = 0; 223 | 224 | /** 225 | * Default constructor for loading and analyzing archive files. 226 | * 227 | * @param string $file path to the archive file 228 | * @param boolean $isFragment true if file is an archive fragment 229 | * @param array $range the start and end byte positions 230 | * @return void 231 | */ 232 | public function __construct($file=null, $isFragment=false, array $range=null) 233 | { 234 | if ($file) $this->open($file, $isFragment, $range); 235 | } 236 | 237 | /** 238 | * Opens a handle to the archive file and analyzes the archive contents, 239 | * optionally within a defined byte range only. 240 | * 241 | * @param string $file path to the file 242 | * @param boolean $isFragment true if file is an archive fragment 243 | * @param array $range the start and end byte positions 244 | * @return boolean false if archive analysis fails 245 | */ 246 | public function open($file, $isFragment=false, array $range=null) 247 | { 248 | $this->reset(); 249 | $this->isFragment = $isFragment; 250 | if (!$this->setRange($range)) {return false;} 251 | 252 | if (!$file || !($archive = realpath($file)) || !is_file($archive)) { 253 | $this->error = "File does not exist ($file)"; 254 | return false; 255 | } 256 | 257 | $this->file = $archive; 258 | $this->fileSize = self::getFileSize($archive); 259 | if (!$this->end) {$this->end = $this->fileSize - 1;} 260 | if (!$this->checkRange()) {return false;} 261 | 262 | // Open the file handle 263 | $this->handle = fopen($archive, 'rb'); 264 | $this->rewind(); 265 | 266 | return $this->analyze(); 267 | } 268 | 269 | /** 270 | * Loads data up to maxReadBytes and analyzes the archive contents, optionally 271 | * within a defined byte range only. 272 | * 273 | * This method is recommended when dealing with file fragments. 274 | * 275 | * @param string $data archive data to be analyzed 276 | * @param boolean $isFragment true if data is an archive fragment 277 | * @param array $range the start and end byte positions 278 | * @return boolean false if archive analysis fails 279 | */ 280 | public function setData($data, $isFragment=false, array $range=null) 281 | { 282 | $this->reset(); 283 | $this->isFragment = $isFragment; 284 | if (!$this->setRange($range)) {return false;} 285 | 286 | if (($dsize = strlen($data)) == 0) { 287 | $this->error = 'No data was passed, nothing to analyze'; 288 | return false; 289 | } 290 | 291 | // Store the data locally up to max bytes 292 | $data = ($dsize > $this->maxReadBytes) ? substr($data, 0, $this->maxReadBytes) : $data; 293 | $this->dataSize = strlen($data); 294 | if (!$this->end) {$this->end = $this->dataSize - 1;} 295 | if (!$this->checkRange()) {return false;} 296 | $this->data = $data; 297 | 298 | $this->rewind(); 299 | return $this->analyze(); 300 | } 301 | 302 | /** 303 | * Closes any open file handle and unsets any stored data. 304 | * 305 | * @return void 306 | */ 307 | public function close() 308 | { 309 | if (is_resource($this->handle)) { 310 | fclose($this->handle); 311 | $this->handle = null; 312 | } 313 | $this->data = ''; 314 | } 315 | 316 | /** 317 | * Sets the maximum number of stored data bytes to analyze. 318 | * 319 | * @param integer $bytes the max bytes to read 320 | * @return void 321 | */ 322 | public function setMaxReadBytes($bytes) 323 | { 324 | if (is_int($bytes) && $bytes > 0) { 325 | $this->maxReadBytes = $bytes; 326 | } 327 | } 328 | 329 | /** 330 | * A full summary will be returned by default when converting the archive 331 | * object to a string, such as when echoing it. 332 | * 333 | * @return string archive summary 334 | */ 335 | public function __toString() 336 | { 337 | return print_r($this->getSummary(true), true); 338 | } 339 | 340 | /** 341 | * Magic method for accessing protected properties. 342 | * 343 | * @param string $name the property name 344 | * @return mixed the property value 345 | * @throws RuntimeException 346 | * @throws LogicException 347 | */ 348 | public function __get($name) 349 | { 350 | // For backwards compatibility 351 | if ($name == 'file') {return $this->file;} 352 | 353 | if (!isset($this->$name)) 354 | throw new RuntimeException('Undefined property: '.get_class($this).'::$'.$name); 355 | 356 | throw new LogicException('Cannot access protected property '.get_class($this).'::$'.$name); 357 | } 358 | 359 | /** 360 | * Class destructor. 361 | * 362 | * @return void 363 | */ 364 | public function __destruct() 365 | { 366 | $this->deleteTempFiles(); 367 | } 368 | 369 | /** 370 | * Convenience method that outputs a summary list of the archive information, 371 | * useful for pretty-printing. 372 | * 373 | * @param boolean $full return a full summary? 374 | * @return array archive summary 375 | */ 376 | abstract public function getSummary($full=false); 377 | 378 | /** 379 | * Parses the stored archive info and returns a list of records for each of the 380 | * files in the archive. 381 | * 382 | * @return array list of file records, empty if none are available 383 | */ 384 | abstract public function getFileList(); 385 | 386 | /** 387 | * Returns the position of the archive marker/signature. 388 | * 389 | * @return mixed Marker position, or false if none found 390 | */ 391 | abstract public function findMarker(); 392 | 393 | /** 394 | * Parses the archive data and stores the results locally. 395 | * 396 | * @return boolean false if parsing fails 397 | */ 398 | abstract protected function analyze(); 399 | 400 | /** 401 | * Path to the archive file (if any). 402 | * @var string 403 | */ 404 | protected $file = ''; 405 | 406 | /** 407 | * File handle for the current archive. 408 | * @var resource 409 | */ 410 | protected $handle; 411 | 412 | /** 413 | * The maximum number of stored data bytes to analyze. 414 | * @var integer 415 | */ 416 | protected $maxReadBytes = 1048576; 417 | 418 | /** 419 | * The maximum length of filenames (for sanity checking). 420 | * @var integer 421 | */ 422 | protected $maxFilenameLength = 256; 423 | 424 | /** 425 | * Is this a file/data fragment? 426 | * @var boolean 427 | */ 428 | protected $isFragment = false; 429 | 430 | /** 431 | * The stored archive file data. 432 | * @var string 433 | */ 434 | protected $data = ''; 435 | 436 | /** 437 | * The size in bytes of the currently stored data. 438 | * @var integer 439 | */ 440 | protected $dataSize = 0; 441 | 442 | /** 443 | * The size in bytes of the archive file. 444 | * @var integer 445 | */ 446 | protected $fileSize = 0; 447 | 448 | /** 449 | * The starting position for the analysis. 450 | * @var integer 451 | */ 452 | protected $start = 0; 453 | 454 | /** 455 | * The ending position for the analysis. 456 | * @var integer 457 | */ 458 | protected $end = 0; 459 | 460 | /** 461 | * The number of bytes to analyze from the $start position. 462 | * @var integer 463 | */ 464 | protected $length = 0; 465 | 466 | /** 467 | * The current position relative to the $start position. 468 | * @var integer 469 | */ 470 | protected $offset = 0; 471 | 472 | /** 473 | * The position of the marker/signature relative to the $start position. 474 | * @var integer 475 | */ 476 | protected $markerPosition; 477 | 478 | /** 479 | * The list of any temporary files created by the reader. 480 | * @var array 481 | */ 482 | protected $tempFiles = array(); 483 | 484 | /** 485 | * Sets the absolute start and end positions in the file/data to be analyzed 486 | * (zero-indexed and inclusive of the end byte). 487 | * 488 | * @param array $range the start and end byte positions 489 | * @return boolean false if ranges are invalid 490 | */ 491 | protected function setRange(array $range=null) 492 | { 493 | $start = isset($range[0]) ? (int) $range[0] : 0; 494 | $end = isset($range[1]) ? (int) $range[1] : 0; 495 | 496 | if ($start != $range[0] || $end != $range[1] || $start < 0 || $end < 0) { 497 | $this->error = "Start ($start) and end ($end) points must be positive integers"; 498 | return false; 499 | } 500 | if ($end < $start) { 501 | $this->error = "End point ($end) must be higher than start point ($start)"; 502 | return false; 503 | } 504 | $this->start = $start; 505 | $this->end = $end; 506 | 507 | return $this->checkRange(); 508 | } 509 | 510 | /** 511 | * Determines whether the currently set start and end ranges are within the 512 | * bounds of the available data, and if not sets an error message. 513 | * 514 | * @return boolean 515 | */ 516 | protected function checkRange() 517 | { 518 | $this->length = $this->end - $this->start + 1; 519 | $mlen = $this->file ? $this->fileSize : $this->dataSize; 520 | if ($mlen && ($this->end >= $mlen || $this->start >= $mlen || $this->length < 1)) { 521 | $this->error = "Byte range ({$this->start}-{$this->end}) is invalid"; 522 | return false; 523 | } 524 | $this->error = ''; 525 | 526 | return true; 527 | } 528 | 529 | /** 530 | * Returns data within the given absolute byte range of the current file/data. 531 | * 532 | * @param array $range the absolute start and end positions 533 | * @return string|boolean the requested data or false on error 534 | */ 535 | protected function getRange(array $range) 536 | { 537 | // Check that the requested range is valid 538 | $original = array($this->start, $this->end, $this->length); 539 | if (!$this->setRange($range)) { 540 | list($this->start, $this->end, $this->length) = $original; 541 | return false; 542 | } 543 | 544 | // Get the data 545 | $this->seek(0); 546 | $data = $this->read($this->length); 547 | 548 | // Restore the original range 549 | list($this->start, $this->end, $this->length) = $original; 550 | 551 | return $data; 552 | } 553 | 554 | /** 555 | * Saves data within the given absolute byte range of the current file/data to 556 | * the destination file. 557 | * 558 | * @param array $range the absolute start and end positions 559 | * @param string $destination full path of the file to create 560 | * @return integer|boolean number of bytes written or false on error 561 | */ 562 | protected function saveRange(array $range, $destination) 563 | { 564 | // Check that the requested range is valid 565 | $original = array($this->start, $this->end, $this->length); 566 | if (!$this->setRange($range)) { 567 | list($this->start, $this->end, $this->length) = $original; 568 | return false; 569 | } 570 | 571 | // Write the buffered data to disk 572 | $this->seek(0); 573 | $fh = fopen($destination, 'wb'); 574 | $rlen = $this->length; 575 | $written = 0; 576 | while ($this->offset < $this->length) { 577 | $data = $this->read(min(1024, $rlen)); 578 | $rlen -= strlen($data); 579 | $written += fwrite($fh, $data); 580 | } 581 | fclose($fh); 582 | 583 | // Restore the original range 584 | list($this->start, $this->end, $this->length) = $original; 585 | 586 | return $written; 587 | } 588 | 589 | /** 590 | * Reads the given number of bytes from the archive file/data and moves the 591 | * offset pointer forward. 592 | * 593 | * @param integer $num number of bytes to read 594 | * @return string the byte string 595 | * @throws InvalidArgumentException 596 | * @throws RangeException 597 | */ 598 | protected function read($num) 599 | { 600 | if ($num == 0) return ''; 601 | 602 | // Check that enough data is available 603 | $newPos = $this->offset + $num; 604 | if ($num < 1 || $newPos > $this->length) 605 | throw new InvalidArgumentException("Could not read {$num} bytes from offset {$this->offset}"); 606 | 607 | // Read the requested bytes 608 | if ($this->file && is_resource($this->handle)) { 609 | $read = fread($this->handle, $num); 610 | } elseif ($this->data) { 611 | $read = substr($this->data, $this->tell(), $num); 612 | } 613 | 614 | // Confirm the read length 615 | if (!isset($read) || (($rlen = strlen($read)) < $num)) { 616 | $rlen = isset($rlen) ? $rlen : 'none'; 617 | $this->error = "Not enough data to read ({$num} bytes requested, {$rlen} available)"; 618 | throw new RangeException($this->error); 619 | } 620 | 621 | // Move the data pointer 622 | $this->offset = $newPos; 623 | 624 | return $read; 625 | } 626 | 627 | /** 628 | * Moves the current offset pointer to a position in the stored data or file 629 | * relative to the start position. 630 | * 631 | * Note that seeking in files past the 2GB limit on 32-bit systems is either 632 | * impossible or needs an incredibly slow hack due to the fseek() pointer not 633 | * behaving after 2GB. The only real solution here is to use a 64-bit system. 634 | * 635 | * @param integer $pos new pointer position 636 | * @return void 637 | * @throws RuntimeException 638 | * @throws InvalidArgumentException 639 | */ 640 | protected function seek($pos) 641 | { 642 | if ($pos > $this->length || $pos < 0) 643 | throw new InvalidArgumentException("Could not seek to {$pos} (max: {$this->length})"); 644 | 645 | if ($this->file && is_resource($this->handle)) { 646 | $max = PHP_INT_MAX; 647 | $file_pos = $this->start + $pos; 648 | if ($file_pos >= $max) { 649 | $this->error = 'The file is too large for this PHP version (> '.self::formatSize($max).')'; 650 | throw new RuntimeException($this->error); 651 | } 652 | fseek($this->handle, $file_pos, SEEK_SET); 653 | } 654 | 655 | $this->offset = $pos; 656 | } 657 | 658 | /** 659 | * Provides the absolute position within the current file/data rather than 660 | * the offset relative to the defined start position. 661 | * 662 | * @return integer the absolute file/data position 663 | */ 664 | protected function tell() 665 | { 666 | if ($this->file && is_resource($this->handle)) 667 | return ftell($this->handle); 668 | 669 | return $this->start + $this->offset; 670 | } 671 | 672 | /** 673 | * Sets the file/data offset pointer to the starting position. 674 | * 675 | * @return void 676 | */ 677 | protected function rewind() 678 | { 679 | if ($this->file && is_resource($this->handle)) { 680 | rewind($this->handle); 681 | } 682 | $this->seek(0); 683 | } 684 | 685 | /** 686 | * Saves the current stored data to a temporary file and returns its name. 687 | * 688 | * @return string path to the temporary file 689 | */ 690 | protected function createTempDataFile() 691 | { 692 | list($hash, $dest) = $this->getTempFileName(); 693 | 694 | if (file_exists($dest)) 695 | return $this->tempFiles[$hash] = $dest; 696 | 697 | file_put_contents($dest, $this->data); 698 | chmod($dest, 0777); 699 | 700 | return $this->tempFiles[$hash] = $dest; 701 | } 702 | 703 | /** 704 | * Calculates a temporary file name based on hashes of the given data string 705 | * or the stored data. 706 | * 707 | * @param string $data the source data to be hashed 708 | * @return array the hash and temporary file path values 709 | */ 710 | protected function getTempFileName($data=null) 711 | { 712 | $hash = $data ? md5($data) : md5(substr($this->data, 0, 16*1024)); 713 | $path = $this->getTempDirectory().DIRECTORY_SEPARATOR.$hash.'.tmp'; 714 | 715 | return array($hash, $path); 716 | } 717 | 718 | /** 719 | * Returns the absolute path to the directory for storing temporary files, 720 | * and creates any parent directories if they don't already exist. 721 | * 722 | * @return string the temporary directory path 723 | */ 724 | protected function getTempDirectory() 725 | { 726 | $dir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'archivereader'; 727 | self::makeDirectory($dir); 728 | 729 | return $dir; 730 | } 731 | 732 | /** 733 | * Deletes any temporary files created by the reader. 734 | * 735 | * @return void 736 | */ 737 | protected function deleteTempFiles() 738 | { 739 | foreach ($this->tempFiles as $temp) { 740 | @unlink($temp); 741 | } 742 | $this->tempFiles = array(); 743 | } 744 | 745 | /** 746 | * Resets the instance variables before parsing new data. 747 | * 748 | * @return void 749 | */ 750 | protected function reset() 751 | { 752 | $this->close(); 753 | $this->file = ''; 754 | $this->data = ''; 755 | $this->fileSize = 0; 756 | $this->dataSize = 0; 757 | $this->start = 0; 758 | $this->end = 0; 759 | $this->length = 0; 760 | $this->offset = 0; 761 | $this->error = ''; 762 | $this->isFragment = false; 763 | $this->fileCount = 0; 764 | $this->markerPosition = null; 765 | $this->deleteTempFiles(); 766 | clearstatcache(); 767 | } 768 | 769 | } // End ArchiveReader class 770 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zeebinz/rarinfo", 3 | "description": "PHP archive reader helper class", 4 | "type": "library", 5 | "license": "", 6 | "authors": [ 7 | { 8 | "name": "zeebinz" 9 | } 10 | ], 11 | "require": { 12 | "php": ">=5.6.0" 13 | }, 14 | "autoload": { 15 | "classmap": [ 16 | "archiveinfo.php", 17 | "archivereader.php", 18 | "par2info.php", 19 | "pipereader.php", 20 | "rarinfo.php", 21 | "sfvinfo.php", 22 | "srrinfo.php", 23 | "szipinfo.php", 24 | "zipinfo.php" 25 | ] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /par2info.php: -------------------------------------------------------------------------------- 1 | 17 | * 18 | * // Load the PAR2 file or data 19 | * $par2 = new Par2Info; 20 | * $par2->open('./foo.par2'); // or $par2->setData($data); 21 | * if ($par2->error) { 22 | * echo "Error: {$par2->error}\n"; 23 | * exit; 24 | * } 25 | * 26 | * // Process the recovery set file list & hashes 27 | * $files = $par2->getFileList(); 28 | * foreach ($files as $fileID => $file) { 29 | * echo "Input file: {$file['name']} ({$file['size']}):\n"; 30 | * echo "-- MD5 hash: {$file['hash']}:\n"; 31 | * echo "-- MD5 hash (16KB): {$file['hash_16K']}:\n"; 32 | * } 33 | * } 34 | * 35 | * 36 | * 37 | * @link http://parchive.sourceforge.net/docs/specifications/parity-volume-spec/article-spec.html 38 | * 39 | * @author Hecks 40 | * @copyright (c) 2010-2013 Hecks 41 | * @license Modified BSD 42 | * @version 1.7 43 | */ 44 | class Par2Info extends ArchiveReader 45 | { 46 | // ------ Class constants ----------------------------------------------------- 47 | 48 | /**#@+ 49 | * PAR2 file format values 50 | */ 51 | 52 | // Packet Marker 53 | const PACKET_MARKER = "PAR2\x00PKT"; 54 | 55 | // Core packet types 56 | const PACKET_MAIN = "PAR 2.0\x00Main\x00\x00\x00\x00"; 57 | const PACKET_FILEDESC = "PAR 2.0\x00FileDesc"; 58 | const PACKET_FILEVER = "PAR 2.0\x00IFSC\x00\x00\x00\x00"; 59 | const PACKET_RECOVERY = "PAR 2.0\x00RecvSlic"; 60 | const PACKET_CREATOR = "PAR 2.0\x00Creator\x00"; 61 | 62 | // Optional packet types 63 | const PACKET_FILENAME_UC = "PAR 2.0\x00UniFileN"; 64 | const PACKET_COMMENT_ASCII = "PAR 2.0\x00CommASCI"; 65 | const PACKET_COMMENT_UC = "PAR 2.0\x00CommUni\x00"; 66 | const PACKET_INPUT_BLOCK = "PAR 2.0\x00FileSlic"; 67 | const PACKET_RECOVERY_VER = "PAR 2.0\x00RFSC\x00\x00\x00\x00"; 68 | const PACKET_PACKED_MAIN = "PAR 2.0\x00PkdMain\x00"; 69 | const PACKET_PACKED_RECOVERY = "PAR 2.0\x00PkdRecvS"; 70 | 71 | /**#@-*/ 72 | 73 | /** 74 | * Format for unpacking each PAR2 packet header, in standard and Perl-compatible 75 | * (PHP >= 5.5.0) versions. 76 | */ 77 | const FORMAT_PACKET_HEADER = 'A8head_marker/Vhead_length/Vhead_length_high/H32head_hash/H32head_set_id/A16head_type'; 78 | const PL_FORMAT_PACKET_HEADER = 'a8head_marker/Vhead_length/Vhead_length_high/H32head_hash/H32head_set_id/a16head_type'; 79 | 80 | /** 81 | * Format for unpacking the body of a Main packet. 82 | */ 83 | const FORMAT_PACKET_MAIN = 'Vblock_size/Vblock_size_high/Vrec_file_count'; 84 | 85 | /** 86 | * Format for unpacking the body of a File Description packet. 87 | */ 88 | const FORMAT_PACKET_FILEDESC = 'H32file_id/H32file_hash/H32file_hash_16K/Vfile_length/Vfile_length_high'; 89 | 90 | 91 | // ------ Instance variables and methods --------------------------------------- 92 | 93 | /** 94 | * List of packet names corresponding to packet types. 95 | * @var array 96 | */ 97 | protected $packetNames = array( 98 | self::PACKET_MAIN => 'Main', 99 | self::PACKET_FILEDESC => 'File Description', 100 | self::PACKET_FILEVER => 'File Block Verification', 101 | self::PACKET_RECOVERY => 'Recovery Block', 102 | self::PACKET_CREATOR => 'Creator', 103 | // Optional 104 | self::PACKET_FILENAME_UC => 'Unicode Filename', 105 | self::PACKET_COMMENT_ASCII => 'Comment ASCII', 106 | self::PACKET_COMMENT_UC => 'Comment Unicode', 107 | self::PACKET_INPUT_BLOCK => 'Input File Block', 108 | self::PACKET_RECOVERY_VER => 'Recovery File Block Verification', 109 | self::PACKET_PACKED_MAIN => 'Packed Main', 110 | self::PACKET_PACKED_RECOVERY => 'Packed Recovery', 111 | ); 112 | 113 | /** 114 | * Number of valid recovery blocks in the file/data. 115 | * @var integer 116 | */ 117 | public $blockCount = 0; 118 | 119 | /** 120 | * Size in bytes of the recovery blocks. 121 | * @var integer 122 | */ 123 | public $blockSize = 0; 124 | 125 | /** 126 | * Details of the client that created the PAR2 file/data. 127 | * @var string 128 | */ 129 | public $client = ''; 130 | 131 | /** 132 | * Convenience method that outputs a summary list of the file/data information, 133 | * useful for pretty-printing. 134 | * 135 | * @param boolean $full add file list to output? 136 | * @return array file/data summary 137 | */ 138 | public function getSummary($full=false) 139 | { 140 | $summary = array( 141 | 'file_name' => $this->file, 142 | 'file_size' => $this->fileSize, 143 | 'data_size' => $this->dataSize, 144 | 'client' => $this->client, 145 | 'block_count' => $this->blockCount, 146 | 'block_size' => $this->blockSize, 147 | 'file_count' => $this->fileCount, 148 | ); 149 | if ($full) { 150 | $summary['file_list'] = $this->getFileList(); 151 | } 152 | if ($this->error) { 153 | $summary['error'] = $this->error; 154 | } 155 | 156 | return $summary; 157 | } 158 | 159 | /** 160 | * Returns a list of the PAR2 packets found in the file/data in human-readable 161 | * format (for debugging purposes only). 162 | * 163 | * @param boolean $full include all packet details in output? 164 | * @return array|boolean list of packets, or false if none available 165 | */ 166 | public function getPackets($full=false) 167 | { 168 | // Check that packets are stored 169 | if (empty($this->packets)) {return false;} 170 | 171 | // Build the packet list 172 | $ret = array(); 173 | 174 | foreach ($this->packets AS $packet) { 175 | 176 | // File Block Verification packets are very verbose 177 | if (!$full && $packet['head_type'] == self::PACKET_FILEVER) {continue;} 178 | 179 | $p = array(); 180 | $p['type_name'] = isset($this->packetNames[$packet['head_type']]) ? $this->packetNames[$packet['head_type']] : 'Unknown'; 181 | $p += $packet; 182 | 183 | // Sanity check filename length 184 | if (isset($p['file_name'])) {$p['file_name'] = substr($p['file_name'], 0, $this->maxFilenameLength);} 185 | $ret[] = $p; 186 | } 187 | 188 | return $ret; 189 | } 190 | 191 | /** 192 | * Parses the stored packets and returns a list of records for each of the 193 | * files in the recovery set. 194 | * 195 | * @return array list of file records, empty if none are available 196 | */ 197 | public function getFileList() 198 | { 199 | $ret = array(); 200 | foreach ($this->packets as $packet) { 201 | if ($packet['head_type'] == self::PACKET_FILEDESC && !isset($ret[$packet['file_id']])) { 202 | $ret[$packet['file_id']] = $this->getFilePacketSummary($packet); 203 | } 204 | if ($packet['head_type'] == self::PACKET_FILEVER && empty($ret[$packet['file_id']]['blocks'])) { 205 | $ret[$packet['file_id']]['blocks'] = count($packet['block_checksums']); 206 | } 207 | } 208 | 209 | return $ret; 210 | } 211 | 212 | /** 213 | * List of File IDs found in the file/data. 214 | * @var array 215 | */ 216 | protected $fileIDs = array(); 217 | 218 | /** 219 | * List of packets found in the file/data. 220 | * @var array 221 | */ 222 | protected $packets = array(); 223 | 224 | /** 225 | * Returns a processed summary of a PAR2 File Description packet. 226 | * 227 | * @param array $packet a valid File Description packet 228 | * @return array summary information 229 | */ 230 | protected function getFilePacketSummary($packet) 231 | { 232 | return array( 233 | 'name' => !empty($packet['file_name']) ? substr($packet['file_name'], 0, $this->maxFilenameLength) : 'Unknown', 234 | 'size' => isset($packet['file_length']) ? $packet['file_length'] : 0, 235 | 'hash' => $packet['file_hash'], 236 | 'hash_16K' => $packet['file_hash_16K'], 237 | 'blocks' => 0, 238 | 'next_offset' => $packet['next_offset'], 239 | ); 240 | } 241 | 242 | /** 243 | * Returns the position of the first PAR2 Packet Marker string in the file/data. 244 | * 245 | * @return mixed Marker position, or false if none found 246 | */ 247 | public function findMarker() 248 | { 249 | if ($this->markerPosition !== null) 250 | return $this->markerPosition; 251 | 252 | try { 253 | $buff = $this->read(min($this->length, $this->maxReadBytes)); 254 | $this->rewind(); 255 | return $this->markerPosition = strpos($buff, self::PACKET_MARKER); 256 | } catch (Exception $e) { 257 | return false; 258 | } 259 | } 260 | 261 | /** 262 | * Parses the PAR2 data and stores a list of valid packets locally. 263 | * 264 | * @return boolean false if parsing fails 265 | */ 266 | protected function analyze() 267 | { 268 | // Find the first Packet Marker, if there is one 269 | if (($startPos = $this->findMarker()) === false) { 270 | $this->error = 'Could not find a Packet Marker, not a valid PAR2 file'; 271 | return false; 272 | } 273 | $this->seek($startPos); 274 | 275 | // Analyze all packets 276 | while ($this->offset < $this->length) try { 277 | 278 | // Get the next packet header 279 | $packet = $this->getNextPacket(); 280 | 281 | // Verify the packet 282 | if ($this->verifyPacket($packet) === false) { 283 | $this->error = "Packet failed checksum (offset: {$this->offset})"; 284 | throw new Exception('Packet checksum failed'); 285 | } 286 | 287 | // Process the current packet by type 288 | $this->processPacket($packet); 289 | 290 | // Add the current packet to the list 291 | $this->packets[] = $packet; 292 | 293 | // Skip to the next packet, if any 294 | if ($this->offset != $packet['next_offset']) { 295 | $this->seek($packet['next_offset']); 296 | } 297 | 298 | // Sanity check 299 | if ($packet['offset'] == $this->offset) { 300 | $this->error = 'Parsing seems to be stuck'; 301 | $this->close(); 302 | return false; 303 | } 304 | 305 | // No more readable data, or read error 306 | } catch (Exception $e) { 307 | if ($this->error) {$this->close(); return false;} 308 | break; 309 | } 310 | 311 | // Check for valid packets 312 | if (empty($this->packets)) { 313 | $this->error = 'No valid PAR2 packets were found'; 314 | $this->close(); 315 | return false; 316 | } 317 | 318 | // Analysis was successful 319 | $this->close(); 320 | return true; 321 | } 322 | 323 | /** 324 | * Reads the start of the next packet header and returns the common packet 325 | * info before further processing by packet type. 326 | * 327 | * @return array the next packet header info 328 | */ 329 | protected function getNextPacket() 330 | { 331 | // Start the packet info 332 | $packet = array('offset' => $this->offset); 333 | 334 | // Unpack the packet header 335 | $format = (version_compare(PHP_VERSION, '5.5.0') >= 0) 336 | ? self::PL_FORMAT_PACKET_HEADER 337 | : self::FORMAT_PACKET_HEADER; 338 | $packet += self::unpack($format, $this->read(64)); 339 | 340 | // Convert packet size (64-bit integer) 341 | $packet['head_length'] = self::int64($packet['head_length'], $packet['head_length_high']); 342 | 343 | // Add offset info for next packet (if any) 344 | $packet['next_offset'] = $packet['offset'] + $packet['head_length']; 345 | 346 | // Return the packet info 347 | return $packet; 348 | } 349 | 350 | /** 351 | * Verifies that the given packet is valid and parsable. 352 | * 353 | * @param array $packet the packet to verify 354 | * @return boolean false on failure 355 | */ 356 | protected function verifyPacket($packet) 357 | { 358 | $offset = $this->offset; 359 | 360 | // Check the MD5 hash of the data from head_set_id to packet end 361 | $this->seek($packet['offset'] + 32); 362 | $data = $this->read($packet['head_length'] - 32); 363 | $this->seek($offset); 364 | 365 | return (md5($data) === $packet['head_hash']); 366 | } 367 | 368 | /** 369 | * Processes a packet passed by reference and unpacks its body. 370 | * 371 | * @param array $packet the packet to process 372 | * @return void 373 | */ 374 | protected function processPacket(&$packet) 375 | { 376 | // Packet type: MAIN 377 | if ($packet['head_type'] == self::PACKET_MAIN) { 378 | $packet += self::unpack(self::FORMAT_PACKET_MAIN, $this->read(12)); 379 | $packet['block_size'] = self::int64($packet['block_size'], $packet['block_size_high']); 380 | $this->blockSize = $packet['block_size']; 381 | 382 | // Unpack the File IDs of all files in the recovery set 383 | $recoverable = array(); 384 | for ($i = 0; $i < $packet['rec_file_count']; $i++) { 385 | $recoverable = array_merge($recoverable, self::unpack('H32', $this->read(16))); 386 | } 387 | $packet['rec_file_ids'] = $recoverable; 388 | 389 | // Unpack any File IDs of files not in the recovery set 390 | $unrecoverable = array(); 391 | while ($this->offset < $packet['next_offset']) { 392 | $unrecoverable = array_merge($unrecoverable, self::unpack('H32', $this->read(16))); 393 | } 394 | if (!empty($unrecoverable)) { 395 | $packet['other_file_ids'] = $unrecoverable; 396 | } 397 | } 398 | 399 | // Packet type: FILE DESCRIPTION 400 | elseif ($packet['head_type'] == self::PACKET_FILEDESC) { 401 | $packet += self::unpack(self::FORMAT_PACKET_FILEDESC, $this->read(56)); 402 | $packet['file_length'] = self::int64($packet['file_length'], $packet['file_length_high']); 403 | $len = ($packet['offset'] + $packet['head_length']) - $this->offset; 404 | $packet['file_name'] = rtrim($this->read($len)); 405 | 406 | // Add ID to the stored file list so we don't double-count 407 | if (!isset($this->fileIDs[$packet['file_id']])) { 408 | $this->fileIDs[$packet['file_id']] = true; 409 | $this->fileCount++; 410 | } 411 | } 412 | 413 | // Packet type: FILE BLOCK VERIFICATION 414 | elseif ($packet['head_type'] == self::PACKET_FILEVER) { 415 | $packet += self::unpack('H32file_id', $this->read(16)); 416 | 417 | // Unpack the MD5/CRC32 checksum pairs 418 | $packet['block_checksums'] = array(); 419 | while ($this->offset < $packet['next_offset']) { 420 | $packet['block_checksums'][] = self::unpack('H32md5/Vcrc32', $this->read(20)); 421 | } 422 | } 423 | 424 | // Packet type: RECOVERY BLOCK 425 | elseif ($packet['head_type'] == self::PACKET_RECOVERY) { 426 | $packet += self::unpack('Vexponent', $this->read(4)); 427 | $this->blockCount++; 428 | } 429 | 430 | // Packet type: CREATOR 431 | elseif ($packet['head_type'] == self::PACKET_CREATOR) { 432 | $len = ($packet['offset'] + $packet['head_length']) - $this->offset; 433 | $packet['client'] = $this->read($len); 434 | $this->client = rtrim($packet['client']); 435 | } 436 | } 437 | 438 | /** 439 | * Resets the instance variables before parsing new data. 440 | * 441 | * @return void 442 | */ 443 | protected function reset() 444 | { 445 | parent::reset(); 446 | 447 | $this->client = ''; 448 | $this->blockCount = 0; 449 | $this->blockSize = 0; 450 | $this->fileIDs = array(); 451 | $this->packets = array(); 452 | } 453 | 454 | } // End Par2Info class 455 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ./tests 7 | ./tests/fixtures 8 | ./tests/bin 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /pipereader.php: -------------------------------------------------------------------------------- 1 | errors.txt' to the command) or by combining with STDOUT ('2>&1'). 13 | * Then the application must either read the file or parse the output for errors. 14 | * 15 | * @author Hecks 16 | * @copyright (c) 2010-2013 Hecks 17 | * @license Modified BSD 18 | * @version 1.2 19 | */ 20 | class PipeReader 21 | { 22 | /** 23 | * The last error message. 24 | * @var string 25 | */ 26 | public $error = ''; 27 | 28 | /** 29 | * The last process exit code. 30 | * @var integer 31 | */ 32 | public $exitCode = 0; 33 | 34 | /** 35 | * Default constructor for opening a pipe. 36 | * 37 | * @param string $command the command to execute 38 | * @return void 39 | */ 40 | public function __construct($command=null) 41 | { 42 | if ($command) $this->open($command); 43 | } 44 | 45 | /** 46 | * Opens a pipe to stream the output of a given command. 47 | * 48 | * Note that it's the responsibility of the calling application to sanitize 49 | * the command with e.g. escapeshellcmd(), escapeshellarg(), etc. 50 | * 51 | * @param string $command the command to execute 52 | * @return boolean false if the pipe could not be openend 53 | */ 54 | public function open($command) 55 | { 56 | $this->reset(); 57 | 58 | if (!($handle = popen($command, 'rb'))) { 59 | $this->error = "Could not execute command: ($command)"; 60 | return false; 61 | } 62 | 63 | $this->handle = $handle; 64 | $this->command = $command; 65 | 66 | return true; 67 | } 68 | 69 | /** 70 | * Closes any open pipe handle and sets the exit code. 71 | * 72 | * @return void 73 | */ 74 | public function close() 75 | { 76 | if (is_resource($this->handle)) { 77 | $this->exitCode = pclose($this->handle); 78 | } 79 | $this->handle = null; 80 | } 81 | 82 | /** 83 | * Reads the given number of bytes from the piped command output and moves the 84 | * offset pointer forward, with optional confirmation that the requested bytes 85 | * are available. 86 | * 87 | * @param integer $num number of bytes to read 88 | * @param booleann $confirm check available bytes? 89 | * @return string the byte string 90 | * @throws InvalidArgumentException 91 | */ 92 | public function read($num, $confirm=true) 93 | { 94 | if ($num < 1) { 95 | throw new InvalidArgumentException("Could not read {$num} bytes from offset {$this->offset}"); 96 | } elseif ($num == 0) { 97 | return ''; 98 | } 99 | 100 | // Read the requested bytes 101 | if ($this->command && is_resource($this->handle)) { 102 | $read = ''; $rlen = $num; 103 | while ($rlen > 0 && !feof($this->handle)) { 104 | $data = fread($this->handle, min($this->maxReadBytes, $rlen)); 105 | $rlen -= strlen($data); 106 | $read .= $data; 107 | } 108 | } 109 | 110 | // Confirm the read length? 111 | if ($confirm && (!isset($read) || strlen($read) < $num)) { 112 | $this->offset = $this->tell(); 113 | throw new InvalidArgumentException("Could not read {$num} bytes from offset {$this->offset}"); 114 | } 115 | 116 | // Move the data pointer 117 | $this->offset = $this->tell(); 118 | 119 | return isset($read) ? $read : ''; 120 | } 121 | 122 | /** 123 | * Convenience method for reading the remaining bytes from the piped output. 124 | * 125 | * @return string the remaining output data 126 | */ 127 | public function readAll() 128 | { 129 | $data = ''; 130 | while ($read = $this->read($this->maxReadBytes, false)) { 131 | $data .= $read; 132 | } 133 | 134 | return $data; 135 | } 136 | 137 | /** 138 | * Convenience method for reading a single line, with the line ending included 139 | * in the output. 140 | * 141 | * @return string|boolean the next output line, or false if none available 142 | */ 143 | public function readLine() 144 | { 145 | if (!$this->command || !is_resource($this->handle) || feof($this->handle)) 146 | return false; 147 | 148 | $line = fgets($this->handle, $this->maxReadBytes); 149 | $this->offset = $this->tell(); 150 | 151 | return $line; 152 | } 153 | 154 | /** 155 | * Moves the current offset pointer to a position in the piped command output. 156 | * 157 | * Since only seeking ahead in the pipe is possible - and only by reading the 158 | * output stream - seeking to an earlier offset necessarily means invoking the 159 | * command again, so care must be taken that any commands are idempotent. 160 | * 161 | * @param integer $pos new pointer position 162 | * @return void 163 | * @throws InvalidArgumentException 164 | */ 165 | public function seek($pos) 166 | { 167 | if ($pos < 0) 168 | throw new InvalidArgumentException("Could not seek to offset: {$pos}"); 169 | 170 | if ($this->command) { 171 | if ($pos < $this->offset) { 172 | $this->open($this->command); 173 | } 174 | if ($pos > $this->offset && $pos > 0) { 175 | $this->read($pos - $this->offset); 176 | } 177 | } 178 | 179 | $this->offset = $this->tell(); 180 | } 181 | 182 | /** 183 | * Provides the absolute position within the current piped output. 184 | * 185 | * @return integer the absolute position 186 | */ 187 | public function tell() 188 | { 189 | if ($this->command && is_resource($this->handle)) 190 | return ftell($this->handle); 191 | 192 | return $this->offset; 193 | } 194 | 195 | /** 196 | * Sets the maximum number of bytes to read in one operation. 197 | * 198 | * @param integer $bytes the max bytes to read 199 | * @return void 200 | */ 201 | public function setMaxReadBytes($bytes) 202 | { 203 | if (is_int($bytes) && $bytes > 0) { 204 | $this->maxReadBytes = $bytes; 205 | } 206 | } 207 | 208 | /** 209 | * The command string to be executed for piped output. 210 | * @var string 211 | */ 212 | protected $command = ''; 213 | 214 | /** 215 | * Stream handle for the current pipe. 216 | * @var resource 217 | */ 218 | protected $handle; 219 | 220 | /** 221 | * The maximum number of bytes to read in one operation. 222 | * @var integer 223 | */ 224 | protected $maxReadBytes = 1048576; 225 | 226 | /** 227 | * The current position in the piped output. 228 | * @var integer 229 | */ 230 | protected $offset = 0; 231 | 232 | /** 233 | * Resets the instance variables. 234 | * 235 | * @return void 236 | */ 237 | protected function reset() 238 | { 239 | $this->close(); 240 | $this->error = ''; 241 | $this->exitCode = 0; 242 | $this->command = ''; 243 | $this->offset = 0; 244 | } 245 | 246 | } // End PipeReader class 247 | -------------------------------------------------------------------------------- /sfvinfo.php: -------------------------------------------------------------------------------- 1 | 14 | * 15 | * // Load the SFV file or data 16 | * $sfv = new SfvInfo; 17 | * $sfv->open('./foo.sfv'); // or $sfv->setData($data); 18 | * if ($sfv->error) { 19 | * echo "Error: {$sfv->error}\n"; 20 | * exit; 21 | * } 22 | * 23 | * // Process the file list 24 | * $files = $sfv->getFileList(); 25 | * foreach ($files as $file) { 26 | * echo $file['name'].' - '.$file['checksum']; 27 | * } 28 | * 29 | * 30 | * 31 | * @author Hecks 32 | * @copyright (c) 2010-2013 Hecks 33 | * @license Modified BSD 34 | * @version 2.1 35 | */ 36 | class SfvInfo extends ArchiveReader 37 | { 38 | /** 39 | * SFV file comments. 40 | * @var string 41 | */ 42 | public $comments = ''; 43 | 44 | /** 45 | * Convenience method that outputs a summary list of the SFV file records, 46 | * useful for pretty-printing. 47 | * 48 | * @param boolean $basenames don't include full file paths? 49 | * @return array file record summary 50 | */ 51 | public function getSummary($full=false, $basenames=false) 52 | { 53 | $summary = array( 54 | 'file_name' => $this->file, 55 | 'file_size' => $this->fileSize, 56 | 'data_size' => $this->dataSize, 57 | 'use_range' => "{$this->start}-{$this->end}", 58 | 'file_count' => $this->fileCount, 59 | ); 60 | if ($full) { 61 | $summary['file_list'] = $this->getFileList($basenames); 62 | } 63 | if ($this->error) { 64 | $summary['error'] = $this->error; 65 | } 66 | 67 | return $summary; 68 | } 69 | 70 | /** 71 | * Returns a list of file records with checksums from the source SFV file. 72 | * 73 | * @param boolean $basenames don't include full file paths? 74 | * @return array list of file records, empty if none are available 75 | */ 76 | public function getFileList($basenames=false) 77 | { 78 | if ($basenames) { 79 | $ret = array(); 80 | foreach ($this->fileList as $item) { 81 | $item['name'] = pathinfo($item['name'], PATHINFO_BASENAME); 82 | $ret[] = $item; 83 | } 84 | return $ret; 85 | } 86 | 87 | return $this->fileList; 88 | } 89 | 90 | /** 91 | * Returns the position of the archive marker/signature. 92 | * 93 | * @return mixed Marker position, or false if none found 94 | */ 95 | public function findMarker() 96 | { 97 | return $this->markerPosition = 0; 98 | } 99 | 100 | /** 101 | * The parsed file list with checksum info. 102 | * @var array 103 | */ 104 | protected $fileList = array(); 105 | 106 | /** 107 | * Parses the source data and stores a list of valid file records locally. 108 | * 109 | * @return boolean false if parsing fails 110 | */ 111 | protected function analyze() 112 | { 113 | // Get the available data up to the maximum allowed 114 | try { 115 | $data = $this->read(min($this->length, $this->maxReadBytes)); 116 | } catch (Exception $e) { 117 | $this->close(); 118 | return false; 119 | } 120 | 121 | // Split on all line ending types 122 | foreach (preg_split('/\R/', $data, -1, PREG_SPLIT_NO_EMPTY) as $line) { 123 | 124 | // Store comment lines 125 | if (strpos($line, ';') === 0) { 126 | $this->comments .= trim(substr($line, 1))."\n"; 127 | continue; 128 | } 129 | 130 | if (preg_match('/^(.+?)\s+([[:xdigit:]]{2,8})$/', trim($line), $matches)) { 131 | 132 | // Store the file record locally 133 | $this->fileList[] = array( 134 | 'name' => $matches[1], 135 | 'checksum' => $matches[2] 136 | ); 137 | 138 | // Increment the filecount 139 | $this->fileCount++; 140 | 141 | } else { 142 | 143 | // Contains invalid chars, so assume this isn't an SFV 144 | $this->error = 'Not a valid SFV file'; 145 | $this->close(); 146 | return false; 147 | } 148 | } 149 | 150 | // Analysis was successful 151 | $this->close(); 152 | return true; 153 | } 154 | 155 | /** 156 | * Resets the instance variables before parsing new data. 157 | * 158 | * @return void 159 | */ 160 | protected function reset() 161 | { 162 | parent::reset(); 163 | $this->fileList = array(); 164 | $this->comments = ''; 165 | } 166 | 167 | } // End SfvInfo class 168 | -------------------------------------------------------------------------------- /srrinfo.php: -------------------------------------------------------------------------------- 1 | 15 | * 16 | * // Load the SRR file or data 17 | * $srr = new SrrInfo; 18 | * $srr->open('./foo.srr'); // or $srr->setData($data); 19 | * if ($srr->error) { 20 | * echo "Error: {$srr->error}\n"; 21 | * exit; 22 | * } 23 | * 24 | * // Get any SRR stored files 25 | * $stored = $srr->getStoredFiles(); 26 | * foreach ($stored as $file) { 27 | * echo "{$file['name']} ({$file['size']}):\n"; 28 | * echo $file['data']; 29 | * } 30 | * 31 | * // Inspect the RAR file list 32 | * $volumes = $srr->getFileList(); 33 | * foreach ($volumes as $vol) { 34 | * echo "Archive volume: {$vol['name']}\n"; 35 | * foreach ($vol['files'] as $file) { 36 | * if ($file['pass'] == true) { 37 | * echo "-- File is passworded: {$file['name']}\n"; 38 | * } 39 | * } 40 | * } 41 | * 42 | * 43 | * 44 | * @author Hecks 45 | * @copyright (c) 2010-2013 Hecks 46 | * @license Modified BSD 47 | * @version 2.3 48 | */ 49 | class SrrInfo extends RarInfo 50 | { 51 | // ------ Class constants ----------------------------------------------------- 52 | 53 | /**#@+ 54 | * SRR file format values 55 | */ 56 | 57 | // SRR Block types 58 | const SRR_BLOCK_MARK = 0x69; 59 | const SRR_STORED_FILE = 0x6a; 60 | const SRR_OSO_HASH = 0x6b; 61 | const SRR_RAR_FILE = 0x71; 62 | 63 | // Flags for SRR Marker block 64 | const APP_NAME_PRESENT = 0x0001; 65 | 66 | /**#@-*/ 67 | 68 | /** 69 | * Format for unpacking any OSO hash blocks. 70 | */ 71 | const FORMAT_SRR_OSO_HASH = 'Vfile_size/Vfile_size_high/h16file_hash/vname_size'; 72 | 73 | 74 | // ------ Instance variables and methods --------------------------------------- 75 | 76 | /** 77 | * Signature for the SRR Marker block. 78 | * @var string 79 | */ 80 | protected $markerBlock = "\x69\x69\x69"; 81 | 82 | /** 83 | * List of block names corresponding to SRR block types. 84 | * @var array 85 | */ 86 | protected $srrBlockNames = array( 87 | self::SRR_BLOCK_MARK => 'SRR Marker', 88 | self::SRR_STORED_FILE => 'Stored File', 89 | self::SRR_OSO_HASH => 'OSO Hash', 90 | self::SRR_RAR_FILE => 'RAR File', 91 | ); 92 | 93 | /** 94 | * List of block types and Subblock subtypes without bodies. 95 | * @var array 96 | */ 97 | protected $headersOnly = array( 98 | 'type' => array(self::BLOCK_FILE), 99 | 'subtype' => array(self::SUBTYPE_RECOVERY), 100 | ); 101 | 102 | /** 103 | * Details of the client that created the file/data. 104 | * @var string 105 | */ 106 | public $client = ''; 107 | 108 | /** 109 | * Initializes the class instance. 110 | * 111 | * @return void 112 | */ 113 | public function __construct($file=null, $isFragment=false, array $range=null) 114 | { 115 | // Merge the SRR and RAR block names 116 | $this->blockNames = $this->srrBlockNames + $this->blockNames; 117 | 118 | parent::__construct($file, $isFragment, $range); 119 | } 120 | 121 | /** 122 | * Convenience method that outputs a summary list of the SRR information, 123 | * useful for pretty-printing. 124 | * 125 | * @param boolean $full include full info, e.g. stored file data? 126 | * @param boolean $skipDirs should RAR directory entries be skipped? 127 | * @return array SRR file summary 128 | */ 129 | public function getSummary($full=false, $skipDirs=false) 130 | { 131 | $summary = array( 132 | 'file_name' => $this->file, 133 | 'file_size' => $this->fileSize, 134 | 'data_size' => $this->dataSize, 135 | 'client' => $this->client, 136 | 'stored_files' => $this->getStoredFiles($full), 137 | ); 138 | if ($osoInfo = $this->getOsoInfo()) { 139 | $summary['oso_info'] = $osoInfo; 140 | } 141 | $fileList = $this->getFileList($skipDirs); 142 | $summary['file_count'] = count($fileList); 143 | if ($full) { 144 | $summary['file_list'] = $fileList; 145 | } 146 | if ($this->error) { 147 | $summary['error'] = $this->error; 148 | } 149 | 150 | return $summary; 151 | } 152 | 153 | /** 154 | * Parses the stored blocks and returns a list of the Stored File records, 155 | * optionally with the file content data included. 156 | * 157 | * @param boolean $extract include file data in the result? 158 | * @return mixed false if no stored files blocks available, or array of records 159 | */ 160 | public function getStoredFiles($extract=true) 161 | { 162 | if (empty($this->blocks)) {return false;} 163 | $ret = array(); 164 | 165 | foreach ($this->blocks as $block) { 166 | if ($block['head_type'] == self::SRR_STORED_FILE) { 167 | $b = array( 168 | 'name' => $block['file_name'], 169 | 'size' => $block['add_size'], 170 | ); 171 | if ($extract) { 172 | $b['data'] = $block['file_data']; 173 | } 174 | $ret[] = $b; 175 | } 176 | } 177 | 178 | return $ret; 179 | } 180 | 181 | /** 182 | * Parses the stored blocks and returns summary info of any OSO hash block 183 | * in the SRR file/data. 184 | * 185 | * @link http://trac.opensubtitles.org/projects/opensubtitles/wiki/HashSourceCodes 186 | * 187 | * @return array|boolean OSO info, or false if none is available 188 | */ 189 | public function getOsoInfo() 190 | { 191 | if (empty($this->blocks)) {return false;} 192 | 193 | foreach ($this->blocks as $block) { 194 | if ($block['head_type'] == self::SRR_OSO_HASH) { 195 | return array( 196 | 'name' => $block['file_name'], 197 | 'size' => $block['file_size'], 198 | 'hash' => $block['file_hash'], 199 | ); 200 | } 201 | } 202 | 203 | return false; 204 | } 205 | 206 | /** 207 | * Parses the stored blocks and returns a list of the RAR volume records that the 208 | * SRR data covers. 209 | * 210 | * @param boolean $skipDirs should directory entries be skipped? 211 | * @return array|boolean list of file records, or false if none are available 212 | */ 213 | public function getFileList($skipDirs=false) 214 | { 215 | $list = array(); 216 | $i = -1; 217 | 218 | foreach ($this->blocks as $block) { 219 | 220 | // Start a new RAR volume record 221 | if ($block['head_type'] == self::SRR_RAR_FILE) { 222 | $list[++$i] = array('name' => $block['file_name']); 223 | 224 | // Append the file summaries to the current volume record 225 | } elseif ($block['head_type'] == self::BLOCK_FILE) { 226 | if ($skipDirs && !empty($block['is_dir'])) {continue;} 227 | $list[$i]['files'][] = $this->getFileBlockSummary($block); 228 | } 229 | } 230 | 231 | return $list; 232 | } 233 | 234 | // SRR files do not include any file contents 235 | public function getFileData($filename) {return false;} 236 | public function saveFileData($filename, $destination) {return false;} 237 | public function extractFile($filename, $destination=null, $password=null) {return false;} 238 | 239 | /** 240 | * Parses the SRR data and stores a list of valid blocks locally. 241 | * 242 | * @return boolean false if parsing fails 243 | */ 244 | protected function analyze() 245 | { 246 | // Find the SRR MARKER block, or abort if none is found 247 | if (($startPos = $this->findMarker()) === false) { 248 | $this->error = 'Could not find Marker block, not a valid SRR file'; 249 | return false; 250 | } 251 | 252 | // Start at the SRR MARKER block 253 | $this->seek($startPos); 254 | 255 | // Analyze all valid blocks 256 | while ($this->offset < $this->length) try { 257 | 258 | // Get the next block header 259 | $block = $this->getNextBlock(); 260 | 261 | // Block type: SRR MARKER 262 | if ($block['head_type'] == self::SRR_BLOCK_MARK) { 263 | if ($block['head_flags'] & self::APP_NAME_PRESENT) { 264 | $block += self::unpack('vapp_name_size', $this->read(2), false); 265 | $block['app_name'] = $this->read($block['app_name_size']); 266 | $this->client = $block['app_name']; 267 | } 268 | if ($block['head_flags'] > 1 || $this->offset != $block['next_offset']) { 269 | $this->error = 'Could not find Marker block, not a valid SRR file'; 270 | return false; 271 | } 272 | 273 | // Block type: STORED FILE 274 | } elseif ($block['head_type'] == self::SRR_STORED_FILE) { 275 | $block += self::unpack('vname_size', $this->read(2), false); 276 | $block['file_name'] = $this->read($block['name_size']); 277 | $block['file_data'] = $this->read($block['add_size']); 278 | 279 | // Block type: OSO HASH 280 | } elseif ($block['head_type'] == self::SRR_OSO_HASH) { 281 | $block += self::unpack(self::FORMAT_SRR_OSO_HASH, $this->read(18)); 282 | $block['file_hash'] = strrev($block['file_hash']); 283 | $block['file_size'] = self::int64($block['file_size'], $block['file_size_high']); 284 | $block['file_name'] = $this->read($block['name_size']); 285 | 286 | // Block type: SRR RAR FILE 287 | } elseif ($block['head_type'] == self::SRR_RAR_FILE) { 288 | $block += self::unpack('vname_size', $this->read(2), false); 289 | $block['file_name'] = $this->read($block['name_size']); 290 | 291 | // Default to RAR block processing 292 | } else { 293 | parent::processBlock($block); 294 | } 295 | 296 | // Add current block to the list 297 | $this->blocks[] = $block; 298 | 299 | // Skip to the next block, if any 300 | if ($this->offset != $block['next_offset']) { 301 | $this->seek($block['next_offset']); 302 | } 303 | 304 | // Sanity check 305 | if ($block['offset'] == $this->offset) { 306 | $this->error = 'Parsing seems to be stuck'; 307 | $this->close(); 308 | return false; 309 | } 310 | 311 | // No more readable data, or read error 312 | } catch (Exception $e) { 313 | if ($this->error) {$this->close(); return false;} 314 | break; 315 | } 316 | 317 | // Analysis was successful 318 | return true; 319 | } 320 | 321 | /** 322 | * Resets the instance variables before parsing new data. 323 | * 324 | * @return void 325 | */ 326 | protected function reset() 327 | { 328 | parent::reset(); 329 | $this->client = ''; 330 | } 331 | 332 | } // End SrrInfo class 333 | -------------------------------------------------------------------------------- /tests/ArchiveReaderTest.php: -------------------------------------------------------------------------------- 1 | fixturesDir = realpath(dirname(__FILE__).'/fixtures'); 22 | $this->testFile = $this->fixturesDir.$ds.'rar'.$ds.'4mb.rar'; 23 | } 24 | 25 | /** 26 | * We need to be able to seek accurately through the available data and check 27 | * that read requests are not out of bounds. Trying to read past the last byte 28 | * or seeking out of bounds should throw an exception. 29 | */ 30 | public function testHandlesBasicSeekingAndReading() 31 | { 32 | $data = "some sample data for testing\n"; 33 | $length = strlen($data); 34 | $archive = new TestArchiveReader; 35 | 36 | $archive->data = $data; 37 | $archive->start = 0; 38 | $archive->end = $length - 1; 39 | $archive->length = $length; 40 | 41 | // Within bounds 42 | $archive->seek(0); 43 | $this->assertSame(0, $archive->offset); 44 | $archive->seek(3); 45 | $this->assertSame(3, $archive->offset); 46 | $archive->seek($archive->end); 47 | $this->assertSame($archive->end, $archive->offset); 48 | $archive->seek(0); 49 | $this->assertSame(0, $archive->offset); 50 | 51 | $read = $archive->read(0); 52 | $this->assertSame(0, $archive->offset); 53 | $this->assertSame('', $read); 54 | $read = $archive->read(1); 55 | $this->assertSame(1, $archive->offset); 56 | $this->assertSame('s', $read); 57 | $read = $archive->read(3); 58 | $this->assertSame(4, $archive->offset); 59 | $this->assertSame('ome', $read); 60 | $read = $archive->read(3); 61 | $this->assertSame(7, $archive->offset); 62 | $this->assertSame(' sa', $read); 63 | 64 | $archive->seek(5); 65 | $read = $archive->read(6); 66 | $this->assertSame(11, $archive->offset); 67 | $this->assertSame('sample', $read); 68 | $archive->seek($archive->end); 69 | $read = $archive->read(1); 70 | $this->assertSame($archive->end + 1, $archive->offset); 71 | $this->assertSame("\n", $read); 72 | 73 | $archive->seek(0); 74 | $read = $archive->read($length); 75 | $this->assertSame($archive->end + 1, $archive->offset); 76 | $this->assertSame($data, $read); 77 | 78 | // Out of bounds 79 | $archive->seek(0); 80 | try { 81 | $archive->seek($archive->end + 5); 82 | } catch (InvalidArgumentException $e) {} 83 | $this->assertSame(0, $archive->offset); 84 | try { 85 | $archive->seek(-1); 86 | } catch (InvalidArgumentException $e) {} 87 | $this->assertSame(0, $archive->offset); 88 | try { 89 | $archive->read($length + 1); 90 | } catch (InvalidArgumentException $e) {} 91 | $this->assertSame(0, $archive->offset); 92 | try { 93 | $archive->seek($archive->end + 1); 94 | $archive->read(1); 95 | } catch (InvalidArgumentException $e) {} 96 | $this->assertSame($archive->end + 1, $archive->offset); 97 | try { 98 | $archive->seek($archive->end); 99 | $read = $archive->read(2); 100 | } catch (InvalidArgumentException $e) {} 101 | $this->assertSame($archive->end, $archive->offset); 102 | try { 103 | $archive->seek($archive->end - 1); 104 | $read = $archive->read(3); 105 | } catch (InvalidArgumentException $e) {} 106 | $this->assertSame($archive->end - 1, $archive->offset); 107 | } 108 | 109 | /** 110 | * Files can be streamed directly using the open() method, with any errors 111 | * reported via the public $error property. 112 | * 113 | * @depends testHandlesBasicSeekingAndReading 114 | */ 115 | public function testHandlesFileStreams() 116 | { 117 | $archive = new TestArchiveReader; 118 | 119 | $this->assertTrue($archive->open($this->testFile)); 120 | $this->assertEmpty($archive->error, $archive->error); 121 | $this->assertSame($this->testFile, $archive->file); 122 | $this->assertTrue(is_resource($archive->handle)); 123 | $this->assertTrue($archive->analyzed); 124 | 125 | $summary = $archive->getSummary(); 126 | $this->assertSame(filesize($this->testFile), $summary['fileSize']); 127 | $this->assertSame(0, $summary['dataSize']); 128 | 129 | $archive->close(); 130 | $this->assertFalse(is_resource($archive->handle)); 131 | 132 | $this->assertFalse($archive->open('missingfile')); 133 | $this->assertSame('File does not exist (missingfile)', $archive->error); 134 | } 135 | 136 | /** 137 | * Data can be passed directly to the instance via the setData() method, with 138 | * any errors reported via the public $error property. 139 | * 140 | * @depends testHandlesBasicSeekingAndReading 141 | */ 142 | public function testHandlesDataFromMemory() 143 | { 144 | $archive = new TestArchiveReader; 145 | $data = file_get_contents($this->testFile); 146 | 147 | $this->assertTrue($archive->setData($data)); 148 | $this->assertEmpty($archive->error, $archive->error); 149 | $this->assertTrue($archive->analyzed); 150 | $this->assertSame($data, $archive->data); 151 | 152 | $summary = $archive->getSummary(); 153 | $this->assertSame(strlen($data), $summary['dataSize']); 154 | $this->assertEmpty($summary['fileSize']); 155 | $archive->close(); 156 | 157 | $this->assertFalse($archive->setData('')); 158 | $this->assertSame('No data was passed, nothing to analyze', $archive->error); 159 | } 160 | 161 | /** 162 | * We should be able to specify the start and end points for the archive analysis 163 | * transparently, with all offsets made relative to the given start point. 164 | * 165 | * @depends testHandlesFileStreams 166 | * @depends testHandlesDataFromMemory 167 | */ 168 | public function testByteRangesCanBeSpecifiedForAnalysis() 169 | { 170 | $data = file_get_contents($this->testFile); 171 | $fsize = filesize($this->testFile); 172 | $archive = new TestArchiveReader; 173 | 174 | // Without set ranges 175 | $len = $fsize - 1; 176 | $tell = $len - ($len % $archive->readSize); 177 | $archive->open($this->testFile); 178 | $this->assertSame(0, $archive->start); 179 | $this->assertSame($fsize - 1, $archive->end); 180 | $this->assertSame($tell, $archive->tell()); 181 | 182 | $archive->setData($data); 183 | $this->assertSame(0, $archive->start); 184 | $this->assertSame($fsize - 1, $archive->end); 185 | $this->assertSame($tell, $archive->tell()); 186 | 187 | // With set ranges 188 | $ranges = array(array(5, 9), array(0, 99), array(50, 249), array(5, $fsize - 1)); 189 | 190 | foreach ($ranges as $range) 191 | { 192 | $len = $range[1] - $range[0] + 1; 193 | $tell = $range[0] + ($len - ($len % $archive->readSize)); 194 | 195 | $archive->open($this->testFile, false, $range); 196 | $this->assertSame($range[0], $archive->start); 197 | $this->assertSame($range[1], $archive->end); 198 | $this->assertSame($tell, $archive->tell(), "Length is: $len, offset is: {$archive->offset}"); 199 | 200 | $archive->setData($data, false, $range); 201 | $this->assertSame($range[0], $archive->start); 202 | $this->assertSame($range[1], $archive->end); 203 | $this->assertSame($tell, $archive->tell(), "Length is: $len, offset is: {$archive->offset}"); 204 | } 205 | } 206 | 207 | /** 208 | * We shouldn't be able to set ranges that are out of the bounds of any set 209 | * data or opened file, and we should handle these as errors rather than throw 210 | * exceptions. 211 | * 212 | * @depends testByteRangesCanBeSpecifiedForAnalysis 213 | */ 214 | public function testInvalidByteRangesReturnErrors() 215 | { 216 | $data = file_get_contents($this->testFile); 217 | $archive = new TestArchiveReader; 218 | 219 | // Common checks 220 | $range = array(-1, -2); 221 | $regex = '/Start.*end.*positive/'; 222 | $this->assertFalse($archive->setData($data, false, $range)); 223 | $this->assertRegExp($regex, $archive->error); 224 | $this->assertEmpty($archive->data); 225 | $this->assertFalse($archive->open($this->testFile, false, $range)); 226 | $this->assertRegExp($regex, $archive->error); 227 | $this->assertNull($archive->handle); 228 | 229 | $range = array(1.5, 3); 230 | $regex = '/Start.*end.*integer/'; 231 | $this->assertFalse($archive->setData($data, false, $range)); 232 | $this->assertRegExp($regex, $archive->error); 233 | $this->assertEmpty($archive->data); 234 | $this->assertFalse($archive->open($this->testFile, false, $range)); 235 | $this->assertRegExp($regex, $archive->error); 236 | $this->assertNull($archive->handle); 237 | 238 | $range = array(2, 1); 239 | $regex = '/End.*must be higher than start/'; 240 | $this->assertFalse($archive->setData($data, false, $range)); 241 | $this->assertRegExp($regex, $archive->error); 242 | $this->assertEmpty($archive->data); 243 | $this->assertFalse($archive->open($this->testFile, false, $range)); 244 | $this->assertRegExp($regex, $archive->error); 245 | $this->assertNull($archive->handle); 246 | 247 | // Setting data 248 | $archive->setMaxReadBytes(100); 249 | 250 | $range = array(1, 105); 251 | $regex = '/range.*is invalid/'; 252 | $this->assertFalse($archive->setData($data, false, $range)); 253 | $this->assertRegExp($regex, $archive->error); 254 | $this->assertEmpty($archive->data); 255 | 256 | $range = array(101, 105); 257 | $this->assertFalse($archive->setData($data, false, $range)); 258 | $this->assertRegExp($regex, $archive->error); 259 | $this->assertEmpty($archive->data); 260 | 261 | // Opening file 262 | $range = array(0, filesize($this->testFile)); 263 | $this->assertFalse($archive->open($this->testFile, false, $range)); 264 | $this->assertRegExp($regex, $archive->error); 265 | $this->assertNull($archive->handle); 266 | } 267 | 268 | /** 269 | * We should be able to retrieve data from the file/data source using any 270 | * absolute byte range, ignoring any analysis range that has been set. 271 | * 272 | * @depends testInvalidByteRangesReturnErrors 273 | */ 274 | public function testAnyDataCanBeFetchedByByteRange() 275 | { 276 | $file = $this->fixturesDir.'/rar/commented.rar'; 277 | $data = file_get_contents($file); 278 | $content = 'file content'; 279 | $range = array(146, 157); 280 | $archive = new TestArchiveReader; 281 | 282 | // Within bounds 283 | $archive->open($file); 284 | $this->assertSame($content, $archive->getRange($range)); 285 | $archive->setData($data); 286 | $this->assertSame($content, $archive->getRange($range)); 287 | $archive->open($file, false, array(0, 1)); 288 | $this->assertSame($content, $archive->getRange($range)); 289 | $archive->setData($data, false, array(0, 1)); 290 | $this->assertSame($content, $archive->getRange($range)); 291 | 292 | // Out of bounds 293 | $archive->open($file); 294 | $archive->getRange(array(0, filesize($file))); 295 | $this->assertRegExp('/range.*is invalid/', $archive->error); 296 | } 297 | 298 | /** 299 | * We need to be able to save any stored data to temporary files so that we 300 | * can e.g. extract the archive contents using an external client. These 301 | * temporary files should be deleted on reset or destruct. 302 | * 303 | * @depends testHandlesDataFromMemory 304 | */ 305 | public function testCanSaveDataToTemporaryFiles() 306 | { 307 | $archive = new TestArchiveReader; 308 | $data = file_get_contents($this->testFile); 309 | 310 | $archive->setData($data); 311 | $temp = $archive->createTempDataFile(); 312 | $name = pathinfo($temp, PATHINFO_FILENAME); 313 | $this->assertArrayHasKey($name, $archive->tempFiles); 314 | $this->assertSame($temp, $archive->tempFiles[$name]); 315 | $this->assertFileExists($temp); 316 | $this->assertSame(strlen($data), filesize($temp)); 317 | unset($archive); 318 | $this->assertFileNotExists($temp); 319 | } 320 | 321 | /** 322 | * As a variation on strpos, we should be able to return a list of all needle 323 | * positions in a given haystack string, and also handle lists of needles. 324 | * 325 | * @dataProvider providerNeedles 326 | * @group static 327 | */ 328 | public function testCanFindAllNeedlePositionsInAHaystack($needle, $result) 329 | { 330 | $haystack = 'Dog the dog in the dog basket did a log'; 331 | $this->assertSame($result, ArchiveReader::strposall($haystack, $needle)); 332 | } 333 | 334 | /** 335 | * Provides test data for the strposall static method. 336 | */ 337 | public function providerNeedles() 338 | { 339 | return array( 340 | 341 | // Single needles 342 | array('D', array(0)), 343 | array('s', array(25)), 344 | array('the dog', array(4, 15)), 345 | array('dog', array(8, 19)), 346 | array('og', array(1, 9, 20, 37)), 347 | array('d', array(8, 19, 30, 32)), 348 | array('g', array(2, 10, 21, 38)), 349 | 350 | // Arrays of needles 351 | array(array('d', 'og', 'dog'), array( 352 | 1 => array(1), 8 => array(0, 2), 9 => array(1), 19 => array(0, 2), 353 | 20 => array(1), 30 => array(0), 32 => array(0), 37 => array(1), 354 | )), 355 | array(array('key1' => 's', 'key2' => 'the', 'key3' => 't'), array( 356 | 4 => array('key2', 'key3'), 357 | 15 => array('key2', 'key3'), 358 | 25 => array('key1'), 359 | 28 => array('key3'), 360 | )), 361 | ); 362 | } 363 | 364 | } // End ArchiveReaderTest 365 | 366 | class TestArchiveReader extends ArchiveReader 367 | { 368 | // Abstract method implementations 369 | public function getSummary($full=false) 370 | { 371 | return array( 372 | 'fileSize' => $this->fileSize, 373 | 'dataSize' => $this->dataSize, 374 | ); 375 | } 376 | 377 | public function getFileList() {} 378 | public function findMarker() {} 379 | 380 | protected function analyze() 381 | { 382 | while ($this->offset < $this->length) try { 383 | $this->read($this->readSize); 384 | } catch(Exception $e) { 385 | break; 386 | } 387 | $this->analyzed = true; 388 | return true; 389 | } 390 | 391 | // Added for test convenience 392 | public $analyzed = false; 393 | public $readSize = 3; 394 | 395 | // Made public for test convenience 396 | public $handle; 397 | public $data = ''; 398 | public $offset = 0; 399 | public $length = 0; 400 | public $start = 0; 401 | public $end = 0; 402 | public $tempFiles = array(); 403 | 404 | public function seek($pos) 405 | { 406 | parent::seek($pos); 407 | } 408 | 409 | public function read($num, $confirm=true) 410 | { 411 | return parent::read($num, $confirm); 412 | } 413 | 414 | public function tell() 415 | { 416 | return parent::tell(); 417 | } 418 | 419 | public function getRange(array $range) 420 | { 421 | return parent::getRange($range); 422 | } 423 | 424 | public function createTempDataFile() 425 | { 426 | return parent::createTempDataFile(); 427 | } 428 | } 429 | -------------------------------------------------------------------------------- /tests/Par2InfoTest.php: -------------------------------------------------------------------------------- 1 | fixturesDir = realpath(dirname(__FILE__).'/fixtures/par2'); 20 | } 21 | 22 | /** 23 | * PAR2 files consist of self-contained packets with their own checksums, of 24 | * various types. Redundancy means that details of the recovery set are repeated 25 | * across multiple files, and within files. Packets should only be added to the 26 | * list if they pass the internal checksum test. 27 | * 28 | * @dataProvider providerTestFixtures 29 | * @param string $filename sample par2 filename 30 | * @param string $packets expected list of valid packets 31 | */ 32 | public function testStoresListOfAllValidPackets($filename, $packets) 33 | { 34 | $par2 = new Par2Info; 35 | $par2->open($filename); 36 | 37 | $this->assertEmpty($par2->error, $par2->error); 38 | $packetList = $par2->getPackets(true); 39 | $this->assertEquals(count($packets), count($packetList)); 40 | $this->assertEquals($packets, $packetList); 41 | } 42 | 43 | /** 44 | * Provides test data from sample files. 45 | */ 46 | public function providerTestFixtures() 47 | { 48 | $ds = DIRECTORY_SEPARATOR; 49 | $fixturesDir = realpath(dirname(__FILE__).'/fixtures/par2'); 50 | $fixtures = array(); 51 | 52 | foreach (glob($fixturesDir.$ds.'*.par2') as $par2file) { 53 | $fname = pathinfo($par2file, PATHINFO_BASENAME).'.packets'; 54 | $fpath = $fixturesDir.$ds.$fname; 55 | if (file_exists($fpath)) { 56 | $packets = include $fpath; 57 | $fixtures[] = array('filename' => $par2file, 'packets' => $packets); 58 | } 59 | } 60 | 61 | return $fixtures; 62 | } 63 | 64 | /** 65 | * PAR2 files can be inspected to provide a full list of the files included 66 | * in their recovery set, with hashes for checking file integrity. Because 67 | * of redundancy, the same File Description packets can be repeated within a 68 | * single file, so we need to ignore duplicates. 69 | */ 70 | public function testListsAllRecoverySetFilesWithHashes() 71 | { 72 | $par2 = new Par2Info; 73 | $par2->open($this->fixturesDir.'/testdata.vol01+02.par2'); 74 | $this->assertEmpty($par2->error, $par2->error); 75 | 76 | $files = $par2->getFileList(); 77 | $this->assertEquals(2, $par2->blockCount); 78 | $this->assertEquals(10, $par2->fileCount); 79 | $this->assertCount(10, $files); 80 | 81 | $this->assertArrayHasKey('4631d494bc34ae0b3131291eeb3238f6', $files); 82 | $this->assertEquals($files['4631d494bc34ae0b3131291eeb3238f6'], array( 83 | 'name' => 'test-3.data', 84 | 'size' => 142129, 85 | 'hash' => '9e44a776f7a1fac4a3569bf734bacb01', 86 | 'hash_16K' => 'bd1a58ae2f233491c450623596c322eb', 87 | 'blocks' => 27, 88 | 'next_offset' => 5576 89 | )); 90 | 91 | // Check the hash of the whole file contents 92 | $data = file_get_contents($this->fixturesDir.'/test-3.data'); 93 | $this->assertSame($files['4631d494bc34ae0b3131291eeb3238f6']['hash'], md5($data)); 94 | 95 | // Check the hash of the first 16KB of the file 96 | $data = substr($data, 0, 16384); 97 | $this->assertSame($files['4631d494bc34ae0b3131291eeb3238f6']['hash_16K'], md5($data)); 98 | } 99 | 100 | /** 101 | * We should be able to report on the client used to create the PAR2 file. 102 | */ 103 | public function testReportsClientInfo() 104 | { 105 | $par2 = new Par2Info; 106 | $par2->open($this->fixturesDir.'/testdata.par2'); 107 | $this->assertSame('Created by QuickPar 0.4', $par2->client); 108 | } 109 | 110 | } // End Par2InfoTest 111 | -------------------------------------------------------------------------------- /tests/PipeReaderTest.php: -------------------------------------------------------------------------------- 1 | fixturesDir = realpath(dirname(__FILE__).'/fixtures'); 22 | $this->testFile = $this->fixturesDir.$ds.'rar'.$ds.'4mb.rar'; 23 | } 24 | 25 | /** 26 | * When dealing with the piped output of a shell command, we should be able 27 | * to seek/read the same as with file/data sources, although with the option 28 | * to read all of the output without knowing its size in advance. 29 | */ 30 | public function testHandlesBasicSeekingAndReading() 31 | { 32 | $command = DIRECTORY_SEPARATOR === '\\' 33 | ? 'type '.escapeshellarg($this->testFile).' 2>nul' 34 | : 'cat '.escapeshellarg($this->testFile); 35 | $filesize = filesize($this->testFile); 36 | 37 | $reader = new TestPipeReader; 38 | $reader->open($command); 39 | $this->assertEmpty($reader->error); 40 | $this->assertSame(0, $reader->tell()); 41 | 42 | // Seeking 43 | $reader->seek(0); 44 | $this->assertSame(0, $reader->tell()); 45 | $reader->seek($filesize); 46 | $this->assertSame($filesize, $reader->tell()); 47 | $reader->seek(15); 48 | $this->assertSame(15, $reader->tell()); 49 | $this->assertSame($reader->offset, $reader->tell()); 50 | $reader->seek(15); 51 | $this->assertSame(15, $reader->tell()); 52 | $this->assertSame($reader->offset, $reader->tell()); 53 | $reader->seek(100); 54 | $this->assertSame(100, $reader->tell()); 55 | $this->assertSame($reader->offset, $reader->tell()); 56 | 57 | try { 58 | $reader->seek(-1); 59 | } catch (InvalidArgumentException $e) {} 60 | $this->assertSame(100, $reader->tell()); 61 | try { 62 | $reader->seek($filesize + 1); 63 | } catch (InvalidArgumentException $e) {} 64 | $this->assertSame($filesize, $reader->tell()); 65 | 66 | // Reading (with known size) 67 | $reader->seek(0); 68 | $data = $reader->read($filesize); 69 | $this->assertSame($filesize, strlen($data)); 70 | $this->assertSame($filesize, $reader->tell()); 71 | $this->assertSame($reader->offset, $reader->tell()); 72 | 73 | try { 74 | $reader->read(1); 75 | } catch (InvalidArgumentException $e) {} 76 | $this->assertSame($filesize, $reader->tell()); 77 | 78 | $reader->seek(0); 79 | $data = $reader->read(100); 80 | $this->assertSame(100, strlen($data)); 81 | $this->assertSame(100, $reader->tell()); 82 | $data = $reader->read(1); 83 | $this->assertSame(1, strlen($data)); 84 | $this->assertSame(101, $reader->tell()); 85 | 86 | try { 87 | $reader->read($filesize); 88 | } catch (InvalidArgumentException $e) {} 89 | $this->assertSame($filesize, $reader->tell()); 90 | 91 | // Reading (with unknown size) 92 | $reader->seek(0); 93 | $data = ''; 94 | while ($read = $reader->read(8192, false)) { 95 | $data .= $read; 96 | } 97 | $this->assertSame($filesize, strlen($data)); 98 | $this->assertSame($filesize, $reader->tell()); 99 | 100 | $reader->seek(0); 101 | $data = $reader->readAll(); 102 | $this->assertSame($filesize, strlen($data)); 103 | $this->assertSame($filesize, $reader->tell()); 104 | } 105 | 106 | /** 107 | * We also need to be able to read one line at a time, with the line ending 108 | * included in the output. 109 | * 110 | * @depends testHandlesBasicSeekingAndReading 111 | */ 112 | public function testReadsSingleLines() 113 | { 114 | $file = realpath($this->fixturesDir.'/sfv/test001.sfv'); 115 | $command = DIRECTORY_SEPARATOR === '\\' 116 | ? 'type '.escapeshellarg($file).' 2>nul' 117 | : 'cat '.escapeshellarg($file); 118 | 119 | $reader = new TestPipeReader; 120 | $reader->open($command); 121 | $this->assertEmpty($reader->error); 122 | 123 | $line = $reader->readLine(); 124 | $this->assertSame("; example comment\r\n", $line); 125 | while ($data = $reader->readLine()) { 126 | $line = $data; 127 | } 128 | $this->assertSame("testrar.rar 36fbdd27\r\n", $line); 129 | $this->assertFalse($data); 130 | $this->assertSame($reader->offset, $reader->tell()); 131 | } 132 | 133 | } // End PipeReaderTest 134 | 135 | class TestPipeReader extends PipeReader 136 | { 137 | // Made public for test convenience 138 | public $offset = 0; 139 | } 140 | -------------------------------------------------------------------------------- /tests/RarInfoTest.php: -------------------------------------------------------------------------------- 1 | fixturesDir = realpath(dirname(__FILE__).'/fixtures/rar'); 20 | } 21 | 22 | /** 23 | * RAR files consist of a series of header blocks and optional bodies for 24 | * certain block types and subblocks. We should be abe to report an accurate 25 | * list of all blocks in summmary form. 26 | * 27 | * @dataProvider providerTestFixtures 28 | * @param string $filename sample rar filename 29 | * @param string $blocks expected list of valid blocks 30 | */ 31 | public function testStoresListOfAllValidBlocks($filename, $blocks) 32 | { 33 | $rar = new RarInfo; 34 | $rar->open($filename, true); 35 | 36 | $this->assertEmpty($rar->error, $rar->error); 37 | $blockList = $rar->getBlocks(); 38 | $this->assertEquals(count($blocks), count($blockList)); 39 | $this->assertEquals($blocks, $blockList); 40 | } 41 | 42 | /** 43 | * Provides test data from sample files. 44 | */ 45 | public function providerTestFixtures() 46 | { 47 | $ds = DIRECTORY_SEPARATOR; 48 | $fixturesDir = realpath(dirname(__FILE__).'/fixtures/rar'); 49 | $fixtures = array(); 50 | 51 | foreach (glob($fixturesDir.$ds.'*.rar') as $rarfile) { 52 | $fname = pathinfo($rarfile, PATHINFO_BASENAME).'.blocks'; 53 | $fpath = $fixturesDir.$ds.$fname; 54 | if (file_exists($fpath)) { 55 | $blocks = include $fpath; 56 | $fixtures[] = array('filename' => $rarfile, 'blocks' => $blocks); 57 | } 58 | } 59 | 60 | return $fixtures; 61 | } 62 | 63 | /** 64 | * We should be able to report on the contents of the RAR file, with some 65 | * simple processing of the raw File blocks to make them human-readable. 66 | */ 67 | public function testListsAllArchiveFiles() 68 | { 69 | $rar = new RarInfo; 70 | $rar->open($this->fixturesDir.'/multi.part1.rar'); 71 | 72 | $files = $rar->getFileList(); 73 | $this->assertCount(2, $files); 74 | 75 | $this->assertSame('file1.txt', $files[0]['name']); 76 | $this->assertSame(18, $files[0]['size']); 77 | $this->assertSame(1258588344, $files[0]['date']); 78 | $this->assertSame(0, $files[0]['pass']); 79 | $this->assertSame(0, $files[0]['compressed']); 80 | $this->assertSame('66-83', $files[0]['range']); 81 | $this->assertSame('52b28202', $files[0]['crc32']); 82 | $this->assertArrayNotHasKey('split', $files[0]); 83 | $this->assertArrayNotHasKey('split_after', $files[0]); 84 | $this->assertArrayNotHasKey('is_dir', $files[0]); 85 | 86 | $this->assertSame('file2.txt', $files[1]['name']); 87 | $this->assertSame(17704, $files[1]['size']); 88 | $this->assertSame(1258588852, $files[1]['date']); 89 | $this->assertSame(0, $files[1]['pass']); 90 | $this->assertSame(1, $files[1]['compressed']); 91 | $this->assertSame('130-4979', $files[1]['range']); 92 | $this->assertSame('e0222912', $files[1]['crc32']); 93 | $this->assertArrayHasKey('split', $files[1]); 94 | $this->assertArrayHasKey('split_after', $files[1]); 95 | $this->assertArrayNotHasKey('is_dir', $files[1]); 96 | } 97 | 98 | /** 99 | * If the archive files are packed with the Store method, we should just be able 100 | * to extract the file data and use it as is, since it isn't compressed. 101 | */ 102 | public function testExtractsFileDataPackedWithStoreMethod() 103 | { 104 | $rar = new RarInfo; 105 | $rarfile = $this->fixturesDir.'/store_method.rar'; 106 | 107 | // With default byte range 108 | $rar->open($rarfile); 109 | $files = $rar->getFileList(); 110 | $this->assertCount(1, $files); 111 | $this->assertSame(0, $files[0]['compressed']); 112 | $data = $rar->getFileData($files[0]['name']); 113 | $this->assertSame($files[0]['size'], strlen($data)); 114 | $this->assertSame($files[0]['crc32'], dechex(crc32(($data)))); 115 | $this->assertStringStartsWith('At each generation,', $data); 116 | $this->assertStringEndsWith('children, y, is', $data); 117 | 118 | // With range, all data available 119 | $rar->open($rarfile, true, array(1, filesize($rarfile) - 5)); 120 | $files = $rar->getFileList(); 121 | $data = $rar->getFileData($files[0]['name']); 122 | $this->assertSame($files[0]['size'], strlen($data)); 123 | $this->assertStringStartsWith('At each generation,', $data); 124 | $this->assertStringEndsWith('children, y, is', $data); 125 | 126 | // With range, partial data available 127 | $rar->open($rarfile, true, array(1, filesize($rarfile) - 10)); 128 | $files = $rar->getFileList(); 129 | $data = $rar->getFileData($files[0]['name']); 130 | $this->assertSame($files[0]['size'] - 2, strlen($data)); 131 | $this->assertStringStartsWith('At each generation,', $data); 132 | $this->assertStringEndsWith('children, y, ', $data); 133 | } 134 | 135 | /** 136 | * Hooray for progress! The RAR 5.0 archive format is quite different from 137 | * earlier versions, but initially we just want to be able to detect the 138 | * the new format automatically and not break the basic public API. 139 | */ 140 | public function testBasicRar50Support() 141 | { 142 | $rar = new RarInfo; 143 | $rar->open($this->fixturesDir.'/rar50_encrypted_files.rar'); 144 | 145 | // New archive format should be detected 146 | $this->assertSame(RarInfo::FMT_RAR50, $rar->format); 147 | $this->assertEmpty($rar->error); 148 | 149 | // File list output should be the same 150 | $this->assertSame(4, $rar->fileCount); 151 | $files = $rar->getFileList(); 152 | $this->assertCount(4, $files); 153 | 154 | $this->assertSame('testdir/4mb.txt', $files[0]['name']); 155 | $this->assertSame(4194304, $files[0]['size']); 156 | $this->assertSame(0, $files[0]['pass']); 157 | $this->assertSame(0, $files[0]['compressed']); 158 | $this->assertSame(1275178921, $files[0]['date']); 159 | $this->assertSame('122-4194425', $files[0]['range']); 160 | $this->assertSame('def82f5', $files[0]['crc32']); 161 | $this->assertArrayNotHasKey('is_dir', $files[0]); 162 | 163 | $this->assertSame('testdir', $files[1]['name']); 164 | $this->assertSame(0, $files[1]['size']); 165 | $this->assertSame(0, $files[1]['pass']); 166 | $this->assertSame(0, $files[1]['compressed']); 167 | $this->assertSame(1368906855, $files[1]['date']); 168 | $this->assertArrayNotHasKey('range', $files[1]); 169 | $this->assertArrayNotHasKey('crc32', $files[1]); 170 | $this->assertArrayHasKey('is_dir', $files[1]); 171 | 172 | $this->assertSame('testdir/bar.txt', $files[2]['name']); 173 | $this->assertSame(13, $files[2]['size']); 174 | $this->assertSame(1, $files[2]['pass']); 175 | $this->assertSame(1, $files[2]['compressed']); 176 | $this->assertSame(1369170252, $files[2]['date']); 177 | $this->assertSame('4194559-4194590', $files[2]['range']); 178 | $this->assertSame('3b947aa0', $files[2]['crc32']); 179 | $this->assertArrayNotHasKey('is_dir', $files[2]); 180 | 181 | $this->assertSame('foo.txt', $files[3]['name']); 182 | $this->assertSame(13, $files[3]['size']); 183 | $this->assertSame(0, $files[3]['pass']); 184 | $this->assertSame(0, $files[3]['compressed']); 185 | $this->assertSame(1369170262, $files[3]['date']); 186 | $this->assertSame('4194647-4194659', $files[3]['range']); 187 | $this->assertSame('d4ac3fee', $files[3]['crc32']); 188 | $this->assertArrayNotHasKey('is_dir', $files[3]); 189 | 190 | $data = $rar->getFileData('foo.txt'); 191 | $this->assertSame('foo test text', $data); 192 | $this->assertSame($files[3]['crc32'], dechex(crc32(($data)))); 193 | 194 | // Bonus! Archive comments are no longer compressed 195 | $this->assertSame("test archive comment\x00", $rar->comments); 196 | 197 | // Encrypted headers 198 | $this->assertFalse($rar->isEncrypted); 199 | $rar->open($this->fixturesDir.'/rar50_encrypted_headers.rar'); 200 | $this->assertTrue($rar->isEncrypted); 201 | $this->assertSame(0, $rar->fileCount); 202 | $this->assertCount(1, $rar->getBlocks()); 203 | } 204 | 205 | /** 206 | * We should have an easy way to retrieve a list of cached file headers from 207 | * a RAR 5.0 Quick Open block, if it exists. 208 | */ 209 | public function testRar50ListsQuickOpenCachedFiles() 210 | { 211 | $rar = new RarInfo; 212 | $rar->open($this->fixturesDir.'/rar50_quickopen.rar'); 213 | $this->assertSame(RarInfo::FMT_RAR50, $rar->format); 214 | $this->assertEmpty($rar->error); 215 | 216 | $files = $rar->getQuickOpenFileList(); 217 | $this->assertCount(4, $files); 218 | 219 | $this->assertSame('testdir/4mb.txt', $files[0]['name']); 220 | $this->assertSame(4194304, $files[0]['size']); 221 | $this->assertSame(0, $files[0]['compressed']); 222 | $this->assertArrayNotHasKey('range', $files[0]); 223 | 224 | $this->assertSame('testdir', $files[1]['name']); 225 | $this->assertArrayHasKey('is_dir', $files[1]); 226 | 227 | $this->assertSame('compressed.txt', $files[2]['name']); 228 | $this->assertSame(4194304, $files[2]['size']); 229 | $this->assertSame(1, $files[2]['compressed']); 230 | $this->assertArrayNotHasKey('range', $files[2]); 231 | 232 | $this->assertSame('bar.txt', $files[3]['name']); 233 | $this->assertSame(13, $files[3]['size']); 234 | $this->assertSame(1, $files[3]['pass']); 235 | $this->assertSame(1, $files[3]['compressed']); 236 | $this->assertArrayNotHasKey('range', $files[3]); 237 | } 238 | 239 | /** 240 | * We want to bail as early as possible when handling archives with encrypted 241 | * headers, as there's not much else we can do with them. 242 | */ 243 | public function testHandlesEncryptedArchivesGracefully() 244 | { 245 | $rar = new RarInfo; 246 | $rar->open($this->fixturesDir.'/encrypted_headers.rar'); 247 | $this->assertTrue($rar->isEncrypted); 248 | $this->assertCount(2, $rar->getBlocks()); 249 | 250 | $summary = $rar->getSummary(true); 251 | $this->assertSame(1, $summary['is_encrypted']); 252 | $this->assertSame(0, $summary['file_count']); 253 | $this->assertSame(0, $rar->fileCount); 254 | $this->assertEmpty($summary['file_list']); 255 | $this->assertCount(0, $rar->getFileList()); 256 | } 257 | 258 | /** 259 | * Provides the path to the external client executable, or false if it 260 | * doesn't exist in the given directory. 261 | * 262 | * @return string|boolean the absolute path to the executable, or false 263 | */ 264 | protected function getUnrarPath() 265 | { 266 | $unrar = DIRECTORY_SEPARATOR === '\\' 267 | ? dirname(__FILE__).'\bin\unrar\UnRAR.exe' 268 | : dirname(__FILE__).'/bin/unrar/unrar'; 269 | 270 | if (file_exists($unrar)) 271 | return $unrar; 272 | 273 | return false; 274 | } 275 | 276 | /** 277 | * Decompression of archive contents should be possible by using an external 278 | * client to read the current file, or temporary files for data sources. The 279 | * test should be skipped if no external client is available. 280 | * 281 | * @group external 282 | */ 283 | public function testDecompressesWithExternalClient() 284 | { 285 | if (!($unrar = $this->getUnrarPath())) { 286 | $this->markTestSkipped(); 287 | } 288 | $rar = new RarInfo; 289 | 290 | // From a file source 291 | $rarfile = $this->fixturesDir.'/solid.rar'; 292 | $rar->open($rarfile); 293 | $this->assertEmpty($rar->error); 294 | 295 | $files = $rar->getFileList(); 296 | $file = $files[1]; 297 | $this->assertSame('unrardll.txt', $file['name']); 298 | $this->assertSame(1, $file['compressed']); 299 | 300 | $data = $rar->extractFile($file['name']); 301 | $this->assertNotEmpty($rar->error); 302 | $this->assertContains('external client', $rar->error); 303 | $this->assertFalse($data); 304 | 305 | $rar->setExternalClient($unrar); 306 | $data = $rar->extractFile($file['name']); 307 | $this->assertEmpty($rar->error); 308 | $this->assertSame($file['size'], strlen($data)); 309 | $this->assertSame($file['crc32'], dechex(crc32(($data)))); 310 | $this->assertStringStartsWith("\r\n UnRAR.dll Manual", $data); 311 | $this->assertStringEndsWith("to access this function.\r\n\r\n", $data); 312 | 313 | // From a data source (via temp file) 314 | $rar->setData(file_get_contents($rarfile)); 315 | $this->assertEmpty($rar->error); 316 | $summary = $rar->getSummary(true); 317 | $this->assertSame(filesize($rarfile), $summary['data_size']); 318 | $data = $rar->extractFile($file['name']); 319 | $this->assertEmpty($rar->error); 320 | $this->assertSame($file['size'], strlen($data)); 321 | } 322 | 323 | /** 324 | * Fixes issue #9, memory exhaustion on bad RAR50 file 325 | * 326 | * @depends testBasicRar50Support 327 | */ 328 | public function testRar50BadExtraSize() 329 | { 330 | $rar = new RarInfo; 331 | $rar->open($this->fixturesDir.'/rar50_bad_extra_size.rar'); 332 | 333 | $this->assertSame(RarInfo::FMT_RAR50, $rar->format); 334 | $this->assertEmpty($rar->error); 335 | 336 | // The third file entry is corrupt, so isn't displayed in file list 337 | $this->assertSame(3, $rar->fileCount); 338 | $files = $rar->getFileList(); 339 | $this->assertCount(2, $files); 340 | 341 | // Bad block, header size points beyond file end 342 | $blocks = $rar->getBlocks(); 343 | $this->assertSame(5, count($blocks)); 344 | $this->assertSame('File', $blocks[4]['type']); 345 | $this->assertSame(11, $blocks[4]['extra_size']); 346 | $this->assertEmpty($blocks[4]['file_name']); 347 | 348 | // Comments in summary 349 | $summary = $rar->getSummary(); 350 | $this->assertArrayHasKey('comments', $summary); 351 | $this->assertStringStartsWith('German:', $summary['comments']); 352 | } 353 | 354 | } // End RarInfoTest 355 | -------------------------------------------------------------------------------- /tests/SfvInfoTest.php: -------------------------------------------------------------------------------- 1 | fixturesDir = realpath(dirname(__FILE__).'/fixtures/sfv'); 20 | } 21 | 22 | /** 23 | * SFV files can cover individual files and whole directory trees with simple 24 | * CRC32 checksums, and can include comments. Parsing should handle spaces and 25 | * different directory separators, and all line ending types. 26 | * 27 | * @dataProvider providerSfvFileRecords 28 | * @param string $filename sample sfv filename 29 | * @param string $filelist parsed file list 30 | */ 31 | public function testListsAllFilesWithChecksums($filename, $filelist) 32 | { 33 | $source = $this->fixturesDir.DIRECTORY_SEPARATOR.$filename; 34 | $filecount = count($filelist); 35 | 36 | $sfv = new SfvInfo; 37 | $sfv->open($source); 38 | $this->assertEmpty($sfv->error, $sfv->error); 39 | $this->assertSame($filecount, $sfv->fileCount); 40 | 41 | // Comments should be stored 42 | $this->assertNotEmpty($sfv->comments); 43 | 44 | // With full file paths, including dirs 45 | $list = $sfv->getFileList(); 46 | for ($i = 0; $i < $filecount; $i++) 47 | { 48 | $this->assertSame($filelist[$i][0], $list[$i]['name']); 49 | $this->assertSame($filelist[$i][1], $list[$i]['checksum']); 50 | } 51 | 52 | // With filenames only, ignoring dirs 53 | $list = $sfv->getFileList(true); 54 | for ($i = 0; $i < $filecount; $i++) 55 | { 56 | $this->assertSame($filelist[$i][2], $list[$i]['name']); 57 | $this->assertSame($filelist[$i][1], $list[$i]['checksum']); 58 | } 59 | 60 | // Summary should return the same list with source info 61 | $summary = $sfv->getSummary(true, true); 62 | $this->assertSame($source, $summary['file_name']); 63 | $this->assertSame($list, $summary['file_list']); 64 | $this->assertSame($filecount, $summary['file_count']); 65 | $this->assertSame(filesize($source), $summary['file_size']); 66 | $this->assertSame('0-'.($summary['file_size'] - 1), $summary['use_range']); 67 | $this->assertEmpty($summary['data_size']); 68 | 69 | // The same results should be returned when data is set directly 70 | $sfv = new SfvInfo; 71 | $data = file_get_contents($source); 72 | $sfv->setData($data); 73 | $this->assertSame($filecount, $sfv->fileCount); 74 | 75 | $summary = $sfv->getSummary(true, true); 76 | $this->assertEmpty($summary['file_name']); 77 | $this->assertSame($list, $summary['file_list']); 78 | $this->assertSame($filecount, $summary['file_count']); 79 | $this->assertEmpty($summary['file_size']); 80 | $this->assertSame(filesize($source), $summary['data_size']); 81 | $this->assertSame('0-'.($summary['data_size'] - 1), $summary['use_range']); 82 | } 83 | 84 | /** 85 | * Provides test data for comparison with sample files. 86 | */ 87 | public function providerSfvFileRecords() 88 | { 89 | return array( 90 | array('test001.sfv', array( 91 | array('testrar.r00', 'f6d8c75f', 'testrar.r00'), 92 | array('testrar.r01', '1e9ba708', 'testrar.r01'), 93 | array('testrar.r02', 'fb171746', 'testrar.r02'), 94 | array('testrar.r03', '1ddbb63a', 'testrar.r03'), 95 | array('testrar.rar', '36fbdd27', 'testrar.rar')) 96 | ), 97 | array('test002.sfv', array( 98 | array('test 1.txt', 'f6d8c75f', 'test 1.txt'), 99 | array('subdir\test_2.txt', '1e9ba708', 'test_2.txt'), 100 | array('subdir\test 3.txt', 'fb171746', 'test 3.txt'), 101 | array('subdir/test 4.txt', '1ddbb63a', 'test 4.txt'), 102 | array('subdir1/subdir 2/test 5.txt', '36fbdd27', 'test 5.txt')) 103 | ), 104 | ); 105 | } 106 | 107 | /** 108 | * We should be able to verify simply that any passed data or file only 109 | * contains valid SFV info. 110 | */ 111 | public function testNonSfvDataShouldReturnError() 112 | { 113 | $sfv = new SfvInfo; 114 | $sfv->setData(";could be a comment\r\ninvalid sfv daT4GHIJ\r\n"); 115 | $this->assertSame('Not a valid SFV file', $sfv->error); 116 | 117 | // RAR 118 | $source = $this->fixturesDir.'/../rar/4mb.rar'; 119 | $sfv = new SfvInfo; 120 | $sfv->open($source); 121 | $this->assertSame('Not a valid SFV file', $sfv->error); 122 | 123 | // PAR2 124 | $source = $this->fixturesDir.'/../par2/testdata.par2'; 125 | $sfv = new SfvInfo; 126 | $sfv->open($source); 127 | $this->assertSame('Not a valid SFV file', $sfv->error); 128 | 129 | // ZIP (contains readable uncompressed SFV file) 130 | $source = $this->fixturesDir.'/test002.zip'; 131 | $sfv = new SfvInfo; 132 | $sfv->open($source); 133 | $this->assertSame('Not a valid SFV file', $sfv->error); 134 | } 135 | 136 | /** 137 | * All line ending types, including mixed types, should be supported. 138 | * 139 | * @dataProvider providerSfvMixedLineEndings 140 | * @param string $data sample sfv data 141 | */ 142 | public function testSupportsAllLineEndingTypes($data) 143 | { 144 | $sfv = new SfvInfo; 145 | $sfv->setData($data); 146 | $this->assertEmpty($sfv->error, $sfv->error); 147 | $this->assertSame(2, $sfv->fileCount); 148 | 149 | $this->assertSame("example comment\n", $sfv->comments); 150 | $this->assertEquals(array( 151 | array( 152 | 'name' => 'testrar.r00', 153 | 'checksum' => 'f6d8c75f' 154 | ), 155 | array( 156 | 'name' => 'testrar.r01', 157 | 'checksum' => '1e9ba708' 158 | ), 159 | ), $sfv->getFileList()); 160 | } 161 | 162 | /** 163 | * Provides test data with different line ending types. 164 | */ 165 | public function providerSfvMixedLineEndings() 166 | { 167 | return array( 168 | // Unix 169 | array("; example comment\ntestrar.r00 f6d8c75f\ntestrar.r01 1e9ba708\n"), 170 | // Windows 171 | array("; example comment\r\ntestrar.r00 f6d8c75f\r\ntestrar.r01 1e9ba708\r\n"), 172 | // Mac 173 | array("; example comment\rtestrar.r00 f6d8c75f\rtestrar.r01 1e9ba708\r"), 174 | // Mixed 175 | array("; example comment\ntestrar.r00 f6d8c75f\r\ntestrar.r01 1e9ba708\r"), 176 | ); 177 | } 178 | 179 | /** 180 | * We should be able to access any file comments simply, stripped of ; and padding. 181 | */ 182 | public function testStoresFileComments() 183 | { 184 | $source = $this->fixturesDir.'/test002.sfv'; 185 | $sfv = new SfvInfo; 186 | $sfv->open($source); 187 | 188 | $comments = "filenames with spaces\nfiles in subdirectories\n"; 189 | $this->assertSame($comments, $sfv->comments); 190 | } 191 | 192 | } // End SfvInfoTest 193 | -------------------------------------------------------------------------------- /tests/SrrInfoTest.php: -------------------------------------------------------------------------------- 1 | fixturesDir = realpath(dirname(__FILE__).'/fixtures/srr'); 20 | } 21 | 22 | /** 23 | * SRR files include both specialised SRR blocks alongside RAR blocks that 24 | * follow the RAR specification, apart from File blocks and certain Subblock 25 | * types lacking bodies. 26 | * 27 | * @dataProvider providerTestFixtures 28 | * @param string $filename sample srr filename 29 | * @param string $blocks expected list of valid blocks 30 | */ 31 | public function testStoresListOfAllValidBlocks($filename, $blocks) 32 | { 33 | $srr = new SrrInfo; 34 | $srr->open($filename); 35 | 36 | $this->assertEmpty($srr->error, $srr->error); 37 | $blockList = $srr->getBlocks(); 38 | $this->assertEquals(count($blocks), count($blockList)); 39 | $this->assertEquals($blocks, $blockList); 40 | } 41 | 42 | /** 43 | * Provides test data from sample files. 44 | */ 45 | public function providerTestFixtures() 46 | { 47 | $ds = DIRECTORY_SEPARATOR; 48 | $fixturesDir = realpath(dirname(__FILE__).'/fixtures/srr'); 49 | $fixtures = array(); 50 | 51 | foreach (glob($fixturesDir.$ds.'*.srr') as $srrfile) { 52 | $fname = pathinfo($srrfile, PATHINFO_BASENAME).'.blocks'; 53 | $fpath = $fixturesDir.$ds.$fname; 54 | if (file_exists($fpath)) { 55 | $blocks = include $fpath; 56 | $fixtures[] = array('filename' => $srrfile, 'blocks' => $blocks); 57 | } 58 | } 59 | 60 | return $fixtures; 61 | } 62 | 63 | /** 64 | * SRR files can include their own Stored File blocks, and we should be able 65 | * to extract their file contents. 66 | */ 67 | public function testExtractsStoredFiles() 68 | { 69 | $srr = new SrrInfo; 70 | $srr->open($this->fixturesDir.'/utf8_filename_added.srr'); 71 | 72 | $stored = $srr->getStoredFiles(); 73 | $this->assertCount(1, $stored); 74 | $this->assertSame(65, $stored[0]['size']); 75 | $this->assertSame('Κείμενο στην ελληνική γλώσσα.txt', $stored[0]['name']); 76 | $this->assertSame("Κείμενο στην ελληνική γλώσσα\nGreek text\n", $stored[0]['data']); 77 | } 78 | 79 | /** 80 | * SRR files are mostly useful for providing a full list of the archive files 81 | * that they cover, including details of the archive contents. 82 | */ 83 | public function testListsAllArchiveFiles() 84 | { 85 | $srr = new SrrInfo; 86 | $srr->open($this->fixturesDir.'/store_rr_solid_auth.part1.srr'); 87 | 88 | $rars = $srr->getFileList(); 89 | $this->assertCount(3, $rars); 90 | 91 | $this->assertSame('store_rr_solid_auth.part1.rar', $rars[0]['name']); 92 | $this->assertCount(3, $rars[0]['files']); 93 | $this->assertSame('empty_file.txt', $rars[0]['files'][0]['name']); 94 | $this->assertSame('little_file.txt', $rars[0]['files'][1]['name']); 95 | $this->assertSame('users_manual4.00.txt', $rars[0]['files'][2]['name']); 96 | 97 | $this->assertSame('store_rr_solid_auth.part2.rar', $rars[1]['name']); 98 | $this->assertCount(1, $rars[1]['files']); 99 | $this->assertSame('users_manual4.00.txt', $rars[1]['files'][0]['name']); 100 | 101 | $this->assertSame('store_rr_solid_auth.part3.rar', $rars[2]['name']); 102 | $this->assertCount(2, $rars[2]['files']); 103 | $this->assertSame('users_manual4.00.txt', $rars[2]['files'][0]['name']); 104 | $this->assertSame('Κείμενο στην ελληνική γλώσσα.txt', $rars[2]['files'][1]['name']); 105 | } 106 | 107 | /** 108 | * We should be able to report on the client used to create the SRR file. 109 | */ 110 | public function testReportsClientInfo() 111 | { 112 | $srr = new SrrInfo; 113 | $srr->open($this->fixturesDir.'/store_rr_solid_auth.part1.srr'); 114 | $this->assertSame('ReScene .NET 1.2', $srr->client); 115 | } 116 | 117 | /** 118 | * SRR files should not contain any file data in the File blocks, but we 119 | * should fail to read it gracefully. 120 | */ 121 | public function testFileDataCannotBeExtracted() 122 | { 123 | $srr = new SrrInfo; 124 | $srr->open($this->fixturesDir.'/store_rr_solid_auth.part1.srr'); 125 | $this->assertFalse($srr->getFileData('users_manual4.00.txt')); 126 | foreach ($srr->getFileList() as $vol) { 127 | foreach ($vol['files'] as $file) { 128 | $this->assertArrayNotHasKey('range', $file); 129 | } 130 | } 131 | } 132 | 133 | } // End SrrInfoTest 134 | -------------------------------------------------------------------------------- /tests/SzipInfoTest.php: -------------------------------------------------------------------------------- 1 | fixturesDir = realpath(dirname(__FILE__).'/fixtures/szip'); 20 | } 21 | 22 | /** 23 | * 7z files consist of headers at the archive start and end, with the blocks 24 | * of packed data streams between. We should be abe to report an accurate list 25 | * of all headers in summmary form. 26 | * 27 | * @dataProvider providerTestFixtures 28 | * @param string $filename sample 7z filename 29 | * @param string $headers expected list of valid headers 30 | */ 31 | public function testStoresListOfAllValidHeaders($filename, $headers) 32 | { 33 | $szip = new SzipInfo; 34 | $szip->open($filename, true); 35 | 36 | $this->assertEmpty($szip->error, $szip->error); 37 | $headerList = $szip->getHeaders(true); 38 | $this->assertEquals(count($headers), count($headerList)); 39 | $this->assertEquals($headers, $headerList); 40 | } 41 | 42 | /** 43 | * Provides test data from sample files. 44 | */ 45 | public function providerTestFixtures() 46 | { 47 | $ds = DIRECTORY_SEPARATOR; 48 | $fixturesDir = realpath(dirname(__FILE__).'/fixtures/szip'); 49 | $fixtures = array(); 50 | 51 | foreach (glob($fixturesDir.$ds.'*.{7z,001,002}', GLOB_BRACE) as $szipfile) { 52 | $fname = pathinfo($szipfile, PATHINFO_BASENAME).'.headers'; 53 | $fpath = $fixturesDir.$ds.$fname; 54 | if (file_exists($fpath)) { 55 | $headers = include $fpath; 56 | $fixtures[] = array('filename' => $szipfile, 'headers' => $headers); 57 | } 58 | } 59 | 60 | return $fixtures; 61 | } 62 | 63 | /** 64 | * We should be able to report on the contents of the 7z archive, with some 65 | * simple processing of the raw headers to make them human-readable. It's 66 | * especially helpful to know if we're dealing with files stored in solid 67 | * archives and/or compressed substreams. 68 | */ 69 | public function testListsAllArchiveFiles() 70 | { 71 | $szip = new SzipInfo; 72 | 73 | // Without substreams 74 | $szip->open($this->fixturesDir.'/store_method.7z'); 75 | $this->assertEmpty($szip->error); 76 | $this->assertFalse($szip->isSolid); 77 | $this->assertSame(2, $szip->blockCount); 78 | $files = $szip->getFileList(); 79 | $this->assertCount(2, $files); 80 | 81 | $file = $files[0]; 82 | $this->assertSame('7zFormat.txt', $file['name']); 83 | $this->assertSame(7573, $file['size']); 84 | $this->assertSame(1284641836, $file['date']); 85 | $this->assertSame(0, $file['pass']); 86 | $this->assertSame(0, $file['compressed']); 87 | $this->assertSame(0, $file['block']); 88 | $this->assertSame('32-7604', $file['range']); 89 | $this->assertSame('3f8ccf66', $file['crc32']); 90 | $this->assertArrayNotHasKey('is_dir', $file); 91 | 92 | $file = $files[1]; 93 | $this->assertSame('foo.txt', $file['name']); 94 | $this->assertSame(15, $file['size']); 95 | $this->assertSame(1373228890, $file['date']); 96 | $this->assertSame(0, $file['pass']); 97 | $this->assertSame(0, $file['compressed']); 98 | $this->assertSame(1, $file['block']); 99 | $this->assertSame('7605-7619', $file['range']); 100 | $this->assertSame('1a92d0b1', $file['crc32']); 101 | $this->assertArrayNotHasKey('is_dir', $file); 102 | 103 | // With compressed substreams 104 | $szip->open($this->fixturesDir.'/solid_lzma_multi.7z'); 105 | $this->assertEmpty($szip->error); 106 | $this->assertTrue($szip->isSolid); 107 | $this->assertSame(1, $szip->blockCount); 108 | $files = $szip->getFileList(); 109 | $this->assertCount(3, $files); 110 | 111 | $file = $files[0]; 112 | $this->assertSame('7zFormat.txt', $file['name']); 113 | $this->assertSame(7573, $file['size']); 114 | $this->assertSame(1, $file['compressed']); 115 | $this->assertSame(0, $file['block']); 116 | $this->assertSame('32-2087', $file['range']); 117 | $this->assertSame('3f8ccf66', $file['crc32']); 118 | 119 | $file = $files[1]; 120 | $this->assertSame('bar.txt', $file['name']); 121 | $this->assertSame(15, $file['size']); 122 | $this->assertSame(1, $file['compressed']); 123 | $this->assertSame(0, $file['block']); 124 | $this->assertSame('32-2087', $file['range']); 125 | $this->assertSame('71afb453', $file['crc32']); 126 | 127 | $file = $files[2]; 128 | $this->assertSame('foo.txt', $file['name']); 129 | $this->assertSame(15, $file['size']); 130 | $this->assertSame(1, $file['compressed']); 131 | $this->assertSame(0, $file['block']); 132 | $this->assertSame('32-2087', $file['range']); 133 | $this->assertSame('1a92d0b1', $file['crc32']); 134 | 135 | // With directories/empty streams 136 | $szip->open($this->fixturesDir.'/store_with_empty.7z'); 137 | $this->assertEmpty($szip->error); 138 | $this->assertSame(2, $szip->blockCount); 139 | $files = $szip->getFileList(); 140 | $this->assertCount(4, $files); 141 | 142 | $file = $files[0]; 143 | $this->assertSame('testdir3/7zFormat.txt', $file['name']); 144 | $this->assertSame(7573, $file['size']); 145 | $this->assertSame(0, $file['block']); 146 | 147 | $file = $files[1]; 148 | $this->assertSame('testdir3/foo.txt', $file['name']); 149 | $this->assertSame(15, $file['size']); 150 | $this->assertSame(1, $file['block']); 151 | 152 | $file = $files[2]; 153 | $this->assertSame('testdir3/empty.txt', $file['name']); 154 | $this->assertSame(0, $file['size']); 155 | $this->assertArrayNotHasKey('block', $file); 156 | $this->assertArrayNotHasKey('range', $file); 157 | $this->assertArrayNotHasKey('crc32', $file); 158 | $this->assertArrayNotHasKey('is_dir', $file); 159 | 160 | $file = $files[3]; 161 | $this->assertSame('testdir3', $file['name']); 162 | $this->assertSame(0, $file['size']); 163 | $this->assertSame(1, $file['is_dir']); 164 | $this->assertArrayNotHasKey('block', $file); 165 | $this->assertArrayNotHasKey('crc32', $file); 166 | $this->assertArrayNotHasKey('range', $file); 167 | } 168 | 169 | /** 170 | * File ranges should reflect the file data actually available in the current 171 | * source, even if only partial and/or the Start header is missing or skipped. 172 | * If no file data is available, ranges should not be included in the file info. 173 | */ 174 | public function testListsPackedRangesWithPartialData() 175 | { 176 | $szip = new SzipInfo; 177 | 178 | // Without start header (multi-volume) 179 | $file = $this->fixturesDir.'/multi_volume.7z.002'; 180 | $end = filesize($file) - 1; 181 | 182 | $szip->open($file, true, array(2, $end)); 183 | $files = $szip->getFileList(); 184 | $this->assertSame('2-3508', $files[0]['range']); 185 | $this->assertSame('3509-3523', $files[1]['range']); 186 | 187 | $szip->open($file, true, array(3508, $end)); 188 | $files = $szip->getFileList(); 189 | $this->assertSame('3508-3508', $files[0]['range']); 190 | $this->assertSame('3509-3523', $files[1]['range']); 191 | 192 | $szip->open($file, true, array(3510, $end)); 193 | $files = $szip->getFileList(); 194 | $this->assertArrayNotHasKey('range', $files[0]); 195 | $this->assertSame('3510-3523', $files[1]['range']); 196 | 197 | // With start header (skipped) 198 | $file = $this->fixturesDir.'/store_with_directories.7z';; 199 | $end = filesize($file) - 1; 200 | 201 | $szip->open($file, true, array(2, $end)); 202 | $files = $szip->getFileList(); 203 | $this->assertSame('32-7604', $files[0]['range']); 204 | $this->assertSame('7605-7619', $files[1]['range']); 205 | 206 | $szip->open($file, true, array(7604, $end)); 207 | $files = $szip->getFileList(); 208 | $this->assertSame('7604-7604', $files[0]['range']); 209 | $this->assertSame('7605-7619', $files[1]['range']); 210 | $this->assertSame('7635-7649', $files[3]['range']); 211 | 212 | $szip->open($file, true, array(7605, $end)); 213 | $files = $szip->getFileList(); 214 | $this->assertArrayNotHasKey('range', $files[0]); 215 | $this->assertSame('7605-7619', $files[1]['range']); 216 | $this->assertSame('7635-7649', $files[3]['range']); 217 | 218 | $szip->open($file, true, array(7640, $end)); 219 | $files = $szip->getFileList(); 220 | $this->assertArrayNotHasKey('range', $files[0]); 221 | $this->assertArrayNotHasKey('range', $files[1]); 222 | $this->assertArrayNotHasKey('range', $files[2]); 223 | $this->assertSame('7640-7649', $files[3]['range']); 224 | } 225 | 226 | /** 227 | * If the archive files aren't compressed, we should just be able to extract 228 | * the file data and use it as is. 229 | * 230 | * @depends testListsPackedRangesWithPartialData 231 | */ 232 | public function testExtractsUncompressedFileData() 233 | { 234 | $szip = new SzipInfo; 235 | 236 | // With all data available 237 | $szip->open($this->fixturesDir.'/store_method.7z'); 238 | $this->assertEmpty($szip->error); 239 | $files = $szip->getFileList(); 240 | $this->assertCount(2, $files); 241 | 242 | $this->assertSame('7zFormat.txt', $files[0]['name']); 243 | $this->assertSame(0, $files[0]['compressed']); 244 | $data = $szip->getFileData($files[0]['name']); 245 | $this->assertSame($files[0]['size'], strlen($data)); 246 | $this->assertSame($files[0]['crc32'], dechex(crc32($data))); 247 | $this->assertStringStartsWith('7z Format description', $data); 248 | $this->assertStringEndsWith("End of document\r\n", $data); 249 | 250 | $this->assertSame('foo.txt', $files[1]['name']); 251 | $this->assertSame(0, $files[1]['compressed']); 252 | $data = $szip->getFileData($files[1]['name']); 253 | $this->assertSame($files[1]['size'], strlen($data)); 254 | $this->assertSame($files[1]['crc32'], dechex(crc32($data))); 255 | $this->assertSame('sample foo text', $data); 256 | 257 | // With partial data available 258 | $szip->open($this->fixturesDir.'/multi_volume.7z.002', true); 259 | $this->assertEmpty($szip->error); 260 | $files = $szip->getFileList(); 261 | $this->assertCount(2, $files); 262 | $this->assertSame('7zFormat.txt', $files[0]['name']); 263 | $this->assertSame(0, $files[0]['compressed']); 264 | $data = $szip->getFileData($files[0]['name']); 265 | $this->assertNotSame($files[0]['size'], strlen($data)); 266 | $this->assertStringStartsWith('odecIdSize', $data); 267 | $this->assertStringEndsWith("End of document\r\n", $data); 268 | } 269 | 270 | /** 271 | * Provides the path to the external client executable, or false if it 272 | * doesn't exist in the given directory. 273 | * 274 | * @return string|boolean the absolute path to the executable, or false 275 | */ 276 | protected function getUnzipPath() 277 | { 278 | $unzip = DIRECTORY_SEPARATOR === '\\' 279 | ? dirname(__FILE__).'\bin\7z\7za.exe' 280 | : dirname(__FILE__).'/bin/7z/7za'; 281 | 282 | if (file_exists($unzip)) 283 | return $unzip; 284 | 285 | return false; 286 | } 287 | 288 | /** 289 | * Decompression and/or decryption of archive contents should be possible by 290 | * using an external client to read the current file, or temporary files for 291 | * data sources. The test should be skipped if no external client is available. 292 | * 293 | * @group external 294 | */ 295 | public function testExtractsFilesWithExternalClient() 296 | { 297 | if (!($unzip = $this->getUnzipPath())) { 298 | $this->markTestSkipped(); 299 | } 300 | $szip = new SzipInfo; 301 | 302 | // Compressed files 303 | $szfile = $this->fixturesDir.'/solid_lzma_multi.7z'; 304 | $szip->open($szfile); 305 | $this->assertEmpty($szip->error); 306 | $files = $szip->getFileList(); 307 | 308 | $file = $files[0]; 309 | $this->assertSame('7zFormat.txt', $file['name']); 310 | $this->assertSame(1, $file['compressed']); 311 | $data = $szip->extractFile($file['name']); 312 | $this->assertNotEmpty($szip->error); 313 | $this->assertContains('external client', $szip->error); 314 | 315 | $szip->setExternalClient($unzip); 316 | 317 | $data = $szip->extractFile($file['name']); 318 | $this->assertEmpty($szip->error); 319 | $this->assertSame(strlen($data), $file['size']); 320 | $this->assertSame($file['crc32'], dechex(crc32($data))); 321 | $this->assertStringStartsWith('7z Format description', $data); 322 | $this->assertStringEndsWith("End of document\r\n", $data); 323 | 324 | // Encrypted files 325 | $szfile = $this->fixturesDir.'/multi_substreams_encrypted.7z'; 326 | $szip->open($szfile); 327 | $this->assertEmpty($szip->error); 328 | $files = $szip->getFileList(); 329 | 330 | $file = $files[2]; 331 | $this->assertSame('7zFormat.txt', $file['name']); 332 | $this->assertSame(1, $file['pass']); 333 | $data = $szip->extractFile($file['name']); 334 | $this->assertNotEmpty($szip->error); 335 | $this->assertContains('passworded', $szip->error); 336 | 337 | $data = $szip->extractFile($file['name'], null, 'password'); 338 | $this->assertEmpty($szip->error, $szip->error); 339 | $this->assertSame(strlen($data), $file['size']); 340 | $this->assertSame($file['crc32'], dechex(crc32($data))); 341 | $this->assertStringStartsWith('7z Format description', $data); 342 | $this->assertStringEndsWith("End of document\r\n", $data); 343 | 344 | // From a data source (via temp file) 345 | $szip->setData(file_get_contents($szfile)); 346 | $this->assertEmpty($szip->error); 347 | $summary = $szip->getSummary(true); 348 | $this->assertSame(filesize($szfile), $summary['data_size']); 349 | $data = $szip->extractFile($file['name'], null, 'password'); 350 | $this->assertEmpty($szip->error); 351 | $this->assertSame($file['size'], strlen($data)); 352 | $this->assertSame($file['crc32'], dechex(crc32($data))); 353 | } 354 | 355 | /** 356 | * Headers can be compressed or encrypted, and we should be able to use an 357 | * external client to extract these before further parsing. 358 | * 359 | * @depends testExtractsFilesWithExternalClient 360 | * @group external 361 | */ 362 | public function testExtractsEncodedHeadersWithExternalClient() 363 | { 364 | if (!($unzip = $this->getUnzipPath())) { 365 | $this->markTestSkipped(); 366 | } 367 | $szip = new SzipInfo; 368 | 369 | // Compressed headers 370 | $file = $this->fixturesDir.'/store_method_enc_headers.7z'; 371 | $szip->open($file); 372 | $this->assertEmpty($szip->error); 373 | $this->assertSame(1, $szip->blockCount); 374 | $this->assertEmpty($szip->getFileList()); 375 | 376 | $szip->setExternalClient($unzip); 377 | 378 | $szip->open($file); 379 | $this->assertEmpty($szip->error); 380 | $this->assertSame(2, $szip->blockCount); 381 | $files = $szip->getFileList(); 382 | $this->assertCount(2, $files); 383 | 384 | $file = $files[0]; 385 | $this->assertSame('7zFormat.txt', $file['name']); 386 | $this->assertSame('32-7604', $file['range']); 387 | $file = $files[1]; 388 | $this->assertSame('foo.txt', $file['name']); 389 | $this->assertSame('7605-7619', $file['range']); 390 | 391 | // Encrypted headers 392 | $file = $this->fixturesDir.'/encrypted_headers.7z'; 393 | $szip->open($file); 394 | $this->assertNotEmpty($szip->error); 395 | $this->assertContains('password needed', $szip->error); 396 | $this->assertTrue($szip->isEncrypted); 397 | $this->assertSame(1, $szip->blockCount); 398 | $this->assertEmpty($szip->getFileList()); 399 | 400 | $szip->setPassword('password'); 401 | 402 | $szip->open($file); 403 | $this->assertEmpty($szip->error); 404 | $this->assertSame(1, $szip->blockCount); 405 | $files = $szip->getFilelist(); 406 | $this->assertCount(1, $files); 407 | 408 | $file = $files[0]; 409 | $this->assertSame('7zFormat.txt', $file['name']); 410 | $this->assertSame(7573, $file['size']); 411 | $this->assertSame('32-7615', $file['range']); 412 | $this->assertSame(1, $file['pass']); 413 | $this->assertSame(0, $file['compressed']); 414 | } 415 | 416 | } // End SzipInfoTest 417 | -------------------------------------------------------------------------------- /tests/ZipInfoTest.php: -------------------------------------------------------------------------------- 1 | fixturesDir = realpath(dirname(__FILE__).'/fixtures/zip'); 20 | } 21 | 22 | /** 23 | * ZIP files consist of a series of records with headers and optional bodies. 24 | * The main info is divided between Local File and Central File records. We 25 | * should be able to report an accurate list of all records in summmary form. 26 | * 27 | * @dataProvider providerTestFixtures 28 | * @param string $filename sample zip filename 29 | * @param string $records expected list of valid records 30 | */ 31 | public function testStoresListOfAllValidRecords($filename, $records) 32 | { 33 | $zip = new ZipInfo; 34 | $zip->open($filename, true); 35 | 36 | $this->assertEmpty($zip->error, $zip->error); 37 | $recordList = $zip->getRecords(); 38 | $this->assertEquals(count($records), count($recordList)); 39 | $this->assertEquals($records, $recordList); 40 | } 41 | 42 | /** 43 | * Provides test data from sample files. 44 | */ 45 | public function providerTestFixtures() 46 | { 47 | $ds = DIRECTORY_SEPARATOR; 48 | $fixturesDir = realpath(dirname(__FILE__).'/fixtures/zip'); 49 | $fixtures = array(); 50 | 51 | foreach (glob($fixturesDir.$ds.'*.zip') as $rarfile) { 52 | $fname = pathinfo($rarfile, PATHINFO_BASENAME).'.records'; 53 | $fpath = $fixturesDir.$ds.$fname; 54 | if (file_exists($fpath)) { 55 | $records = include $fpath; 56 | $fixtures[] = array('filename' => $rarfile, 'records' => $records); 57 | } 58 | } 59 | 60 | return $fixtures; 61 | } 62 | 63 | /** 64 | * We should be able to report on the contents of the ZIP file, with some 65 | * simple processing of the raw File blocks to make them human-readable. 66 | */ 67 | public function testListsAllArchiveFiles() 68 | { 69 | $zip = new ZipInfo; 70 | $zip->open($this->fixturesDir.'/pecl_test.zip'); 71 | 72 | $files = $zip->getFileList(); 73 | $this->assertCount(4, $files); 74 | 75 | $this->assertSame('bar', $files[0]['name']); 76 | $this->assertSame(4, $files[0]['size']); 77 | $this->assertSame(1123171908, $files[0]['date']); 78 | $this->assertSame(0, $files[0]['pass']); 79 | $this->assertSame(0, $files[0]['compressed']); 80 | $this->assertSame('54-57', $files[0]['range']); 81 | $this->assertSame('4a2b3e9', $files[0]['crc32']); 82 | $this->assertArrayNotHasKey('is_dir', $files[0]); 83 | 84 | $this->assertSame('foobar/', $files[1]['name']); 85 | $this->assertSame(0, $files[1]['size']); 86 | $this->assertSame(1123171948, $files[1]['date']); 87 | $this->assertSame(0, $files[1]['pass']); 88 | $this->assertSame(0, $files[1]['compressed']); 89 | $this->assertArrayNotHasKey('range', $files[1]); 90 | $this->assertArrayNotHasKey('crc32', $files[1]); 91 | $this->assertArrayHasKey('is_dir', $files[1]); 92 | 93 | $this->assertSame('foobar/baz', $files[2]['name']); 94 | $this->assertSame(27, $files[2]['size']); 95 | $this->assertSame(1123171948, $files[2]['date']); 96 | $this->assertSame(0, $files[2]['pass']); 97 | $this->assertSame(1, $files[2]['compressed']); 98 | $this->assertSame('177-203', $files[2]['range']); 99 | $this->assertSame('1dc53e58', $files[2]['crc32']); 100 | $this->assertArrayNotHasKey('is_dir', $files[2]); 101 | 102 | $this->assertSame('entry1.txt', $files[3]['name']); 103 | $this->assertSame(8, $files[3]['size']); 104 | $this->assertSame(1152216782, $files[3]['date']); 105 | $this->assertSame(0, $files[3]['pass']); 106 | $this->assertSame(1, $files[3]['compressed']); 107 | $this->assertSame('241-248', $files[3]['range']); 108 | $this->assertSame('f40c64db', $files[3]['crc32']); 109 | $this->assertArrayNotHasKey('is_dir', $files[3]); 110 | 111 | // Encrypted files should be reported 112 | $zip->open($this->fixturesDir.'/encrypted_file.zip'); 113 | $files = $zip->getFileList(); 114 | $this->assertCount(1, $files); 115 | $this->assertSame('test_date.txt', $files[0]['name']); 116 | $this->assertSame(1, $files[0]['pass']); 117 | $this->assertSame(0, $files[0]['compressed']); 118 | $this->assertArrayNotHasKey('is_dir', $files[0]); 119 | } 120 | 121 | /** 122 | * The End of Central Directory record keeps a count of files in the current 123 | * volume, but if it's missing we should count the Local File records instead. 124 | */ 125 | public function testCountsLocalFileRecordsIfCentralDirectoryIsMissing() 126 | { 127 | $zip = new ZipInfo; 128 | 129 | // Missing CDR, but has Local File record: 130 | $zip->open($this->fixturesDir.'/large_file_start.zip'); 131 | $summary = $zip->getSummary(); 132 | $this->assertSame($zip->file, $summary['file_name']); 133 | $this->assertSame($zip->fileCount, $summary['file_count']); 134 | $this->assertSame(1, $summary['file_count']); 135 | 136 | // Missing Local File record, but has CDR: 137 | $zip->open($this->fixturesDir.'/large_file_end.zip'); 138 | $summary = $zip->getSummary(); 139 | $this->assertSame($zip->file, $summary['file_name']); 140 | $this->assertSame($zip->fileCount, $summary['file_count']); 141 | $this->assertSame(1, $summary['file_count']); 142 | } 143 | 144 | /** 145 | * If the archive files aren't compressed, we should just be able to extract 146 | * the file data and use it as is. 147 | */ 148 | public function testExtractsUncompressedFileData() 149 | { 150 | $zip = new ZipInfo; 151 | $zip->open($this->fixturesDir.'/little_file.zip'); 152 | 153 | $files = $zip->getFileList(); 154 | $this->assertCount(1, $files); 155 | $this->assertSame(0, $files[0]['compressed']); 156 | 157 | $data = $zip->getFileData($files[0]['name']); 158 | $this->assertSame($files[0]['size'], strlen($data)); 159 | $this->assertSame($files[0]['crc32'], dechex(crc32(($data)))); 160 | $this->assertSame("Some text.\n", $data); 161 | } 162 | 163 | /** 164 | * Provides the path to the external client executable, or false if it 165 | * doesn't exist in the given directory. 166 | * 167 | * @return string|boolean the absolute path to the executable, or false 168 | */ 169 | protected function getUnzipPath() 170 | { 171 | $unzip = DIRECTORY_SEPARATOR === '\\' 172 | ? dirname(__FILE__).'\bin\7z\7za.exe' 173 | : dirname(__FILE__).'/bin/7z/7za'; 174 | 175 | if (file_exists($unzip)) 176 | return $unzip; 177 | 178 | return false; 179 | } 180 | 181 | /** 182 | * Decompression of archive contents should be possible by using an external 183 | * client to read the current file, or temporary files for data sources. The 184 | * test should be skipped if no external client is available. 185 | * 186 | * @group external 187 | */ 188 | public function testDecompressesWithExternalClient() 189 | { 190 | if (!($unzip = $this->getUnzipPath())) { 191 | $this->markTestSkipped(); 192 | } 193 | $zip = new ZipInfo; 194 | 195 | // From a file source 196 | $zipfile = $this->fixturesDir.'/pecl_test.zip'; 197 | $zip->open($zipfile); 198 | $this->assertEmpty($zip->error); 199 | 200 | $files = $zip->getFileList(); 201 | $file = $files[3]; 202 | $this->assertSame('entry1.txt', $file['name']); 203 | $this->assertSame(1, $file['compressed']); 204 | 205 | $data = $zip->extractFile($file['name']); 206 | $this->assertNotEmpty($zip->error); 207 | $this->assertContains('external client', $zip->error); 208 | $this->assertFalse($data); 209 | 210 | $zip->setExternalClient($unzip); 211 | $data = $zip->extractFile($file['name']); 212 | $this->assertEmpty($zip->error,$zip->error); 213 | $this->assertSame($file['size'], strlen($data)); 214 | $this->assertSame($file['crc32'], dechex(crc32(($data)))); 215 | $this->assertSame("entry #1", $data); 216 | 217 | // From a data source (via temp file) 218 | $zip->setData(file_get_contents($zipfile)); 219 | $this->assertEmpty($zip->error); 220 | $summary = $zip->getSummary(true); 221 | $this->assertSame(filesize($zipfile), $summary['data_size']); 222 | $data = $zip->extractFile($file['name']); 223 | $this->assertEmpty($zip->error); 224 | $this->assertSame($file['size'], strlen($data)); 225 | } 226 | 227 | } // End ZipInfoTest 228 | -------------------------------------------------------------------------------- /tests/bin/README.md: -------------------------------------------------------------------------------- 1 | Use this directory to store any executables needed for the tests. None are added 2 | to the repo by default. Currently these are required for all tests to pass: 3 | 4 | On Windows: 5 | - .\unrar\UnRAR.exe 6 | - .\7z\7za.exe 7 | 8 | On *nix: 9 | - ./unrar/unrar 10 | - ./7z/7za 11 | 12 | Sources: 13 | - [UnRAR](http://www.rarlab.com/rar_add.htm) 14 | - [7za](http://www.7-zip.org/download.html) 15 | 16 | Notes: 17 | - For 7za, download Windows commandline version or p7zip package for *nix 18 | (e.g. on CentOS: add RPMForge, then `yum install p7zip`, then look for 19 | /usr/libexec/p7zip/7za or similar) 20 | -------------------------------------------------------------------------------- /tests/fixtures/generate.php: -------------------------------------------------------------------------------- 1 | The type of fixture to generate: rar, srr, par2, zip (default is all) 8 | * -r Regenerate existing fixture files (default is to create only missing ones) 9 | * -p Run in pretend mode, output debug info without making changes 10 | */ 11 | error_reporting(E_ALL | E_STRICT); 12 | ini_set('display_startup_errors', 'on'); 13 | ini_set('display_errors', 'on'); 14 | 15 | $opts = getopt('prt:'); 16 | $pretend = isset($opts['p']) ? true : false; 17 | $refresh = isset($opts['r']) ? true : false; 18 | 19 | if ($pretend) {echo "*** Running in pretend mode ***\n";} 20 | if (!isset($opts['t']) || $opts['t'] == 'rar') makeRarFixtures($pretend, $refresh); 21 | if (!isset($opts['t']) || $opts['t'] == 'srr') makeSrrFixtures($pretend, $refresh); 22 | if (!isset($opts['t']) || $opts['t'] == 'par2') makePar2Fixtures($pretend, $refresh); 23 | if (!isset($opts['t']) || $opts['t'] == 'zip') makeZipFixtures($pretend, $refresh); 24 | if (!isset($opts['t']) || $opts['t'] == 'szip') makeSzipFixtures($pretend, $refresh); 25 | 26 | /** 27 | * Generates test fixtures from RAR sample files. 28 | * 29 | * @param boolean $pretend debug output only? 30 | * @param boolean $refresh regenerate existing files? 31 | * @return void 32 | */ 33 | function makeRarFixtures($pretend=false, $refresh=true) 34 | { 35 | require_once dirname(__FILE__).'/../../rarinfo.php'; 36 | $rar = new RarInfo; 37 | foreach(glob(dirname(__FILE__).'/rar/*.rar') as $rarfile) { 38 | $fname = pathinfo($rarfile, PATHINFO_BASENAME).'.blocks'; 39 | $file = dirname(__FILE__)."/rar/$fname"; 40 | if (!$refresh && file_exists($file)) {continue;} 41 | echo "Generating for $rarfile:\n"; 42 | $rar->open($rarfile, true); 43 | if ($rar->error) { 44 | echo "Error: {$rar->error}\n"; 45 | continue; 46 | } 47 | $data = $rar->getBlocks(); 48 | if (!$pretend) { 49 | $data = "open($srrfile); 73 | if ($srr->error) { 74 | echo "Error: {$srr->error}\n"; 75 | continue; 76 | } 77 | $data = $srr->getBlocks(); 78 | if (!$pretend) { 79 | $data = "open($par2file); 103 | if ($par2->error) { 104 | echo "Error: {$par2->error}\n"; 105 | continue; 106 | } 107 | $data = $par2->getPackets(true); 108 | if (!$pretend) { 109 | $data = "open($zipfile); 133 | if ($zip->error) { 134 | echo "Error: {$zip->error}\n"; 135 | continue; 136 | } 137 | $data = $zip->getRecords(); 138 | if (!$pretend) { 139 | $data = "open($szipfile, true); 163 | if ($szip->error) { 164 | echo "Error: {$szip->error}\n"; 165 | continue; 166 | } 167 | $data = $szip->getHeaders(); 168 | if (!$pretend) { 169 | $data = " 15 | * 16 | * // Load the ZIP file or data 17 | * $zip = new ZipInfo; 18 | * $zip->open('./foo.zip'); // or $zip->setData($data); 19 | * if ($zip->error) { 20 | * echo "Error: {$zip->error}\n"; 21 | * exit; 22 | * } 23 | * 24 | * // Check encryption 25 | * if ($zip->isEncrypted) { 26 | * echo "Archive is encrypted\n"; 27 | * exit; 28 | * } 29 | * 30 | * // Process the file list 31 | * $files = $zip->getFileList(); 32 | * foreach ($files as $file) { 33 | * if ($file['pass'] == true) { 34 | * echo "File is passworded: {$file['name']}\n"; 35 | * } 36 | * if ($file['compressed'] == false) { 37 | * echo "Extracting uncompressed file: {$file['name']}\n"; 38 | * $zip->saveFileData($file['name'], "./destination/{$file['name']}"); 39 | * // or $data = $zip->getFileData($file['name']); 40 | * } 41 | * } 42 | * 43 | * 44 | * 45 | * The ZIP specification is quite bloated (particularly when it comes to extra 46 | * fields and OS-specific info) and only a small part of it is implemented here, 47 | * but there's lots still to explore: 48 | * 49 | * @link http://www.pkware.com/documents/casestudies/APPNOTE.TXT 50 | * 51 | * @author Hecks 52 | * @copyright (c) 2010-2013 Hecks 53 | * @license Modified BSD 54 | * @version 2.1 55 | */ 56 | class ZipInfo extends ArchiveReader 57 | { 58 | // ------ Class constants ----------------------------------------------------- 59 | 60 | /**#@+ 61 | * ZIP file format values 62 | */ 63 | 64 | // Record type signatures 65 | const RECORD_CENTRAL_FILE = 0x02014b50; 66 | const RECORD_LOCAL_FILE = 0x04034b50; 67 | const RECORD_SIGNATURE = 0x05054b50; 68 | const RECORD_ENDCENTRAL = 0x06054b50; 69 | const RECORD_Z64_ENDCENTRAL = 0x06064b50; 70 | const RECORD_Z64_ENDCENTRAL_LOC = 0x07064b50; 71 | const RECORD_ARCHIVE_EXTRA = 0x08064b50; 72 | const RECORD_DATA_DESCR = 0x08074b50; 73 | 74 | // General purpose flags 75 | const FILE_ENCRYPTED = 0x0001; 76 | const FILE_DESCRIPTOR_USED = 0x0008; 77 | const FILE_STRONG_ENCRYPTED = 0x0040; 78 | const FILE_EFS_UTF8 = 0x0800; 79 | const FILE_CDR_ENCRYPTED = 0x2000; 80 | 81 | // Extra Field IDs 82 | const EXTRA_ZIP64 = 0x0001; 83 | const EXTRA_NTFS = 0x000a; 84 | const EXTRA_UNIX = 0x000d; 85 | const EXTRA_STRONG_ENCR = 0x0017; 86 | const EXTRA_POSZIP = 0x4690; 87 | const EXTRA_UNIXTIME = 0x5455; 88 | const EXTRA_IZUNIX = 0x5855; 89 | const EXTRA_IZUNIX2 = 0x7855; 90 | const EXTRA_IZUNIX3 = 0x7875; 91 | const EXTRA_WZ_AES = 0x9901; 92 | 93 | // OS Types 94 | const OS_FAT = 0; 95 | const OS_AMIGA = 1; 96 | const OS_VMS = 2; 97 | const OS_UNIX = 3; 98 | const OS_VM_CMS = 4; 99 | const OS_ATARI = 5; 100 | const OS_HPFS = 6; 101 | const OS_MAC = 7; 102 | const OS_Z_SYSTEM = 8; 103 | const OS_CPM = 9; 104 | const OS_NTFS = 10; 105 | const OS_MVS = 11; 106 | const OS_VSE = 12; 107 | const OS_ACORN = 13; 108 | const OS_VFAT = 14; 109 | const OS_ALT_MVS = 15; 110 | const OS_BEOS = 16; 111 | const OS_TANDEM = 17; 112 | const OS_OS400 = 18; 113 | const OS_OSX = 19; 114 | 115 | /**#@-*/ 116 | 117 | /** 118 | * Format for unpacking Local File records. 119 | */ 120 | const FORMAT_LOCAL_FILE = 'Cversion_need_num/Cversion_need_os/vflags/vmethod/vlast_mod_time/vlast_mod_date/Vcrc32/Vcompressed_size/Vuncompressed_size/vfile_name_length/vextra_length'; 121 | 122 | /** 123 | * Format for unpacking Central File records. 124 | */ 125 | const FORMAT_CENTRAL_FILE = 'Cversion_made_num/Cversion_made_os/Cversion_need_num/Cversion_need_os/vflags/vmethod/vlast_mod_time/vlast_mod_date/Vcrc32/Vcompressed_size/Vuncompressed_size/vfile_name_length/vextra_length/vcomment_length/vdisk_start/vattr_int/Vattr_ext/Vrel_offset'; 126 | 127 | /** 128 | * Format for unpacking End of Central Directory records. 129 | */ 130 | const FORMAT_ENDCENTRAL = 'vdisk_num/vstart_disk/ventries_disk/ventries_total/Vcentral_size/Vcentral_offset/vcomment_length'; 131 | 132 | /** 133 | * Format for unpacking ZIP64 format End of Central Directory records. 134 | */ 135 | const FORMAT_Z64_ENDCENTRAL = 'Vcentral_size/Vcentral_size_high/Cversion_made_num/Cversion_made_os/Cversion_need_num/Cversion_need_os/Vdisk_num/Vstart_disk/Ventries_disk/Ventries_disk_high/Ventries_total/Ventries_total_high/Vcentral_offset/Vcentral_offset_high'; 136 | 137 | /** 138 | * Format for unpacking ZIP64 format End of Central Directory Locator records. 139 | */ 140 | const FORMAT_Z64_ENDCENTRAL_LOC = 'Vstart_disk/Vcentral_offset/Vcentral_offset_high/Vtotal_disks'; 141 | 142 | /** 143 | * Format for unpacking Data Descriptor blocks. 144 | */ 145 | const FORMAT_DATA_DESCR = 'Vsignature/Vcrc32/Vcompressed_size/Vuncompressed_size'; 146 | 147 | /** 148 | * Format for unpacking Extra Field blocks. 149 | */ 150 | const FORMAT_EXTRA_FIELD = 'vheaderID/vdata_size'; 151 | 152 | 153 | // ------ Instance variables and methods --------------------------------------- 154 | 155 | /** 156 | * List of record names corresponding to record types. 157 | * @var array 158 | */ 159 | protected $recordNames = array( 160 | self::RECORD_CENTRAL_FILE => 'Central File', 161 | self::RECORD_LOCAL_FILE => 'Local File', 162 | self::RECORD_SIGNATURE => 'Digital Signature', 163 | self::RECORD_ENDCENTRAL => 'End of Central Directory', 164 | self::RECORD_Z64_ENDCENTRAL => 'ZIP64 End of Central Directory', 165 | self::RECORD_Z64_ENDCENTRAL_LOC => 'ZIP64 End of Central Directory Locator', 166 | self::RECORD_ARCHIVE_EXTRA => 'Archive Extra Data', 167 | self::RECORD_DATA_DESCR => 'Data Descriptor', 168 | ); 169 | 170 | /** 171 | * List of Extra Field names corresponding to header IDs. 172 | * @var array 173 | */ 174 | protected $extraFieldNames = array( 175 | self::EXTRA_ZIP64 => 'Zip64', 176 | self::EXTRA_NTFS => 'NTFS', 177 | self::EXTRA_UNIX => 'Unix', 178 | self::EXTRA_STRONG_ENCR => 'Strong Encryption', 179 | self::EXTRA_POSZIP => 'POSZIP', 180 | self::EXTRA_UNIXTIME => 'Unix Time', 181 | self::EXTRA_IZUNIX => 'Info-ZIP (UX)', 182 | self::EXTRA_IZUNIX2 => 'Info-ZIP (Ux)', 183 | self::EXTRA_IZUNIX3 => 'Info-ZIP (ux)', 184 | self::EXTRA_WZ_AES => 'AES-256 Password Encryption', 185 | ); 186 | 187 | /** 188 | * List of Host OS names by type. 189 | * @var array 190 | */ 191 | protected $hostOSNames = array( 192 | self::OS_FAT => 'MS-DOS and OS/2 (FAT)', 193 | self::OS_AMIGA => 'Amiga', 194 | self::OS_VMS => 'OpenVMS', 195 | self::OS_UNIX => 'Unix', 196 | self::OS_VM_CMS => 'VM/CMS', 197 | self::OS_ATARI => 'Atari', 198 | self::OS_HPFS => 'OS/2 HPFS', 199 | self::OS_MAC => 'Macintosh', 200 | self::OS_Z_SYSTEM => 'Z-System', 201 | self::OS_CPM => 'CP/M', 202 | self::OS_NTFS => 'Windows NTFS', 203 | self::OS_MVS => 'MVS (OS/390 - Z/OS)', 204 | self::OS_VSE => 'VSE', 205 | self::OS_ACORN => 'Acorn Risc', 206 | self::OS_VFAT => 'VFAT', 207 | self::OS_ALT_MVS => 'Alternative MVS', 208 | self::OS_BEOS => 'BEOS', 209 | self::OS_TANDEM => 'Tandem', 210 | self::OS_OS400 => 'OS/400', 211 | self::OS_OSX => 'OS X (Darwin)', 212 | ); 213 | 214 | /** 215 | * Is the archive Central Directory encrypted? 216 | * @var boolean 217 | */ 218 | public $isEncrypted = false; 219 | 220 | /** 221 | * Convenience method that outputs a summary list of the file/data information, 222 | * useful for pretty-printing. 223 | * 224 | * @param boolean $full add file list to output? 225 | * @param boolean $skipDirs should directory entries be skipped? 226 | * @param boolean $central should Central File records be used? 227 | * @return array file/data summary 228 | */ 229 | public function getSummary($full=false, $skipDirs=false, $central=false) 230 | { 231 | $summary = array( 232 | 'file_name' => $this->file, 233 | 'file_size' => $this->fileSize, 234 | 'data_size' => $this->dataSize, 235 | 'use_range' => "{$this->start}-{$this->end}", 236 | 'file_count' => $this->fileCount, 237 | ); 238 | if ($full) { 239 | $summary['file_list'] = $this->getFileList($skipDirs, $central); 240 | } 241 | if ($this->error) { 242 | $summary['error'] = $this->error; 243 | } 244 | 245 | return $summary; 246 | } 247 | 248 | /** 249 | * Returns a list of the ZIP records found in the file/data in human-readable 250 | * format (for debugging purposes only). 251 | * 252 | * @return array|boolean list of stored records, or false if none available 253 | */ 254 | public function getRecords() 255 | { 256 | // Check that records are stored 257 | if (empty($this->records)) {return false;} 258 | 259 | // Build the record list 260 | $ret = array(); 261 | 262 | foreach ($this->records AS $record) { 263 | 264 | $r = array(); 265 | $r['type_name'] = $this->recordNames[$record['type']]; 266 | $r += $record; 267 | 268 | // Sanity check filename length 269 | if (isset($r['file_name'])) {$r['file_name'] = substr($r['file_name'], 0, $this->maxFilenameLength);} 270 | $ret[] = $r; 271 | } 272 | 273 | return $ret; 274 | } 275 | 276 | /** 277 | * Parses the stored records and returns a list of each of the file entries, 278 | * optionally using the Central Directory File record instead of the (more 279 | * limited) Local File record data. Valid file records include directory entries, 280 | * but these can be skipped. 281 | * 282 | * @return array list of file records, empty if none are available 283 | */ 284 | public function getFileList($skipDirs=false, $central=false) 285 | { 286 | $ret = array(); 287 | foreach ($this->records as $record) { 288 | if (($central && $record['type'] == self::RECORD_CENTRAL_FILE) 289 | || (!$central && $record['type'] == self::RECORD_LOCAL_FILE) 290 | ) { 291 | if ($skipDirs && !empty($record['is_dir'])) {continue;} 292 | $ret[] = $this->getFileRecordSummary($record); 293 | } 294 | } 295 | 296 | return $ret; 297 | } 298 | 299 | /** 300 | * Retrieves the raw data for the given filename. Note that this is only useful 301 | * if the file hasn't been compressed or encrypted. 302 | * 303 | * @param string $filename name of the file to retrieve 304 | * @return mixed file data, or false if no file records available 305 | */ 306 | public function getFileData($filename) 307 | { 308 | // Check that records are stored and data source is available 309 | if (empty($this->records) || ($this->data == '' && $this->handle == null)) { 310 | return false; 311 | } 312 | 313 | // Get the absolute start/end positions 314 | if (!($info = $this->getFileInfo($filename)) || empty($info['range'])) { 315 | $this->error = "Could not find file info for: ({$filename})"; 316 | return false; 317 | } 318 | $this->error = ''; 319 | 320 | return $this->getRange(explode('-', $info['range'])); 321 | } 322 | 323 | /** 324 | * Saves the raw data for the given filename to the given destination. Note that 325 | * this is only useful if the file isn't compressed or encrypted. 326 | * 327 | * @param string $filename name of the file to extract 328 | * @param string $destination full path of the file to create 329 | * @return integer|boolean number of bytes saved or false on error 330 | */ 331 | public function saveFileData($filename, $destination) 332 | { 333 | // Check that records are stored and data source is available 334 | if (empty($this->records) || ($this->data == '' && $this->handle == null)) { 335 | return false; 336 | } 337 | 338 | // Get the absolute start/end positions 339 | if (!($info = $this->getFileInfo($filename)) || empty($info['range'])) { 340 | $this->error = "Could not find file info for: ({$filename})"; 341 | return false; 342 | } 343 | $this->error = ''; 344 | 345 | return $this->saveRange(explode('-', $info['range']), $destination); 346 | } 347 | 348 | /** 349 | * Sets the absolute path to the external 7za client. 350 | * 351 | * @param string $client path to the client 352 | * @return void 353 | * @throws InvalidArgumentException 354 | */ 355 | public function setExternalClient($client) 356 | { 357 | if ($client && (!is_file($client) || !is_executable($client))) 358 | throw new InvalidArgumentException("Not a valid client: {$client}"); 359 | 360 | $this->externalClient = $client; 361 | } 362 | 363 | /** 364 | * Extracts a compressed or encrypted file using the configured external 7za 365 | * client, optionally returning the data or saving it to file. 366 | * 367 | * @param string $filename name of the file to extract 368 | * @param string $destination full path of the file to create 369 | * @param string $password password to use for decryption 370 | * @return mixed extracted data, number of bytes saved or false on error 371 | */ 372 | public function extractFile($filename, $destination=null, $password=null) 373 | { 374 | if (!$this->externalClient || (!$this->file && !$this->data)) { 375 | $this->error = 'An external client and valid data source are needed'; 376 | return false; 377 | } 378 | 379 | // Check that the file is extractable 380 | if (!($info = $this->getFileInfo($filename))) { 381 | $this->error = "Could not find file info for: ({$filename})"; 382 | return false; 383 | } 384 | if (!empty($info['pass']) && $password == null) { 385 | $this->error = "The file is passworded: ({$filename})"; 386 | return false; 387 | } 388 | 389 | // Set the data file source 390 | $source = $this->file ? $this->file : $this->createTempDataFile(); 391 | 392 | // Set the external command 393 | $pass = $password ? '-p'.escapeshellarg($password) : ''; 394 | $command = '"'.$this->externalClient.'"' 395 | ." e -so -bd -y -tzip {$pass} -- " 396 | .escapeshellarg($source).' '.escapeshellarg($filename); 397 | 398 | // Set STDERR to write to a temporary file 399 | list($hash, $errorFile) = $this->getTempFileName($source.'errors'); 400 | $this->tempFiles[$hash] = $errorFile; 401 | $command .= ' 2> '.escapeshellarg($errorFile); 402 | 403 | // Start the new pipe reader 404 | $pipe = new PipeReader; 405 | if (!$pipe->open($command)) { 406 | $this->error = $pipe->error; 407 | return false; 408 | } 409 | $this->error = ''; 410 | 411 | // Open destination file or start buffer 412 | if ($destination) { 413 | $handle = fopen($destination, 'wb'); 414 | $written = 0; 415 | } else { 416 | $data = ''; 417 | } 418 | 419 | // Buffer the piped data or save it to file 420 | while ($read = $pipe->read(1024, false)) { 421 | if ($destination) { 422 | $written += fwrite($handle, $read); 423 | } else { 424 | $data .= $read; 425 | } 426 | } 427 | if ($destination) {fclose($handle);} 428 | $pipe->close(); 429 | 430 | // Check for errors (only after the pipe is closed) 431 | if (($error = @file_get_contents($errorFile)) && strpos($error, 'Everything is Ok') === false) { 432 | if ($destination) {@unlink($destination);} 433 | $this->error = $error; 434 | return false; 435 | } 436 | 437 | return $destination ? $written : $data; 438 | } 439 | 440 | /** 441 | * List of records found in the file/data. 442 | * @var array 443 | */ 444 | protected $records = array(); 445 | 446 | /** 447 | * Full path to the external 7za client. 448 | * @var string 449 | */ 450 | protected $externalClient = ''; 451 | 452 | /** 453 | * Returns a processed summary of a Local or Central File record. 454 | * 455 | * @param array $record a valid file record 456 | * @return array summary information 457 | */ 458 | protected function getFileRecordSummary($record) 459 | { 460 | $ret = array( 461 | 'name' => substr($record['file_name'], 0, $this->maxFilenameLength), 462 | 'size' => $record['uncompressed_size'], 463 | 'date' => self::dos2unixtime(($record['last_mod_date'] << 16) | $record['last_mod_time']), 464 | 'pass' => isset($record['is_encrypted']) ? ((int) $record['is_encrypted']) : 0, 465 | 'compressed' => (int) ($record['method'] > 0), 466 | 'next_offset' => $record['next_offset'], 467 | ); 468 | if (!empty($record['is_dir'])) { 469 | $ret['is_dir'] = 1; 470 | } elseif ($record['type'] == self::RECORD_LOCAL_FILE) { 471 | $start = $this->start + $record['offset'] + 30 + $record['file_name_length'] + $record['extra_length']; 472 | $end = min($this->end, $start + $record['uncompressed_size'] - 1); 473 | $ret['range'] = "{$start}-{$end}"; 474 | } 475 | if (!empty($record['crc32'])) { 476 | $ret['crc32'] = dechex($record['crc32']); 477 | } 478 | 479 | return $ret; 480 | } 481 | 482 | /** 483 | * Returns information for the given filename in the current file/data. 484 | * 485 | * @param string $filename the filename to search 486 | * @return array|boolean the file info or false on error 487 | */ 488 | protected function getFileInfo($filename) 489 | { 490 | foreach ($this->getFileList(true) as $file) { 491 | if (isset($file['name']) && $file['name'] == $filename) { 492 | return $file; 493 | } 494 | } 495 | return false; 496 | } 497 | 498 | /** 499 | * Returns the position of the starting record signature in the file/data. 500 | * An 'empty' ZIP file consists only of an End of Central Directory record. 501 | * 502 | * @return mixed start position, or false if no valid signature found 503 | */ 504 | public function findMarker() 505 | { 506 | if ($this->markerPosition !== null) 507 | return $this->markerPosition; 508 | 509 | // Buffer the data to search 510 | try { 511 | $buff = $this->read(min($this->length, $this->maxReadBytes)); 512 | $this->rewind(); 513 | } catch (Exception $e) { 514 | return false; 515 | } 516 | 517 | // Try to find the first Local File or Central File record 518 | if (($pos = strpos($buff, pack('V', self::RECORD_LOCAL_FILE))) !== false 519 | || ($pos = strpos($buff, pack('V', self::RECORD_CENTRAL_FILE))) !== false 520 | ) { 521 | return $this->markerPosition = $pos; 522 | } 523 | 524 | // Otherwise this could be an empty ZIP file 525 | return $this->markerPosition = strpos($buff, pack('V', self::RECORD_ENDCENTRAL)); 526 | } 527 | 528 | /** 529 | * Parses the ZIP data and stores a list of valid records locally. 530 | * 531 | * @return boolean false if parsing fails 532 | */ 533 | protected function analyze() 534 | { 535 | // Find the first record signature, if there is one 536 | if (($startPos = $this->findMarker()) === false) { 537 | $this->error = 'Could not find any records, not a valid ZIP file'; 538 | return false; 539 | } 540 | $this->seek($startPos); 541 | 542 | // Analyze all records 543 | while ($this->offset < $this->length) try { 544 | 545 | // Get the next record header 546 | if (($record = $this->getNextRecord()) === false) 547 | continue; 548 | 549 | // Process the current record by type 550 | $this->processRecord($record); 551 | 552 | // Add the current record to the list 553 | $this->records[] = $record; 554 | 555 | // Skip to the next record, if any 556 | if ($this->offset != $record['next_offset']) { 557 | $this->seek($record['next_offset']); 558 | } 559 | 560 | // Sanity check 561 | if ($record['offset'] == $this->offset) { 562 | $this->error = 'Parsing seems to be stuck'; 563 | $this->close(); 564 | return false; 565 | } 566 | 567 | // No more readable data, or read error 568 | } catch (Exception $e) { 569 | if ($this->error) {$this->close(); return false;} 570 | break; 571 | } 572 | 573 | // Check for valid records 574 | if (empty($this->records)) { 575 | $this->error = 'No valid ZIP records were found'; 576 | return false; 577 | } 578 | 579 | // Analysis was successful 580 | return true; 581 | } 582 | 583 | /** 584 | * Reads the start of the next record header and checks the header signature 585 | * before further processing by record type. 586 | * 587 | * @return mixed the next record info, or false on invalid signature 588 | */ 589 | protected function getNextRecord() 590 | { 591 | // Start the record info 592 | $record = array('offset' => $this->offset); 593 | 594 | // Unpack the record signature 595 | $record += self::unpack('Vtype', $this->read(4)); 596 | 597 | // Check that the record signature is valid 598 | if (!isset($this->recordNames[$record['type']])) { 599 | $this->seek($this->offset - 3); 600 | return false; 601 | } 602 | 603 | // Return the record info 604 | return $record; 605 | } 606 | 607 | /** 608 | * Processes a record passed by reference based on its type. We start with just 609 | * the header signature, and unpack the rest of each header/body from there. 610 | * 611 | * @param array $record the record to process 612 | * @return void 613 | */ 614 | protected function processRecord(&$record) 615 | { 616 | // Record type: LOCAL FILE 617 | if ($record['type'] == self::RECORD_LOCAL_FILE) { 618 | $record += self::unpack(self::FORMAT_LOCAL_FILE, $this->read(26)); 619 | $record['file_name'] = $this->read($record['file_name_length']); 620 | if ($record['extra_length'] > 0) { 621 | $this->processExtraFields($record); 622 | } 623 | $record['next_offset'] = $this->offset + $record['compressed_size']; 624 | $this->fileCount++; 625 | 626 | // Data Descriptor follows file data? 627 | if ($record['flags'] & self::FILE_DESCRIPTOR_USED) { 628 | $this->seek($record['next_offset']); 629 | $descr = self::unpack(self::FORMAT_DATA_DESCR, $this->read(16)); 630 | $record['has_descriptor'] = true; 631 | $record['crc32'] = $descr['crc32']; 632 | $record['compressed_size'] = $descr['compressed_size']; 633 | $record['uncompressed_size'] = $descr['uncompressed_size']; 634 | $record['next_offset'] = $this->offset; 635 | } 636 | } 637 | 638 | // Record type: CENTRAL FILE 639 | elseif ($record['type'] == self::RECORD_CENTRAL_FILE) { 640 | $record += self::unpack(self::FORMAT_CENTRAL_FILE, $this->read(42)); 641 | $record['file_name'] = $this->read($record['file_name_length']); 642 | if ($record['extra_length'] > 0) { 643 | $this->processExtraFields($record); 644 | } 645 | if ($record['comment_length'] > 0) { 646 | $record['comment'] = $this->read($record['comment_length']); 647 | } 648 | $record['next_offset'] = $this->offset; 649 | } 650 | 651 | // Record type: END OF CENTRAL DIRECTORY 652 | elseif ($record['type'] == self::RECORD_ENDCENTRAL) { 653 | $record += self::unpack(self::FORMAT_ENDCENTRAL, $this->read(18)); 654 | if ($record['comment_length'] > 0) { 655 | $record['comment'] = $this->read($record['comment_length']); 656 | } 657 | $record['next_offset'] = $this->offset; 658 | $this->fileCount = $record['entries_disk']; 659 | } 660 | 661 | // Record type: ZIP64 END OF CENTRAL DIRECTORY 662 | elseif ($record['type'] == self::RECORD_Z64_ENDCENTRAL) { 663 | $record += self::unpack(self::FORMAT_Z64_ENDCENTRAL, $this->read(50)); 664 | $record['next_offset'] = $record['offset'] + self::int64($record['central_size'], $record['central_size_high']); 665 | $this->fileCount = self::int64($record['entries_disk'], $record['entries_disk_high']); 666 | } 667 | 668 | // Record type: ZIP64 END OF CENTRAL DIRECTORY LOCATOR 669 | elseif ($record['type'] == self::RECORD_Z64_ENDCENTRAL_LOC) { 670 | $record += self::unpack(self::FORMAT_Z64_ENDCENTRAL_LOC, $this->read(16)); 671 | $record['next_offset'] = $this->offset; 672 | } 673 | 674 | // Skip everything else 675 | else { 676 | $record['next_offset'] = $this->offset + 1; 677 | } 678 | 679 | // Process any version numbers (-> major.minor) 680 | if (isset($record['version_made_num'])) { 681 | $num = $record['version_made_num']; 682 | $record['made_version'] = floor($num / 10).'.'.($num % 10); 683 | } 684 | if (isset($record['version_need_num'])) { 685 | $num = $record['version_need_num']; 686 | $record['need_version'] = floor($num / 10).'.'.($num % 10); 687 | } 688 | 689 | // Process Host OS info 690 | if (isset($record['version_made_os'])) { 691 | $os = $record['version_made_os']; 692 | $record['made_host_os'] = isset($this->hostOSNames[$os]) ? $this->hostOSNames[$os] : 'Unknown'; 693 | } 694 | if (isset($record['version_need_os'])) { 695 | $os = $record['version_need_os']; 696 | $record['need_host_os'] = isset($this->hostOSNames[$os]) ? $this->hostOSNames[$os] : 'Unknown'; 697 | } 698 | 699 | if ($record['type'] == self::RECORD_LOCAL_FILE || $record['type'] == self::RECORD_CENTRAL_FILE) { 700 | 701 | // Is the file encrypted? 702 | if ($record['flags'] & self::FILE_ENCRYPTED) { 703 | $record['is_encrypted'] = true; 704 | } 705 | 706 | // Is the Central Directory encrypted (masking Local File values)? 707 | if ($record['flags'] & self::FILE_CDR_ENCRYPTED) { 708 | $this->isEncrypted = true; 709 | } 710 | 711 | // Is this a directory entry? (quick check) 712 | if ($record['file_name'][$record['file_name_length'] - 1] == '/') { 713 | $record['is_dir'] = true; 714 | } 715 | 716 | // Is UTF8 encoding used? 717 | if ($record['flags'] & self::FILE_EFS_UTF8) { 718 | $record['is_utf8'] = true; 719 | } 720 | } 721 | } 722 | 723 | /** 724 | * Processes Extra Field blocks for the current record. 725 | * 726 | * @param array $record the current record to process 727 | * @return void 728 | */ 729 | protected function processExtraFields(&$record) 730 | { 731 | $end = $this->offset + $record['extra_length']; 732 | while ($this->offset < $end) 733 | { 734 | $field = array('type_name' => ''); 735 | $field += self::unpack(self::FORMAT_EXTRA_FIELD, $this->read(4)); 736 | $field['type_name'] = isset($this->extraFieldNames[$field['headerID']]) ? $this->extraFieldNames[$field['headerID']] : 'Unknown'; 737 | 738 | // Field: ZIP64 format 739 | if ($field['headerID'] == self::EXTRA_ZIP64) { 740 | 741 | // Values are only included if the record values are set to 0xFFFFFFFF or 0xFFFF 742 | if ($record['uncompressed_size'] == 0xFFFFFFFF) { 743 | $field += self::unpack('Vuncompressed_size/Vuncompressed_size_high', $this->read(8)); 744 | $record['uncompressed_size'] = self::int64($field['uncompressed_size'], $field['uncompressed_size_high']); 745 | } 746 | if ($record['compressed_size'] == 0xFFFFFFFF) { 747 | $field += self::unpack('Vcompressed_size/Vcompressed_size_high', $this->read(8)); 748 | $record['compressed_size'] = self::int64($field['compressed_size'], $field['compressed_size_high']); 749 | } 750 | if (isset($record['rel_offset']) && $record['rel_offset'] == 0xFFFFFFFF) { 751 | $field += self::unpack('Vrel_offset/Vrel_offset_high', $this->read(8)); 752 | $record['rel_offset'] = self::int64($field['rel_offset'], $field['rel_offset_high']); 753 | } 754 | if (isset($record['disk_start']) && $record['disk_start'] == 0xFFFF) { 755 | $field += self::unpack('Vdisk_start', $this->read(4)); 756 | $record['disk_start'] = $field['disk_start']; 757 | } 758 | } 759 | 760 | // Field: UNIXTIME 761 | elseif ($field['headerID'] == self::EXTRA_UNIXTIME) { 762 | // Probably could do something with this 763 | $this->read($field['data_size']); 764 | 765 | // Default: skip field 766 | } else { 767 | $this->read($field['data_size']); 768 | } 769 | 770 | // Add the extra field info to the record 771 | $field['end_offset'] = $this->offset; 772 | $record['extra_fields'][] = $field; 773 | } 774 | } 775 | 776 | 777 | /** 778 | * Resets the instance variables before parsing new data. 779 | * 780 | * @return void 781 | */ 782 | protected function reset() 783 | { 784 | parent::reset(); 785 | 786 | $this->records = array(); 787 | $this->isEncrypted = false; 788 | } 789 | 790 | } // End ZipInfo class 791 | --------------------------------------------------------------------------------