NimでTarファイル作成を実装する ~ちゃぶだい返し(理論編)~

Tarは特にLinuxで普及しているアーカイブ形式。英語圏でTarballなどとも呼ばれます。
複数のファイルやディレクトリ構造を一つのtarファイルにまとめることができますが、あくまでもアーカイブ用途のコンテナなので、サイズを減らすには圧縮形式(gzipなど)を併用する必要があります。

ご存知の通り拡張子は.tar
tar.gz(tgz), tar.xzは、tarでアーカイブした後に圧縮したもの(たぶん)、なので、展開したい場合は逆に伸長(decompress)した後に分割(unarchive)、という手順になります。

Linuxにはtarコマンドがあるのでそう困ることはありませんが、Windowsではちょっと事情が違います。
Windowsではアーカイブ、圧縮ともにzip形式が一般的なのでそう使う頻度は高くありませんが、稀によくtarを扱う必要に迫られる場面があります。

今回はTarの仕様と、簡単な実装についてメモしておきます。

もくじ:

アーカイブの構造

NimのパッケージマネージャNimbleのインデックスを見ていると、展開側(untar)はあるようですがアーカイブ作成側はないようですね。
折角なので練習がてら、ゆるっとした仕様で作成してみることにします。

Tarの仕様でぐぐると、要はファイルなりディレクトリなりを表すメタデータをヘッダ(header)として、内容(コンテント, content)を連結していけばいいみたいですね。

ただ、歴史の長い形式だけあって、実装形式が複数存在するようです。
主要な形式としてはPOSIX ustarGNU tarPax交換フォーマット、などなどありますが、ustarかGNU tarに寄せておけば大抵のソフトウェアと互換性の面で問題なさそうです。今回は完璧な互換性を目指すのではなく、GNU tarっぽく実用的な範囲で実装できればよいことにします。

基本はこんな感じ。
– 512バイトのヘッダ(Cの構造体)にファイル名などの属性情報を格納
– ヘッダはascii文字列、数値は8進数
– コンテントは512バイト単位のブロックで構成、余りはゼロパディング
– ファイルの末尾には長さ1024バイトのバイナリゼロで終端ブロック(end of archive)を付ける

ただし、ヘッダが固定長なことに由来して色々と制約が生じていることから、各形式で拡張が加えられています。拡張のやり方は形式毎に異なっていますが、基本的には互換性のためにヘッダはそのまま、かつ拡張したメタデータをコンテントに書く、という方針のようです。

ヘッダは次の構造体で定義されます(POSIX ustarの例)。

ユーザやグループなど、UnixでサポートされているメタデータはWindowsでは直接対応するものがないこともあり、今回はハードコーディングします。
実際手元のマシンで試した限りでは、Windows向けの実装では無視されていることが多いようです。

長いパス

ヘッダのファイル名用フィールドname[100]には相対パスを格納します。仕様上は絶対パスを許容しているようですが、あまり使われていないもよう。

素のファイル名用フィールドは100バイト、ustarでプレフィクス用フィールドを使っても255バイトまで。
Ascii英数なら1バイト1文字、なので二百と何十文字の計算になりますが、ちょっとディレクトリ階層が深くなったり、日本語などのマルチバイト文字(1文字~4バイトとか)を使うとすぐに枯渇します。

GNU tarではLフラグを使って、長いパスをコンテントのほうに書く、という拡張を実装しているようなので、今回はこれに倣います。長いパスを表すヘッダでは、"././@LongLink"が固定でファイル名フィールドname[100]に入ります。順番として、Lフラグのヘッダ、パスを格納したコンテント、以降に通常のヘッダ…、と並びます。通常のヘッダに格納されているファイル名は不完全なので展開時には使いません。

また、POSIX ustarでは、プレフィクス用のフィールドも使う仕様になっています。100バイトを超えるパスの場合は、セパレータ(‘/’や’\’)を境に、ファイル名用フィールドname[100]と、プレフィクス用のフィールドprefix[155]に分けて格納される可能性があります。フィールドに格納された文字列は、バイナリゼロ’\0’に到達するまでの範囲が有効であるとみなされます。

Pax交換フォーマットでは拡張用ヘッダに”PaxHeaders”を含むパスがこのファイル名フィールドname[100]に入ります。

