libmagic via python (fileコマンドな話)

困り方が少なくて困ってもこの調べ方をしてこなかった、てタイプのネタ。

「拡張子」という言葉は「なんかつよそう」なのだが、実は「たった8文字しかファイル名に使えないなんてクソ過ぎる、3文字も拡張しちゃるぜ、えらいボク」というアホな「拡張」のこと。石器時代の「汎用機」と「DOS」はそれぞれ別々の事情でファイルシステムに制約が大きいわけだが、どちらも「ファイル名」という情報を固定長として扱おうとした点は共通で、「汎用機」よりも20年は後発の DOS が「8文字では足りないので増やせ」という残念な拡張をしたのが「8+3」。そしてこの「+3」は、「フロッピーディスクに収めねばならぬ」という非常に厳しい制約のための「単純化」の一つとして、「ファイルの種類はこの拡張子をみればわかる」という役割のために導入されたものだ。これにより、DOS システム内のプログラムたちは、(常に約束が守られる限りにおいては)ファイルの中身を実際に確かめることなくそれが「どこの馬の骨なのかがわかる」、というわけである。

けれども少し考えればわかるように、「常に約束が守られる」なんてことはまず考えられないのであり、誤りや誤解や悪意によって、「拡張子による主張」と内容が乖離するなんてことは当たり前のように起こる。Unix 文化ではそもそも「.exe」のようなものは「所詮は補助情報」であり「「拡張」子」なんて言い方は、Windows が流行る前は「一切」してなかった。そう、これは「ただの suffix」である。Unix ではこの suffix に主張を込めてもいいし込めなくてもよい。約束事がないと色々不都合なもの、たとえば「プログラミング言語のソースファイルの種別(実装ファイルなのかインターフェイスファイルなのか、など)」では関連ツールは suffix 駆動で動作するし、そうでないものは、多くは suffix はよくても目安にする程度、一切 suffix を参照しないものも多い。そもそも「ひろみ」という名前の人物が女性であることを、名前だけに基いていったい誰が保証出来る?

では、Unix プログラムたちは中身が何者であるか/自分が処理対象とするものなのか、を suffix なしでどうやって判別するのか? たとえばファイルの中に埋め込まれる目印(マジックナンバー)を調べる。あるいは、自分が期待する読み込みをとりあえずは進めてみて、ダメならエラーで終了すればいい、つまり「判別しない」。

ファイル種別ごとに処理をディスパッチしたいようなケースで、その種別を問い合わせるのに、Unix はかなり大昔から「file コマンド」またの名を「magic データベース」に問い合わせるというインターフェイスを提供し続けている。たとえば:

1 [me@host: Videos]$ file Asterik.wav
2 Asterik.wav: RIFF (little-endian) data, WAVE audio, Microsoft PCM, 16 bit, stereo 48000 Hz

てな具合。オリジナルの発想は「目印(マジックナンバー)を調べる」だが、やってくれることはもう少し複雑で、「ファイルの特徴を(ある程度)調べ」る。file コマンドは「magic」という定義ファイルに基づいてファイルの中身をテストするようになっていて、これは今でもアクティブにメンテナンスされている。標準化団体みたいなのが主導して保守してるのかどうかについてはワタシは知らないけど、入手可能な file コマンドのソースが入手可能であるなら magic ファイルは同じように入手出来、たとえば cygwin や msys2 のものなどがある。

ここまでが「Unix における file コマンド(と magic)」の話。

で、問題は、(ワタシのように)古い msys を日常使いしてるが、file だけアップデートでけんもんだらうか、と思ったとき、なのよね。試してみたのがだいぶ前なので断言はしないけど、古い msys に入ってる file と、最新で手に入る magic は確か互換性がなくて、使えなかった、と思う。

ならば、と、「だったら file コマンド外(python など)から magic データベースに直接アプローチ出来ないか?」と。そうすれば、もしかしたら古い msys の呪縛から逃れられたりせんか?

冒頭で言った通り、こうしたことはかなり前から思ってたことだけど、まぁ大抵は「古い msys のもので結局は事足り」て、まぁいいか、と、次のフェーズに思考を進めてこなかった。今更探してみた、てハナシ。

なんつーか一発で見つかった二つが、ひじょーに悩ましいことになっとんのよね。python-magicfilemagic。どちらも libmagic にインターフェイスする作りになってる。なんだけど、どっちも同じパッケージ名「magic」になってて、完全に衝突しとる。

