mount_msdosfs の文字コード

21世紀になってから20年近くも経つと言うのに、私たちは未だに 文字化けに取り憑かれている。滑稽である。

MS-DOS時代(だいたい1990年代後半)のMOディスクの中身を取り出そうと思い、 FreeBSD機に繋げて mount_msdosfs を使用したところ、ファイル名に関連して エラーが出た。カーネルハックして原因が判ったので、対策を記録しておく。

OS のバージョンは FreeBSD 11.2R 。

直面したエラーには 2種類あった。 チルダ記号(0x7e)に関するものと、「最」の字に関するもの。

チルダ記号(0x7e)のエラー

mount_msdosfs(8) は -D と -L オプションで文字コードを指定するようになっている。 -D はマウントするファイルシステムの文字コードの指定で、 今回は日本語MS-DOSで使用していたMOディスクなので SJIS になる。 -L の方はOS側の文字コードで、-L ja_JP.UTF-8 のようにロケール名を与える。 -D と -L を指定することで、カーネル内部で自動的に文字コードの変換が行われ、 ファイルシステムとOSの文字コードの違いが吸収される仕組みになっている。

さて、ネットを漁ると、mount_msdosfs には -L ja_JP.eucJP を与える方が良い、 と書かれたブログ記事が多いようだ。10年くらい前の記事で、かつて FreeBSD カーネルのマルチバイト化が不十分だった頃の対処法らしい。 しかし現在では UTF-8 でほぼ問題が出ないようだし、これから述べるエラーが出るので EUC-JP を指定する利点はあまりないように思う。

さてそのエラーだが、ファイル名にチルダ記号(0x7e)が含まれている時に発生する。 20年ほど前、MS-DOSとWindows95との併用期に、ファイル名の長さの違いを回避するために MS-DOS側のファイル名にチルダが付与され「REPORT~1.TXT」のようになったりしたことを 覚えている人も多いだろう。今回はこのチルダが悪さをする。

まず、条件と現象を記すと、 mount_msdosfs -D sjis -L ja_JP.eucJP でマウントし、 たとえば REPORT~1.TXT が含まれるディレクトリに対して ls(1) を実行すると、

REPORT?1.TXT: No such file or directory

となる。

原因は、実は SJIS と EUC-JP では 0x7e に割り当てられている文字が違うことにある。 EUC-JP は 0x7f 以下の 7ビット部分の文字セットは ISO646-US で、これは 0x7e が ASCII と同じくチルダ記号になっている。 一方 SJIS は 7ビット文字セットが ISO646-JP で、こちらの 0x7e はチルダではなく 上付き横棒 ‾ (UTF8:0xe280be, UCS2:0x203E)なのである。 さらに、この上付き横棒は EUC-JP には存在しないため、EUC-JP に変換しようとすると EUC-JP の INVALID 記号である〓(いわゆるゲタ文字、EUC-JP:0xAEA2)に置換される。

このため、カーネル内部で次のような処理が行われる。 仮にファイル名を REPORT~1.TXT とすると、 まず SJIS から UCS2 を経由して EUC-JP に変換する。

SJIS:   52 45 50 4f 52 54   7e 31 2e 54 58 54
UCS2:   52 45 50 4f 52 54 203e 31 2e 54 58 54
EUC-JP: 52 45 50 4f 52 54 aea2 31 2e 54 58 54

カーネルはゲタ文字化した文字列をファイル名として保持する。 カーネルからファイルシステムにアクセスする時には逆変換が行われ、 ゲタ文字は INVALID記号ではなく普通の文字として変換される。

EUC-JP: 52 45 50 4f 52 54 aea2 31 2e 54 58 54
UCS2:   52 45 50 4f 52 54 3013 31 2e 54 58 54
SJIS:   52 45 50 4f 52 54 ac81 31 2e 54 58 54

MS-DOSファイルシステムのファイル名は 8.3式なのに 変換後のものは 9.3になっている。9文字目が切り捨てられて、 またエンディアンの関係か下位バイトが先になるようなので、結局

52 45 50 4f 52 54 81 ac 2e 54 58 54

というバイト列を探すことになるが、もちろんこんな名前のファイルは ディレクトリエントリに存在しないので、ENOENT になる。

