PHP
Shift_JIS
dirname
__DIR__
0x5C

PHPのdirname関数でWindows環境のShift_JISパス文字列のディレクトリ名の末尾に特定のコードが入るとディレクトリが欠落する現象

More than 1 year has passed since last update.

Windows環境で、カレントディレクトリのディレクトリ名末尾がとかの場合、__DIR__定数やdirname(__FILE__)の返却値から、現在のディレクトリが消滅し、親ディレクトリまでになってしまうという現象があります。

PHP 7.1ではWindowsのパス処理をUTF-8対応とすることで改善されているようです。

PHPのソースコードを眺めてみたところ、dirname関数と思われる処理(よく知らないので推測です)で、バックスラッシュの前の1バイトをIsDBCSLeadByte関数に渡し、FALSEが返却されたらそのバックスラッシュは1バイト文字のバックスラッシュとして処理するような判定になっていました。

PHPソースコード - zend_dirname関数

PHPソースコード - IS_SLASH_Pマクロ(Win32)

IsDBCSLeadByte関数

しかし、IsDBCSLeadByteTRUEを返す場合で2バイト文字の2バイト目の可能性があり、この場合、そのバックスラッシュを2バイト文字の2バイト目(ディレクトリの区切りではない)と見なしてスキップしてしまうようです。

この条件に該当するのは、2バイト文字の2バイト目が、0x81-0x9Fなど(2バイト文字の1バイト目と2バイト目どちらでもあり得る範囲)の場合と思われます。

0x8393なので、この条件に該当します。

basename関数では、mbrlen関数を使用しているようで、こちらでは1バイト文字かつバックスラッシュという条件のようです。こちらではdirname関数のような現象は起きません。basename(__FILE__)とすれば正しくファイル名が返却されます。もしdirnameと同じような処理であれば、ファイル名にディレクトリ名が混入することになります。

PHPソースコード - php_basename関数

PHPソースコード - php_mblenマクロ(Win32)

mbrlen関数

英語力が皆無な自分にはバグ報告はなかなか厳しいですが、気が向けばやります。

現時点でこの現象を回避するにはいくつか方法が考えられます。

__DIR__定数についてはいじれないので対処の方法は現実的にはありません。


  1. 日本語Windows環境などを使用しない。この現象はWindowsビルドのShift_JIS環境などで発生する現象です。

  2. 2バイト目が0x81-0x9Fなどの2バイト文字をディレクトリ名の末尾に使用しない。

  3. 2バイト目が0x81-0x9Fなどの2バイト文字をディレクトリ名の末尾に使用したディレクトリよりも下層で、2バイト目が0x81-0x9Fなどの2バイト文字をディレクトリ名の末尾に使用していないディレクトリで使用する。

  4. ディレクトリ区切りをスラッシュにして使用する。この現象は区切り文字がスラッシュの場合には影響ありません。バックスラッシュの場合だけ関係するのは、0x5C問題対策のためと思われます。


  5. basename関数の返却値を使用して、パス文字列からファイル名部分を除去する。


  6. SplFileInfo::getPath()を使用する。これは0x5C問題に対応していないようなのであまり意味はありません。

  7. 自前でファイル名部分を除去する。

  8. Shift_JISをうまく処理してくれるライブラリを探して使用する。

  9. 自前で改修・ビルドして使用する。

実際にdirname関数にかけて比較したところ、3600個の文字(使われていないコードも含まれていると思われる)がこの現象に該当しました。机上でのコード範囲の組み合わせの数と一致しています。

1バイト目が0x81-0x9F,0xE0-0xFC

2バイト目が0x81-0x9F,0xE0-0xFC

の組み合わせが該当します。つまり2バイト文字の1バイト目にあり得るコードが2バイト目にあるものがすべて該当します。

1バイト文字については、制御コード、スラッシュ、コロン、バックスラッシュは除外して確認しました。


テストコードを書いてみました。全パターンにすると出力が大変なことになるので、コード範囲の端だけにしています。

ComposerPeridotLeoをインストールして、peridotコマンドで実行する前提のコードです。

<?php

require 'vendor/autoload.php';

describe('dirname: Shift_JIS character + backslash + file name', function() {

describe('Shift_JIS: 1 byte (edge)', function() {
foreach ([0x20, 0x2E, 0x30, 0x39, 0x3B, 0x5B, 0x5D, 0x7E, 0xA1, 0xDF] as $single) {
$char = sprintf("%c", $single);

it('0x'.strtoupper(bin2hex($char)), function() use ($char) {
$expect = 'a'.$char;
$path = $expect.'\\'.'b.c';
if (PHP_VERSION_ID >= 70100) { // PHP 7.1以降
$expect = mb_convert_encoding($expect, 'UTF-8', 'SJIS-win');
$path = mb_convert_encoding($path, 'UTF-8', 'SJIS-win');
}
expect(dirname($path))->to->equal($expect);
});

}
});

describe('Shift_JIS: 2 bytes (edge)', function() {
$last_range = [0x40, 0x7E, 0x80, 0x81, 0x9F, 0xE0, 0xFC];
foreach ([0x81, 0x9F, 0xE0, 0xFC] as $first) {
foreach ($last_range as $last) {
$char = sprintf("%c%c", $first, $last);

it('0x'.strtoupper(bin2hex($char)), function() use ($char) {
$expect = 'a'.$char;
$path = $expect.'\\'.'b.c';
if (PHP_VERSION_ID >= 70100) { // PHP 7.1以降
$expect = mb_convert_encoding($expect, 'UTF-8', 'SJIS-win');
$path = mb_convert_encoding($path, 'UTF-8', 'SJIS-win');
}
expect(dirname($path))->to->equal($expect);
});

}
}
});

});