ただ幸いにも、真っ先に「filemagic はないわー」と思って良さそうかなぁと思った。これはダメだろと:

filemagic の magic/api.py
 1 """File type identification using libmagic.
 2 
 3 A ctypes Python wrapper for the libmagic library.
 4 
 5 See libmagic(3) for low level details.
 6 """
 7 import ctypes.util
 8 import ctypes
 9 import platform
10 import warnings
11 
12 libname = ctypes.util.find_library('magic')
13 if not libname:
14     if platform.system() == 'SunOS':
15         libname = 'libmagic.so'
16         warnings.warn("ctypes.util.find_library does not function as "
17                       "expected on Solaris; manually setting libname to {0}. "
18                       "If import fails, verify that libmagic is installed "
19                       "to a directory registered with crle. ".format(libname),
20                       ImportWarning)
21     else:
22         raise ImportError('Unable to find magic library')
23 
24 try:
25     lib = ctypes.CDLL(libname)
26 except Exception:
27     raise ImportError('Loading {0} failed'.format(libname))

全部読んだわけじゃないけどさ、この作者は少なくともかなり知識が欠けてる。(世界には Soralis と Windows しかないと思ってる、ってコードだよ、これ。)

衝突さえしてないなら、これだけを理由に却下はしないんだけれど、ひとまず「python-magic に問題がないならあえて filemagic にスタックする必要なんかなかろ?」てことで、まずは filemagic を動かす努力をすることもせずに、python-magic てみる。どうかな?

python-magic がありがたいのは、Windows 版のバイナリパッケージが用意されてること。pip では「python-magic-bin」名でインストール出来る。

インストールしてみたら、コンパイル済みの magic ファイルも一緒にインストールされた:

うん、いいね、magic の更新だけしたい場合は、ここの magic を置き換えればいいわけだ。ただ、まぁ DLL のロードの問題は鬱陶しいことは鬱陶しい:

 1 [me@host: ~]$ cd /c/Windows/cursors
 2 [me@host: cursors]$ py -3
 3 Python 3.9.2 (tags/v3.9.2:1a79785, Feb 19 2021, 13:44:55) [MSC v.1928 64 bit (AMD64)] on win32
 4 Type "help", "copyright", "credits" or "license" for more information.
 5 >>> import magic
 6 Traceback (most recent call last):
 7   File "<stdin>", line 1, in <module>
 8   File "C:\Program Files\Python39\lib\site-packages\magic\__init__.py", line 208, in <module>
 9     libmagic = loader.load_lib()
10   File "C:\Program Files\Python39\lib\site-packages\magic\loader.py", line 49, in load_lib
11     raise ImportError('failed to find libmagic.  Check your installation')
12 ImportError: failed to find libmagic.  Check your installation
13 [me@host: cursors]$ export PATH="/c/Program Files/Python39/Lib/site-packages/magic/libmagic":$PATH
14 [me@host: cursors]$ py -3
15 Python 3.9.2 (tags/v3.9.2:1a79785, Feb 19 2021, 13:44:55) [MSC v.1928 64 bit (AMD64)] on win32
16 Type "help", "copyright", "credits" or "license" for more information.
17 >>> import magic

いいね:

 1 Python 3.9.2 (tags/v3.9.2:1a79785, Feb 19 2021, 13:44:55) [MSC v.1928 64 bit (AMD64)] on win32
 2 Type "help", "copyright", "credits" or "license" for more information.
 3 >>> import magic
 4 >>> from glob import glob
 5 >>> for cur in glob("aero*.*"):
 6 ...     print(cur, magic.from_file(cur))
 7 ...
 8 aero_arrow.cur MS Windows cursor resource - 5 icons, 128x128, hotspot @0x0
 9 aero_arrow_l.cur MS Windows cursor resource - 5 icons, 128x128, hotspot @0x0
