PHPでZIPエンコーダを実装してみた
概要
PHPでZIPエンコーダを実装した。以下の特徴がある
- 一時ファイルを作成せずに出力可能
- 出力サイズを計測可能(ContenLengthとして使える)
- 大きなファイルを扱っても省メモリ
- 暗号化も可能
- ZIP64(ファイルサイズ4GB以上など)にも対応
- 詳細な更新日/作成日を設定可能
- PHP5.6以上、32bit版でも動作。ただしZIP64には64bit版が必要
ソースは [github - Rezipe] (https://github.com/wealandwoe/Rezipe) から
開発の動機
以前、ZIPダウンロード機能のより良い実装についてやさしいZIPダウンロード機能という記事を書き、その中で maennchen/ZipStream-PHP を利用したPHPの実装例を紹介した。
その記事では、無圧縮ZIPならばほぼ待ち時間なしでダウンロードが始められると書いたが、圧縮時もやり方次第で同じようなことができるのではと思い検証を始めたのがきっかけ。結果それなりに動作するものができたので公開する。
PHPには標準でZip関数や ZipArchiveクラスが存在するが標準出力への出力には対応していない為、一時ファイルを利用する必要がある。
動作環境
PHP 5.6以上(PHP8.1, 7.3, 5.6で検証した。それ以下は未検証)。64bitを推奨、ZIP64を使わないなら32bitでも動作する。AES暗号化機能を使う場合は openssl 関数が必要。
使い方
例1. 一時ファイルを作成せずに出力(Downloadさせる)
Zip::add_file()
でファイルを追加し、Zip::bytes()
でファイルサイズを計測し、Zip::save()
で出力する
<?php
require_once 'rezipe.php';
$zip = new Rezipe\Zip();
$zip->compress = false; # 無圧縮
$zip->is_utf8 = true;
foreach(glob('path/to/dir/*.jpg') as $file) {
$zip->add_file($file);
}
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename=download.zip');
header('Content-Length: ' . $zip->bytes());
$zip->save('php://output');
圧縮してもDataDescriptorを利用すれば、すぐさまダウンロード開始される。
上と違いContentLengthをセットしていない。圧縮する場合は Zip::bytes()
実行時に圧縮処理で遅くなるため。
<?php
require_once 'rezipe.php';
$zip = new Rezipe\Zip();
$zip->compress = true; # Deflate圧縮
$zip->datadesc = true; # DataDescriptorを利用
$zip->is_utf8 = true;
foreach(glob('path/to/dir/*.jpg') as $file) {
$zip->add_file($file);
}
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename=download.zip');
$zip->save('php://output');
例2. ローカルに保存
Zip::save()
にファイルパスを指定すればローカルに保存される
<?php
require_once 'rezipe.php';
$zip = new Rezipe\Zip();
$zip->compress = true;
$zip->add_data("test data\nzipzipzip", 'test.txt');
$zip->save('saveTo/foo.zip');
例3. 暗号化
※昔ながらの脆弱な暗号化なので注意
※DataDescriptorと併用してはいけない
<?php
require_once 'rezipe.php';
$zip = new Rezipe\Zip();
$zip->compress = true;
$zip->zipcrypto = 'p4$5w0rd';
$zip->add_file("secret.pdf");
$zip->add_file("otakara.jpg");
$zip->add_file("passwords.txt");
$zip->save('crypted.zip');
例4. AES暗号化
より強力な暗号化方式だが、展開できるアーカイバを選ぶ(Windows/Mac標準では開けない。7-zipでは開ける)。
こちらはDataDescriptorと併用可能なので、圧縮しつつ即ダウンロードできる
<?php
require_once 'rezipe.php';
$zip = new Rezipe\Zip();
$zip->compress = true;
$zip->aescrypto = 'verylongstrongpassword';
$zip->datadesc = true;
$zip->add_file("secret.pdf");
$zip->add_file("otakara.jpg");
$zip->add_file("passwords.txt");
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename=aes_crypted.zip');
$zip->save('php://output');
例5. ファイル名としてShift-JISを使う場合
Windows7以前の標準アーカイバでは、ZIP内ファイル名のエンコーディングがShift-JISでないと文字化けしていた。あえてそのようなzipファイルを作りたい場合は以下のようにすれば良い。文字コード変換にmbstring拡張が必要。
<?php
require_once 'rezipe.php';
$zip = new Rezipe\Zip();
$zip->is_utf8 = false; # UTF-8フラグはOFFにしておく
$zip->add_file("元ファイル名(UTF-8).txt",
mb_convert_encoding("変換後ファイル名(SJIS).txt", "CP932", "UTF-8"));
$zip->save('for_old_windows.zip');
設定項目
<?php
$zip = new Rezipe\Zip();
# 圧縮するかどうかのBool値
$zip->compress = false;
# 拡張フィールドに精度の高いNTFS時刻(64bit Mtime,Atime,Ctime)を追加する
$zip->ntfsdate = true;
# 拡張フィールドにUnixTime(32bit Mtime,Atime,Ctime)を追加する
$zip->extime = false;
# 拡張フィールドにUnix情報(Atime,Mtime,Uid,Gid)を追加する(※未検証)
$zip->unixextra = false;
# 拡張フィールドにUnix情報(Uid,Gid)を追加する(※未検証)
$zip->unixextra2 = false;
# 拡張フィールドにUnicodePathを追加する
$zip->upath = false;
# LocalFileHeader直後にDataDescriptorを付け加える
$zip->datadesc = false;
# DataDescriptorにsignature("PK\007\008")を付けるかどうか
$zip->datadesc_signature = false;
# ZIPファイル内エンコーディングをUTF-8にする
$zip->is_utf8 = true;
# 暗号化(ZipCrypto)ZIPを利用する場合、パスワードを指定する
$zip->zipcrypto = null;
# AES暗号化(WinZipのAES-256暗号)ZIPを利用する場合、パスワードを指定する
$zip->aescrypto = null;
参考情報
- wikipedia/Zip
- PKWARE/APPNOTE.txt
- 私的ZIPファイル研究所
- WinZip/AES Encryption Information
- A Password Based File Encryption Utility
こぼれ話
以下に、実装する上で苦労した部分をメモしておく。PHPに慣れていないのもあるかもしれないが、色々なところで躓いた。どこかで誰かが(自分が?)同じ苦労をしないですんだら良いなと思う。
pack/unpack
Rubyのイメージで使おうとして失敗した。特に unpack()
が独特に感じる。慣れれば短く記述できて良いのだが。
<?php
$bin = pack('vvVV', 0, 1, 0x1234, 0xabcd);
var_dump(bin2hex($bin)); #=> '0000010034120000cdab0000'
# これは間違い。/で区切る必要がある
var_dump(unpack('vvVV', $bin)); #=> array('vVV'=>0)
# これも間違い。出現数のあとに名前をつけないと上書きされてしまう
var_dump(unpack('v2/V2', $bin)); #=> array('1'=>0x1234, '2'=>0xabcd)
# 正しい例1 書式コード+出現数+名前 を / 区切りで書けば、名前+出現順(1,2,...) のキーが付く
var_dump(unpack('v2short/V2word', $bin)); #=> array('short1'=>0, 'short2'=>1, 'word1'=>0x1234, 'word2'=>0xabcd)
# 正しい例2 それぞれに名前をつける場合
var_dump(unpack('vID/vLEN/VvalX/VvalY', $bin)); #=> array('ID'=>0, 'LEN'=>1, 'valX'=>0x1234, 'valY'=>0xabcd)
32bit対応
filesize()
などの関数が signed int を返す為、32bit版では2GB以上のファイルのファイルサイズがマイナスになる。そのまま pack()
するなら問題ないが、加算・乗算・ビットシフトでおかしなことになる。
暗号化処理にCRC-32の計算が必要になるが、Cのサンプルのまま実装するとそのあたりで躓く。
ストリームフィルタの使い方
PHPのストリーム関連の関数は便利だが、Manualに記述が少なく使い方がわからないことが多い。
例えばRezipeのDeflate圧縮は gzdeflate()
関数と zlib.deflate
ストリームフィルタを使い分けている。gzdeflate()
関数だけですめば簡単だが大きなファイルをまるごと読み込むとメモリが不足してしまうからだ。
そしてこの zlib.deflate
フィルタは、flose()
しないと最後のバッファを書き込まないので、複数ファイルを1つの出力ストリームに書き出すにはもう一段別のストリームが必要になる。
さらにRezipeではCRC32・圧縮サイズ測定も一度のファイル読み込みで済ませる為、以下のように実装した
<?php
#...
class TransferFilter extends \php_user_filter {
function filter($in, $out, &$consumed, $closing) {
$transfer = $this->params["transfer"];
while($bucket = stream_bucket_make_writeable($in)) {
$consumed += $bucket->datalen;
if ($transfer) {fwrite($transfer, $bucket->data);}
stream_bucket_append($out, $bucket);
}
return PSFS_PASS_ON;
}
}
stream_filter_register("transferfilter", 'TransferFilter') or die("Failed to register filter");
#...
$mem = fopen('php:memory', 'wb');
stream_filter_append($mem, 'crc32filter', $crc32_params);
stream_filter_append($mem, 'zlib.deflate', $deflate_params);
stream_filter_append($mem, 'transferfilter', $transfer_params);
stream_filter_append($mem, 'strlenfilter', $strlen_params);
$in = fopen($path, 'rb');
while(!feof($in)) {
fwrite($mem, fread($in, 10240));
}
fclose($in);
fclose($mem);
#...
このように $transfer_params
に出力先のストリームを渡したら、1ファイル分のデータを テンポラリーなストリーム($mem
) に fwrite()
, fclose()
すればよい。
まどろっこしいが、出力ストリームに直接 stream_filter_append()
するとうまくいかない。
ストリームフィルタのconsumed と stream_copy_to_stream()
独自のストリームフィルタを作るには、php_user_filter
を継承したクラスを作成し、filter()
メソッドを実装すれば良い。
<?php
/* フィルタクラスを定義する */
class strtoupper_filter extends php_user_filter {
function filter($in, $out, &$consumed, $closing)
{
while ($bucket = stream_bucket_make_writeable($in)) {
$bucket->data = strtoupper($bucket->data);
$consumed += $bucket->datalen;
stream_bucket_append($out, $bucket);
}
return PSFS_PASS_ON;
}
}
となっているが、この $consumed
がよくわからなかった。
php_user_filter::filter メソッドの説明では
consumed は常に参照渡しとする必要があります。 フィルタで読み込んだり変更したりしたデータの長さをここで加算します。 大半の場合、各 $bucket 上で $bucket->datalen をコールするたびに consumed を増やすことになります。
と書かれていて、ここを変更した場合にどうなるかは書かれていない。なんとなく「バケットデータのサイズが変わったら、変更後サイズを加算するのかな」と考えていたが実はそうではなかった。
この $consumed
の値は stream_copy_to_stream()
関数を使ったときに影響が出る。
<?php
class nozero_filter extends \php_user_filter {
function filter($in, $out, &$consumed, $closing) {
while ($bucket = stream_bucket_make_writeable($in)) {
#echo "nozero: " . strlen($bucket->data) . "Byte\n";
$bucket->data = strtr($bucket->data, array('0'=>''));
$consumed += strlen($bucket->data);
stream_bucket_append($out, $bucket);
}
return PSFS_PASS_ON;
}
}
stream_filter_register("nozero_filter", 'nozero_filter') or die("Failed to register filter");
# 0~7を1024回繰り返し続ける8MBのテキスト('00...11...22......77...00...')
$fh = fopen('src.txt', 'wb');
for($i=0; $i<1024; $i++) {
for($j=0; $j<8; $j++) {
fwrite($fh, str_repeat("$j", 1024));
}
}
fclose($fh);
$len = filesize('src.txt');
echo "SRC: " . $len . "\n"; # 8MB
$in = fopen('src.txt', 'rb');
$out = fopen('dst.txt', 'wb');
stream_filter_append($out, 'nozero_filter', STREAM_FILTER_WRITE);
stream_copy_to_stream($in, $out, $len, 0);
fclose($in);
fclose($out);
echo "DST: ". filesize('dst.txt') . "\n"; # 8MB(7MBではない!)
上記スクリプトでは、文字列の0を消すだけのストリームフィルタ(nozero_filter
)を通している。入力が0~8なので出力結果は1~7だけになりファイルサイズも8MBから7MBに減りそうなものだがそうはならない。dst.txt の中をよく見るとわかるのだが、1が1024回出現し次に2が1024回、3~6も1024回ずつ出現した後、7が2048回出てくる。0を削除した分だけ7がもう一度出てきてしまっている。
どうやら、$consumed
に加算する数値を $bucket->datalen
より小さくすると、減らした分だけ再読み込みされるようだ。これの便利な使い道がよくわからないが、「大半の場合」
#$consumed += strlen($bucket->data); # 間違い
$consumed += $bucket->datalen;
と記述すべきと理解はできた。
ちなみに4MB以下のファイルではこの現象は起きない(正確には stream_copy_to_stream()
の第3引数が 4194304 以上の場合)。
ただ、これ意味なくね?みたいなcommit もあるしいずれ変わるかもしれない。
参考: main/streams/stream.c の _php_stream_copy_to_stream_ex() 内の再読み込み処理
ストリームフィルタの closing と stream_bucket_new
ストリームフィルタの filter()
メソッドの第4引数 $closing
はストリームの最後の呼び出し時に true となるようだ。
最後の呼び出しと同時に $in
にデータがあれば while($bucket = stream_bucket_make_writable($in)) {...}
のブロック内処理が有効だが、$in
にデータが無い場合は自分で bucket を作成しないといけない。その為の関数が stream_bucket_new()
である。
この stream_bucket_new()
は使い方がマニュアルに一切書かれていないがstackoverflowにあったので紹介する。
class append_filter extends php_user_filter {
public $stream;
function filter($in, $out, &$consumed, $closing) {
while ($bucket = stream_bucket_make_writeable($in)) {
$consumed += $bucket->datalen;
stream_bucket_append($out, $bucket);
}
// always append a terminating \n
if ($closing) {
$bucket = stream_bucket_new($this->stream, "\n");
stream_bucket_append($out, $bucket);
}
return PSFS_PASS_ON;
}
}
stream_filter_register("append", "append_filter")
stream_bucket_new()
の第1引数に php_user_filter
のメンバ変数 $stream
を渡してやれば bucket を自作できる、というのがミソだった。これは知ってないと書けないだろうに何故ドキュメントが何も無いのか。もしかして誰もストリームフィルタ使ってない?
参考: stackoverflow/What is a bucket brigade?
ZipCryptoによる暗号化
PKWAREのAPPNOTE.txt に複合についての説明はあるが、暗号化についての説明はない。暗号化の実装はほかを参考にする必要がある。
APPNOTE.txtでは複合について
6.1.7 Decrypting the compressed data stream
The compressed data stream can be decrypted as follows:
loop until done
read a character into C
Temp <- C ^ decrypt_byte()
update_keys(temp)
output Temp
end loop
とあった。この書式にしたがえば暗号化は
loop until done
read a character into C
Temp <- decrypt_byte()
update_keys(C)
output C ^ Temp
end loop
だろうか。ここに出てくる decrypt_byte()
関数は Key2
を参照していて、update_keys()
関数は Key0
, Key1
, Key2
を更新するための関数である。そしてこの update_keys()
にも説明が省かれている箇所がある。
Where update_keys() is defined as:
update_keys(char):
Key(0) <- crc32(key(0),char)
Key(1) <- Key(1) + (Key(0) & 000000ffH)
Key(1) <- Key(1) * 134775813 + 1
Key(2) <- crc32(key(2),key(1) >> 24)
end update_keys
Where crc32(old_crc,char) is a routine that given a CRC value and a
character, returns an updated CRC value after applying the CRC-32
algorithm described elsewhere in this document.
わざわざ「このドキュメントのどこかに crc32(old_crc,char)
の説明があるよ」と言っておいて実はない。名前と文脈からCRC32が関係するのはわかるが、PHPのcrc32()は引数に old_crc
を取らないので自前で実装するしかない。Rezipeでは以下のようにした
<?php
# ...snip...
class ZipCrypto {
# ...snip...
function update_keys_int8($char) {
$this->key0 = static::crc_update($this->key0, $char);
$this->key1 = ($this->key1 + ($this->key0 & 0xff)) & 0xffffffff;
$this->key1 = ($this->key1 * 134775813 + 1) & 0xffffffff;
$this->key2 = static::crc_update($this->key2, $this->key1 >> 24);
}
static function crc32($crc, $buf) {
$crc ^= 0xffffffff;
$len = strlen($buf);
for ($i = 0; $i < $len; $i++) {
$crc = static::crc_update($crc, ord($buf[$i]));
}
return $crc ^ 0xffffffff;
}
static function crc_update($crc, $char) {
return static::$crc_table[($crc ^ $char) & 0xff] ^ ($crc >> 8) & 0x00ffffff;
}
# ...snip...
APPNOTE.txtで出てくる crc32(old_crc,char)
に該当するものが ZipCrypto::crc_update($crc, $char)
になる。ZipCrypto::crc32(0, $str)
は、PHPの crc32($str) 関数と同等になる。ちなみに update_keys_int8()
内の key1 * 134775813 + 1
の部分、32bit版PHPでおかしくなるので別途対応が必要。Rezipeではめんどくさく上下16bitを分解して計算したが、BC関数やGMP関数を使うのが簡単か。
AE-x Encryption(AES暗号化)
AES暗号化も理解に多少苦労した。別記事にまとめておく。
LocalFileHeaderとCentralDirectoryのZIP64拡張フィールド
APPNOTE.txtには、「もしアーカイブがZIP64 formatなら○○の値を××にしろ」という説明が各所に散らばっていて非常に見渡しが悪く理解に苦労した。
例えば5GBのファイルを圧縮しようとすると、そのファイルに対応する LocalFileHeader と CentralDirectory にZIP64拡張フィールドを追加する必要がある。ZIP64拡張フィールドは最大4つの項目を持つが、LocalFileHeader のZIP64拡張フィールドが持つ項目は必ずオリジナルサイズと圧縮サイズの2つだけだ。
対して、CentralDirectory のZIP64拡張フィールドは必要な項目だけを持つ。5GBのファイルだとしたらオリジナルサイズは必要だが、圧縮サイズが4GB以下ならば持ってはいけない。同様に残りの2項目“LocalFileHeaderへのオフセット値”と“開始ディスク番号”もCentralDirectoryに収まらない場合にだけ追加する。
LocalFileHeaderとZIP64拡張フィールド の構造
項目名 | サイズ | 詳細 |
---|---|---|
local file header signature | 4 bytes | (0x04034b50) |
version needed to extract | 2 bytes | |
general purpose bit flag | 2 bytes | |
compression method | 2 bytes | |
last mod file time | 2 bytes | |
last mod file date | 2 bytes | |
crc-32 | 4 bytes | |
compressed size | 4 bytes | ※1 |
uncompressed size | 4 bytes | ※2 |
file name length | 2 bytes | |
extra field length | 2 bytes | |
file name | (variable size) | |
extra field | (variable size) |
ZIP64拡張フィールドの項目 | サイズ | 詳細 |
---|---|---|
Tag | 2 bytes | Tag for this "extra" block type(0x0001) |
Size | 2 bytes | Size of this "extra" block(0x0010) |
Original Size | 8 bytes | Original uncompressed file size |
Compressed Size | 8 bytes | Size of compressed data |
-
compressed size
が 0xffffffff を超える場合、compressed size
に 0xffffffff をセットする。超えても超えなくてもZIP64拡張フィールドのCompressed Size
には正しい値をセットする。 -
uncompressed size
が 0xffffffff を超える場合、uncompressed size
に 0xffffffff をセットする。ZIP64拡張フィールドのOriginal Size
には正しい値をセットする。
CentralDirectoryとZIP64拡張フィールド の構造
項目名 | サイズ | 詳細 |
---|---|---|
central file header signature | 4 bytes | (0x02014b50) |
version made by | 2 bytes | |
version needed to extract | 2 bytes | |
general purpose bit flag | 2 bytes | |
compression method | 2 bytes | |
last mod file time | 2 bytes | |
last mod file date | 2 bytes | |
crc-32 | 4 bytes | |
compressed size | 4 bytes | ※1 |
uncompressed size | 4 bytes | ※2 |
file name length | 2 bytes | |
extra field length | 2 bytes | |
file comment length | 2 bytes | |
disk number start | 2 bytes | ※2 |
internal file attributes | 2 bytes | |
external file attributes | 4 bytes | |
relative offset of local header | 4 bytes | ※2 |
file name | (variable size) | |
extra field | (variable size) | |
file comment | (variable size) |
ZIP64拡張フィールドの項目 | サイズ | 詳細 |
---|---|---|
Tag | 2 bytes | Tag for this "extra" block type(0x0001) |
Size | 2 bytes | Size of this "extra" block |
Original Size | 8 bytes | Original uncompressed file size |
Compressed Size | 8 bytes | Size of compressed data |
Relative Header Offset | 8 bytes | Offset of local header record |
Disk Start Number | 4 bytes | Number of the disk on which this file starts |
-
compressed size
が 0xffffffff を超えた場合、compressed size
に 0xffffffff をセットして、ZIP64拡張フィールドのCompressed Size
に正しい値を入れる。超えない場合はCompressed Size
の項目自体を省略する -
uncompressed size
についてもcompressed size
と同様。超えたら拡張フィールドに追加し、超えないなら追加しない。disk number start
,relative offset of local header
も同様。
CentralDirectoryのZIP64拡張フィールドは項目数が場合によって変わる。その為、出現する順番は変えてはいけない。
Data descriptor が難しい
Data descriptor
は、本来 Local file header
に付けるべきCRC値・ファイルサイズ・圧縮後ファイルサイズといった情報を File data
後に付ける為のエントリーだ。
これによって File data
送信前にCRCや圧縮後サイズを計算せずに済むので、圧縮したいファイル一覧が決定すれば即座に保存処理が開始できる。即保存できるというのは、すぐダウンロードが開始されるということだ。大量のデータをダウンロードさせたい場合には重要な仕組みである。
仕様が曖昧で複雑
その重要なハズの Data descriptor
だが仕様がなんだか曖昧だ。
APPNOTE.txt の 4.3.9 Data descriptor
をまとめると
Data descriptorの項目 | サイズ |
---|---|
crc-32 | 4 bytes |
compressed size | 4 bytes |
uncompressed size | 4 bytes |
- このdescriptorを利用する場合は
general purpose bit flag
の bit 3 を立てて、圧縮データの直後においてね - 圧縮サイズか元サイズが 0xFFFFFFFF を超える場合、
compressed size
とuncompressed size
は 8 bytes になるよ - シグニチャとして頭に 0x08074b50 が付くことがまれに良くあるんで読み取り時には気をつけてね
- 書き込み時にはシグニチャ付けた方がいいよ
- 将来もっと使いやすい
Data descriptor
が出るかもよ? -
Central Directory Encryption
利用時は、このdescriptor不要だけど(以下略)
といった感じか。なんとも取ってつけた感がある。
シグニチャがないとMacOSXでエラー
仕様4でシグニチャは付けた方が良い(SHOULD)とあるが、Windowsではあってもなくてもあまり問題は無い。
ただしMacOSXの標準アーカイバはシグニチャを付けないとエラーになり展開できない。つまりMacで利用されそうであればシグニチャを付けるか、そもそも Data descriptor
を使わないかのどちらかだ。
シグニチャ値がコンフリクトしている
このシグニチャにはもう一つ問題があり、zipファイル分割・結合の目印としても同じ値 0x08074b50 が利用される。Rezipeでは分割・結合を実装していないので関係ないが、同機能をサポートする際には注意が必要だ。