玩式草子─ソフトウェアとたわむれる日々

第2回「あなたを、犯人です」 ─ デバッグという名のミステリー

Linuxに代表されるオープンソースソフトウェア(OSS)は、文字通りソースコードを公開していることが最大の特徴です。ソースコードが企業秘密のベールに覆われている伝統的な商用ソフトウェアでは、ソースコードにアクセスできる開発者とコンパイル済みのバイナリファイルしか利用できないユーザは厳格に区別され、ユーザがソフトウェアに不具合を見つけても、開発者がそれに対応してくれるまでは指をくわえて待つしかありませんでした。

一方、ソースコードが公開されているOSSの世界では、ユーザと開発者がソースコードという同じ土俵の上で互いに切磋琢磨することができます。今回はそのような例として、ソースコードの不具合を見つけて修正した例を紹介しましょう。

今回取りあげるソフトウェアはfile-rollerという GNOME用の書庫ソフトウェアです。

file-rollerとは

file-roller とは、GNOMEデスクトップ環境で標準となっている書庫ソフトウェアです。

書庫ソフトウェアとは、複数のファイルを一つにまとめて圧縮した書庫(アーカイブ)ファイルを操作するためのソフトウェアで、file-roller はLinux環境で標準的な tar.gz(tar+gzip⁠⁠、tar.bz2(tar+bzip2)といった形式だけではなく、Windowsで標準的なzipやrarの形式にも対応した、高機能な書庫ソフトウェアです。

file-rollerはGNOME環境の標準的な書庫ソフトウェアで、書庫ファイルのアイコンをダブルクリックすると自動的に起動され、そのファイルの中身を表示してくれます。しかしながら、Windowsなユーザから流れてきたファイル名が日本語になっている書庫ファイルを、GNOMEのファイルマネージャであるnautilus経由で開こうとすると、⁠ファイルが見つからない」旨のエラーになってしまいます。

図1 日本語名の書庫ファイルはエラーになる
図1 日本語名の書庫ファイルはエラーになる

もう少し具体的なエラーメッセージが表示されないかと、コマンドラインからfile-rollerを起動しても同じ結果になりました。一方、同じファイルを英語名にシンボリックリンクしたり、file-rollerをあらかじめ起動しておいて、メニューから書庫ファイルを指定して開いてみると、日本語のファイル名を持つ書庫ファイルも問題なく開けるようです。

図2 書庫ファイルを開くこと自体は問題なさそう
図2 書庫ファイルを開くこと自体は問題なさそう

書庫ファイル名が日本語でなければ大丈夫そうなのでLinux環境だけで使う場合はそれほど困らないし、いったん file-roller を起動してからファイル名を指定すれば日本語のファイル名でも開けるのでそれほど致命的な問題ではなさそうですが、Windowsな環境とファイルをやりとりする際にファイルマネージャから直接file-rollerを使えないのは不便です。そこで、なぜ日本語のファイル名を持つファイルが開けないのかを調べてみることにしました。

ソースファイルの構成調査

ソフトウェアの不具合を調べる作業をデバッグ(de-bug)と言います。

デバッグでは、どのような条件で、どのような現象が生じているかをきちんと把握することが重要で、さまざまな状況証拠を積みあげて問題箇所を見つけ出すには、推理小説に出てくる探偵のような推理力が必要になります。

ソースコードのないバイナリファイルはブラックボックスのようなもので、内部でどのような処理をしているかは入出力の関係から推測するしかありませんが、ソースコードも手に入るOSSでは、実際の処理の流れを追いかけて問題点を特定することができます。しかしながら、最近の多機能化したソフトウェアではソースコードも大規模になっており、ソースコードを追いかけるのもかなり大変です。今回扱おうとしているfile-rollerも、ソースコードに含まれているファイルは413、全体で11Mバイトほどもあります。

このような規模のソースコードを追いかける場合、いかに効率よく問題箇所を絞り込むかが重要で、そのためにはさまざまな視点からソースコードを見ていく必要があります。

まず、file-rollerのソースコードの構成を見てみましょう。

% ls
AUTHORS    MAINTAINERS  TODO           configure*     file-roller.spec      intltool-extract.in  mkinstalldirs*
COPYING    Makefile.am  aclocal.m4     configure.ac   file-roller.spec.in   intltool-merge.in    nautilus/
ChangeLog  Makefile.in  config.guess*  copy-n-paste/  gnome-doc-utils.make  intltool-update.in   po/
HACKING    NEWS         config.h.in    data/          help/                 ltmain.sh*           src/
INSTALL    README       config.sub*    depcomp*       install-sh*           missing*

実際のソースコードはsrc/以下のディレクトリにありそうなので、このディレクトリの中身を見てみます。

% ls src
Makefile.am         dlg-prop.c         fr-command-cfile.c    fr-command.c     glib-utils.h
Makefile.in         dlg-prop.h         fr-command-cfile.h    fr-command.h     gtk-utils.c
actions.c           dlg-update.c       fr-command-cpio.c     fr-enum-types.c  gtk-utils.h
actions.h           dlg-update.h       fr-command-cpio.h     fr-enum-types.h  java-utils.c
dlg-add-files.c     eggtreemultidnd.c  fr-command-iso.c      fr-error.c       java-utils.h
dlg-add-files.h     eggtreemultidnd.h  fr-command-iso.h      fr-error.h       main.c
dlg-add-folder.c    file-data.c        fr-command-jar.c      fr-list-model.c  main.h
dlg-add-folder.h    file-data.h        fr-command-jar.h      fr-list-model.h  mkdtemp.c
dlg-ask-password.c  file-utils.c       fr-command-lha.c      fr-marshal.c     mkdtemp.h
dlg-ask-password.h  file-utils.h       fr-command-lha.h      fr-marshal.h     open-file.c
dlg-batch-add.c     fr-archive.c       fr-command-rar.c      fr-marshal.list  open-file.h
dlg-batch-add.h     fr-archive.h       fr-command-rar.h      fr-process.c     preferences.c
dlg-delete.c        fr-command-7z.c    fr-command-rpm.c      fr-process.h     preferences.h
dlg-delete.h        fr-command-7z.h    fr-command-rpm.h      fr-stock.c       sexy-icon-entry.c
dlg-extract.c       fr-command-ace.c   fr-command-tar.c      fr-stock.h       sexy-icon-entry.h
dlg-extract.h       fr-command-ace.h   fr-command-tar.h      fr-window.c      sh/
dlg-new.c           fr-command-alz.c   fr-command-unstuff.c  fr-window.h      typedefs.h
dlg-new.h           fr-command-alz.h   fr-command-unstuff.h  gconf-utils.c    ui.h
dlg-open-with.c     fr-command-ar.c    fr-command-zip.c      gconf-utils.h
dlg-open-with.h     fr-command-ar.h    fr-command-zip.h      gio-utils.c
dlg-password.c      fr-command-arj.c   fr-command-zoo.c      gio-utils.h
dlg-password.h      fr-command-arj.h   fr-command-zoo.h      glib-utils.c

このディレクトリに限ればCのソースコード(拡張子が .c のファイル)が50、ヘッダファイル(拡張子が .h のファイル)が52ほどあります。果してこれらのファイルのどこに問題が潜んでいるのでしょうか?

まず、上記のsrc/以下のファイルの名前を見ると、これらのファイルはdlg-XXXというグループとfr-XXXというグループ、それ以外のグループの3種に分かれてることに気づきます。それぞれのグループのファイル名を詳しく見てみると、dlg-XXX系のファイルはadd-filesとかask-password、deleteといった操作コマンドの系列のようです。ということは、接頭辞のdlgはdialogの略で、どうやら各種操作の時のダイアログ・ボックスを作る処理をしてそうです。

一方、fr-XXX系のコマンドのほとんどはfr-command-XXで、commandの後ろはarとかlha、tar、zipといった圧縮コマンドが並んでいるので、これらはファイルの圧縮形式に応じた処理を担っているのでしょう。

今回のトラブルは、file-rollerの起動時に引数として指定した日本語ファイル名の処理がうまくいかない、というものなので、ダイアログボックス経由の操作や、それぞれの圧縮形式の操作用のファイルは無関係でしょう。それらのファイルを除外すると、対象となるファイルは絞り込めそうです。

dlg-XXXやfr-command-XXXというファイルを除いて、ファイル処理に関連しそうな名前のファイル(file-utils.cやopen-file.cなど)を眺めてみましたが、それぞれのファイルで定義されている関数は小さな機能に分割されており、なかなか処理の流れはつかめません。

そこで少し視点を変えて、エラーメッセージが出力されている場所から探してみることにしました。

メッセージ出力箇所の調査

今回出力されているメッセージは図1のように「"書庫ファイル.tar.bz2"が開けませんでした」⁠ファイルが見つかりませんでした」の2種類です。これらのメッセージがどこで出力されているかを調べれば、処理の流れがわかるかも知れません。

ソフトウェアが出力するメッセージは全てソースコードの中に書かれています。そのため、伝統的なソフトウェアでは英文のエラーメッセージを元にソースコードをgrepするだけで出力箇所を特定することが可能でしたが、file-roller も含めて国際化機能に対応した最近のソフトウェアの場合は、出力されるメッセージ(今回の例では日本語のエラーメッセージ)がそのままCのソースコードに埋め込まれているわけではないので注意が必要です。

国際化機能に対応したソフトウェアでは、Cライブラリの持つlocale(ロケール)という機能を使って、出力時にソースコード中のメッセージを各言語に合わせた文章に自動的に変換しています。変換処理は言語ごとに文字列の対応表を用意することで実現しており、日本語の場合はソースコードディレクトリのpo/ja.poというファイルに、ソースコード中のメッセージとそれに対応する日本語の文字列が集められています。

ソースコードをコンパイルすると、ja.po ファイルはバイナリ化されたja.gmoファイルに変換され、このja.gmoファイルが/usr/share/locale/ja/LC_MESSAGE/file-roller.mo というファイル(メッセージカタログ)にインストールされます。C ライブラリはこのファイルを用いてソフトウェアに埋め込まれた英文の出力メッセージを動的に日本語に変換して出力しています。

それではこのja.poファイルを見てましょう。なお、file-rollerのja.poファイルはUTF-8のエンコーディングで記述されているので、Plamo Linuxの環境で見るにはUTF-8に対応したlvやemacsで開く必要があります。

リスト1 file-rollerのja.po(先頭部分)
# file-roller ja.po.
# Copyright (C) 2001-2009 Free Software Foundation, Inc.
# Takeshi AIHANA <takeshi.aihana@gmail.com>, 2001-2009.
#
msgid ""
msgstr ""
"Project-Id-Version: file-roller trunk\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2009-02-22 10:38+0900\n"
"PO-Revision-Date: 2009-02-22 10:28+0900\n"
"Last-Translator: Takeshi AIHANA <takeshi.aihana@gmail.com>\n"
"Language-Team: Japanese <gnome-translation@gnome.gr.jp>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"

#: ../copy-n-paste/eggdesktopfile.c:165
#, c-format
msgid "File is not a valid .desktop file"
msgstr "妥当な .desktop ファイルではありません"

#: ../copy-n-paste/eggdesktopfile.c:188
#, c-format
msgid "Unrecognized desktop file Version '%s'"
msgstr "バージョン '%s' の .desktop ファイルはサポートしていません"

このように、ja.poファイルはソースコードのどのファイルのどの行で出力される英文のメッセージがどういう日本語に対応するかを逐一記述したファイルになっています。そのため、出力された日本語のメッセージ(⁠⁠開けませんでした」⁠ファイルが見つかりませんでした」等)を元に調べれば、出力箇所が特定できるはずです。今回の例ではどうやらこのあたりのようです。

#: ../src/fr-archive.c:1225
#, c-format
msgid "The file doesn't exist"
msgstr "ファイルが見つかりませんでした。"
...
#: ../src/fr-window.c:2908
#, c-format
msgid "Could not open \"%s\""
msgstr "\"%s\" を開けませんでした"

この結果から、それぞれの該当箇所を調べてみます。

src/fr-archive.c の1225行目はこのあたりです。

リスト2 fr-archive.c(1225行目付近)
1217 static void
1218 copy_remote_file (FrArchive  *archive,
1219                   const char *password) 
1220 { 
1221         XferData *xfer_data; 
1222  
1223         if (! g_file_query_exists (archive->file, NULL)) {
1224                 GError *error;
1225                 error = g_error_new (G_IO_ERROR, G_IO_ERROR_NOT_FOUND, _("The file doesn't exist"));
1226                 fr_archive_copy_done (archive, FR_ACTION_LOADING_ARCHIVE, error);
1227                 g_error_free (error);
1228                 return;
1229         }

src/fr-window.cでは多少行番号がずれているようですが、"Could not open" というメッセージは1ヵ所だけなのでこの部分でしょう。

リスト3 fr-window.c(2920行目付近)
2917                 case FR_ACTION_LOADING_ARCHIVE:
2918                         dialog_parent = window->priv->load_error_parent_window;
2919                         utf8_name = g_uri_display_basename (window->priv->archive_uri);
2920                         msg = g_strdup_printf (_("Could not open \"%s\""), utf8_name);
2921                         g_free (utf8_name);
2922                         break;

このコードを眺めると、前者では archive->file の有無を g_file_query_exists() という関数で調べて、見つからなかったからエラーを出す処理、後者ではこの case 文自体が handle_errors() という関数の中にあり、すでに生じたエラー(FR_ACTION_LOADING_ARCHIVE)に応じて、エラーメッセージと開けなかったファイルの名前(utf8_name)を表示するようになっているようです。

po/ja.poファイルは、ある程度ソースコードとは独立してメンテナンスされているので、ja.poファイルの生成後にソースコードを更新すると、上記のように行番号がずれることがあります。行番号が多少ずれても、文字列の変換は行番号とは無関係に行われるので、表示されるメッセージそのものが変更されない限り、各国語への変換処理は正しく行われます。

これらのファイルの名前がどうなっているかを調べるために、それぞれのファイルの該当箇所にprintf()文を追加してみました。

リスト4 fr-archive.cにprintfを追加
1221         XferDataxfer_data;
1222  
1223         printf("my_debug/fr-archive.c:  archive->file:%s\n", archive->file);
1224         if (! g_file_query_exists (archive->file, NULL)) {
リスト5 fr-window.cにprintfを追加
2919                         utf8_name = g_uri_display_basename (window->priv->archive_uri);
2920                         printf("my_debug/fr-window.c: utf8_name:%s\n", utf8_name);
2921                         msg = g_strdup_printf (_("Could not open \"%s\""), utf8_name);

デバッグメッセージを追加したソースコードをコンパイルし直して、日本語の書庫ファイルを引数に実行してみると、コンソールには図3のような出力が出ました。

図3 デバッグメッセージの出力
図3 デバッグメッセージの出力

コマンドは2度実行しており、1度目の結果を見ると出力される文字のエンコーディングがUTF-8のようだったので、2度目では出力結果をnkfに通してEUC-JPに変換しています。

この結果を見ると、fr-window.cではファイル名が正しくUTF-8になっているものの、fr-archive.cのcopy_remote_file()関数の中のarchive->fileはファイル名が壊れてしまっているようです。

そこで、この copy_remote_file() がどこから呼ばれているかを調べてることにしました。

関数の呼び出し関係の追跡

まず、copy_remote_fileを手掛りに、Cのソースコードをgrepしてみます。

% grep 'copy_remote_file ' *.c
fr-archive.c:copy_remote_file (FrArchive  *archive,
fr-archive.c:	copy_remote_file (archive, password);

この結果を見ると copy_remote_file()はfr-archive.cの2ヵ所にしか存在しないようです。前者は上記1218行目の定義している箇所のようですが、後者を調べてみると、1256行目からのfr_archive_load()という関数中にありました。

リスト6 fr-archive.c(1260行目付近)
1255 gboolean
1256 fr_archive_load (FrArchive  *archive,
1257                  const char *uri,
1258                  const char *password)
1259 {
1260         g_return_val_if_fail (archive != NULL, FALSE);
1261
1262         g_signal_emit (G_OBJECT (archive),
1263                        fr_archive_signals[START],
1264                        0,
1265                        FR_ACTION_LOADING_ARCHIVE);
1266
1267         fr_archive_set_uri (archive, uri);
1268         copy_remote_file (archive, password);
1269         
1270         return TRUE;
1271 }       

この部分にもprintf()文を入れて調べると、すでにarchiveという構造体中のファイル名が壊れているようなので、fr_archive_load()を呼び出している部分に遡って調べていきました。

リストは省きますが、fr_archive_load()はfr-window.cのfr_window_archive_open()という関数から呼び出され、この fr_window_archive_open()はmain.cのprepare_apps()から呼び出されていました。

リスト7 main.c(918行目付近)
907                 int i = 0;
908                 while ((filename = remaining_args[i++]) != NULL) {
909                         GtkWidget *window;
910                         GFile     *file;
911                         char      *uri;
912
913                         window = fr_window_new ();
914                         gtk_widget_show (window);
915
916                         file = g_file_new_for_commandline_arg (filename);
917                         uri = g_file_get_uri (file);
918                         fr_window_archive_open (FR_WINDOW (window), uri, GTK_WINDOW (window)); 
919                         g_free (uri); 

このコードを見ると、引数として指定したファイル名がremaining_args[]経由でfilenameに入り、そのfilenameからfileとuriという変数を用意して、fr_window_archive_open()を呼び出す、という流れのようです。ここにもデバッグ用の printf() 文を埋めこんでみましょう。

リスト8 main.cにprintfを追加
917                         uri = g_file_get_uri (file);
918                         printf("my_debug/main.c: filename:%s, file:%s, uri:%s\n", filename, file, uri);
919                         fr_window_archive_open (FR_WINDOW (window), uri, GTK_WINDOW (window)); 

再度コンパイルし直して動かしてみると、図4のような結果になりました

図4 main.c の時点でエンコーディングがUTF-8になっている
図4 main.c の時点でエンコーディングがUTF-8になっている

Google等で調べると、g_file_new_for_commandline_arg()やg_file_get_uri()という関数はGNOMEが元にしているGTK+のベースとなるGlibで提供されている関数で、前者が引数として指定したファイル名からGFileの構造体を作る機能、後者がGFile構造体からファイル名のURI表現を作る機能のようです。

出力結果にfileは正しく表示されていませんが、この変数は本来GFileという構造体なので、単純に文字列として表示しようとしたことが間違いなのでしょう。

ここで気になるのはfilenameがUTF-8形式で表示されていることです。g_file_new_for_commandline_arg()を調べると、ドキュメントには以下のように説明されています。

Creates a GFile with the given argument from the command line. The value of arg can be either a URI,
an absolute path or a relative path resolved relative to the current working directory. This operation
never fails, but the returned object might not support any I/O operation if arg points to a malformed path.

この関数が引数に取るのはURIか絶対パス、あるいは相対パスということですが、現在のPlamoではlocaleにja_JP.eucJP を使っているので、ファイルシステム上のファイル名は絶対パスでも相対パスでもEUC-JPのエンコーディングになっているはずです。ところがfile-rollerに埋めこんだデバッグ文を見ると、引数から受けとったfilenameがUTF-8として表示されています。

どうやらファイル名のエンコーディングの扱いに齟齬が生じている気配なので、UTF-8化されているfilenameをEUC-JPに変換してみることにしました。

リスト9 filenameを強制的にEUC-JPに変換する処理を追加
916                         gchar *tmpname = g_filename_from_utf8(filename, -1, NULL, NULL, NULL);
917                         file = g_file_new_for_commandline_arg (tmpname);
918                         uri = g_file_get_uri (file);
919                         printf("my_debug/main.c: filename:%s, file:%s, tmpname:%s, uri:%s\n", filename, file, tmpname, uri);
920                         fr_window_archive_open (FR_WINDOW (window), uri, GTK_WINDOW (window));

UTF-8化されているfilenameをg_filename_from_utf8()という関数を使ってファイルシステムのlocaleに戻してtmpnameに格納し、以後の処理はtmpnameを対象にしてみます。g_filename_from_utf8()はGlibに用意されている関数で、UTF-8な文字列をlocaleに合わせて変換してくれます。

g_filename_from_utf8()が正しく機能するには、 G_FILENAME_ENCODINGという環境変数にファイル名のエンコーディング形式を指定してやる必要があります。解説等を見ると複数のエンコーディング形式を指定することもできるようですが、通常は'@locale'を設定してデフォルトのlocaleに合わせておくのが良いでしょう。

このように修正すると、コンソールに出力されているデバッグメッセージでもtmpnameはEUC-JPで表示され、日本語名の書庫ファイルも開けるようになりました。

図5 日本語名の書庫ファイルも開けるようになった
図5 日本語名の書庫ファイルも開けるようになった

どうやら今回のバグは、file-rollerに引数としてあたえたファイル名が、ファイルシステム上のエンコーディング(EUC-JP)ではなく、内部処理用のエンコーディング(UTF-8)に変換されてしまったため、ファイルが正しく読めなかったことが原因のようです。

後日談

これらの内容を簡単に整理してPlamo Linuxのメンテナ日記に書いてみたら、最近新しくメンテナにご参加いただいた本多さんから、⁠ファイル名がUTF-8になるのは、GOptionEntryのオプション処理の指定によるのでは」という指摘をいただきました。

改めてmain.cのオプション処理部を調べてみると、こうなっていました。

リスト9 main.c(オプション処理関係の部分)
173 static const GOptionEntry options[] = {
174         { "add-to", 'a', 0, G_OPTION_ARG_STRING, &add_to,
175           N_("Add files to the specified archive and quit the program"),
176           N_("ARCHIVE") },
177
178         { "add", 'd', 0, G_OPTION_ARG_NONE, &add,
179           N_("Add files asking the name of the archive and quit the program"),
180           NULL },
181
182         { "extract-to", 'e', 0, G_OPTION_ARG_STRING, &extract_to,
....
198         { "force", '\0', 0, G_OPTION_ARG_NONE, &ForceDirectoryCreation,
199           N_("Create destination folder without asking confirmation"),
200           NULL },
201
202         { G_OPTION_REMAINING, 0, 0, G_OPTION_ARG_STRING_ARRAY, &remaining_args,
203           NULL,
204           NULL },
205
206         { NULL }
207 };

ここで"add-to"や"extract-to"はfile-roller コマンドに与えることができるオプションパラメータです。それらあらかじめ指定されたオプション以外の部分は、202行目のG_OPTION_ARG_STRING_ARRAYの指定で文字列としてremaining_args[] を経由して扱うようになっています。

一方、Glibのドキュメント等を調べてみると、この部分はG_OPTION_ARG_FILENAME_ARRAYという指定も可能で、両者の違いはSTRING_ARRAYが文字列として内部処理用のUTF-8に変換するのに対して、FILENAME_ARRAYではファイル名としてファイルシステムのlocaleを保つようになっているようです。

そこで、この部分をG_OPTION_ARG_FILENAME_ARRAYにしてやれば、tmpnameでファイルシステムのlocaleに戻すような処理を加えなくても、正しく日本語の書庫ファイル名も扱えるようになりました。

結論として、今回のfile-rollerの問題は、本来はlocaleを保ったファイル名として扱うべき引数を、UTF-8な文字列として処理してしまったためにファイル名が読めなくなっていた、というものでした。この問題はlocaleをUTF-8にしている環境では、ファイル名が内部処理用の文字列と一致するので、なかなか気づきにくい類のものでしょう。

今回の記事だけを見れば、ずいぶんスムーズにデバッグが進んだように思われるかも知れませんが、実際のデバッグ作業は迷路の中を手探りで進んだり戻ったりするようなもので、ここに紹介した流れは、試行錯誤の末にようやく見つけた最適通路の紹介、といったところでしょうか。

先にデバッグ作業には探偵のような推理力が必要と述べましたが、カンや推論、仮説を積みあげながらソースコードのどこかに潜んでいるバグを見つけだして退治する作業は、まさに犯人のわからないミステリー小説を読むようなものです。

デバッグ作業は、小説のようにうまくは行かず、結局最後まで犯人(バグ)がわからずに投げ出してしまうことも多々ありますが、その分、今回のようにきれいに犯人やその動機までわかった際には、自分自身で小説を書きあげたような感動を味わうことができます。ミステリー小説は一度読んでしまえば終わりですが、バグを退治したソフトウェアは、一層便利に使えるというおまけもついてきます。

与えられたソフトウェアを、作者が想定した範囲内で使うだけではなく、このように能動的にソフトウェアに関わっていけるのがOSSの面白さでしょう。

おすすめ記事

記事・ニュース一覧