10 aero_arrow_xl.cur MS Windows cursor resource - 6 icons, 128x128, hotspot @0x0
11 aero_busy.ani RIFF (little-endian) data, animated cursor
12 aero_busy_l.ani RIFF (little-endian) data, animated cursor
13 aero_busy_xl.ani RIFF (little-endian) data, animated cursor
14 aero_ew.cur MS Windows cursor resource - 5 icons, 128x128, hotspot @47x19
15 aero_ew_l.cur MS Windows cursor resource - 5 icons, 128x128, hotspot @48x20
16 aero_ew_xl.cur MS Windows cursor resource - 5 icons, 128x128, hotspot @53x21
17 aero_helpsel.cur MS Windows cursor resource - 5 icons, 128x128, hotspot @0x0
18 aero_helpsel_l.cur MS Windows cursor resource - 5 icons, 128x128, hotspot @0x0
19 aero_helpsel_xl.cur MS Windows cursor resource - 5 icons, 128x128, hotspot @0x0
20 aero_link.cur MS Windows cursor resource - 5 icons, 128x128, hotspot @25x0
21 aero_link_i.cur MS Windows cursor resource - 5 icons, 128x128, 2 colors, hotspot @25x0
22 aero_link_il.cur MS Windows cursor resource - 5 icons, 128x128, 2 colors, hotspot @40x0
23 aero_link_im.cur MS Windows cursor resource - 5 icons, 128x128, 2 colors, hotspot @35x0
24 aero_link_l.cur MS Windows cursor resource - 5 icons, 128x128, hotspot @35x0
25 aero_link_xl.cur MS Windows cursor resource - 5 icons, 128x128, hotspot @40x0
26 aero_move.cur MS Windows cursor resource - 5 icons, 128x128, hotspot @45x45
27 aero_move_l.cur MS Windows cursor resource - 5 icons, 128x128, hotspot @51x50
28 aero_move_xl.cur MS Windows cursor resource - 5 icons, 128x128, hotspot @54x53
29 aero_nesw.cur MS Windows cursor resource - 5 icons, 128x128, hotspot @32x32
30 aero_nesw_l.cur MS Windows cursor resource - 5 icons, 128x128, hotspot @38x36
31 aero_nesw_xl.cur MS Windows cursor resource - 5 icons, 128x128, hotspot @43x40
32 aero_ns.cur MS Windows cursor resource - 5 icons, 128x128, hotspot @19x45
33 aero_ns_l.cur MS Windows cursor resource - 5 icons, 128x128, hotspot @19x46
34 aero_ns_xl.cur MS Windows cursor resource - 5 icons, 128x128, hotspot @21x51
35 aero_nwse.cur MS Windows cursor resource - 5 icons, 128x128, hotspot @32x32
36 aero_nwse_l.cur MS Windows cursor resource - 5 icons, 128x128, hotspot @36x36
37 aero_nwse_xl.cur MS Windows cursor resource - 5 icons, 128x128, hotspot @40x40
38 aero_pen.cur MS Windows cursor resource - 5 icons, 128x128, hotspot @0x0
39 aero_pen_l.cur MS Windows cursor resource - 5 icons, 128x128, hotspot @0x0
40 aero_pen_xl.cur MS Windows cursor resource - 5 icons, 128x128, hotspot @0x0
41 aero_person.cur MS Windows cursor resource - 5 icons, 32x32, hotspot @6x0
42 aero_person_l.cur MS Windows cursor resource - 5 icons, 32x32, hotspot @6x0
43 aero_person_xl.cur MS Windows cursor resource - 5 icons, 32x32, hotspot @7x0
44 aero_pin.cur MS Windows cursor resource - 5 icons, 32x32, hotspot @6x0
45 aero_pin_l.cur MS Windows cursor resource - 5 icons, 32x32, hotspot @7x0
46 aero_pin_xl.cur MS Windows cursor resource - 5 icons, 32x32, hotspot @7x0
47 aero_unavail.cur MS Windows cursor resource - 5 icons, 128x128, hotspot @31x31
48 aero_unavail_l.cur MS Windows cursor resource - 5 icons, 128x128, hotspot @40x40
49 aero_unavail_xl.cur MS Windows cursor resource - 5 icons, 128x128, hotspot @48x48
50 aero_up.cur MS Windows cursor resource - 5 icons, 128x128, hotspot @19x0
51 aero_up_l.cur MS Windows cursor resource - 5 icons, 128x128, hotspot @20x0
52 aero_up_xl.cur MS Windows cursor resource - 5 icons, 128x128, hotspot @21x0
53 aero_working.ani RIFF (little-endian) data, animated cursor
54 aero_working_l.ani RIFF (little-endian) data, animated cursor
55 aero_working_xl.ani RIFF (little-endian) data, animated cursor

うん、filemagic の方は試す必要はなかろう。

