Skip to content

Instantly share code, notes, and snippets.

@hperrin
Last active May 9, 2018 01:05
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save hperrin/8833977 to your computer and use it in GitHub Desktop.
Save hperrin/8833977 to your computer and use it in GitHub Desktop.
PHP Slim archiving class. Slim is a portable file archive format based on JSON. It can be self extracting in multiple languages.
<?php
/**
* Slim archiving class.
*
* Slim is a portable file archive format based on JSON. It can be self
* extracting in multiple languages.
*
* @license https://www.apache.org/licenses/LICENSE-2.0
* @author Hunter Perrin <hperrin@gmail.com>
* @copyright SciActive.com
* @link http://sciactive.com/
*/
class Slim {
/**
* Slim file format version.
*/
const SLIM_VERSION = '1.0';
/**
* The header data of the archive.
*
* The header array contains information about the archive:
*
* - files - An array of the files, directories, and links in the archive.
* - comp - Type of compression used on the archive.
* - compl - Level of compression used on the archive.
* - ichk - Whether integrity checks are used on the file data.
* - ext - Extra data.
*
* @var array
*/
private $header = array();
/**
* The files to be written to the archive.
*
* @var array
*/
private $files = array();
/**
* The entries (virtual files) to be written to the archive.
*
* @var array
*/
private $entries = array();
/**
* The offset in bytes of the beginning of the file stream.
*
* @var int
*/
private $streamOffset;
/**
* The compression filter's resource handle.
*
* @var resource
*/
private $compressionFilter;
/**
* The stub to place at the beginning of the file.
*
* The stub may begin with a shebang, such as:
*
* - #!/bin/sh
* - #! /usr/bin/php
*
* The next line (or the first line, if the shebang is omitted) *must* end
* with the string "slim1.0", such as:
*
* - slim1.0
* - <?php //slim1.0
* - #slim1.0
*
* The stub cannot contain a line with only the string "HEADER", because
* that line signifies the beginning of the archive header.
*
* @var string
*/
public $stub = 'slim1.0';
/**
* Extra data to be included in the archive.
*
* This can be anything.
*
* @var array
*/
public $ext = array();
/**
* The filename of the archive.
*
* @var string
*/
public $filename = '';
/**
* Whether to compress the JSON header.
*
* Header compression always uses deflate. (RFC 1951)
*
* @var bool
*/
public $headerCompression = true;
/**
* The compression level (1-9) to use during header compression.
*
* -1 signifies default compression level.
*
* @var int
*/
public $headerCompressionLevel = 9;
/**
* The type of compression to use when saving the file.
*
* Currently the only compression types supported by this implementation of
* the Slim format are deflate and bzip2.
*
* @var string
*/
public $compression = 'deflate';
/**
* The compression level (1-9) to use during compression.
*
* -1 signifies default compression level.
*
* Only works for deflate.
*
* @var int
*/
public $compressionLevel = 9;
/**
* The directory to work within.
*
* When adding/extracting files, relative paths will be based on this path.
*
* @var string
*/
public $workingDirectory = '';
/**
* Try to preserve file user and group.
*
* @var bool
*/
public $preserveOwner = false;
/**
* Try to preserve file permissions.
*
* @var bool
*/
public $preserveMode = false;
/**
* Try to preserve file access/modified times.
*
* @var bool
*/
public $preserveTimes = false;
/**
* Use MD5 sums of files to check their integrity.
*
* @var bool
*/
public $fileIntegrity = false;
/**
* Don't extract files into parent directories.
*
* This causes '..' directories to be changed to '__' (two underscores).
*
* @var bool
*/
public $noParents = true;
/**
* Add a slash to the end of a path, if it's not already there.
*
* @param string $path The path.
* @return string The new path.
*/
private function addSlash($path) {
if ($path != '' && substr($path, -1) != '/') {
return "{$path}/";
}
return $path;
}
/**
* Alter a path, with a regard to the current working directory.
*
* @param string $path The path to alter.
* @param bool $addingWorkingDir Whether to add or strip the working directory.
* @return string The new path.
*/
private function makePath($path, $addingWorkingDir = true) {
if ($addingWorkingDir) {
if (substr($path, 1) != '/' && $this->workingDirectory != '') { // && substr($path, strlen($this->workingDirectory)) != $this->workingDirectory)
return $this->addSlash($this->workingDirectory) . $path;
}
return $path;
} else {
if ($this->workingDirectory != '' && substr($path, 0, strlen($this->workingDirectory)) == $this->workingDirectory) { // && substr($path, strlen($this->workingDirectory)) != $this->workingDirectory)
return substr($path, strlen($this->workingDirectory));
}
return $path;
}
}
/**
* Apply the selected filters to a file handle.
*
* @param resource $handle The handle.
* @param string $mode 'r' for read filters, 'w' for write filters.
*/
private function applyFilters($handle, $mode) {
switch ($this->compression) {
case 'deflate':
$this->compressionFilter = stream_filter_append($handle, $mode == 'w' ? 'zlib.deflate' : 'zlib.inflate', $mode == 'w' ? STREAM_FILTER_WRITE : STREAM_FILTER_READ, $this->compressionLevel);
break;
case 'bzip2':
$this->compressionFilter = stream_filter_append($handle, $mode == 'w' ? 'bzip2.compress' : 'bzip2.decompress', $mode == 'w' ? STREAM_FILTER_WRITE : STREAM_FILTER_READ);
break;
}
}
/**
* Check a path according to a regex filter.
*
* @param string $path The path to check.
* @param string|array $filter A regex pattern or an array of regex patterns.
* @return bool True if the path does not match any of the filters, false otherwise.
*/
private function pathFilter($path, $filter) {
if (is_string($filter)) {
return !preg_match($filter, $path);
}
if (!is_array($filter)) {
return false;
}
foreach ($filter as $curFilter) {
if (preg_match($curFilter, $path)) {
return false;
}
}
return true;
}
/**
* Seek in a file.
*
* This function uses a workaround for seeking in compressed streams. It
* will use fread() instead of fseek().
*
* @param resource $handle The handle.
* @param int $offset The offset to seek to.
* @param int $whence When set to SEEK_CUR, $offset will be based on $this->streamOffset.
* @return int 0 on success, -1 on failure.
*/
private function fseek($handle, $offset, $whence = null) {
// SEEK_CUR always seeks from $this->streamOffset.
switch ($this->compression) {
case 'deflate':
case 'bzip2':
if (isset($whence)) {
if ($whence == SEEK_CUR){
$distance = ftell($handle) - $this->streamOffset;
if ($distance) {
$test = $offset - $distance;
if ($test < 0) {
fseek($handle, 0);
stream_filter_remove($this->compressionFilter);
fseek($handle, $this->streamOffset);
$this->applyFilters($handle, 'r');
} else {
$offset = $test;
}
}
if (!$offset) {
return 0;
}
do {
fread($handle, ($offset > 8192) ? 8192 : $offset);
$offset -= 8192;
} while ($offset > 0);
return 0;
}
return fseek($handle, $offset, $whence);
} else {
return fseek($handle, $offset);
}
break;
default:
if ($whence == SEEK_CUR) {
return fseek($handle, $this->streamOffset + $offset);
} elseif (isset($whence)) {
return fseek($handle, $offset, $whence);
} else {
return fseek($handle, $offset);
}
break;
}
}
/**
* Add a directory to the archive.
*
* @param string $path The path of the directory.
* @param bool $contents Whether to add the contents of the directory.
* @param bool $recursive Whether to recurse into subdirectories.
* @param mixed $filter A regex pattern or an array of regex patterns, which when matches a path, it will be excluded.
* @param bool $excludeVcs Whether to exclude SVN and CVS directories.
* @return bool True on success, false on failure. (All files being filtered is not considered a failure.)
*/
public function addDirectory($path, $contents = true, $recursive = true, $filter = null, $excludeVcs = true) {
$relPath = $this->addSlash($path);
$absPath = $this->addSlash($this->makePath($path));
if ($absPath != '' && !is_dir($absPath)) {
return false;
}
if ($absPath != '' && (is_null($filter) || $this->pathFilter($relPath, $filter))) {
$this->files[] = $absPath;
}
if (!$contents) {
return true;
}
$dirContents = scandir($absPath == '' ? '.' : $absPath);
if ($dirContents === false) {
return false;
}
foreach ($dirContents as $curPath) {
if ($curPath === '.'
|| $curPath === '..'
|| (
$excludeVcs
&& (
$curPath === '.git'
|| $curPath === '.hg'
|| $curPath === '.hgtags'
|| $curPath === '.svn'
|| $curPath === '.cvs'
)
)
|| (
isset($filter)
&& !$this->pathFilter($relPath.$curPath, $filter)
)
) {
continue;
}
if (is_file($absPath.$curPath)) {
$this->files[] = $absPath.$curPath;
} elseif (is_dir($absPath.$curPath)) {
if ($recursive) {
if (!$this->addDirectory($relPath.$curPath, $contents, $recursive, $filter, $excludeVcs)) {
return false;
}
} else {
$this->files[] = $this->addSlash($absPath.$curPath);
}
}
}
return true;
}
/**
* Add a file to the archive.
*
* @param string $path The path of the file.
* @param mixed $filter A regex pattern or an array of regex patterns, which when matches a path, it will be excluded.
* @return bool True on success, false on failure. (The file being filtered is not considered a failure.)
*/
public function addFile($path, $filter = null) {
if (!is_file($this->makePath($path))) {
return false;
}
if (isset($filter) && !$this->pathFilter($path, $filter)) {
return true;
}
$this->files[] = $this->makePath($path);
return true;
}
/**
* Add an entry directly to the archive.
*
* You can add files and directories that don't actually exist using this
* function. The entry path must be relative to the root of the archive.
* (Absolute paths will be cleaned.)
*
* The entry array requires at least the following entries:
*
* - "type" - The type of entry. One of "link", "file", or "dir".
* - "path" - The path of the entry.
*
* If "type" is "link", the following entry is required:
*
* - "target" - The target of the symlink.
*
* If "type" is "file", the following entry is required:
*
* - "data" - The contents of the file.
*
* The following entries are optional:
*
* - "uid" - The user id of the entry. (preserveOwner)
* - "gid" - The group id of the entry. (preserveOwner)
* - "mode" - The protection mode of the entry. (preserveMode)
* - "atime" - The last access time of the entry. (preserveTimes)
* - "mtime" - The last modified time of the entry. (preserveTimes)
*
* Be sure to add any parent directories first, before adding the entries
* they contain.
*
* @param array $entry The entry array.
* @return bool True on success, false on failure.
*/
public function addEntry($entry) {
if (empty($entry) || !isset($entry['type']) || !isset($entry['path']) || $entry['path'] == '') {
return false;
}
$newEntry = array(
'type' => $entry['type'],
'path' => preg_replace('/^\/+/', '', $entry['path'])
);
switch ($entry['type']) {
case 'link':
if (!isset($entry['target']) || $entry['target'] == '') {
return false;
}
$newEntry['target'] = $entry['target'];
break;
case 'file':
if (!isset($entry['data'])) {
return false;
}
$newEntry['data'] = (string) $entry['data'];
break;
case 'dir':
$newEntry['path'] = $this->addSlash($newEntry['path']);
break;
default:
return false;
}
if (isset($entry['uid'])) {
$newEntry['uid'] = (int) $entry['uid'];
}
if (isset($entry['gid'])) {
$newEntry['gid'] = (int) $entry['gid'];
}
if (isset($entry['mode'])) {
$newEntry['mode'] = (int) $entry['mode'];
}
if (isset($entry['atime'])) {
$newEntry['atime'] = (int) $entry['atime'];
}
if (isset($entry['mtime'])) {
$newEntry['mtime'] = (int) $entry['mtime'];
}
$this->entries[] = $newEntry;
return true;
}
/**
* Write the archive to a file.
*
* @param string|null $filename The filename to write the archive to.
* @return bool True on success, false on failure.
*/
public function write($filename = NULL) {
if (is_null($filename)) {
$filename = $this->filename;
} else {
$this->filename = $filename;
}
unset($this->header['comp']);
unset($this->header['compl']);
if (!empty($this->compression)) {
$this->header['comp'] = (string) $this->compression;
if ($this->compression == 'deflate') {
$this->header['compl'] = (int) $this->compressionLevel;
}
}
$this->header['files'] = array();
$this->header['ichk'] = (bool) $this->fileIntegrity;
$this->header['ext'] = (array) $this->ext;
$offset = 0.00;
// Handle real files.
foreach ($this->files as $curFile) {
$curPath = $this->makePath($curFile, false);
if (is_link($curFile)) {
$newArray = array(
'type' => 'link',
'path' => $curPath,
'target' => readlink($curFile)
);
$fileInfo = lstat($curFile);
} elseif (is_file($curFile)) {
$curFileSize = (float) sprintf("%u", filesize($curFile));
$newArray = array(
'type' => 'file',
'path' => $curPath,
'offset' => $offset,
'size' => $curFileSize
);
if ($this->fileIntegrity) {
$newArray['md5'] = md5_file($curFile);
}
$offset += $curFileSize;
$fileInfo = stat($curFile);
} elseif (is_dir($curFile)) {
$newArray = array(
'type' => 'dir',
'path' => $curPath
);
$fileInfo = stat($curFile);
} else {
continue;
}
if ($this->preserveOwner) {
$newArray['uid'] = $fileInfo['uid'];
$newArray['gid'] = $fileInfo['gid'];
}
if ($this->preserveMode) {
$newArray['mode'] = $fileInfo['mode'];
}
if ($this->preserveTimes) {
$newArray['atime'] = $fileInfo['atime'];
$newArray['mtime'] = $fileInfo['mtime'];
}
$this->header['files'][] = $newArray;
}
// Handle virtual files.
foreach ($this->entries as $curEntry) {
$newArray = array(
'type' => $curEntry['type'],
'path' => $curEntry['path']
);
switch ($curEntry['type']) {
case 'link':
$newArray['target'] = $curEntry['target'];
break;
case 'file':
$newArray['offset'] = $offset;
$newArray['size'] = (float) sprintf("%u", strlen($curEntry['data']));
if ($this->fileIntegrity) {
$newArray['md5'] = md5($curEntry['data']);
}
$offset += $newArray['size'];
break;
}
if ($this->preserveOwner) {
if (isset($curEntry['uid'])) {
$newArray['uid'] = (int) $curEntry['uid'];
}
if (isset($curEntry['gid'])) {
$newArray['gid'] = (int) $curEntry['gid'];
}
}
if ($this->preserveMode && isset($curEntry['mode'])) {
$newArray['mode'] = (int) $curEntry['mode'];
}
if ($this->preserveTimes) {
if (isset($curEntry['atime'])) {
$newArray['atime'] = (int) $curEntry['atime'];
}
if (isset($curEntry['mtime'])) {
$newArray['mtime'] = (int) $curEntry['mtime'];
}
}
$this->header['files'][] = $newArray;
}
if (!($fhandle = fopen($filename, 'w'))) {
return false;
}
$header = $this->headerCompression ? 'D'.gzdeflate(json_encode($this->header), $this->headerCompressionLevel) : json_encode($this->header);
$beforeStream = "{$this->stub}\nHEADER\n{$header}\nSTREAM\n";
$this->streamOffset = strlen($beforeStream);
fwrite($fhandle, $beforeStream);
$this->applyFilters($fhandle, 'w');
foreach ($this->files as $curFile) {
if (is_link($curFile) || !is_file($curFile)) {
continue;
}
if (!($fread = fopen($curFile, 'r'))) {
return false;
}
@set_time_limit(21600);
stream_copy_to_stream($fread, $fhandle);
}
foreach ($this->entries as $curEntry) {
if ($curEntry['type'] != 'file') {
continue;
}
fwrite($fhandle, $curEntry['data']);
}
return fclose($fhandle);
}
/**
* Open an archive for reading.
*
* @param string $filename The filename of the archive to open.
* @return bool True on success, false on failure.
*/
public function read($filename = null) {
if (is_null($filename)) {
$filename = $this->filename;
} else {
$this->filename = $filename;
}
if (!file_exists($filename) || !($fhandle = fopen($filename, 'r'))) {
return false;
}
$this->stub = '';
$check = fgets($fhandle);
if (substr($check, 0, 2) == '#!') {
$this->stub = $check;
$check = fgets($fhandle);
}
if (substr($check, -8) != "slim1.0\n") {
return false;
}
do {
$this->stub .= $check;
$check = fgets($fhandle);
} while (!feof($fhandle) && $check != "HEADER\n");
if (!($this->stub = substr($this->stub, 0, -1))) {
return false;
}
$header = '';
do {
$header .= fgets($fhandle);
} while (!feof($fhandle) && substr($header, -7) != "STREAM\n");
if (substr($header, -7) != "STREAM\n" || !($header = substr($header, 0, -7))) {
return false;
}
if (substr($header, 0, 1) == 'D') {
$header = gzinflate(substr($header, 1));
}
if (!($this->header = json_decode($header, true))) {
return false;
}
$this->compression = (string) $this->header['comp'];
$this->compressionLevel = (int) $this->header['compl'];
$this->fileIntegrity = (bool) $this->header['ichk'];
$this->ext = (array) $this->header['ext'];
$this->streamOffset = ftell($fhandle);
return fclose($fhandle);
}
/**
* Get an array of information about files in the archive.
*
* @return array File information.
*/
public function getCurrentFiles() {
return $this->header['files'];
}
/**
* Return a file's content from the archive.
*
* @param string $filename The filename of the file to return.
* @return string The contents of the file.
*/
public function getFile($filename) {
foreach ($this->header['files'] as $curEntry) {
if ($curEntry['path'] != $filename || $curEntry['type'] != 'file') {
continue;
}
if (!($fhandle = fopen($this->filename, 'r'))) {
return false;
}
$this->fseek($fhandle, $this->streamOffset);
$this->applyFilters($fhandle, 'r');
$this->fseek($fhandle, $curEntry['offset'], SEEK_CUR);
do {
$data = fread($fhandle, $curEntry['size'] - strlen($data));
} while (!feof($fhandle) && strlen($data) < $curEntry['size']);
fclose($fhandle);
if ($this->fileIntegrity && $curEntry['md5'] != md5($data)) {
return false;
}
return $data;
}
return false;
}
/**
* Extract from the archive.
*
* @param string $path The path of the file or directory to extract. If it is an empty string (''), the entire archive will be extracted.
* @param bool $recursive Whether to extract the contents of directories. (If false, only the directory will be created.)
* @param mixed $filter A regex pattern or an array of regex patterns, which when matches a path, it will be excluded.
* @return bool True on success, false on failure.
*/
public function extract($path = '', $recursive = true, $filter = null) {
$return = true;
$pathSlash = $this->addSlash($path);
if (!is_array($this->header['files']) || !($fhandle = fopen($this->filename, 'r'))) {
return false;
}
$this->fseek($fhandle, $this->streamOffset);
$this->applyFilters($fhandle, 'r');
foreach ($this->header['files'] as $curEntry) {
if ($path != '') {
if ($recursive) {
$curPathSlash = $this->addSlash($curEntry['path']);
if ($curEntry['path'] != $path && substr($curPathSlash, 0, strlen($pathSlash)) != $pathSlash) {
continue;
}
} else {
if ($curEntry['path'] != $path) {
continue;
}
}
}
if (isset($filter) && !$this->pathFilter($curEntry['path'], $filter)) {
continue;
}
$curPath = $this->makePath($curEntry['path']);
if ($this->noParents) {
$curPath = preg_replace('/(^|\/)\.\.(\/|$)/S', '__', $curPath);
}
switch ($curEntry['type']) {
case 'file':
$this->fseek($fhandle, $curEntry['offset'], SEEK_CUR);
if (!($fwrite = fopen($curPath, 'w'))) {
$return = false;
continue;
}
@set_time_limit(21600);
$bytes = stream_copy_to_stream($fhandle, $fwrite, $curEntry['size']);
$return = $return && ($bytes == $curEntry['size']) && fclose($fwrite);
if ($this->fileIntegrity && $curEntry['md5'] != md5_file($curPath)) {
$return = false;
}
break;
case 'dir':
if (!is_dir($curPath)) {
$return = $return && mkdir($curPath);
}
break;
case 'link':
// TODO: Symlink owner/perms.
// Save cwd.
$cwd = getcwd();
// Change to the current path's dir.
if (!chdir(dirname($curPath))) {
$return = false;
}
// Make a symlink from that path.
if (!is_file($curPath)) {
$return = $return && symlink($curEntry['target'], basename($curPath));
}
// Change back to the original dir.
if (!chdir($cwd)) {
$return = false;
}
break;
}
if ($this->preserveOwner && isset($curEntry['uid'])) {
chown($curPath, $curEntry['uid']);
}
if ($this->preserveOwner && isset($curEntry['gid'])) {
chgrp($curPath, $curEntry['gid']);
}
if ($this->preserveMode && isset($curEntry['mode'])) {
chmod($curPath, $curEntry['mode']);
}
if ($this->preserveTimes && (isset($curEntry['atime']) || isset($curEntry['mtime']))) {
touch($curPath, $curEntry['mtime'], $curEntry['atime']);
}
}
$return = $return && fclose($fhandle);
return $return;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment