Archive Utility で gzip ファイルが解凍できない問題を調べた

2023-07-21

はじめに

こんにちは。Belong Inc. で Backend Engineer を担当している niwa です。

ここ最近、担当しているプロダクトにおいて「システムが出力したファイルが、Mac の Archive Utility で解凍できない」という報告を寄せられることがありました。 そちらについて調査を行った時の内容を紹介できればと思います。

問題の概要

ユーザから寄せられた問い合わせ、およびエンジニアの初期調査をまとめると以下のような内容になりました。

  • 解凍できないのは、システムが出力した gzip 形式のファイル。
    • シンプルなテキスト形式のファイル (.csv) を gzip で圧縮したもの。
    • 解凍時に、「エラー 79: ファイルタイプまたはフォーマットが不適切です。」というエラーが表示される。
  • 全てのファイルが解凍できないわけではない。
    • 同じ処理から出力されたものでも、データによって解凍できるものと、エラーになるものが混在している。
  • どのファイルも、 gzip -d コマンドを利用した場合、問題なく解凍できる。
  • The Unarchiver などのアプリケーションを利用した場合も、問題なく解凍できる。

エラー画面のイメージ

example

当初はシステムのファイル出力を行う処理に問題があり、一部ケースで出力した gzip ファイルが壊れてしまっているのでは、と考えていました。
ただ、gzip コマンドでは正常に解凍できること、および The Unarchiver などでは正常に解凍できることから、Archive Utility 特有の問題があるのでは?と思い、その線での調査を行いました。

エラーメッセージの発見

そして、現象の再現調査を行っていくうちに、「上記エラーが発生した際に Mac の Terminal 上において、気になるメッセージが出力されている」ことを発見しました。
出力されているメッセージは下記のようなものです。