magic ファイルのコンパイルとかは出来ないんかな? コンパイル済みのが簡単に手に入るならそれでもいいけど、出来たっけか? うーん、わかったらここに追記するわ。今はひとまず「file command alternative with python」が出来た、てことで。


2021-09-02追記:
「最新で手に入る magic は確か互換性がなくて、使えなかった」についてのやや正確な話から。「python_magic_bin-0.4.14」に付属の libmagic.dll を使う場合、たとえば:

 1 Traceback (most recent call last):
 2     ...
 3   File "C:\Program Files\Python39\lib\site-packages\magic\__init__.py", line 177, in from_file
 4     m = _get_magic_type(mime)
 5   File "C:\Program Files\Python39\lib\site-packages\magic\__init__.py", line 164, in _get_magic_type
 6     i = _instances[mime] = Magic(mime=mime)
 7   File "C:\Program Files\Python39\lib\site-packages\magic\__init__.py", line 73, in __init__
 8     magic_load(self.cookie, magic_file)
 9   File "C:\Program Files\Python39\lib\site-packages\magic\__init__.py", line 315, in magic_load
10     return _magic_load(cookie, coerce_filename(filename))
11   File "C:\Program Files\Python39\lib\site-packages\magic\__init__.py", line 224, in errorcheck_negative_one
12     raise MagicException(err)
13 magic.MagicException: b"File 5.32 supports only version 14 magic files. `/path/to/your/magic.mgc' is version 16"

これはコンパイル済みのバイナリフォーマットについての互換性の話。そうか、ちゃんと自身のバージョンとフォーマットバージョンに基いてエラーリポートしてくれるんね、これは優秀。

対して、magic ファイルのソースの方にも互換性の問題があって、当然新しい file ほど「ステキな書き方が増えてる」のだが、その場合に「Warning という名のエラー」で mgc ファイルが作られない。なんぢゃそりゃ。これは python からの libmagic 利用で試みるまでもなく、file コマンドそのもので確かめられる。

てなところまでざっと理解した上で、file-4.26~(本日時点で)file-5.40 までのソースが入手出来る(この ftp サーバより)ので、これで片っ端からコンパイルしてみようかと:

magic_compile.py
 1 # -*- coding: utf-8 -*-
 2 import io
 3 import os
 4 import tarfile
 5 import sys
 6 
 7 import magic
 8 
 9 
10 if __name__ == '__main__':
11     # extract source
12     with tarfile.TarFile.open(sys.argv[1], "r:gz") as tf:
13         # "file-4.26/magic/Magdir/acorn" -> "file-4.26/magic/acorn"
14         for mem in [mem for mem in tf.getmembers() if "/Magdir/" in mem.name]:
15             name, cont = mem.name, tf.extractfile(mem)
16             name = name.replace("/Magdir/", "/")
17             dn, fn = os.path.split(name)
18             tdir = os.path.dirname(dn)
19             os.makedirs(dn, exist_ok=True)
20             with io.open(name, "wb") as fo:
21                 fo.write(cont.read())
22             print(name)
23             #break
24     # compile "magic"
25     os.chdir(tdir)
26     ma = magic.Magic()
27     # if target magic is incompatible for my libmagic,
28     # this process will produce nothing, otherwise
29     # you will find generated "magic.mgc".
30     magic.magic_compile(ma.cookie, "magic".encode())

file のソースをダウンロードしてみてから気付いたのだが、file 自身が python バインディングを書いてるのよね、して、「バイナリ配布」の問題を考えないなら、この「コンパイル」に関しては実は file 公式のバインディングの方が良かったりする。上のワタシの「magic_compile.py」は、python-magic 版の class Magic が compile メソッド実装をサボってることが理由で、かなり気持ち悪いことしてる(「ma.cookie」のハナシね)。まぁどれを使おうと、ここまでみてきた3つ全部がストレートに ctypes でインターフェイスしてるだけなので、なんでもいいんだけどね。

そういうわけで、「File 5.32」では「5.19~5.32」までをコンパイル出来た。うーん、マイナーバージョンが変わった程度なら少し上のバージョンのものも出来るかもしれない、という期待はハズれ。結局今の python-magic-bin が配布してるものと一緒かもしれんな、と思うのだが、バイナリ自体には差異はあるらしい。サイズが少し違う。(python-magic-bin のものは 4917824 bytes、自力でコンパイルしたものは 4918856 bytes。)