「tarを展開したら変な名前のディレクトリ/ファイルが出てきた!」という問題は、たいていこのあたりの拡張に原因があるので、ちゃんと形式を合わせてやれば読むことができます。

ファイルサイズ

ファイルサイズ用のフィールドは12バイト、ヌル終端込み。つまり拡張を考えなければ11桁の8進数でファイルサイズのバイト数を表します。なので、サイズの最大値は、8進数で7が11個並ぶ約8G(8,589,934,591 Bytes)。
古い形式に8GBのサイズ制限があるのは、ここに理由があるんですね。

まあ最近でも8GBを越えるファイルを、しかもアーカイブしたいという用途はあまり無いと思いますので、ここは素のままにしておきます。

ディレクトリの場合(‘5’フラグ)の場合はサイズがゼロになります。
‘L’フラグ、長いパスを格納するためのヘッダでは、ファイルサイズのところにパスの長さ(バイト数)が入ります。

チェックサム

ちゃんとしたアーカイバはチェックサムでヘッダの内容をチェックしています。

ヘッダの各文字を符号なし整数(unsigned int)で表した場合の総和(8進数)がチェックサムになります。したがって、MD5やAESなどのような強固なハッシュではなく、今となってはあくまでもデバッグに使える程度のものと言っていいと思います。

intへの変換は、最適化するとうまいやり方がありますが、簡便には型のキャストで計算できます。
もちろんヘッダにはチェックサムのフィールド自身が含まれるので、計算する際には(ヌル終端なし)長さ8の連続した空白で仮に埋めておいて計算します。

計算した結果は8進数6桁の数値で表します。上位桁をゼロパディングして最後の桁は空白を入れます。

例えば総和が10進で”2649″だった場合、8進で表現して”0o5131″なので、フィールドには"005131\0 "が入ります。

種別フラグ (typeflag)

種別フラグtypeflagは、ファイル(‘0’ or ‘\0’)か、ディレクトリ(‘5’)か、シンボリックリンクか、などの種類を判別するためのものです。今回は実装を簡単にするため、ファイルとディレクトリのみ考慮します。
Pax交換フォーマットでは拡張用ヘッダに”x”が入るようです。

マジックナンバーとバージョン

これも実装により異なります。Hexで書くとこんな感じ。

実装例の調査

Linux (というかGNU/Linux)では大抵バンドルされているところのtarコマンドで形式が指定できます。zlibやxz utilsなど圧縮ライブラリとも連携しますので、サイズを減らしたい場合でも困ることはないと思います。

問題はWindowsで、参考のためいくつかのソフトウェアでデフォルトの実装を調べてみました。

Python (3.6)7-ZipGNU tar (cmder, ConEmu)
Default formatGNU tarustarGNU tar
magic + version“ustar__\0”“ustar\000”“ustar__\0”
Long link‘L’‘L’‘L’
mode[0]”0000666\0″,
[L]”0000000\0″,
[5]”0000777\0″
“0100777\0”[0,L]”0000644\0″, [5]”0000755\0″
uid, gid“0000000\0”“0000000\0”(UID)
uname, gname“\0”“\0”(Logon user name)
devmajor, devminor“0000000\0”“\0”“\0”

Python (tarfileモジュール)はデフォルトでGNU tarを出力しますが、公式マニュアルによると他の形式もサポートしています。しかし、何故か終端(end-of-archive)がやたらと長い気が。ソースを読めば分かるのでしょうが、まあ実装上は問題にならないので気にしないことにしましょう。

7zはmagic numberはPOSIX ustarのものでありながら、長いファイル名はGNU tarっぽいLフラグを使う、というよくわからない実装になっています。私が誤解しているのでしょうか。。。

Cmder、内部的にはおそらくConEmu、が呼んでいるGNU tarが、ここで比較した中では最も厳密に実装されているように見えます。他の実装ではuidやgidをゼロパディングやバイナリゼロにしていますが、WindowsでもちゃんとユーザIDを拾おうとしているようですね。

参考までに、Pythonで出力したtarファイル(GNU tar形式)のバイナリ表示の例を掲載しておきます。
絵を作るだけで力尽きたので、説明は省略します。

例によって長くなりましたので、続きは実装編で。