Mac の PHP はパーセントエンコーディングな日本語リダイレクトを解釈できない？

**『Mac 環境の PHP だけ挙動が違う』**という、首をかしげる問題に遭遇したのでメモ。

### *追記 (2016/06/23 00:28)*

厳密には

- OS 依存ではなく、**ロケールに依存する**
- パーセントエンコーディングが引き金ではなく、日本語の文字コードに含まれる制御文字が引き金

という問題のようです。詳しくは下部の追記および[コメント](http://qiita.com/yhatt/items/77ec756ea51dcc3a0713#comment-b718ddb7ba4193d8721f)をどうぞ。
@rryu さん ご教示ありがとうございます :bow:

## Location ヘッダのパーセントエンコーディングが解釈できない

`file_get_contents` を使って URL の内容を取得する、なんてことはよくあります。楽ですし。

そこで指定した **URL が 301 リダイレクトを返す場合、Location ヘッダにパーセントエンコーディングを含むと `Invalid redirect URL!` の Warning が出る** 、という問題に遭遇しました。

### 例：Wikipedia の HTTP ページ

例えば、Wikipediaのトップページは https://ja.wikipedia.org/wiki/メインページ です。このプロトコルを `http` にすると、`https` への 301 リダイレクトになります。

で、日本語部分をパーセントエンコーディングして、"http://ja.wikipedia.org/wiki/%E3%83%A1%E3%82%A4%E3%83%B3%E3%83%9A%E3%83%BC%E3%82%B8" を `file_get_contents()` で取ってみます。

```bash
$ curl --head "http://ja.wikipedia.org/wiki/%E3%83%A1%E3%82%A4%E3%83%B3%E3%83%9A%E3%83%BC%E3%82%B8"
HTTP/1.1 301 TLS Redirect
Server: Varnish
Location: https://ja.wikipedia.org/wiki/%E3%83%A1%E3%82%A4%E3%83%B3%E3%83%9A%E3%83%BC%E3%82%B8
(...省略)

$ php -r "file_get_contents('http://ja.wikipedia.org/wiki/%E3%83%A1%E3%82%A4%E3%83%B3%E3%83%9A%E3%83%BC%E3%82%B8');"
PHP Warning:  file_get_contents(http://ja.wikipedia.org/wiki/%E3%83%A1%E3%82%A4%E3%83%B3%E3%83%9A%E3%83%BC%E3%82%B8):
failed to open stream: Invalid redirect URL! https://ja.wikipedia.org/wiki/%E3%83%A1%E3%82%A4%E3%83%B3%E3%83%9A%E3%83%BC%E3%82%B8 in Command line code on line 1

PHP Stack trace:
PHP   1. {main}() Command line code:0
PHP   2. file_get_contents() Command line code:1
```

**`Invalid redirect URL!`** と怒られてしまいました。 (※ 上記は整形してます)

El Capitan に添付されている PHP 5.5.34 、および phpenv + php-build で導入した PHP 5.6.22、さらには現時点の最新の PHP 7.0.7 でもこの問題が発生します。

もちろんブラウザでは正しく閲覧できますし、`curl -L` ならちゃんとリダイレクトも追えます。

### エンコーディングされていない Location ヘッダは？

パーセントエンコーディングされていない（日本語のままの）Wikipedia の URL を渡してあげると、Location ヘッダには そのまま日本語が入ってきます。

この場合、 `file_get_contents()` で Warning は **出ません。**

```bash
$ curl --head "http://ja.wikipedia.org/wiki/メインページ"
HTTP/1.1 301 TLS Redirect
Server: Varnish
Location: https://ja.wikipedia.org/wiki/メインページ
(...省略)

$ php -r "file_get_contents('http://ja.wikipedia.org/wiki/メインページ');"
(正常終了)
```

### Mac 固有の問題？

パーセントエンコーディングを含んだリダイレクトを検証するため、

- ubuntu
- Debian
- CentOS

といった各種 Linux ディストリビューション向けにビルドされた PHP でも試してみました。

ところが、**いずれの PHP もパーセントエンコーディングを正しく解釈し、Warning が出ずに正常終了** してしまいます。

```bash
$ curl --head "http://ja.wikipedia.org/wiki/%E3%83%A1%E3%82%A4%E3%83%B3%E3%83%9A%E3%83%BC%E3%82%B8"
HTTP/1.1 301 TLS Redirect
Server: Varnish
Location: https://ja.wikipedia.org/wiki/%E3%83%A1%E3%82%A4%E3%83%B3%E3%83%9A%E3%83%BC%E3%82%B8
(...省略)

$ php -r "file_get_contents('http://ja.wikipedia.org/wiki/%E3%83%A1%E3%82%A4%E3%83%B3%E3%83%9A%E3%83%BC%E3%82%B8');"
(...正常終了!?)
```

…えっ、Mac 固有の問題なのこれ……！？

### エラー発生箇所
PHP のソースで言うと、~~[発生しているエラーはおそらくこのへん](https://github.com/php/php-src/blob/PHP-5.6.23/ext/standard/http_fopen_wrapper.c#L874)~~。
*__間違ってました。__[詳しくはコメントをどうぞ。](http://qiita.com/yhatt/items/77ec756ea51dcc3a0713#comment-b718ddb7ba4193d8721f)*

## 回避策

もしこの問題に出会ってしまったら、 [cURL 関数群](http://php.net/manual/ja/ref.curl.php) を使って回避するのが堅実でしょう。
`file_get_contents` は非常に楽ちんですが、curl は安心を買えます。

http://php.net/manual/ja/function.curl-exec.php#refsect1-function.curl-exec-examples

*追記：file_get_contents のまま問題を回避することも可能ですが、ロケールへの依存を残すよりは やはり curl を素直に使った方が堅実です。*

### なぜ Mac だけ？

とはいえ、なぜ Mac の PHP だけで このような Warning が出るのか、正しい原因がまだ掴めてません。
PHP のバージョンや php.ini の設定も疑ったのですが、同一にしても Mac 環境だけはやはりエラーになります。

そもそも、確実に再現するのが Mac というだけで、『本当に Mac 固有の問題なのか』も微妙ですし、ちょっとモヤモヤしています。

この Warning の原因に関して、ご存知の方はご教示いただけると嬉しいです。 :bow:

---

### *追記：[ご教示いただきました](http://qiita.com/yhatt/items/77ec756ea51dcc3a0713#comment-b718ddb7ba4193d8721f)*

> コントロールコードかどうかの判定に`iscntrl()`関数を使っていますが、この関数の挙動はロケールに依存します。
ということでMacとそれ以外で環境変数''LC_*''系の値が異なるのではないかと思いますがどうでしょうか。

PHP のソースも読んだつもりでしたが、ここの部分だけ思いっきり見落としていました…。

#### *追加検証*

Warning が出た時の Mac のロケールは `ja_JP.UTF-8` です。

```bash
$ locale
LANG="ja_JP.UTF-8"
LC_COLLATE="ja_JP.UTF-8"
LC_CTYPE="ja_JP.UTF-8"
LC_MESSAGES="ja_JP.UTF-8"
LC_MONETARY="ja_JP.UTF-8"
LC_NUMERIC="ja_JP.UTF-8"
LC_TIME="ja_JP.UTF-8"
LC_ALL=

$ php -r "file_get_contents('http://ja.wikipedia.org/wiki/%E3%83%A1%E3%82%A4%E3%83%B3%E3%83%9A%E3%83%BC%E3%82%B8');"
PHP Warning:  file_get_contents(http://ja.wikipedia.org/wiki/%E3%83%A1%E3%82%A4%E3%83%B3%E3%83%9A%E3%83%BC%E3%82%B8):
failed to open stream: Invalid redirect URL! https://ja.wikipedia.org/wiki/%E3%83%A1%E3%82%A4%E3%83%B3%E3%83%9A%E3%83%BC%E3%82%B8 in Command line code on line 1
```

試しに `LC_CTYPE=C` に設定して同じコードを実行すると、**Warning が出ず正常終了** しました。PHP コード内部でも **`setlocale()`** 関数を使用することで回避可能。

```bash
$ LC_CTYPE=C php -r "file_get_contents('http://ja.wikipedia.org/wiki/%E3%83%A1%E3%82%A4%E3%83%B3%E3%83%9A%E3%83%BC%E3%82%B8');"
(正常終了)

$ php -r "setlocale(LC_CTYPE, 'C'); file_get_contents('http://ja.wikipedia.org/wiki/%E3%83%A1%E3%82%A4%E3%83%B3%E3%83%9A%E3%83%BC%E3%82%B8');"
(正常終了)
```

#### *Mac だけ Warning になる真相*

どの文字が `iscntrl()` で制御文字と判定されているのか調べてみました。以下の検証用コード使用。

> ```php:検証用PHPコード
<?php
for ($i = 0; $i <= 255; $i++) {
  if (ctype_cntrl($i)) printf('0x%02x ', $i);
}
```

- **Mac 10.11**
    - `LC_CTYPE=C`：`0x00`〜`0x1f` `0x7f`
    - `LC_CTYPE=ja_JP.UTF-8`：`0x00`〜`0x1f` `0x7f` **`0x80`〜`0x9f`**
- *自分が試した Linux ディストリビューション (ubuntu, Debian, CentOS)*
    - `LC_CTYPE=C`：`0x00`〜`0x1f` `0x7f`
    - `LC_CTYPE=ja_JP.UTF-8`：`0x00`〜`0x1f` `0x7f`

Mac 環境**『だけ』、日本語 UTF-8 の時は `0x80`〜`0x9f` が制御文字扱いになってます。**
この範囲は UTF-8 の3バイト文字のうち、1バイト目が `0xed` で始まる文字の 2バイト目 にあたります (RFC 3629 / UTF8-3)。

この範囲が、URL にある `%82` や `%83` とぶつかって、制御文字が含まれていると認識されてエラーになる訳ですね。1バイト目は `%ED` じゃないんだけどなぁ。。。  
　

#### *正しい回避策は？*

仮に `setlocale(LC_CTYPE, 'C')` で回避したとしても ロケール依存問題は消えるわけじゃないので、やっぱり curl を素直に使うべきなんでしょうね…

逆に言えば、PHPコード内で `setlocale()` を使用してロケールを変更したり、PHP を動作させる環境のロケール設定が変わった場合（仮にLinuxだろうと）、*想定せずこの Warning の影響を受ける可能性がある* とも言えます。

　

## 追記2：私が本当に踏んだモノ (2016/06/24 23:00)

実は、この記事を書くきっかけになった「地雷」は、Mac 環境のコトじゃありません。__CentOS__ です。

- CentOS 6.8 環境で PHP が動作していた。
- 使っていた PHP フレームワークの中で `setlocale()` を使用していた。
- `LC_CTYPE` に **"`ja_JP`"** を設定していた。

その結果、やはり例の文字コードが制御文字に。 :sob:

- :o: `LC_CTYPE=ja_JP.UTF-8`：`0x00`〜`0x1f` `0x7f`
- :x: `LC_CTYPE=ja_JP`：`0x00`〜`0x1f` `0x7f` **`0x80`〜`0x8d`** **`0x90`〜`0x9f`**

UTF-8 設定を勝手に使ってくれると思い込むものでは無いですね。

Mac 環境と異なり、**`0x8e` `0x8f`** の2字は制御文字扱いに *なりません。* これは UNIX 由来の EUC-JP に合わせられたものと考えられます（それぞれ、半角カナ・補助漢字で使用されるコードで、領域も `ja_JP.eucjp` と一致します）。

## 結論

要は、こんな混沌としたハマり方をしないためにも、**素直に `curl_exec` 使っておきましょう。**
http://php.net/manual/ja/curl.examples-basic.php