まぁそういうわけで、「コンパイル出来た」ことが、即ハッピーには繋がらなかったのではあるけれど、ただ、一応将来への道筋だけは担保出来たことにはなる。つまり、ctypes でインターフェイス出来る libmagic.dll をどうにか見繕えて、なおかつその libmagic.dll バージョンがサポートする Magdir ソースを入手出来る限り、まぁ一応自由に新しいバージョンを使える、てことではある。無論一番ハードルが高いのは「libmagic.dll を入手する」こと。vcpkg のものは試してはみたが、ダメだった。残念。


2021-09-02追記 (2):
実のところ、ひとつ上の追記を書くために行った作業で、「問題を起こしている magic ソース」は特定出来てたりする。Magdir 内におさめられているソースはカテゴリごとにファイルが分割管理されていて、個々にはエラーとなるのは少なく、Magdir 全体としてはほとんどが 5.32 で 5.40 配布の Magdir のものをコンパイル出来る。だとするならば、Magdir というソースディレクトリ全体を使うのではなくて、たとえば「file-5.40 のもののうち、エラーとなるものだけ「エラーにならないバージョンの最新」を使う」てことをしたら嬉しいよなぁと:

各ソースツリーMagdir内単体をコンパイルしてみてうまくいったものだけ掻き集めるてことさ
 1 # -*- coding: utf-8 -*-
 2 import io
 3 import os
 4 import tarfile
 5 import sys
 6 import shutil
 7 from collections import defaultdict
 8 from glob import glob
 9 
10 import magic
11 
12 
13 if __name__ == '__main__':
14     ma_srcs = defaultdict(list)
15     for arc in glob("file-*.tar.gz"):
16         # extract source
17         with tarfile.TarFile.open(arc, "r:gz") as tf:
18             # "file-4.26/magic/Magdir/acorn" -> "file-4.26/magic/acorn"
19             for mem in [mem for mem in tf.getmembers() if "/Magdir/" in mem.name]:
20                 name, cont = mem.name, tf.extractfile(mem)
21                 name = name.replace("/Magdir/", "/")
22                 dn, fn = os.path.split(name)
23                 os.makedirs(dn, exist_ok=True)
24                 with io.open(name, "wb") as fo:
25                     fo.write(cont.read())
26             k = os.path.dirname(dn)
27             curd = os.path.abspath(os.curdir)
28             os.chdir(dn)
29             for sn in glob("*"):
30                 ma = magic.Magic()
31                 r = magic.magic_compile(ma.cookie, sn.encode())
32                 success = not r
33                 ma_srcs[k].append((sn, success))
34                 if success:
35                     os.remove(sn + ".mgc")
36             os.chdir(curd)
37     # collect only compatible sources
38     os.makedirs("magic", exist_ok=True)
39 
40     verkeys = list(reversed(ma_srcs.keys()))
41     latest = verkeys.pop(0)
42     for sn, suc in ma_srcs[latest]:
43         if suc:
44             shutil.copy(os.path.join(latest, "magic", sn), "magic")
45         else:
46             for ik in verkeys:
47                 found = None
48                 for sn2, suc2 in ma_srcs[ik]:
49                     if sn == sn2 and suc2:
50                         found = (ik, sn)
51                         break
52                 if found:
53                     shutil.copy(os.path.join(found[0], "magic", found[1]), "magic")
54                     break
55     # compile "magic"
56     ma = magic.Magic()
57     # if target magic is incompatible for my libmagic,
58     # this process will produce nothing, otherwise
59     # you will find generated "magic.mgc".
60     magic.magic_compile(ma.cookie, "magic".encode())
61 
62     # 注意:
63     # たとえば「ASF」の定義が「animation」から「asf」に移動するなどの変更が行われ
64     # ているので、このスクリプトはそうしたものを重複させてしまう可能性があるが、
65     # それについてなんの措置もしていない。少なくともそうしたことが起こった場合に
66     # エラーになったりはしない? しなさげ。わからんけど。

たとえば「images」なんかがエラーになるので、そうしたものだけは古いバージョンものを使う、てことね。まぁ「images」なんかが一番よく使うものなので相当負けた気はするけれど、でもコンパイルしたサイズはかなり大きくなり(6231560 bytes)、きっと正体を暴けるようになったものが結構増えたんだろうな、てことはわかる。