嘘つきPHP ZipArchive::addGlobと壊れたファイルパス

こんにちは.
KMC5回生のtyageです.

この記事は, KMCアドベントカレンダー 5日目の記事です.
昨日はnonyleneさんの Android Studio をビルドする でした.

唐突ですが, 今日はPHPの話をします.

TL;DR

PHPのZipArchiveライブラリには, globパターンでファイルを追加する addGlob メソッドがある.
これにはいくつかoptionが指定できるのだが, 挙動がおかしい.
送った修正PRに反応をもらえたため, 12/1にリリースされた PHP 7.1 に含まれないかなと期待していたが, 特に進展はなかった.
みんな困ってないんか…?

ZipArchive::addGlob

PHPでオシゴトをしていると, PHPでZipファイルを作りたい瞬間があるのではないかと思います.
私も, リクエスト内容が書かれたjsonファイルや画像をZipファイルでまとめてPOSTする超コズミックなAPIを叩く, 最高の機会がありました. (社会の歯車である一労働者は, 用意されたAPIに文句を言う前に黙々と作業をしなければいけない時があります.)
また, 様々な都合でZip圧縮前のフォルダを残しておく必要があったため, こんなコードを書いたのです.

$dir = '/tmp/workdir/';
 
// create jsons/api.json
$jsonDir = $dir . 'jsons';
mkdir($jsonDir);
file_put_contents($jsonDir. 'api.json', json_encode($request));
 
// create archive.zip
$zip = new ZipArchive();
$zip->open($dir . 'archive.zip', ZipArchive::CREATE);
$zip->addGlob($dir . '**/**', 0, ['remove_path' => $dir]);
$zip->close();

ZipArchive::addGlobは, 第一引数にファイル検索パターンを, 第二引数にglobのフラグを, 第三引数にその他optionを指定します.
このoptionが今回の焦点となるのですが, 以下の項目が設定できます.

オプションの連想配列。次のオプションが使えます。

  • “add_path”

    アーカイブ内のファイルのローカルパスに変換するときにつけるプレフィックス。
    これが適用されるのは、
    “remove_path”“remove_all_path”
    で定義された削除処理がすべて終わった後です。

  • “remove_path”

    マッチしたファイルをアーカイブに追加する前に削除するプレフィックス。

  • “remove_all_path”

    TRUE にすると、ファイル名だけを使ってアーカイブのルートに追加します。

ref: http://php.net/manual/ja/ziparchive.addglob.php

ふむふむ.

今回は remove_path を指定しており, 期待していた挙動としては,

  1. archive.zip というファイルができる
  2. そこには jsons/api.json というファイルが含まれている

です.

もし仮に, remove_path を指定していなければ /tmp/workdir/jsons/api.json というファイルが追加されるため, 正しくAPIを叩けなくなります.

で, 得られた結果がこれです.

/tmp/workdir ᐅ php -v
PHP 7.0.13 (cli) (built: Nov 15 2016 23:52:36) ( NTS )
Copyright (c) 1997-2016 The PHP Group
Zend Engine v3.0.0, Copyright (c) 1998-2016 Zend Technologies

/tmp/workdir ᐅ unzip -l archive.zip
Archive:  archive.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
        6  12-05-2016 18:04   /tmp/workdir/jsons/api.json
---------                     -------
        6                     1 file

あれ? remove_path optionを指定したのに /tmp/workdir/jsons/api.json のままですね…
どういうことでしょう?

問題1: add_path optionが必須

調べてみると, どうも add_path optionが有効でないと他のoptionが機能しないバグがあるようです.

issuePR はあるものの, まだマージされていませんでした.

仕方ないので, とりあえず add_path optionをつけて試してみることにします.

$zip->addGlob($dir . '**/**', 0, ['add_path' => 'prefix/', 'remove_path' => $dir]);

さて, この場合どうなるでしょうか?
/tmp/workdir/jsons/api.json から /tmp/workdir/ を削って, prefix/ を足すので prefix/jsons/api.json になりそうですね.
さて, 結果はというと…

