Initial commit

This commit is contained in:
2021-10-26 13:02:53 +02:00
commit 73843b66ce
4678 changed files with 319494 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
Copyright (c) 2015 Andreas Gohr <gohr@cosmocode.de>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,70 @@
PHPArchive - Pure PHP ZIP and TAR handling
==========================================
This library allows to handle new ZIP and TAR archives without the need for any special PHP extensions (gz and bzip are
needed for compression). It can create new files or extract existing ones.
To keep things simple, the modification (adding or removing files) of existing archives is not supported.
[![Build Status](https://travis-ci.org/splitbrain/php-archive.svg)](https://travis-ci.org/splitbrain/php-archive)
Install
-------
Use composer:
```php composer.phar require splitbrain/php-archive```
Usage
-----
The usage for the Zip and Tar classes are basically the same. Here are some
examples for working with TARs to get you started.
Check the [API docs](https://splitbrain.github.io/php-archive/) for more
info.
```php
require_once 'vendor/autoload.php';
use splitbrain\PHPArchive\Tar;
// To list the contents of an existing TAR archive, open() it and use
// contents() on it:
$tar = new Tar();
$tar->open('myfile.tgz');
$toc = $tar->contents();
print_r($toc); // array of FileInfo objects
// To extract the contents of an existing TAR archive, open() it and use
// extract() on it:
$tar = new Tar();
$tar->open('myfile.tgz');
$tar->extract('/tmp');
// To create a new TAR archive directly on the filesystem (low memory
// requirements), create() it:
$tar = new Tar();
$tar->create('myfile.tgz');
$tar->addFile(...);
$tar->addData(...);
...
$tar->close();
// To create a TAR archive directly in memory, create() it, add*()
// files and then either save() or getArchive() it:
$tar = new Tar();
$tar->setCompression(9, Archive::COMPRESS_BZIP);
$tar->create();
$tar->addFile(...);
$tar->addData(...);
...
$tar->save('myfile.tbz'); // compresses and saves it
echo $tar->getArchive(); // compresses and returns it
```
Differences between Tar and Zip: Tars are compressed as a whole, while Zips compress each file individually. Therefore
you can call ```setCompression``` before each ```addFile()``` and ```addData()``` function call.
The FileInfo class can be used to specify additional info like ownership or permissions when adding a file to
an archive.

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false"
backupStaticAttributes="false"
bootstrap="vendor/autoload.php"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
processIsolation="false"
stopOnFailure="false"
syntaxCheck="false">
<testsuites>
<testsuite name="Test Suite">
<directory suffix=".php">./tests/</directory>
</testsuite>
</testsuites>
<filter>
<whitelist processUncoveredFilesFromWhitelist="false">
<directory suffix=".php">src</directory>
</whitelist>
</filter>
</phpunit>

View File

@@ -0,0 +1,135 @@
<?php
namespace splitbrain\PHPArchive;
abstract class Archive
{
const COMPRESS_AUTO = -1;
const COMPRESS_NONE = 0;
const COMPRESS_GZIP = 1;
const COMPRESS_BZIP = 2;
/** @var callable */
protected $callback;
/**
* Set the compression level and type
*
* @param int $level Compression level (0 to 9)
* @param int $type Type of compression to use (use COMPRESS_* constants)
* @throws ArchiveIllegalCompressionException
*/
abstract public function setCompression($level = 9, $type = Archive::COMPRESS_AUTO);
/**
* Open an existing archive file for reading
*
* @param string $file
* @throws ArchiveIOException
*/
abstract public function open($file);
/**
* Read the contents of an archive
*
* This function lists the files stored in the archive, and returns an indexed array of FileInfo objects
*
* The archive is closed afer reading the contents, because rewinding is not possible in bzip2 streams.
* Reopen the file with open() again if you want to do additional operations
*
* @return FileInfo[]
*/
abstract public function contents();
/**
* Extract an existing archive
*
* The $strip parameter allows you to strip a certain number of path components from the filenames
* found in the archive file, similar to the --strip-components feature of GNU tar. This is triggered when
* an integer is passed as $strip.
* Alternatively a fixed string prefix may be passed in $strip. If the filename matches this prefix,
* the prefix will be stripped. It is recommended to give prefixes with a trailing slash.
*
* By default this will extract all files found in the archive. You can restrict the output using the $include
* and $exclude parameter. Both expect a full regular expression (including delimiters and modifiers). If
* $include is set, only files that match this expression will be extracted. Files that match the $exclude
* expression will never be extracted. Both parameters can be used in combination. Expressions are matched against
* stripped filenames as described above.
*
* The archive is closed afterwards. Reopen the file with open() again if you want to do additional operations
*
* @param string $outdir the target directory for extracting
* @param int|string $strip either the number of path components or a fixed prefix to strip
* @param string $exclude a regular expression of files to exclude
* @param string $include a regular expression of files to include
* @throws ArchiveIOException
* @return array
*/
abstract public function extract($outdir, $strip = '', $exclude = '', $include = '');
/**
* Create a new archive file
*
* If $file is empty, the archive file will be created in memory
*
* @param string $file
*/
abstract public function create($file = '');
/**
* Add a file to the current archive using an existing file in the filesystem
*
* @param string $file path to the original file
* @param string|FileInfo $fileinfo either the name to us in archive (string) or a FileInfo oject with all meta data, empty to take from original
* @throws ArchiveIOException
*/
abstract public function addFile($file, $fileinfo = '');
/**
* Add a file to the current archive using the given $data as content
*
* @param string|FileInfo $fileinfo either the name to us in archive (string) or a FileInfo oject with all meta data
* @param string $data binary content of the file to add
* @throws ArchiveIOException
*/
abstract public function addData($fileinfo, $data);
/**
* Close the archive, close all file handles
*
* After a call to this function no more data can be added to the archive, for
* read access no reading is allowed anymore
*/
abstract public function close();
/**
* Returns the created in-memory archive data
*
* This implicitly calls close() on the Archive
*/
abstract public function getArchive();
/**
* Save the created in-memory archive data
*
* Note: It is more memory effective to specify the filename in the create() function and
* let the library work on the new file directly.
*
* @param string $file
*/
abstract public function save($file);
/**
* Set a callback function to be called whenever a file is added or extracted.
*
* The callback is called with a FileInfo object as parameter. You can use this to show progress
* info during an operation.
*
* @param callable $callback
*/
public function setCallback($callback)
{
$this->callback = $callback;
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace splitbrain\PHPArchive;
/**
* The archive is unreadable
*/
class ArchiveCorruptedException extends \Exception
{
}

View File

@@ -0,0 +1,10 @@
<?php
namespace splitbrain\PHPArchive;
/**
* Read/Write Errors
*/
class ArchiveIOException extends \Exception
{
}

View File

@@ -0,0 +1,10 @@
<?php
namespace splitbrain\PHPArchive;
/**
* Bad or unsupported compression settings requested
*/
class ArchiveIllegalCompressionException extends \Exception
{
}

View File

@@ -0,0 +1,340 @@
<?php
namespace splitbrain\PHPArchive;
/**
* Class FileInfo
*
* stores meta data about a file in an Archive
*
* @author Andreas Gohr <andi@splitbrain.org>
* @package splitbrain\PHPArchive
* @license MIT
*/
class FileInfo
{
protected $isdir = false;
protected $path = '';
protected $size = 0;
protected $csize = 0;
protected $mtime = 0;
protected $mode = 0664;
protected $owner = '';
protected $group = '';
protected $uid = 0;
protected $gid = 0;
protected $comment = '';
/**
* initialize dynamic defaults
*
* @param string $path The path of the file, can also be set later through setPath()
*/
public function __construct($path = '')
{
$this->mtime = time();
$this->setPath($path);
}
/**
* Factory to build FileInfo from existing file or directory
*
* @param string $path path to a file on the local file system
* @param string $as optional path to use inside the archive
* @throws FileInfoException
* @return FileInfo
*/
public static function fromPath($path, $as = '')
{
clearstatcache(false, $path);
if (!file_exists($path)) {
throw new FileInfoException("$path does not exist");
}
$stat = stat($path);
$file = new FileInfo();
$file->setPath($path);
$file->setIsdir(is_dir($path));
$file->setMode(fileperms($path));
$file->setOwner(fileowner($path));
$file->setGroup(filegroup($path));
$file->setSize(filesize($path));
$file->setUid($stat['uid']);
$file->setGid($stat['gid']);
$file->setMtime($stat['mtime']);
if ($as) {
$file->setPath($as);
}
return $file;
}
/**
* @return int the filesize. always 0 for directories
*/
public function getSize()
{
if($this->isdir) return 0;
return $this->size;
}
/**
* @param int $size
*/
public function setSize($size)
{
$this->size = $size;
}
/**
* @return int
*/
public function getCompressedSize()
{
return $this->csize;
}
/**
* @param int $csize
*/
public function setCompressedSize($csize)
{
$this->csize = $csize;
}
/**
* @return int
*/
public function getMtime()
{
return $this->mtime;
}
/**
* @param int $mtime
*/
public function setMtime($mtime)
{
$this->mtime = $mtime;
}
/**
* @return int
*/
public function getGid()
{
return $this->gid;
}
/**
* @param int $gid
*/
public function setGid($gid)
{
$this->gid = $gid;
}
/**
* @return int
*/
public function getUid()
{
return $this->uid;
}
/**
* @param int $uid
*/
public function setUid($uid)
{
$this->uid = $uid;
}
/**
* @return string
*/
public function getComment()
{
return $this->comment;
}
/**
* @param string $comment
*/
public function setComment($comment)
{
$this->comment = $comment;
}
/**
* @return string
*/
public function getGroup()
{
return $this->group;
}
/**
* @param string $group
*/
public function setGroup($group)
{
$this->group = $group;
}
/**
* @return boolean
*/
public function getIsdir()
{
return $this->isdir;
}
/**
* @param boolean $isdir
*/
public function setIsdir($isdir)
{
// default mode for directories
if ($isdir && $this->mode === 0664) {
$this->mode = 0775;
}
$this->isdir = $isdir;
}
/**
* @return int
*/
public function getMode()
{
return $this->mode;
}
/**
* @param int $mode
*/
public function setMode($mode)
{
$this->mode = $mode;
}
/**
* @return string
*/
public function getOwner()
{
return $this->owner;
}
/**
* @param string $owner
*/
public function setOwner($owner)
{
$this->owner = $owner;
}
/**
* @return string
*/
public function getPath()
{
return $this->path;
}
/**
* @param string $path
*/
public function setPath($path)
{
$this->path = $this->cleanPath($path);
}
/**
* Cleans up a path and removes relative parts, also strips leading slashes
*
* @param string $path
* @return string
*/
protected function cleanPath($path)
{
$path = str_replace('\\', '/', $path);
$path = explode('/', $path);
$newpath = array();
foreach ($path as $p) {
if ($p === '' || $p === '.') {
continue;
}
if ($p === '..') {
array_pop($newpath);
continue;
}
array_push($newpath, $p);
}
return trim(implode('/', $newpath), '/');
}
/**
* Strip given prefix or number of path segments from the filename
*
* The $strip parameter allows you to strip a certain number of path components from the filenames
* found in the tar file, similar to the --strip-components feature of GNU tar. This is triggered when
* an integer is passed as $strip.
* Alternatively a fixed string prefix may be passed in $strip. If the filename matches this prefix,
* the prefix will be stripped. It is recommended to give prefixes with a trailing slash.
*
* @param int|string $strip
*/
public function strip($strip)
{
$filename = $this->getPath();
$striplen = strlen($strip);
if (is_int($strip)) {
// if $strip is an integer we strip this many path components
$parts = explode('/', $filename);
if (!$this->getIsdir()) {
$base = array_pop($parts); // keep filename itself
} else {
$base = '';
}
$filename = join('/', array_slice($parts, $strip));
if ($base) {
$filename .= "/$base";
}
} else {
// if strip is a string, we strip a prefix here
if (substr($filename, 0, $striplen) == $strip) {
$filename = substr($filename, $striplen);
}
}
$this->setPath($filename);
}
/**
* Does the file match the given include and exclude expressions?
*
* Exclude rules take precedence over include rules
*
* @param string $include Regular expression of files to include
* @param string $exclude Regular expression of files to exclude
* @return bool
*/
public function match($include = '', $exclude = '')
{
$extract = true;
if ($include && !preg_match($include, $this->getPath())) {
$extract = false;
}
if ($exclude && preg_match($exclude, $this->getPath())) {
$extract = false;
}
return $extract;
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace splitbrain\PHPArchive;
/**
* File meta data problems
*/
class FileInfoException extends \Exception
{
}

View File

@@ -0,0 +1,692 @@
<?php
namespace splitbrain\PHPArchive;
/**
* Class Tar
*
* Creates or extracts Tar archives. Supports gz and bzip compression
*
* Long pathnames (>100 chars) are supported in POSIX ustar and GNU longlink formats.
*
* @author Andreas Gohr <andi@splitbrain.org>
* @package splitbrain\PHPArchive
* @license MIT
*/
class Tar extends Archive
{
protected $file = '';
protected $comptype = Archive::COMPRESS_AUTO;
protected $complevel = 9;
protected $fh;
protected $memory = '';
protected $closed = true;
protected $writeaccess = false;
/**
* Sets the compression to use
*
* @param int $level Compression level (0 to 9)
* @param int $type Type of compression to use (use COMPRESS_* constants)
* @throws ArchiveIllegalCompressionException
*/
public function setCompression($level = 9, $type = Archive::COMPRESS_AUTO)
{
$this->compressioncheck($type);
if ($level < -1 || $level > 9) {
throw new ArchiveIllegalCompressionException('Compression level should be between -1 and 9');
}
$this->comptype = $type;
$this->complevel = $level;
if($level == 0) $this->comptype = Archive::COMPRESS_NONE;
if($type == Archive::COMPRESS_NONE) $this->complevel = 0;
}
/**
* Open an existing TAR file for reading
*
* @param string $file
* @throws ArchiveIOException
* @throws ArchiveIllegalCompressionException
*/
public function open($file)
{
$this->file = $file;
// update compression to mach file
if ($this->comptype == Tar::COMPRESS_AUTO) {
$this->setCompression($this->complevel, $this->filetype($file));
}
// open file handles
if ($this->comptype === Archive::COMPRESS_GZIP) {
$this->fh = @gzopen($this->file, 'rb');
} elseif ($this->comptype === Archive::COMPRESS_BZIP) {
$this->fh = @bzopen($this->file, 'r');
} else {
$this->fh = @fopen($this->file, 'rb');
}
if (!$this->fh) {
throw new ArchiveIOException('Could not open file for reading: '.$this->file);
}
$this->closed = false;
}
/**
* Read the contents of a TAR archive
*
* This function lists the files stored in the archive
*
* The archive is closed afer reading the contents, because rewinding is not possible in bzip2 streams.
* Reopen the file with open() again if you want to do additional operations
*
* @throws ArchiveIOException
* @throws ArchiveCorruptedException
* @returns FileInfo[]
*/
public function contents()
{
if ($this->closed || !$this->file) {
throw new ArchiveIOException('Can not read from a closed archive');
}
$result = array();
while ($read = $this->readbytes(512)) {
$header = $this->parseHeader($read);
if (!is_array($header)) {
continue;
}
$this->skipbytes(ceil($header['size'] / 512) * 512);
$result[] = $this->header2fileinfo($header);
}
$this->close();
return $result;
}
/**
* Extract an existing TAR archive
*
* The $strip parameter allows you to strip a certain number of path components from the filenames
* found in the tar file, similar to the --strip-components feature of GNU tar. This is triggered when
* an integer is passed as $strip.
* Alternatively a fixed string prefix may be passed in $strip. If the filename matches this prefix,
* the prefix will be stripped. It is recommended to give prefixes with a trailing slash.
*
* By default this will extract all files found in the archive. You can restrict the output using the $include
* and $exclude parameter. Both expect a full regular expression (including delimiters and modifiers). If
* $include is set only files that match this expression will be extracted. Files that match the $exclude
* expression will never be extracted. Both parameters can be used in combination. Expressions are matched against
* stripped filenames as described above.
*
* The archive is closed afer reading the contents, because rewinding is not possible in bzip2 streams.
* Reopen the file with open() again if you want to do additional operations
*
* @param string $outdir the target directory for extracting
* @param int|string $strip either the number of path components or a fixed prefix to strip
* @param string $exclude a regular expression of files to exclude
* @param string $include a regular expression of files to include
* @throws ArchiveIOException
* @throws ArchiveCorruptedException
* @return FileInfo[]
*/
public function extract($outdir, $strip = '', $exclude = '', $include = '')
{
if ($this->closed || !$this->file) {
throw new ArchiveIOException('Can not read from a closed archive');
}
$outdir = rtrim($outdir, '/');
@mkdir($outdir, 0777, true);
if (!is_dir($outdir)) {
throw new ArchiveIOException("Could not create directory '$outdir'");
}
$extracted = array();
while ($dat = $this->readbytes(512)) {
// read the file header
$header = $this->parseHeader($dat);
if (!is_array($header)) {
continue;
}
$fileinfo = $this->header2fileinfo($header);
// apply strip rules
$fileinfo->strip($strip);
// skip unwanted files
if (!strlen($fileinfo->getPath()) || !$fileinfo->match($include, $exclude)) {
$this->skipbytes(ceil($header['size'] / 512) * 512);
continue;
}
// create output directory
$output = $outdir.'/'.$fileinfo->getPath();
$directory = ($fileinfo->getIsdir()) ? $output : dirname($output);
@mkdir($directory, 0777, true);
// extract data
if (!$fileinfo->getIsdir()) {
$fp = @fopen($output, "wb");
if (!$fp) {
throw new ArchiveIOException('Could not open file for writing: '.$output);
}
$size = floor($header['size'] / 512);
for ($i = 0; $i < $size; $i++) {
fwrite($fp, $this->readbytes(512), 512);
}
if (($header['size'] % 512) != 0) {
fwrite($fp, $this->readbytes(512), $header['size'] % 512);
}
fclose($fp);
@touch($output, $fileinfo->getMtime());
@chmod($output, $fileinfo->getMode());
} else {
$this->skipbytes(ceil($header['size'] / 512) * 512); // the size is usually 0 for directories
}
if(is_callable($this->callback)) {
call_user_func($this->callback, $fileinfo);
}
$extracted[] = $fileinfo;
}
$this->close();
return $extracted;
}
/**
* Create a new TAR file
*
* If $file is empty, the tar file will be created in memory
*
* @param string $file
* @throws ArchiveIOException
* @throws ArchiveIllegalCompressionException
*/
public function create($file = '')
{
$this->file = $file;
$this->memory = '';
$this->fh = 0;
if ($this->file) {
// determine compression
if ($this->comptype == Archive::COMPRESS_AUTO) {
$this->setCompression($this->complevel, $this->filetype($file));
}
if ($this->comptype === Archive::COMPRESS_GZIP) {
$this->fh = @gzopen($this->file, 'wb'.$this->complevel);
} elseif ($this->comptype === Archive::COMPRESS_BZIP) {
$this->fh = @bzopen($this->file, 'w');
} else {
$this->fh = @fopen($this->file, 'wb');
}
if (!$this->fh) {
throw new ArchiveIOException('Could not open file for writing: '.$this->file);
}
}
$this->writeaccess = true;
$this->closed = false;
}
/**
* Add a file to the current TAR archive using an existing file in the filesystem
*
* @param string $file path to the original file
* @param string|FileInfo $fileinfo either the name to us in archive (string) or a FileInfo oject with all meta data, empty to take from original
* @throws ArchiveCorruptedException when the file changes while reading it, the archive will be corrupt and should be deleted
* @throws ArchiveIOException there was trouble reading the given file, it was not added
* @throws FileInfoException trouble reading file info, it was not added
*/
public function addFile($file, $fileinfo = '')
{
if (is_string($fileinfo)) {
$fileinfo = FileInfo::fromPath($file, $fileinfo);
}
if ($this->closed) {
throw new ArchiveIOException('Archive has been closed, files can no longer be added');
}
$fp = @fopen($file, 'rb');
if (!$fp) {
throw new ArchiveIOException('Could not open file for reading: '.$file);
}
// create file header
$this->writeFileHeader($fileinfo);
// write data
$read = 0;
while (!feof($fp)) {
$data = fread($fp, 512);
$read += strlen($data);
if ($data === false) {
break;
}
if ($data === '') {
break;
}
$packed = pack("a512", $data);
$this->writebytes($packed);
}
fclose($fp);
if($read != $fileinfo->getSize()) {
$this->close();
throw new ArchiveCorruptedException("The size of $file changed while reading, archive corrupted. read $read expected ".$fileinfo->getSize());
}
if(is_callable($this->callback)) {
call_user_func($this->callback, $fileinfo);
}
}
/**
* Add a file to the current TAR archive using the given $data as content
*
* @param string|FileInfo $fileinfo either the name to us in archive (string) or a FileInfo oject with all meta data
* @param string $data binary content of the file to add
* @throws ArchiveIOException
*/
public function addData($fileinfo, $data)
{
if (is_string($fileinfo)) {
$fileinfo = new FileInfo($fileinfo);
}
if ($this->closed) {
throw new ArchiveIOException('Archive has been closed, files can no longer be added');
}
$len = strlen($data);
$fileinfo->setSize($len);
$this->writeFileHeader($fileinfo);
for ($s = 0; $s < $len; $s += 512) {
$this->writebytes(pack("a512", substr($data, $s, 512)));
}
if (is_callable($this->callback)) {
call_user_func($this->callback, $fileinfo);
}
}
/**
* Add the closing footer to the archive if in write mode, close all file handles
*
* After a call to this function no more data can be added to the archive, for
* read access no reading is allowed anymore
*
* "Physically, an archive consists of a series of file entries terminated by an end-of-archive entry, which
* consists of two 512 blocks of zero bytes"
*
* @link http://www.gnu.org/software/tar/manual/html_chapter/tar_8.html#SEC134
* @throws ArchiveIOException
*/
public function close()
{
if ($this->closed) {
return;
} // we did this already
// write footer
if ($this->writeaccess) {
$this->writebytes(pack("a512", ""));
$this->writebytes(pack("a512", ""));
}
// close file handles
if ($this->file) {
if ($this->comptype === Archive::COMPRESS_GZIP) {
gzclose($this->fh);
} elseif ($this->comptype === Archive::COMPRESS_BZIP) {
bzclose($this->fh);
} else {
fclose($this->fh);
}
$this->file = '';
$this->fh = 0;
}
$this->writeaccess = false;
$this->closed = true;
}
/**
* Returns the created in-memory archive data
*
* This implicitly calls close() on the Archive
* @throws ArchiveIOException
*/
public function getArchive()
{
$this->close();
if ($this->comptype === Archive::COMPRESS_AUTO) {
$this->comptype = Archive::COMPRESS_NONE;
}
if ($this->comptype === Archive::COMPRESS_GZIP) {
return gzencode($this->memory, $this->complevel);
}
if ($this->comptype === Archive::COMPRESS_BZIP) {
return bzcompress($this->memory);
}
return $this->memory;
}
/**
* Save the created in-memory archive data
*
* Note: It more memory effective to specify the filename in the create() function and
* let the library work on the new file directly.
*
* @param string $file
* @throws ArchiveIOException
* @throws ArchiveIllegalCompressionException
*/
public function save($file)
{
if ($this->comptype === Archive::COMPRESS_AUTO) {
$this->setCompression($this->complevel, $this->filetype($file));
}
if (!@file_put_contents($file, $this->getArchive())) {
throw new ArchiveIOException('Could not write to file: '.$file);
}
}
/**
* Read from the open file pointer
*
* @param int $length bytes to read
* @return string
*/
protected function readbytes($length)
{
if ($this->comptype === Archive::COMPRESS_GZIP) {
return @gzread($this->fh, $length);
} elseif ($this->comptype === Archive::COMPRESS_BZIP) {
return @bzread($this->fh, $length);
} else {
return @fread($this->fh, $length);
}
}
/**
* Write to the open filepointer or memory
*
* @param string $data
* @throws ArchiveIOException
* @return int number of bytes written
*/
protected function writebytes($data)
{
if (!$this->file) {
$this->memory .= $data;
$written = strlen($data);
} elseif ($this->comptype === Archive::COMPRESS_GZIP) {
$written = @gzwrite($this->fh, $data);
} elseif ($this->comptype === Archive::COMPRESS_BZIP) {
$written = @bzwrite($this->fh, $data);
} else {
$written = @fwrite($this->fh, $data);
}
if ($written === false) {
throw new ArchiveIOException('Failed to write to archive stream');
}
return $written;
}
/**
* Skip forward in the open file pointer
*
* This is basically a wrapper around seek() (and a workaround for bzip2)
*
* @param int $bytes seek to this position
*/
protected function skipbytes($bytes)
{
if ($this->comptype === Archive::COMPRESS_GZIP) {
@gzseek($this->fh, $bytes, SEEK_CUR);
} elseif ($this->comptype === Archive::COMPRESS_BZIP) {
// there is no seek in bzip2, we simply read on
// bzread allows to read a max of 8kb at once
while($bytes) {
$toread = min(8192, $bytes);
@bzread($this->fh, $toread);
$bytes -= $toread;
}
} else {
@fseek($this->fh, $bytes, SEEK_CUR);
}
}
/**
* Write the given file meta data as header
*
* @param FileInfo $fileinfo
* @throws ArchiveIOException
*/
protected function writeFileHeader(FileInfo $fileinfo)
{
$this->writeRawFileHeader(
$fileinfo->getPath(),
$fileinfo->getUid(),
$fileinfo->getGid(),
$fileinfo->getMode(),
$fileinfo->getSize(),
$fileinfo->getMtime(),
$fileinfo->getIsdir() ? '5' : '0'
);
}
/**
* Write a file header to the stream
*
* @param string $name
* @param int $uid
* @param int $gid
* @param int $perm
* @param int $size
* @param int $mtime
* @param string $typeflag Set to '5' for directories
* @throws ArchiveIOException
*/
protected function writeRawFileHeader($name, $uid, $gid, $perm, $size, $mtime, $typeflag = '')
{
// handle filename length restrictions
$prefix = '';
$namelen = strlen($name);
if ($namelen > 100) {
$file = basename($name);
$dir = dirname($name);
if (strlen($file) > 100 || strlen($dir) > 155) {
// we're still too large, let's use GNU longlink
$this->writeRawFileHeader('././@LongLink', 0, 0, 0, $namelen, 0, 'L');
for ($s = 0; $s < $namelen; $s += 512) {
$this->writebytes(pack("a512", substr($name, $s, 512)));
}
$name = substr($name, 0, 100); // cut off name
} else {
// we're fine when splitting, use POSIX ustar
$prefix = $dir;
$name = $file;
}
}
// values are needed in octal
$uid = sprintf("%6s ", decoct($uid));
$gid = sprintf("%6s ", decoct($gid));
$perm = sprintf("%6s ", decoct($perm));
$size = sprintf("%11s ", decoct($size));
$mtime = sprintf("%11s", decoct($mtime));
$data_first = pack("a100a8a8a8a12A12", $name, $perm, $uid, $gid, $size, $mtime);
$data_last = pack("a1a100a6a2a32a32a8a8a155a12", $typeflag, '', 'ustar', '', '', '', '', '', $prefix, "");
for ($i = 0, $chks = 0; $i < 148; $i++) {
$chks += ord($data_first[$i]);
}
for ($i = 156, $chks += 256, $j = 0; $i < 512; $i++, $j++) {
$chks += ord($data_last[$j]);
}
$this->writebytes($data_first);
$chks = pack("a8", sprintf("%6s ", decoct($chks)));
$this->writebytes($chks.$data_last);
}
/**
* Decode the given tar file header
*
* @param string $block a 512 byte block containing the header data
* @return array|false returns false when this was a null block
* @throws ArchiveCorruptedException
*/
protected function parseHeader($block)
{
if (!$block || strlen($block) != 512) {
throw new ArchiveCorruptedException('Unexpected length of header');
}
// null byte blocks are ignored
if(trim($block) === '') return false;
for ($i = 0, $chks = 0; $i < 148; $i++) {
$chks += ord($block[$i]);
}
for ($i = 156, $chks += 256; $i < 512; $i++) {
$chks += ord($block[$i]);
}
$header = @unpack(
"a100filename/a8perm/a8uid/a8gid/a12size/a12mtime/a8checksum/a1typeflag/a100link/a6magic/a2version/a32uname/a32gname/a8devmajor/a8devminor/a155prefix",
$block
);
if (!$header) {
throw new ArchiveCorruptedException('Failed to parse header');
}
$return['checksum'] = OctDec(trim($header['checksum']));
if ($return['checksum'] != $chks) {
throw new ArchiveCorruptedException('Header does not match it\'s checksum');
}
$return['filename'] = trim($header['filename']);
$return['perm'] = OctDec(trim($header['perm']));
$return['uid'] = OctDec(trim($header['uid']));
$return['gid'] = OctDec(trim($header['gid']));
$return['size'] = OctDec(trim($header['size']));
$return['mtime'] = OctDec(trim($header['mtime']));
$return['typeflag'] = $header['typeflag'];
$return['link'] = trim($header['link']);
$return['uname'] = trim($header['uname']);
$return['gname'] = trim($header['gname']);
// Handle ustar Posix compliant path prefixes
if (trim($header['prefix'])) {
$return['filename'] = trim($header['prefix']).'/'.$return['filename'];
}
// Handle Long-Link entries from GNU Tar
if ($return['typeflag'] == 'L') {
// following data block(s) is the filename
$filename = trim($this->readbytes(ceil($return['size'] / 512) * 512));
// next block is the real header
$block = $this->readbytes(512);
$return = $this->parseHeader($block);
// overwrite the filename
$return['filename'] = $filename;
}
return $return;
}
/**
* Creates a FileInfo object from the given parsed header
*
* @param $header
* @return FileInfo
*/
protected function header2fileinfo($header)
{
$fileinfo = new FileInfo();
$fileinfo->setPath($header['filename']);
$fileinfo->setMode($header['perm']);
$fileinfo->setUid($header['uid']);
$fileinfo->setGid($header['gid']);
$fileinfo->setSize($header['size']);
$fileinfo->setMtime($header['mtime']);
$fileinfo->setOwner($header['uname']);
$fileinfo->setGroup($header['gname']);
$fileinfo->setIsdir((bool) $header['typeflag']);
return $fileinfo;
}
/**
* Checks if the given compression type is available and throws an exception if not
*
* @param $comptype
* @throws ArchiveIllegalCompressionException
*/
protected function compressioncheck($comptype)
{
if ($comptype === Archive::COMPRESS_GZIP && !function_exists('gzopen')) {
throw new ArchiveIllegalCompressionException('No gzip support available');
}
if ($comptype === Archive::COMPRESS_BZIP && !function_exists('bzopen')) {
throw new ArchiveIllegalCompressionException('No bzip2 support available');
}
}
/**
* Guesses the wanted compression from the given file
*
* Uses magic bytes for existing files, the file extension otherwise
*
* You don't need to call this yourself. It's used when you pass Archive::COMPRESS_AUTO somewhere
*
* @param string $file
* @return int
*/
public function filetype($file)
{
// for existing files, try to read the magic bytes
if(file_exists($file) && is_readable($file) && filesize($file) > 5) {
$fh = @fopen($file, 'rb');
if(!$fh) return false;
$magic = fread($fh, 5);
fclose($fh);
if(strpos($magic, "\x42\x5a") === 0) return Archive::COMPRESS_BZIP;
if(strpos($magic, "\x1f\x8b") === 0) return Archive::COMPRESS_GZIP;
}
// otherwise rely on file name
$file = strtolower($file);
if (substr($file, -3) == '.gz' || substr($file, -4) == '.tgz') {
return Archive::COMPRESS_GZIP;
} elseif (substr($file, -4) == '.bz2' || substr($file, -4) == '.tbz') {
return Archive::COMPRESS_BZIP;
}
return Archive::COMPRESS_NONE;
}
}

View File

@@ -0,0 +1,895 @@
<?php
namespace splitbrain\PHPArchive;
/**
* Class Zip
*
* Creates or extracts Zip archives
*
* for specs see http://www.pkware.com/appnote
*
* @author Andreas Gohr <andi@splitbrain.org>
* @package splitbrain\PHPArchive
* @license MIT
*/
class Zip extends Archive
{
protected $file = '';
protected $fh;
protected $memory = '';
protected $closed = true;
protected $writeaccess = false;
protected $ctrl_dir;
protected $complevel = 9;
/**
* Set the compression level.
*
* Compression Type is ignored for ZIP
*
* You can call this function before adding each file to set differen compression levels
* for each file.
*
* @param int $level Compression level (0 to 9)
* @param int $type Type of compression to use ignored for ZIP
* @throws ArchiveIllegalCompressionException
*/
public function setCompression($level = 9, $type = Archive::COMPRESS_AUTO)
{
if ($level < -1 || $level > 9) {
throw new ArchiveIllegalCompressionException('Compression level should be between -1 and 9');
}
$this->complevel = $level;
}
/**
* Open an existing ZIP file for reading
*
* @param string $file
* @throws ArchiveIOException
*/
public function open($file)
{
$this->file = $file;
$this->fh = @fopen($this->file, 'rb');
if (!$this->fh) {
throw new ArchiveIOException('Could not open file for reading: '.$this->file);
}
$this->closed = false;
}
/**
* Read the contents of a ZIP archive
*
* This function lists the files stored in the archive, and returns an indexed array of FileInfo objects
*
* The archive is closed afer reading the contents, for API compatibility with TAR files
* Reopen the file with open() again if you want to do additional operations
*
* @throws ArchiveIOException
* @return FileInfo[]
*/
public function contents()
{
if ($this->closed || !$this->file) {
throw new ArchiveIOException('Can not read from a closed archive');
}
$result = array();
$centd = $this->readCentralDir();
@rewind($this->fh);
@fseek($this->fh, $centd['offset']);
for ($i = 0; $i < $centd['entries']; $i++) {
$result[] = $this->header2fileinfo($this->readCentralFileHeader());
}
$this->close();
return $result;
}
/**
* Extract an existing ZIP archive
*
* The $strip parameter allows you to strip a certain number of path components from the filenames
* found in the tar file, similar to the --strip-components feature of GNU tar. This is triggered when
* an integer is passed as $strip.
* Alternatively a fixed string prefix may be passed in $strip. If the filename matches this prefix,
* the prefix will be stripped. It is recommended to give prefixes with a trailing slash.
*
* By default this will extract all files found in the archive. You can restrict the output using the $include
* and $exclude parameter. Both expect a full regular expression (including delimiters and modifiers). If
* $include is set only files that match this expression will be extracted. Files that match the $exclude
* expression will never be extracted. Both parameters can be used in combination. Expressions are matched against
* stripped filenames as described above.
*
* @param string $outdir the target directory for extracting
* @param int|string $strip either the number of path components or a fixed prefix to strip
* @param string $exclude a regular expression of files to exclude
* @param string $include a regular expression of files to include
* @throws ArchiveIOException
* @return FileInfo[]
*/
public function extract($outdir, $strip = '', $exclude = '', $include = '')
{
if ($this->closed || !$this->file) {
throw new ArchiveIOException('Can not read from a closed archive');
}
$outdir = rtrim($outdir, '/');
@mkdir($outdir, 0777, true);
$extracted = array();
$cdir = $this->readCentralDir();
$pos_entry = $cdir['offset']; // begin of the central file directory
for ($i = 0; $i < $cdir['entries']; $i++) {
// read file header
@fseek($this->fh, $pos_entry);
$header = $this->readCentralFileHeader();
$header['index'] = $i;
$pos_entry = ftell($this->fh); // position of the next file in central file directory
fseek($this->fh, $header['offset']); // seek to beginning of file header
$header = $this->readFileHeader($header);
$fileinfo = $this->header2fileinfo($header);
// apply strip rules
$fileinfo->strip($strip);
// skip unwanted files
if (!strlen($fileinfo->getPath()) || !$fileinfo->match($include, $exclude)) {
continue;
}
$extracted[] = $fileinfo;
// create output directory
$output = $outdir.'/'.$fileinfo->getPath();
$directory = ($header['folder']) ? $output : dirname($output);
@mkdir($directory, 0777, true);
// nothing more to do for directories
if ($fileinfo->getIsdir()) {
if(is_callable($this->callback)) {
call_user_func($this->callback, $fileinfo);
}
continue;
}
// compressed files are written to temporary .gz file first
if ($header['compression'] == 0) {
$extractto = $output;
} else {
$extractto = $output.'.gz';
}
// open file for writing
$fp = @fopen($extractto, "wb");
if (!$fp) {
throw new ArchiveIOException('Could not open file for writing: '.$extractto);
}
// prepend compression header
if ($header['compression'] != 0) {
$binary_data = pack(
'va1a1Va1a1',
0x8b1f,
chr($header['compression']),
chr(0x00),
time(),
chr(0x00),
chr(3)
);
fwrite($fp, $binary_data, 10);
}
// read the file and store it on disk
$size = $header['compressed_size'];
while ($size != 0) {
$read_size = ($size < 2048 ? $size : 2048);
$buffer = fread($this->fh, $read_size);
$binary_data = pack('a'.$read_size, $buffer);
fwrite($fp, $binary_data, $read_size);
$size -= $read_size;
}
// finalize compressed file
if ($header['compression'] != 0) {
$binary_data = pack('VV', $header['crc'], $header['size']);
fwrite($fp, $binary_data, 8);
}
// close file
fclose($fp);
// unpack compressed file
if ($header['compression'] != 0) {
$gzp = @gzopen($extractto, 'rb');
if (!$gzp) {
@unlink($extractto);
throw new ArchiveIOException('Failed file extracting. gzip support missing?');
}
$fp = @fopen($output, 'wb');
if (!$fp) {
throw new ArchiveIOException('Could not open file for writing: '.$extractto);
}
$size = $header['size'];
while ($size != 0) {
$read_size = ($size < 2048 ? $size : 2048);
$buffer = gzread($gzp, $read_size);
$binary_data = pack('a'.$read_size, $buffer);
@fwrite($fp, $binary_data, $read_size);
$size -= $read_size;
}
fclose($fp);
gzclose($gzp);
unlink($extractto); // remove temporary gz file
}
@touch($output, $fileinfo->getMtime());
//FIXME what about permissions?
if(is_callable($this->callback)) {
call_user_func($this->callback, $fileinfo);
}
}
$this->close();
return $extracted;
}
/**
* Create a new ZIP file
*
* If $file is empty, the zip file will be created in memory
*
* @param string $file
* @throws ArchiveIOException
*/
public function create($file = '')
{
$this->file = $file;
$this->memory = '';
$this->fh = 0;
if ($this->file) {
$this->fh = @fopen($this->file, 'wb');
if (!$this->fh) {
throw new ArchiveIOException('Could not open file for writing: '.$this->file);
}
}
$this->writeaccess = true;
$this->closed = false;
$this->ctrl_dir = array();
}
/**
* Add a file to the current ZIP archive using an existing file in the filesystem
*
* @param string $file path to the original file
* @param string|FileInfo $fileinfo either the name to us in archive (string) or a FileInfo oject with all meta data, empty to take from original
* @throws ArchiveIOException
*/
/**
* Add a file to the current archive using an existing file in the filesystem
*
* @param string $file path to the original file
* @param string|FileInfo $fileinfo either the name to use in archive (string) or a FileInfo oject with all meta data, empty to take from original
* @throws ArchiveIOException
* @throws FileInfoException
*/
public function addFile($file, $fileinfo = '')
{
if (is_string($fileinfo)) {
$fileinfo = FileInfo::fromPath($file, $fileinfo);
}
if ($this->closed) {
throw new ArchiveIOException('Archive has been closed, files can no longer be added');
}
$data = @file_get_contents($file);
if ($data === false) {
throw new ArchiveIOException('Could not open file for reading: '.$file);
}
// FIXME could we stream writing compressed data? gzwrite on a fopen handle?
$this->addData($fileinfo, $data);
}
/**
* Add a file to the current Zip archive using the given $data as content
*
* @param string|FileInfo $fileinfo either the name to us in archive (string) or a FileInfo oject with all meta data
* @param string $data binary content of the file to add
* @throws ArchiveIOException
*/
public function addData($fileinfo, $data)
{
if (is_string($fileinfo)) {
$fileinfo = new FileInfo($fileinfo);
}
if ($this->closed) {
throw new ArchiveIOException('Archive has been closed, files can no longer be added');
}
// prepare info and compress data
$size = strlen($data);
$crc = crc32($data);
if ($this->complevel) {
$data = gzcompress($data, $this->complevel);
$data = substr($data, 2, -4); // strip compression headers
}
$csize = strlen($data);
$offset = $this->dataOffset();
$name = $fileinfo->getPath();
$time = $fileinfo->getMtime();
// write local file header
$this->writebytes($this->makeLocalFileHeader(
$time,
$crc,
$size,
$csize,
$name,
(bool) $this->complevel
));
// we store no encryption header
// write data
$this->writebytes($data);
// we store no data descriptor
// add info to central file directory
$this->ctrl_dir[] = $this->makeCentralFileRecord(
$offset,
$time,
$crc,
$size,
$csize,
$name,
(bool) $this->complevel
);
if(is_callable($this->callback)) {
call_user_func($this->callback, $fileinfo);
}
}
/**
* Add the closing footer to the archive if in write mode, close all file handles
*
* After a call to this function no more data can be added to the archive, for
* read access no reading is allowed anymore
* @throws ArchiveIOException
*/
public function close()
{
if ($this->closed) {
return;
} // we did this already
if ($this->writeaccess) {
// write central directory
$offset = $this->dataOffset();
$ctrldir = join('', $this->ctrl_dir);
$this->writebytes($ctrldir);
// write end of central directory record
$this->writebytes("\x50\x4b\x05\x06"); // end of central dir signature
$this->writebytes(pack('v', 0)); // number of this disk
$this->writebytes(pack('v', 0)); // number of the disk with the start of the central directory
$this->writebytes(pack('v',
count($this->ctrl_dir))); // total number of entries in the central directory on this disk
$this->writebytes(pack('v', count($this->ctrl_dir))); // total number of entries in the central directory
$this->writebytes(pack('V', strlen($ctrldir))); // size of the central directory
$this->writebytes(pack('V',
$offset)); // offset of start of central directory with respect to the starting disk number
$this->writebytes(pack('v', 0)); // .ZIP file comment length
$this->ctrl_dir = array();
}
// close file handles
if ($this->file) {
fclose($this->fh);
$this->file = '';
$this->fh = 0;
}
$this->writeaccess = false;
$this->closed = true;
}
/**
* Returns the created in-memory archive data
*
* This implicitly calls close() on the Archive
* @throws ArchiveIOException
*/
public function getArchive()
{
$this->close();
return $this->memory;
}
/**
* Save the created in-memory archive data
*
* Note: It's more memory effective to specify the filename in the create() function and
* let the library work on the new file directly.
*
* @param $file
* @throws ArchiveIOException
*/
public function save($file)
{
if (!@file_put_contents($file, $this->getArchive())) {
throw new ArchiveIOException('Could not write to file: '.$file);
}
}
/**
* Read the central directory
*
* This key-value list contains general information about the ZIP file
*
* @return array
*/
protected function readCentralDir()
{
$size = filesize($this->file);
if ($size < 277) {
$maximum_size = $size;
} else {
$maximum_size = 277;
}
@fseek($this->fh, $size - $maximum_size);
$pos = ftell($this->fh);
$bytes = 0x00000000;
while ($pos < $size) {
$byte = @fread($this->fh, 1);
$bytes = (($bytes << 8) & 0xFFFFFFFF) | ord($byte);
if ($bytes == 0x504b0506) {
break;
}
$pos++;
}
$data = unpack(
'vdisk/vdisk_start/vdisk_entries/ventries/Vsize/Voffset/vcomment_size',
fread($this->fh, 18)
);
if ($data['comment_size'] != 0) {
$centd['comment'] = fread($this->fh, $data['comment_size']);
} else {
$centd['comment'] = '';
}
$centd['entries'] = $data['entries'];
$centd['disk_entries'] = $data['disk_entries'];
$centd['offset'] = $data['offset'];
$centd['disk_start'] = $data['disk_start'];
$centd['size'] = $data['size'];
$centd['disk'] = $data['disk'];
return $centd;
}
/**
* Read the next central file header
*
* Assumes the current file pointer is pointing at the right position
*
* @return array
*/
protected function readCentralFileHeader()
{
$binary_data = fread($this->fh, 46);
$header = unpack(
'vchkid/vid/vversion/vversion_extracted/vflag/vcompression/vmtime/vmdate/Vcrc/Vcompressed_size/Vsize/vfilename_len/vextra_len/vcomment_len/vdisk/vinternal/Vexternal/Voffset',
$binary_data
);
if ($header['filename_len'] != 0) {
$header['filename'] = fread($this->fh, $header['filename_len']);
} else {
$header['filename'] = '';
}
if ($header['extra_len'] != 0) {
$header['extra'] = fread($this->fh, $header['extra_len']);
$header['extradata'] = $this->parseExtra($header['extra']);
} else {
$header['extra'] = '';
$header['extradata'] = array();
}
if ($header['comment_len'] != 0) {
$header['comment'] = fread($this->fh, $header['comment_len']);
} else {
$header['comment'] = '';
}
$header['mtime'] = $this->makeUnixTime($header['mdate'], $header['mtime']);
$header['stored_filename'] = $header['filename'];
$header['status'] = 'ok';
if (substr($header['filename'], -1) == '/') {
$header['external'] = 0x41FF0010;
}
$header['folder'] = ($header['external'] == 0x41FF0010 || $header['external'] == 16) ? 1 : 0;
return $header;
}
/**
* Reads the local file header
*
* This header precedes each individual file inside the zip file. Assumes the current file pointer is pointing at
* the right position already. Enhances the given central header with the data found at the local header.
*
* @param array $header the central file header read previously (see above)
* @return array
*/
protected function readFileHeader($header)
{
$binary_data = fread($this->fh, 30);
$data = unpack(
'vchk/vid/vversion/vflag/vcompression/vmtime/vmdate/Vcrc/Vcompressed_size/Vsize/vfilename_len/vextra_len',
$binary_data
);
$header['filename'] = fread($this->fh, $data['filename_len']);
if ($data['extra_len'] != 0) {
$header['extra'] = fread($this->fh, $data['extra_len']);
$header['extradata'] = array_merge($header['extradata'], $this->parseExtra($header['extra']));
} else {
$header['extra'] = '';
$header['extradata'] = array();
}
$header['compression'] = $data['compression'];
foreach (array(
'size',
'compressed_size',
'crc'
) as $hd) { // On ODT files, these headers are 0. Keep the previous value.
if ($data[$hd] != 0) {
$header[$hd] = $data[$hd];
}
}
$header['flag'] = $data['flag'];
$header['mtime'] = $this->makeUnixTime($data['mdate'], $data['mtime']);
$header['stored_filename'] = $header['filename'];
$header['status'] = "ok";
$header['folder'] = ($header['external'] == 0x41FF0010 || $header['external'] == 16) ? 1 : 0;
return $header;
}
/**
* Parse the extra headers into fields
*
* @param string $header
* @return array
*/
protected function parseExtra($header)
{
$extra = array();
// parse all extra fields as raw values
while (strlen($header) !== 0) {
$set = unpack('vid/vlen', $header);
$header = substr($header, 4);
$value = substr($header, 0, $set['len']);
$header = substr($header, $set['len']);
$extra[$set['id']] = $value;
}
// handle known ones
if(isset($extra[0x6375])) {
$extra['utf8comment'] = substr($extra[0x7075], 5); // strip version and crc
}
if(isset($extra[0x7075])) {
$extra['utf8path'] = substr($extra[0x7075], 5); // strip version and crc
}
return $extra;
}
/**
* Create fileinfo object from header data
*
* @param $header
* @return FileInfo
*/
protected function header2fileinfo($header)
{
$fileinfo = new FileInfo();
$fileinfo->setSize($header['size']);
$fileinfo->setCompressedSize($header['compressed_size']);
$fileinfo->setMtime($header['mtime']);
$fileinfo->setComment($header['comment']);
$fileinfo->setIsdir($header['external'] == 0x41FF0010 || $header['external'] == 16);
if(isset($header['extradata']['utf8path'])) {
$fileinfo->setPath($header['extradata']['utf8path']);
} else {
$fileinfo->setPath($this->cpToUtf8($header['filename']));
}
if(isset($header['extradata']['utf8comment'])) {
$fileinfo->setComment($header['extradata']['utf8comment']);
} else {
$fileinfo->setComment($this->cpToUtf8($header['comment']));
}
return $fileinfo;
}
/**
* Convert the given CP437 encoded string to UTF-8
*
* Tries iconv with the correct encoding first, falls back to mbstring with CP850 which is
* similar enough. CP437 seems not to be available in mbstring. Lastly falls back to keeping the
* string as is, which is still better than nothing.
*
* On some systems iconv is available, but the codepage is not. We also check for that.
*
* @param $string
* @return string
*/
protected function cpToUtf8($string)
{
if (function_exists('iconv') && @iconv_strlen('', 'CP437') !== false) {
return iconv('CP437', 'UTF-8', $string);
} elseif (function_exists('mb_convert_encoding')) {
return mb_convert_encoding($string, 'UTF-8', 'CP850');
} else {
return $string;
}
}
/**
* Convert the given UTF-8 encoded string to CP437
*
* Same caveats as for cpToUtf8() apply
*
* @param $string
* @return string
*/
protected function utf8ToCp($string)
{
// try iconv first
if (function_exists('iconv')) {
$conv = @iconv('UTF-8', 'CP437//IGNORE', $string);
if($conv) return $conv; // it worked
}
// still here? iconv failed to convert the string. Try another method
// see http://php.net/manual/en/function.iconv.php#108643
if (function_exists('mb_convert_encoding')) {
return mb_convert_encoding($string, 'CP850', 'UTF-8');
} else {
return $string;
}
}
/**
* Write to the open filepointer or memory
*
* @param string $data
* @throws ArchiveIOException
* @return int number of bytes written
*/
protected function writebytes($data)
{
if (!$this->file) {
$this->memory .= $data;
$written = strlen($data);
} else {
$written = @fwrite($this->fh, $data);
}
if ($written === false) {
throw new ArchiveIOException('Failed to write to archive stream');
}
return $written;
}
/**
* Current data pointer position
*
* @fixme might need a -1
* @return int
*/
protected function dataOffset()
{
if ($this->file) {
return ftell($this->fh);
} else {
return strlen($this->memory);
}
}
/**
* Create a DOS timestamp from a UNIX timestamp
*
* DOS timestamps start at 1980-01-01, earlier UNIX stamps will be set to this date
*
* @param $time
* @return int
*/
protected function makeDosTime($time)
{
$timearray = getdate($time);
if ($timearray['year'] < 1980) {
$timearray['year'] = 1980;
$timearray['mon'] = 1;
$timearray['mday'] = 1;
$timearray['hours'] = 0;
$timearray['minutes'] = 0;
$timearray['seconds'] = 0;
}
return (($timearray['year'] - 1980) << 25) |
($timearray['mon'] << 21) |
($timearray['mday'] << 16) |
($timearray['hours'] << 11) |
($timearray['minutes'] << 5) |
($timearray['seconds'] >> 1);
}
/**
* Create a UNIX timestamp from a DOS timestamp
*
* @param $mdate
* @param $mtime
* @return int
*/
protected function makeUnixTime($mdate = null, $mtime = null)
{
if ($mdate && $mtime) {
$year = (($mdate & 0xFE00) >> 9) + 1980;
$month = ($mdate & 0x01E0) >> 5;
$day = $mdate & 0x001F;
$hour = ($mtime & 0xF800) >> 11;
$minute = ($mtime & 0x07E0) >> 5;
$seconde = ($mtime & 0x001F) << 1;
$mtime = mktime($hour, $minute, $seconde, $month, $day, $year);
} else {
$mtime = time();
}
return $mtime;
}
/**
* Returns a local file header for the given data
*
* @param int $offset location of the local header
* @param int $ts unix timestamp
* @param int $crc CRC32 checksum of the uncompressed data
* @param int $len length of the uncompressed data
* @param int $clen length of the compressed data
* @param string $name file name
* @param boolean|null $comp if compression is used, if null it's determined from $len != $clen
* @return string
*/
protected function makeCentralFileRecord($offset, $ts, $crc, $len, $clen, $name, $comp = null)
{
if(is_null($comp)) $comp = $len != $clen;
$comp = $comp ? 8 : 0;
$dtime = dechex($this->makeDosTime($ts));
list($name, $extra) = $this->encodeFilename($name);
$header = "\x50\x4b\x01\x02"; // central file header signature
$header .= pack('v', 14); // version made by - VFAT
$header .= pack('v', 20); // version needed to extract - 2.0
$header .= pack('v', 0); // general purpose flag - no flags set
$header .= pack('v', $comp); // compression method - deflate|none
$header .= pack(
'H*',
$dtime[6] . $dtime[7] .
$dtime[4] . $dtime[5] .
$dtime[2] . $dtime[3] .
$dtime[0] . $dtime[1]
); // last mod file time and date
$header .= pack('V', $crc); // crc-32
$header .= pack('V', $clen); // compressed size
$header .= pack('V', $len); // uncompressed size
$header .= pack('v', strlen($name)); // file name length
$header .= pack('v', strlen($extra)); // extra field length
$header .= pack('v', 0); // file comment length
$header .= pack('v', 0); // disk number start
$header .= pack('v', 0); // internal file attributes
$header .= pack('V', 0); // external file attributes @todo was 0x32!?
$header .= pack('V', $offset); // relative offset of local header
$header .= $name; // file name
$header .= $extra; // extra (utf-8 filename)
return $header;
}
/**
* Returns a local file header for the given data
*
* @param int $ts unix timestamp
* @param int $crc CRC32 checksum of the uncompressed data
* @param int $len length of the uncompressed data
* @param int $clen length of the compressed data
* @param string $name file name
* @param boolean|null $comp if compression is used, if null it's determined from $len != $clen
* @return string
*/
protected function makeLocalFileHeader($ts, $crc, $len, $clen, $name, $comp = null)
{
if(is_null($comp)) $comp = $len != $clen;
$comp = $comp ? 8 : 0;
$dtime = dechex($this->makeDosTime($ts));
list($name, $extra) = $this->encodeFilename($name);
$header = "\x50\x4b\x03\x04"; // local file header signature
$header .= pack('v', 20); // version needed to extract - 2.0
$header .= pack('v', 0); // general purpose flag - no flags set
$header .= pack('v', $comp); // compression method - deflate|none
$header .= pack(
'H*',
$dtime[6] . $dtime[7] .
$dtime[4] . $dtime[5] .
$dtime[2] . $dtime[3] .
$dtime[0] . $dtime[1]
); // last mod file time and date
$header .= pack('V', $crc); // crc-32
$header .= pack('V', $clen); // compressed size
$header .= pack('V', $len); // uncompressed size
$header .= pack('v', strlen($name)); // file name length
$header .= pack('v', strlen($extra)); // extra field length
$header .= $name; // file name
$header .= $extra; // extra (utf-8 filename)
return $header;
}
/**
* Returns an allowed filename and an extra field header
*
* When encoding stuff outside the 7bit ASCII range it needs to be placed in a separate
* extra field
*
* @param $original
* @return array($filename, $extra)
*/
protected function encodeFilename($original)
{
$cp437 = $this->utf8ToCp($original);
if ($cp437 === $original) {
return array($original, '');
}
$extra = pack(
'vvCV',
0x7075, // tag
strlen($original) + 5, // length of file + version + crc
1, // version
crc32($original) // crc
);
$extra .= $original;
return array($cp437, $extra);
}
}

View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2016 Andreas Gohr
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,158 @@
# PHP-CLI
PHP-CLI is a simple library that helps with creating nice looking command line scripts.
It takes care of
- **option parsing**
- **help page generation**
- **automatic width adjustment**
- **colored output**
- **optional PSR3 compatibility**
It is lightweight and has **no 3rd party dependencies**. Note: this is for non-interactive scripts only. It has no readline or similar support.
[![Build Status](https://travis-ci.org/splitbrain/php-cli.svg)](https://travis-ci.org/splitbrain/php-cli)
## Installation
Use composer:
```php composer.phar require splitbrain/php-cli```
## Usage and Examples
Minimal example:
```php
#!/usr/bin/php
<?php
require __DIR__ . '/../vendor/autoload.php';
use splitbrain\phpcli\CLI;
use splitbrain\phpcli\Options;
class Minimal extends CLI
{
// register options and arguments
protected function setup(Options $options)
{
$options->setHelp('A very minimal example that does nothing but print a version');
$options->registerOption('version', 'print version', 'v');
}
// implement your code
protected function main(Options $options)
{
if ($options->getOpt('version')) {
$this->info('1.0.0');
} else {
echo $options->help();
}
}
}
// execute it
$cli = new Minimal();
$cli->run();
```
![Screenshot](screenshot.png)
The basic usage is simple:
- create a class and ``extend splitbrain\phpcli\CLI``
- implement the ```setup($options)``` method and register options, arguments, commands and set help texts
- ``$options->setHelp()`` adds a general description
- ``$options->registerOption()`` adds an option
- ``$options->registerArgument()`` adds an argument
- ``$options->registerCommand()`` adds a sub command
- implement the ```main($options)``` method and do your business logic there
- ``$options->getOpts`` lets you access set options
- ``$options->getArgs()`` returns the remaining arguments after removing the options
- ``$options->getCmd()`` returns the sub command the user used
- instantiate your class and call ```run()``` on it
More examples can be found in the examples directory. Please refer to the [API docs](https://splitbrain.github.io/php-cli/)
for further info.
## Exceptions
By default the CLI class registers an exception handler and will print the exception's message to the end user and
exit the programm with a non-zero exit code. You can disable this behaviour and catch all exceptions yourself by
passing false to the constructor.
You can use the provided ``splitbrain\phpcli\Exception`` to signal any problems within your main code yourself. The
exceptions's code will be used as the exit code then.
Stacktraces will be printed on log level `debug`.
## Colored output
Colored output is handled through the ``Colors`` class. It tries to detect if a color terminal is available and only
then uses terminal colors. You can always suppress colored output by passing ``--no-colors`` to your scripts.
Disabling colors will also disable the emoticon prefixes.
Simple colored log messages can be printed by you using the convinence methods ``success()`` (green), ``info()`` (cyan),
``error()`` (red) or ``fatal()`` (red). The latter will also exit the programm with a non-zero exit code.
For more complex coloring you can access the color class through ``$this->colors`` in your script. The ``wrap()`` method
is probably what you want to use.
The table formatter allows coloring full columns. To use that mechanism pass an array of colors as third parameter to
its ``format()`` method. Please note that you can not pass colored texts in the second parameters (text length calculation
and wrapping will fail, breaking your texts).
## Table Formatter
The ``TableFormatter`` class allows you to align texts in multiple columns. It tries to figure out the available
terminal width on its own. It can be overwritten by setting a ``COLUMNS`` environment variable.
The formatter is used through the ``format()`` method which expects at least two arrays: The first defines the column
widths, the second contains the texts to fill into the columns. Between each column a border is printed (a single space
by default).
See the ``example/table.php`` for sample usage.
Columns width can be given in three forms:
- fixed width in characters by providing an integer (eg. ``15``)
- precentages by provifing an integer and a percent sign (eg. ``25%``)
- a single fluid "rest" column marked with an asterisk (eg. ``*``)
When mixing fixed and percentage widths, percentages refer to the remaining space after all fixed columns have been
assigned.
Space for borders is automatically calculated. It is recommended to always have some relative (percentage) or a fluid
column to adjust for different terminal widths.
The table formatter is used for the automatic help screen accessible when calling your script with ``-h`` or ``--help``.
## PSR-3 Logging
The CLI class is a fully PSR-3 compatible logger (printing colored log data to STDOUT and STDERR). This is useful when
you call backend code from your CLI that expects a Logger instance to produce any sensible status output while running.
To use this ability simply inherit from `splitbrain\phpcli\PSR3CLI` instead of `splitbrain\phpcli\CLI`, then pass `$this`
as the logger instance. Be sure you have the suggested `psr/log` composer package installed.
![Screenshot](screenshot2.png)
You can adjust the verbosity of your CLI tool using the `--loglevel` parameter. Supported loglevels are the PSR-3
loglevels and our own `success` level:
* debug
* info
* notice
* success
* warning
* error
* critical
* alert
* emergency
Convenience methods for all log levels are available. Placeholder interpolation as described in PSR-3 is available, too.
Messages from `warning` level onwards are printed to `STDERR` all below are printed to `STDOUT`.
The default log level of your script can be set by overwriting the `$logdefault` member.
See `example/logging.php` for an example.

View File

@@ -0,0 +1,362 @@
<?php
namespace splitbrain\phpcli;
/**
* Class CLI
*
* Your commandline script should inherit from this class and implement the abstract methods.
*
* @author Andreas Gohr <andi@splitbrain.org>
* @license MIT
*/
abstract class CLI
{
/** @var string the executed script itself */
protected $bin;
/** @var Options the option parser */
protected $options;
/** @var Colors */
public $colors;
/** @var array PSR-3 compatible loglevels and their prefix, color, output channel */
protected $loglevel = array(
'debug' => array('', Colors::C_RESET, STDOUT),
'info' => array(' ', Colors::C_CYAN, STDOUT),
'notice' => array('☛ ', Colors::C_CYAN, STDOUT),
'success' => array('✓ ', Colors::C_GREEN, STDOUT),
'warning' => array('⚠ ', Colors::C_BROWN, STDERR),
'error' => array('✗ ', Colors::C_RED, STDERR),
'critical' => array('☠ ', Colors::C_LIGHTRED, STDERR),
'alert' => array('✖ ', Colors::C_LIGHTRED, STDERR),
'emergency' => array('✘ ', Colors::C_LIGHTRED, STDERR),
);
protected $logdefault = 'info';
/**
* constructor
*
* Initialize the arguments, set up helper classes and set up the CLI environment
*
* @param bool $autocatch should exceptions be catched and handled automatically?
*/
public function __construct($autocatch = true)
{
if ($autocatch) {
set_exception_handler(array($this, 'fatal'));
}
$this->colors = new Colors();
$this->options = new Options($this->colors);
}
/**
* Register options and arguments on the given $options object
*
* @param Options $options
* @return void
*
* @throws Exception
*/
abstract protected function setup(Options $options);
/**
* Your main program
*
* Arguments and options have been parsed when this is run
*
* @param Options $options
* @return void
*
* @throws Exception
*/
abstract protected function main(Options $options);
/**
* Execute the CLI program
*
* Executes the setup() routine, adds default options, initiate the options parsing and argument checking
* and finally executes main() - Each part is split into their own protected function below, so behaviour
* can easily be overwritten
*
* @throws Exception
*/
public function run()
{
if ('cli' != php_sapi_name()) {
throw new Exception('This has to be run from the command line');
}
$this->setup($this->options);
$this->registerDefaultOptions();
$this->parseOptions();
$this->handleDefaultOptions();
$this->setupLogging();
$this->checkArgments();
$this->execute();
exit(0);
}
// region run handlers - for easier overriding
/**
* Add the default help, color and log options
*/
protected function registerDefaultOptions()
{
$this->options->registerOption(
'help',
'Display this help screen and exit immediately.',
'h'
);
$this->options->registerOption(
'no-colors',
'Do not use any colors in output. Useful when piping output to other tools or files.'
);
$this->options->registerOption(
'loglevel',
'Minimum level of messages to display. Default is ' . $this->colors->wrap($this->logdefault, Colors::C_CYAN) . '. ' .
'Valid levels are: debug, info, notice, success, warning, error, critical, alert, emergency.',
null,
'level'
);
}
/**
* Handle the default options
*/
protected function handleDefaultOptions()
{
if ($this->options->getOpt('no-colors')) {
$this->colors->disable();
}
if ($this->options->getOpt('help')) {
echo $this->options->help();
exit(0);
}
}
/**
* Handle the logging options
*/
protected function setupLogging()
{
$level = $this->options->getOpt('loglevel', $this->logdefault);
if (!isset($this->loglevel[$level])) $this->fatal('Unknown log level');
foreach (array_keys($this->loglevel) as $l) {
if ($l == $level) break;
unset($this->loglevel[$l]);
}
}
/**
* Wrapper around the option parsing
*/
protected function parseOptions()
{
$this->options->parseOptions();
}
/**
* Wrapper around the argument checking
*/
protected function checkArgments()
{
$this->options->checkArguments();
}
/**
* Wrapper around main
*/
protected function execute()
{
$this->main($this->options);
}
// endregion
// region logging
/**
* Exits the program on a fatal error
*
* @param \Exception|string $error either an exception or an error message
* @param array $context
*/
public function fatal($error, array $context = array())
{
$code = 0;
if (is_object($error) && is_a($error, 'Exception')) {
/** @var Exception $error */
$this->debug(get_class($error) . ' caught in ' . $error->getFile() . ':' . $error->getLine());
$this->debug($error->getTraceAsString());
$code = $error->getCode();
$error = $error->getMessage();
}
if (!$code) {
$code = Exception::E_ANY;
}
$this->critical($error, $context);
exit($code);
}
/**
* System is unusable.
*
* @param string $message
* @param array $context
*
* @return void
*/
public function emergency($message, array $context = array())
{
$this->log('emergency', $message, $context);
}
/**
* Action must be taken immediately.
*
* Example: Entire website down, database unavailable, etc. This should
* trigger the SMS alerts and wake you up.
*
* @param string $message
* @param array $context
*/
public function alert($message, array $context = array())
{
$this->log('alert', $message, $context);
}
/**
* Critical conditions.
*
* Example: Application component unavailable, unexpected exception.
*
* @param string $message
* @param array $context
*/
public function critical($message, array $context = array())
{
$this->log('critical', $message, $context);
}
/**
* Runtime errors that do not require immediate action but should typically
* be logged and monitored.
*
* @param string $message
* @param array $context
*/
public function error($message, array $context = array())
{
$this->log('error', $message, $context);
}
/**
* Exceptional occurrences that are not errors.
*
* Example: Use of deprecated APIs, poor use of an API, undesirable things
* that are not necessarily wrong.
*
* @param string $message
* @param array $context
*/
public function warning($message, array $context = array())
{
$this->log('warning', $message, $context);
}
/**
* Normal, positive outcome
*
* @param string $string
* @param array $context
*/
public function success($string, array $context = array())
{
$this->log('success', $string, $context);
}
/**
* Normal but significant events.
*
* @param string $message
* @param array $context
*/
public function notice($message, array $context = array())
{
$this->log('notice', $message, $context);
}
/**
* Interesting events.
*
* Example: User logs in, SQL logs.
*
* @param string $message
* @param array $context
*/
public function info($message, array $context = array())
{
$this->log('info', $message, $context);
}
/**
* Detailed debug information.
*
* @param string $message
* @param array $context
*/
public function debug($message, array $context = array())
{
$this->log('debug', $message, $context);
}
/**
* @param string $level
* @param string $message
* @param array $context
*/
public function log($level, $message, array $context = array())
{
// is this log level wanted?
if (!isset($this->loglevel[$level])) return;
/** @var string $prefix */
/** @var string $color */
/** @var resource $channel */
list($prefix, $color, $channel) = $this->loglevel[$level];
if (!$this->colors->isEnabled()) $prefix = '';
$message = $this->interpolate($message, $context);
$this->colors->ptln($prefix . $message, $color, $channel);
}
/**
* Interpolates context values into the message placeholders.
*
* @param $message
* @param array $context
* @return string
*/
function interpolate($message, array $context = array())
{
// build a replacement array with braces around the context keys
$replace = array();
foreach ($context as $key => $val) {
// check that the value can be casted to string
if (!is_array($val) && (!is_object($val) || method_exists($val, '__toString'))) {
$replace['{' . $key . '}'] = $val;
}
}
// interpolate replacement values into the message and return
return strtr($message, $replace);
}
// endregion
}

View File

@@ -0,0 +1,170 @@
<?php
namespace splitbrain\phpcli;
/**
* Class Colors
*
* Handles color output on (Linux) terminals
*
* @author Andreas Gohr <andi@splitbrain.org>
* @license MIT
*/
class Colors
{
// these constants make IDE autocompletion easier, but color names can also be passed as strings
const C_RESET = 'reset';
const C_BLACK = 'black';
const C_DARKGRAY = 'darkgray';
const C_BLUE = 'blue';
const C_LIGHTBLUE = 'lightblue';
const C_GREEN = 'green';
const C_LIGHTGREEN = 'lightgreen';
const C_CYAN = 'cyan';
const C_LIGHTCYAN = 'lightcyan';
const C_RED = 'red';
const C_LIGHTRED = 'lightred';
const C_PURPLE = 'purple';
const C_LIGHTPURPLE = 'lightpurple';
const C_BROWN = 'brown';
const C_YELLOW = 'yellow';
const C_LIGHTGRAY = 'lightgray';
const C_WHITE = 'white';
/** @var array known color names */
protected $colors = array(
self::C_RESET => "\33[0m",
self::C_BLACK => "\33[0;30m",
self::C_DARKGRAY => "\33[1;30m",
self::C_BLUE => "\33[0;34m",
self::C_LIGHTBLUE => "\33[1;34m",
self::C_GREEN => "\33[0;32m",
self::C_LIGHTGREEN => "\33[1;32m",
self::C_CYAN => "\33[0;36m",
self::C_LIGHTCYAN => "\33[1;36m",
self::C_RED => "\33[0;31m",
self::C_LIGHTRED => "\33[1;31m",
self::C_PURPLE => "\33[0;35m",
self::C_LIGHTPURPLE => "\33[1;35m",
self::C_BROWN => "\33[0;33m",
self::C_YELLOW => "\33[1;33m",
self::C_LIGHTGRAY => "\33[0;37m",
self::C_WHITE => "\33[1;37m",
);
/** @var bool should colors be used? */
protected $enabled = true;
/**
* Constructor
*
* Tries to disable colors for non-terminals
*/
public function __construct()
{
if (function_exists('posix_isatty') && !posix_isatty(STDOUT)) {
$this->enabled = false;
return;
}
if (!getenv('TERM')) {
$this->enabled = false;
return;
}
}
/**
* enable color output
*/
public function enable()
{
$this->enabled = true;
}
/**
* disable color output
*/
public function disable()
{
$this->enabled = false;
}
/**
* @return bool is color support enabled?
*/
public function isEnabled()
{
return $this->enabled;
}
/**
* Convenience function to print a line in a given color
*
* @param string $line the line to print, a new line is added automatically
* @param string $color one of the available color names
* @param resource $channel file descriptor to write to
*
* @throws Exception
*/
public function ptln($line, $color, $channel = STDOUT)
{
$this->set($color);
fwrite($channel, rtrim($line) . "\n");
$this->reset();
}
/**
* Returns the given text wrapped in the appropriate color and reset code
*
* @param string $text string to wrap
* @param string $color one of the available color names
* @return string the wrapped string
* @throws Exception
*/
public function wrap($text, $color)
{
return $this->getColorCode($color) . $text . $this->getColorCode('reset');
}
/**
* Gets the appropriate terminal code for the given color
*
* @param string $color one of the available color names
* @return string color code
* @throws Exception
*/
public function getColorCode($color)
{
if (!$this->enabled) {
return '';
}
if (!isset($this->colors[$color])) {
throw new Exception("No such color $color");
}
return $this->colors[$color];
}
/**
* Set the given color for consecutive output
*
* @param string $color one of the supported color names
* @param resource $channel file descriptor to write to
* @throws Exception
*/
public function set($color, $channel = STDOUT)
{
fwrite($channel, $this->getColorCode($color));
}
/**
* reset the terminal color
*
* @param resource $channel file descriptor to write to
*
* @throws Exception
*/
public function reset($channel = STDOUT)
{
$this->set('reset', $channel);
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace splitbrain\phpcli;
/**
* Class Exception
*
* The code is used as exit code for the CLI tool. This should probably be extended. Many cases just fall back to the
* E_ANY code.
*
* @author Andreas Gohr <andi@splitbrain.org>
* @license MIT
*/
class Exception extends \RuntimeException
{
const E_ANY = -1; // no error code specified
const E_UNKNOWN_OPT = 1; //Unrecognized option
const E_OPT_ARG_REQUIRED = 2; //Option requires argument
const E_OPT_ARG_DENIED = 3; //Option not allowed argument
const E_OPT_ABIGUOUS = 4; //Option abiguous
const E_ARG_READ = 5; //Could not read argv
/**
* @param string $message The Exception message to throw.
* @param int $code The Exception code
* @param \Exception $previous The previous exception used for the exception chaining.
*/
public function __construct($message = "", $code = 0, \Exception $previous = null)
{
if (!$code) {
$code = self::E_ANY;
}
parent::__construct($message, $code, $previous);
}
}

View File

@@ -0,0 +1,478 @@
<?php
namespace splitbrain\phpcli;
/**
* Class Options
*
* Parses command line options passed to the CLI script. Allows CLI scripts to easily register all accepted options and
* commands and even generates a help text from this setup.
*
* @author Andreas Gohr <andi@splitbrain.org>
* @license MIT
*/
class Options
{
/** @var array keeps the list of options to parse */
protected $setup;
/** @var array store parsed options */
protected $options = array();
/** @var string current parsed command if any */
protected $command = '';
/** @var array passed non-option arguments */
protected $args = array();
/** @var string the executed script */
protected $bin;
/** @var Colors for colored help output */
protected $colors;
/**
* Constructor
*
* @param Colors $colors optional configured color object
* @throws Exception when arguments can't be read
*/
public function __construct(Colors $colors = null)
{
if (!is_null($colors)) {
$this->colors = $colors;
} else {
$this->colors = new Colors();
}
$this->setup = array(
'' => array(
'opts' => array(),
'args' => array(),
'help' => ''
)
); // default command
$this->args = $this->readPHPArgv();
$this->bin = basename(array_shift($this->args));
$this->options = array();
}
/**
* Gets the bin value
*/
public function getBin()
{
return $this->bin;
}
/**
* Sets the help text for the tool itself
*
* @param string $help
*/
public function setHelp($help)
{
$this->setup['']['help'] = $help;
}
/**
* Register the names of arguments for help generation and number checking
*
* This has to be called in the order arguments are expected
*
* @param string $arg argument name (just for help)
* @param string $help help text
* @param bool $required is this a required argument
* @param string $command if theses apply to a sub command only
* @throws Exception
*/
public function registerArgument($arg, $help, $required = true, $command = '')
{
if (!isset($this->setup[$command])) {
throw new Exception("Command $command not registered");
}
$this->setup[$command]['args'][] = array(
'name' => $arg,
'help' => $help,
'required' => $required
);
}
/**
* This registers a sub command
*
* Sub commands have their own options and use their own function (not main()).
*
* @param string $command
* @param string $help
* @throws Exception
*/
public function registerCommand($command, $help)
{
if (isset($this->setup[$command])) {
throw new Exception("Command $command already registered");
}
$this->setup[$command] = array(
'opts' => array(),
'args' => array(),
'help' => $help
);
}
/**
* Register an option for option parsing and help generation
*
* @param string $long multi character option (specified with --)
* @param string $help help text for this option
* @param string|null $short one character option (specified with -)
* @param bool|string $needsarg does this option require an argument? give it a name here
* @param string $command what command does this option apply to
* @throws Exception
*/
public function registerOption($long, $help, $short = null, $needsarg = false, $command = '')
{
if (!isset($this->setup[$command])) {
throw new Exception("Command $command not registered");
}
$this->setup[$command]['opts'][$long] = array(
'needsarg' => $needsarg,
'help' => $help,
'short' => $short
);
if ($short) {
if (strlen($short) > 1) {
throw new Exception("Short options should be exactly one ASCII character");
}
$this->setup[$command]['short'][$short] = $long;
}
}
/**
* Checks the actual number of arguments against the required number
*
* Throws an exception if arguments are missing.
*
* This is run from CLI automatically and usually does not need to be called directly
*
* @throws Exception
*/
public function checkArguments()
{
$argc = count($this->args);
$req = 0;
foreach ($this->setup[$this->command]['args'] as $arg) {
if (!$arg['required']) {
break;
} // last required arguments seen
$req++;
}
if ($req > $argc) {
throw new Exception("Not enough arguments", Exception::E_OPT_ARG_REQUIRED);
}
}
/**
* Parses the given arguments for known options and command
*
* The given $args array should NOT contain the executed file as first item anymore! The $args
* array is stripped from any options and possible command. All found otions can be accessed via the
* getOpt() function
*
* Note that command options will overwrite any global options with the same name
*
* This is run from CLI automatically and usually does not need to be called directly
*
* @throws Exception
*/
public function parseOptions()
{
$non_opts = array();
$argc = count($this->args);
for ($i = 0; $i < $argc; $i++) {
$arg = $this->args[$i];
// The special element '--' means explicit end of options. Treat the rest of the arguments as non-options
// and end the loop.
if ($arg == '--') {
$non_opts = array_merge($non_opts, array_slice($this->args, $i + 1));
break;
}
// '-' is stdin - a normal argument
if ($arg == '-') {
$non_opts = array_merge($non_opts, array_slice($this->args, $i));
break;
}
// first non-option
if ($arg[0] != '-') {
$non_opts = array_merge($non_opts, array_slice($this->args, $i));
break;
}
// long option
if (strlen($arg) > 1 && $arg[1] === '-') {
$arg = explode('=', substr($arg, 2), 2);
$opt = array_shift($arg);
$val = array_shift($arg);
if (!isset($this->setup[$this->command]['opts'][$opt])) {
throw new Exception("No such option '$opt'", Exception::E_UNKNOWN_OPT);
}
// argument required?
if ($this->setup[$this->command]['opts'][$opt]['needsarg']) {
if (is_null($val) && $i + 1 < $argc && !preg_match('/^--?[\w]/', $this->args[$i + 1])) {
$val = $this->args[++$i];
}
if (is_null($val)) {
throw new Exception("Option $opt requires an argument",
Exception::E_OPT_ARG_REQUIRED);
}
$this->options[$opt] = $val;
} else {
$this->options[$opt] = true;
}
continue;
}
// short option
$opt = substr($arg, 1);
if (!isset($this->setup[$this->command]['short'][$opt])) {
throw new Exception("No such option $arg", Exception::E_UNKNOWN_OPT);
} else {
$opt = $this->setup[$this->command]['short'][$opt]; // store it under long name
}
// argument required?
if ($this->setup[$this->command]['opts'][$opt]['needsarg']) {
$val = null;
if ($i + 1 < $argc && !preg_match('/^--?[\w]/', $this->args[$i + 1])) {
$val = $this->args[++$i];
}
if (is_null($val)) {
throw new Exception("Option $arg requires an argument",
Exception::E_OPT_ARG_REQUIRED);
}
$this->options[$opt] = $val;
} else {
$this->options[$opt] = true;
}
}
// parsing is now done, update args array
$this->args = $non_opts;
// if not done yet, check if first argument is a command and reexecute argument parsing if it is
if (!$this->command && $this->args && isset($this->setup[$this->args[0]])) {
// it is a command!
$this->command = array_shift($this->args);
$this->parseOptions(); // second pass
}
}
/**
* Get the value of the given option
*
* Please note that all options are accessed by their long option names regardless of how they were
* specified on commandline.
*
* Can only be used after parseOptions() has been run
*
* @param mixed $option
* @param bool|string $default what to return if the option was not set
* @return bool|string|string[]
*/
public function getOpt($option = null, $default = false)
{
if ($option === null) {
return $this->options;
}
if (isset($this->options[$option])) {
return $this->options[$option];
}
return $default;
}
/**
* Return the found command if any
*
* @return string
*/
public function getCmd()
{
return $this->command;
}
/**
* Get all the arguments passed to the script
*
* This will not contain any recognized options or the script name itself
*
* @return array
*/
public function getArgs()
{
return $this->args;
}
/**
* Builds a help screen from the available options. You may want to call it from -h or on error
*
* @return string
*
* @throws Exception
*/
public function help()
{
$tf = new TableFormatter($this->colors);
$text = '';
$hascommands = (count($this->setup) > 1);
foreach ($this->setup as $command => $config) {
$hasopts = (bool)$this->setup[$command]['opts'];
$hasargs = (bool)$this->setup[$command]['args'];
// usage or command syntax line
if (!$command) {
$text .= $this->colors->wrap('USAGE:', Colors::C_BROWN);
$text .= "\n";
$text .= ' ' . $this->bin;
$mv = 2;
} else {
$text .= "\n";
$text .= $this->colors->wrap(' ' . $command, Colors::C_PURPLE);
$mv = 4;
}
if ($hasopts) {
$text .= ' ' . $this->colors->wrap('<OPTIONS>', Colors::C_GREEN);
}
if (!$command && $hascommands) {
$text .= ' ' . $this->colors->wrap('<COMMAND> ...', Colors::C_PURPLE);
}
foreach ($this->setup[$command]['args'] as $arg) {
$out = $this->colors->wrap('<' . $arg['name'] . '>', Colors::C_CYAN);
if (!$arg['required']) {
$out = '[' . $out . ']';
}
$text .= ' ' . $out;
}
$text .= "\n";
// usage or command intro
if ($this->setup[$command]['help']) {
$text .= "\n";
$text .= $tf->format(
array($mv, '*'),
array('', $this->setup[$command]['help'] . "\n")
);
}
// option description
if ($hasopts) {
if (!$command) {
$text .= "\n";
$text .= $this->colors->wrap('OPTIONS:', Colors::C_BROWN);
}
$text .= "\n";
foreach ($this->setup[$command]['opts'] as $long => $opt) {
$name = '';
if ($opt['short']) {
$name .= '-' . $opt['short'];
if ($opt['needsarg']) {
$name .= ' <' . $opt['needsarg'] . '>';
}
$name .= ', ';
}
$name .= "--$long";
if ($opt['needsarg']) {
$name .= ' <' . $opt['needsarg'] . '>';
}
$text .= $tf->format(
array($mv, '30%', '*'),
array('', $name, $opt['help']),
array('', 'green', '')
);
$text .= "\n";
}
}
// argument description
if ($hasargs) {
if (!$command) {
$text .= "\n";
$text .= $this->colors->wrap('ARGUMENTS:', Colors::C_BROWN);
}
$text .= "\n";
foreach ($this->setup[$command]['args'] as $arg) {
$name = '<' . $arg['name'] . '>';
$text .= $tf->format(
array($mv, '30%', '*'),
array('', $name, $arg['help']),
array('', 'cyan', '')
);
}
}
// head line and intro for following command documentation
if (!$command && $hascommands) {
$text .= "\n";
$text .= $this->colors->wrap('COMMANDS:', Colors::C_BROWN);
$text .= "\n";
$text .= $tf->format(
array($mv, '*'),
array('', 'This tool accepts a command as first parameter as outlined below:')
);
$text .= "\n";
}
}
return $text;
}
/**
* Safely read the $argv PHP array across different PHP configurations.
* Will take care on register_globals and register_argc_argv ini directives
*
* @throws Exception
* @return array the $argv PHP array or PEAR error if not registered
*/
private function readPHPArgv()
{
global $argv;
if (!is_array($argv)) {
if (!@is_array($_SERVER['argv'])) {
if (!@is_array($GLOBALS['HTTP_SERVER_VARS']['argv'])) {
throw new Exception(
"Could not read cmd args (register_argc_argv=Off?)",
Exception::E_ARG_READ
);
}
return $GLOBALS['HTTP_SERVER_VARS']['argv'];
}
return $_SERVER['argv'];
}
return $argv;
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace splitbrain\phpcli;
use Psr\Log\LoggerInterface;
/**
* Class PSR3CLI
*
* The same as CLI, but implements the PSR-3 logger interface
*/
abstract class PSR3CLI extends CLI implements LoggerInterface {
}

View File

@@ -0,0 +1,325 @@
<?php
namespace splitbrain\phpcli;
/**
* Class TableFormatter
*
* Output text in multiple columns
*
* @author Andreas Gohr <andi@splitbrain.org>
* @license MIT
*/
class TableFormatter
{
/** @var string border between columns */
protected $border = ' ';
/** @var int the terminal width */
protected $max = 74;
/** @var Colors for coloring output */
protected $colors;
/**
* TableFormatter constructor.
*
* @param Colors|null $colors
*/
public function __construct(Colors $colors = null)
{
// try to get terminal width
$width = $this->getTerminalWidth();
if ($width) {
$this->max = $width - 1;
}
if ($colors) {
$this->colors = $colors;
} else {
$this->colors = new Colors();
}
}
/**
* The currently set border (defaults to ' ')
*
* @return string
*/
public function getBorder()
{
return $this->border;
}
/**
* Set the border. The border is set between each column. Its width is
* added to the column widths.
*
* @param string $border
*/
public function setBorder($border)
{
$this->border = $border;
}
/**
* Width of the terminal in characters
*
* initially autodetected
*
* @return int
*/
public function getMaxWidth()
{
return $this->max;
}
/**
* Set the width of the terminal to assume (in characters)
*
* @param int $max
*/
public function setMaxWidth($max)
{
$this->max = $max;
}
/**
* Tries to figure out the width of the terminal
*
* @return int terminal width, 0 if unknown
*/
protected function getTerminalWidth()
{
// from environment
if (isset($_SERVER['COLUMNS'])) return (int)$_SERVER['COLUMNS'];
// via tput
$process = proc_open('tput cols', array(
1 => array('pipe', 'w'),
2 => array('pipe', 'w'),
), $pipes);
$width = (int)stream_get_contents($pipes[1]);
proc_close($process);
return $width;
}
/**
* Takes an array with dynamic column width and calculates the correct width
*
* Column width can be given as fixed char widths, percentages and a single * width can be given
* for taking the remaining available space. When mixing percentages and fixed widths, percentages
* refer to the remaining space after allocating the fixed width
*
* @param array $columns
* @return int[]
* @throws Exception
*/
protected function calculateColLengths($columns)
{
$idx = 0;
$border = $this->strlen($this->border);
$fixed = (count($columns) - 1) * $border; // borders are used already
$fluid = -1;
// first pass for format check and fixed columns
foreach ($columns as $idx => $col) {
// handle fixed columns
if ((string)intval($col) === (string)$col) {
$fixed += $col;
continue;
}
// check if other colums are using proper units
if (substr($col, -1) == '%') {
continue;
}
if ($col == '*') {
// only one fluid
if ($fluid < 0) {
$fluid = $idx;
continue;
} else {
throw new Exception('Only one fluid column allowed!');
}
}
throw new Exception("unknown column format $col");
}
$alloc = $fixed;
$remain = $this->max - $alloc;
// second pass to handle percentages
foreach ($columns as $idx => $col) {
if (substr($col, -1) != '%') {
continue;
}
$perc = floatval($col);
$real = (int)floor(($perc * $remain) / 100);
$columns[$idx] = $real;
$alloc += $real;
}
$remain = $this->max - $alloc;
if ($remain < 0) {
throw new Exception("Wanted column widths exceed available space");
}
// assign remaining space
if ($fluid < 0) {
$columns[$idx] += ($remain); // add to last column
} else {
$columns[$fluid] = $remain;
}
return $columns;
}
/**
* Displays text in multiple word wrapped columns
*
* @param int[] $columns list of column widths (in characters, percent or '*')
* @param string[] $texts list of texts for each column
* @param array $colors A list of color names to use for each column. use empty string for default
* @return string
* @throws Exception
*/
public function format($columns, $texts, $colors = array())
{
$columns = $this->calculateColLengths($columns);
$wrapped = array();
$maxlen = 0;
foreach ($columns as $col => $width) {
$wrapped[$col] = explode("\n", $this->wordwrap($texts[$col], $width, "\n", true));
$len = count($wrapped[$col]);
if ($len > $maxlen) {
$maxlen = $len;
}
}
$last = count($columns) - 1;
$out = '';
for ($i = 0; $i < $maxlen; $i++) {
foreach ($columns as $col => $width) {
if (isset($wrapped[$col][$i])) {
$val = $wrapped[$col][$i];
} else {
$val = '';
}
$chunk = $this->pad($val, $width);
if (isset($colors[$col]) && $colors[$col]) {
$chunk = $this->colors->wrap($chunk, $colors[$col]);
}
$out .= $chunk;
// border
if ($col != $last) {
$out .= $this->border;
}
}
$out .= "\n";
}
return $out;
}
/**
* Pad the given string to the correct length
*
* @param string $string
* @param int $len
* @return string
*/
protected function pad($string, $len)
{
$strlen = $this->strlen($string);
if ($strlen > $len) return $string;
$pad = $len - $strlen;
return $string . str_pad('', $pad, ' ');
}
/**
* Measures char length in UTF-8 when possible
*
* @param $string
* @return int
*/
protected function strlen($string)
{
// don't count color codes
$string = preg_replace("/\33\\[\\d+(;\\d+)?m/", '', $string);
if (function_exists('mb_strlen')) {
return mb_strlen($string, 'utf-8');
}
return strlen($string);
}
/**
* @param string $string
* @param int $start
* @param int|null $length
* @return string
*/
protected function substr($string, $start = 0, $length = null)
{
if (function_exists('mb_substr')) {
return mb_substr($string, $start, $length);
} else {
// mb_substr() treats $length differently than substr()
if ($length) {
return substr($string, $start, $length);
} else {
return substr($string, $start);
}
}
}
/**
* @param string $str
* @param int $width
* @param string $break
* @param bool $cut
* @return string
* @link http://stackoverflow.com/a/4988494
*/
protected function wordwrap($str, $width = 75, $break = "\n", $cut = false)
{
$lines = explode($break, $str);
foreach ($lines as &$line) {
$line = rtrim($line);
if ($this->strlen($line) <= $width) {
continue;
}
$words = explode(' ', $line);
$line = '';
$actual = '';
foreach ($words as $word) {
if ($this->strlen($actual . $word) <= $width) {
$actual .= $word . ' ';
} else {
if ($actual != '') {
$line .= rtrim($actual) . $break;
}
$actual = $word;
if ($cut) {
while ($this->strlen($actual) > $width) {
$line .= $this->substr($actual, 0, $width) . $break;
$actual = $this->substr($actual, $width);
}
}
$actual .= ' ';
}
}
$line .= trim($actual);
}
return implode($break, $lines);
}
}