原因が判ったところで対策だが、SJIS の 0x7e がそのまま 0x7e になるように改造する 方法を試してみた。FreeBSD のソースコードに手を入れて色々試したところ、

/usr/src/share/i18n/esdb/MISC/Shift_JIS.src
/usr/src/share/i18n/esdb/MISC/Shift_JIS-2004.src
/usr/src/lib/libkiconv/quirks.c

を修正すれば良いらしい。

上の2つはSJISの文字セットを定義しているところで、 ファイル内の ISO646-JP のところを ISO646-US に変更する。 quirks.c は文字変換テーブルから 0x7e のエントリをコメントアウトする。 それぞれビルドして、

/usr/share/i18n/esdb/MISC/Shift_JIS.esdb
/usr/share/i18n/esdb/MISC/Shift_JIS-2004.esdb
/lib/libkiconv.so.4

を置き換えてOSを再起動すれば、0x7e 文字の変換が抑制されて、エラーが出なくなった。

「最」のエラー

mount_msdosfs の -L オプションに ja_JP.UTF-8 を指定してもほとんど問題はなかったのだが、 唯一、ファイル名に「最」の字が含まれているとエラーが出た。

条件と現象を記すと、 mount_msdosfs -D sjis -L ja_JP.UTF-8 でマウントし、 たとえば 最初.TXT が含まれるディレクトリに対して ls(1) を実行すると、

最初.TXT: No such file or directory.

とエラーになる。

原因を調べた結果、 /usr/src/sys/libkern/iconv_xlat16.c:iconv_xlat16_toupper()でおかしな挙動が 起こっていることが判った。

MS-DOSはファイル名の大文字小文字を区別せず、ファイル名は大文字で格納される。 そのためカーネルからMS-DOSファイルシステムにアクセスする時に大文字化処理をしていて、 上記関数はその処理をする関数である。この関数は20行程度の短い関数なので、 実際に中を見た方が理解が早い。

static int
iconv_xlat16_toupper(void *d2p, int c)
{
        struct iconv_xlat16 *dp = (struct iconv_xlat16*)d2p;
        int c1, c2, out;

        if (c < 0x100) {
                c1 = C2I1(c << 8);
                c2 = C2I2(c << 8);
        } else if (c < 0x10000) {
                c1 = C2I1(c);
                c2 = C2I2(c);
        } else
                return (c);

        if (dp->d_table[c1] && dp->d_table[c1][c2] & XLAT16_HAS_UPPER_CASE) {
                out = dp->d_table[c1][c2] & 0xffff;
                if ((out & 0xff) == 0)
                        out = (out >> 8) & 0xff;
                return (out);
        } else
                return (c);
}

見て判る通り、1バイト文字はビットシフトして下位バイトを 0 にして、 後で逆方向にビットシフトして元に戻す処理をしている。 そのため、下位バイトが 0 のマルチバイト文字は 1バイト文字に間違えられてしまうのだ。

ここまで書けば想像がつくと思うが、くだんの「最」の字は、UCS2 のコードが 0x6700 なのである。 そのためこの関数においては 0x67 つまり ‘g’ と認識され、これが大文字化して 0x47 ‘G’ が 戻り値になる。

カーネルは G初.TXT というファイル名を探すが、ファイルシステムのディレクトリエントリには そのようなファイル名はもちろん存在しないため、ENOENT になる。

これを回避するため、私は以下のように修正した。

--- iconv_xlat16.c.orig     2018-06-22 08:02:52.000000000 +0900
+++ iconv_xlat16.c  2019-09-22 21:08:40.097838000 +0900
@@ -334,6 +334,8 @@
    } else if (c < 0x10000) {
                 c1 = C2I1(c);
                 c2 = C2I2(c);
+                if (c1 == 0)
+                        return (c);
    } else
            return (c);

この関数はカーネルモジュールの libiconv.ko に格納されるため、 libiconv.ko をリビルドして置き換えれば良い。 Makefile は /usr/src/sys/modules/libiconv/ にある。

予期せぬ副作用があるかも知れないので、mount_msdosfs を使用する時だけ libiconv.ko を入れ替えるようにする方が安全だろう。