Error unarchiving Error Domain=NSPOSIXErrorDomain Code=79 "Inappropriate file type or format" (Missing type keyword in mtree specification) UserInfo={NSURL=file:///private/tmp/example.csv.gz, NSDebugDescription=Missing type keyword in mtree specification}

中でも、表示されている Missing type keyword in mtree specification というメッセージは大きなヒントになりそうな匂いがします。

このメッセージを手掛かりに調査したところ、この一節はどうも libarchive の処理に由来しているものだ、ということがわかりました。
libarchive のリポジトリ より、下記のコードを発見することができます。

if (r == ARCHIVE_OK && (*parsed_kws & MTREE_HAS_TYPE) == 0) {
		archive_set_error(&a->archive, ARCHIVE_ERRNO_FILE_FORMAT,
		    "Missing type keyword in mtree specification");
		return (ARCHIVE_WARN);
	}

libarchive は、アーカイブファイルを扱うためのライブラリです。 tarzip などのアーカイブファイルを扱うためのライブラリとして、多くのプロジェクトで利用されているようです。

おそらく Archive Utility も何らかの形でこのライブラリに依存していると思い、libarchive 側の挙動を調べてみることにしました。

libarchive の挙動

libarchive はその名の通り共有ライブラリなので、利用するためにはプログラムからの呼び出しを行う必要があります。
折角 golang をプロジェクトで利用しているので、今回は cgo を使った呼び出しを試してみました。
準備したコードは下記のような形になります。1

package archive

// #include <archive.h>
// #include <archive_entry.h>
import "C"
import "fmt"

type Archive struct {
}

func (a *Archive) Detect(path string) string {
	archive := C.archive_read_new()
	var entry *C.struct_archive_entry

	C.archive_read_support_filter_all(archive)
	C.archive_read_support_format_all(archive)

	file := C.CString(path)

	result := C.archive_read_open_filename(archive, file, 10240)

	if result != C.ARCHIVE_OK {
		err := C.archive_error_string(archive)
		fmt.Printf("err: %s\n", C.GoString(err))
		return err
	}

	for result == C.ARCHIVE_OK {
		result = C.archive_read_next_header(archive, &entry)
		if result != C.ARCHIVE_OK {
			break
		}
		fmt.Printf("%s\n", C.GoString(C.archive_entry_pathname(entry)))
		C.archive_read_data_skip(archive)
	}

	if result != C.ARCHIVE_OK {
		err := C.archive_error_string(archive)
		fmt.Printf("err: %s\n", C.GoString(err))
		return err
	}

	result = C.archive_read_free(archive)

	return fmt.Sprintf("%s\n", result)
}

上記のコードに、解凍に失敗するファイルのパスを渡してみると、Archive Utility と同様に Missing type keyword in mtree specification のエラーが出力されました。
本エラーは、libarchive の処理に由来している可能性がかなり高そうです。

ただ、どうしてエラーになるのかはいまだにわかっていない状態なので、libarchive の処理を確認していきます。

詳しい調査

libarchive は渡されたファイルのフォーマット自動検出機能を備えており、そのアーキテクチャに関しては下記リンクに概説があります。
https://github.com/libarchive/libarchive/wiki/FormatDetection

上記内容を紐解くと、libarchive は下記のような動作を行い、ファイル検出を行っていることがわかります。

  • filter module を利用して、ファイルの圧縮形式、エンコード形式を検出する。
    • その後、検出した形式に応じてファイルの解凍、およびデコードを実施する。
  • filter module による処理が完了した後に、format module によりアーカイブ形式の解析を行う

上記前提を踏まえて、コードを確認すると、archive_read_support_filter_all では gzip に対応する filter module を登録しています。 https://github.com/libarchive/libarchive/blob/6e444a9fb9783565d49c76960941e6e60dcc12b2/libarchive/archive_read_support_filter_all.c#L42

また、archive_read_support_format_all では、tarzip, mtree などに対応する format module を登録しています。 https://github.com/libarchive/libarchive/blob/6e444a9fb9783565d49c76960941e6e60dcc12b2/libarchive/archive_read_support_format_all.c#L33

改めてエラーメッセージをちゃんと確認すると、「Missing type keyword in mtree specification」、つまり 「 mtree の仕様で規定されている type のキーワードが存在しない」、という風に読み取れます。
つまり、今回の問題は、 mtree フォーマットに関しての問題であるようです。

mtree とは

テキスト形式のファイルであり、ファイル名と属性 (サイズ、タイムスタンプ、権限、チェックサムなど) のリストが含まれます。 mtree コマンドにより作成可能であり、.DS_Store のみあるようなディレクトリを表現する mtree ファイルは下記のようになります。

/set type=file uid=501 gid=20 mode=0644 nlink=1 flags=hidden
.               type=dir mode=0755 nlink=3 size=96 \
                time=1684226382.229716189 flags=none
    .DS_Store   size=6148 time=1684226382.230581947
..

/set type=file というテキストが上記に存在しています。先程見つからないと言われていた type keyword は、どうもこれに該当していそうです。

Refs: https://manpages.ubuntu.com/manpages/lunar/en/man5/mtree.5.html

仮説

今回は上記で述べたように、シンプルな CSV を gzip 化したものを渡しているため、アーカイブ形式の検出は動作しないことが期待値です。
ただ、当該エラーが mtree に関連しているメッセージだったため、filter module により gzip が解凍された後のファイルが、誤って mtree 形式と判定されてしまっていることによるエラーではないかと考えられます。

仮説の検証

上記の仮説を検証するために、libarchive を変更してデバッグ情報を無理やり出力するようにしてみます。

libarchive の FormatDetection の解説に該当するコードは、どうも以下のようです。 https://github.com/libarchive/libarchive/blob/master/libarchive/archive_read.c#L502-L530

  • choose_filters
  • choose_format

で フィルタ、および フォーマットの判定をしているような動きになっていそうですね。 上記 choose_format を対象に、
( https://github.com/libarchive/libarchive/blob/master/libarchive/archive_read.c#L710-L712 )
下記のような形でコードに変更を加えます。

今回は、怪しいと睨んでいるフォーマット判定において、各種フォーマットの名前とそれに応じた bid を出力するようにしてみました。

if (a->format->bid) {
	bid = (a->format->bid)(a, best_bid);
+	# add code for debug
+	printf("[libarchive] format = %s, bid: %d\n", a->format->name, bid);
	if (bid == ARCHIVE_FATAL)
		return (ARCHIVE_FATAL);

その後、公式ページ を参考に自前で libarchive を再ビルドし、cgo から呼び出せるようにしました。
自前でビルドした libarchive を利用して、再び当初の「解凍に失敗するファイル」を読み込んだところ、下記の出力が得られました。

[libarchive] format = mtree, bid = 32
[libarchive] format = tar, bid = -1
[libarchive] format = xar, bid = 0
[libarchive] format = warc, bid = -1
[libarchive] format = 7zip, bid = 0
[libarchive] format = cab, bid = 0
[libarchive] format = rar, bid = -1
[libarchive] format = rar5, bid = -1
[libarchive] format = iso9660, bid = -1
[libarchive] format = zip, bid = 0
[libarchive] format = zip, bid = 0

mtree format の bid が一番高く、その他のものは 0 もしくは -1 となっていました。 上記の結果から、このファイルは mtree 形式として判定されている、ということが確認できます。

Archive Utility で正常に解凍できる gzip についても試したところ、下記のような結果になりました。

[libarchive] format = mtree, bid = 0
[libarchive] format = tar, bid = -1
[libarchive] format = xar, bid = 0
[libarchive] format = warc, bid = -1
[libarchive] format = 7zip, bid = 0
[libarchive] format = cab, bid = 0
[libarchive] format = rar, bid = -1
[libarchive] format = rar5, bid = -1
[libarchive] format = iso9660, bid = -1
[libarchive] format = zip, bid = 0
[libarchive] format = zip, bid = 0

こちらに関しては、mtree 形式の bid も 0 となっており、0 以上の bid を取るフォーマットがなく、フォーマット推定ができていないということがわかります。

よって、今回のエラーに関しては下記が原因だと考えられました。

  • libarchive の Archive Format 認識には、ファイル内容に応じて mtree 形式と誤認される可能性がある
  • その場合、mtree として認識されたファイルは、ファイルの展開に失敗する

libarchive の issue にも、同様の報告を確認することができました。 mtree 自体がテキストファイルベースの形式であるため、単に gzip 圧縮をかけただけのような、アーカイブではない圧縮ファイルについては、このような問題が発生しやすいようです。

今後の対応

今回の問題は、「単に gzip 圧縮されたのみのファイルは Archive Utility で解凍できないことがある」ことによる問題でした。
なので、gzip オンリーではなく、おそらく tar.gz など、アーカイブ形式ありで返してあげれば問題なく解凍ができそうです。 が、以下の考慮もあり、今回はアーカイブ化は見送りました。

  • 返却するのは 1 ファイルのみであり、特に複数ファイルをまとめてダウンロードするというモチベーションはない
  • tar のようなアーカイブ処理を挟むことによって、gzip による圧縮効率が落ちてしまう可能性がある

今回はユーザの手元にダウンロードされる gzip ファイルが十分小さいという前提の元、ファイルの解凍処理自体はフロントの js で行い2、ユーザの手元には解凍済みのファイルを返すことにしました。

まとめ

今回はユーザから寄せられた問い合わせを元に、問題についての踏み込んだ調査を行いました。
皆様のプロジェクトにおいて同様の問題が発生した際に、解決の一助になれば幸いです。

最後に、Belong Inc. では我々と一緒にサービスの成長にコミットしてくれるメンバーを募集中です!
ご興味がある方は、ぜひ https://entrancebook.belonginc.dev/ をご覧いただけたらと思います 。

Footnotes

  1. サンプル を元に ChatGPT 先生が 30 秒くらいで書いてくれた温かみのあるコード

  2. Compression Streams API の普及が進み、ほぼほぼ全部の環境で利用できるため