/tmp/workdir ᐅ unzip -l archive.zip
Archive:  archive.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
        6  12-05-2016 18:12   prefix/sons/api.json
---------                     -------
        6                     1 file

oh…
jsons の j がどこかに消えて prefix/sons/api.json になってしまいました.

問題2: 消えた j の謎

これは霊障でしょうか?
それとも宇宙線の影響でしょうか?

PHPソースコードを見ながら検証してみましょう.
addGlob でファイルを追加する場合は php_zip_add_from_pattern という関数を見ればよさそうです. (ext/zip/php_zip.c#L1618)

追加するファイルのファイルパスを操作しているのはここのようです.
remove_path_lenremove_path optionの文字数, zval_file は恐らく検索して見つかったファイルのファイルパスを指しているのでしょう.

1
2
3
4
5
6
7
8
9
10
11
12
if ((zval_file = zend_hash_index_find(Z_ARRVAL_P(return_value), i)) != NULL) {
	if (remove_all_path) {
		basename = php_basename(Z_STRVAL_P(zval_file), Z_STRLEN_P(zval_file), NULL, 0);
		file_stripped = ZSTR_VAL(basename);
		file_stripped_len = ZSTR_LEN(basename);
	} else if (remove_path && strstr(Z_STRVAL_P(zval_file), remove_path) != NULL) {
		file_stripped = Z_STRVAL_P(zval_file) + remove_path_len + 1;
		file_stripped_len = Z_STRLEN_P(zval_file) - remove_path_len - 1;
	} else {
		file_stripped = Z_STRVAL_P(zval_file);
		file_stripped_len = Z_STRLEN_P(zval_file);
	}

7行目を読んでみると, remove_path を指定した場合, ファイルパス指すポインタの指すアドレスが remove_path_len + 1 分進みます.
上の例であれば /tmp/workdir/j の文字数分進みます.

どうやらこれが原因みたいですね.
なんで + 1 されているのかさっぱりわからないのですが, まだバグ報告すらされていないようなので, とりあえずissueを立ててPRを送りましょう.

Bug #72374 ZipArchive::addGlob remove_path option strips first char of filename
Fix #72374: ZipArchive::addGlob remove_path option strips first char of filename #1939

2週間くらいで返事がもらえ, bugfixとして認識してもらえたようです.
ただ, Breaking ChangeなのでPHP7.1.0かPHP8にマージされそうです.(などと言っているうちに7.1.0がリリースされたのですが…)

PRを出してから半年が経過し, このままだと忘れ去られそうなのでここに記録することにしました.
早くマージされるといいなぁ.

既存のテストは?

ところで, remove_path optionに対するテストがなかったかというと, 実はありました.
ext/zip/tests/oo_addpattern.phpt

こんなコードでテストしています.

$dir = realpath($dirname);
$options = array('add_path' => 'baz/', 'remove_path' => $dir);
if (!$zip->addPattern('/\.txt$/', $dir, $options)) {
        echo "failed\n";
}

ここで, $dirname = "/tmp/workdir/"; であれば, $dir == "/tmp/workdir" になります.
ということは /tmp/workdir/hoge.txt から /tmp/workdir を削って baz/ を先頭に足すoptionになるわけですが, 上記のバグから baz//hoge.txt ではなく baz/hoge.txt が生成されます.
おめでたいテストですね.
これも修正対象です.

終わりに

問題の原因は見つかり解決策も出したのですが, まだマージされていません.
自前ビルドのPHPを運用するのも厳しいです.
結局, working directoryを移動して, remove_path optionを利用しない方向で解決しました.

$dir = '/tmp/workdir/';
chdir($dir); // change working dir!
 
// create jsons/api.json
$jsonDir = 'jsons';
mkdir($jsonDir);
file_put_contents($jsonDir. 'api.json', json_encode($request));
 
// create archive.zip
$zip = new ZipArchive();
$zip->open('archive.zip', ZipArchive::CREATE);
$zip->addGlob('**/**');
$zip->close();

もし同じ問題を抱えている人がいれば, この記事が救いになりますように…

明日は dnek_ さんの「1年間の進捗」です.