👜

ZIP圧縮したファイルの更新日時にタイムゾーンが考慮されない問題について調べてみた

2022/10/15に公開

業務で以下のような問題にぶつかりました。

「Goで出力したZIPファイルをWindowsのエクスプローラーで開くと更新日時が9時間ずれてしまう。7-Zipなどサードパーティを利用したり、Macで展開した場合は発生しない。」

調査していくと、ZIPの仕様に関する問題のようでした。参考になるまとまった資料がなかったのでまとめてみます。

TL;DR

  • ZIPの標準仕様ではファイルの更新日時のタイムゾーン情報は失われてしまう
  • 拡張フィールドを使うとタイムゾーン付きorUTC固定で更新日時を付与できる
  • 拡張フィールドへの対応状況は圧縮/解凍ソフトによって異なる

事象の再現

再現するGoのコードが次の通りです。

main.go
package main

import (
	"archive/zip"
	"log"
	"os"
	"time"
)

var JST *time.Location

func init() {
	os.Setenv("TZ", "UTC")
	jst, err := time.LoadLocation("Asia/Tokyo")
	if err != nil {
		log.Fatal(err)
	}
	JST = jst
}

func main() {
	saveAsZip("/tmp/default.zip", "default.txt", "default", false)
	saveAsZip("/tmp/in_jst.zip", "in_jst.txt", "in_jst", true)
}

func saveAsZip(path, txtFileName, txtContent string, inJST bool) {
	f, err := os.Create(path)
	if err != nil {
		log.Fatal(err)
	}
	defer f.Close()

	zipWriter := zip.NewWriter(f)
	defer zipWriter.Close()

	modified := func() time.Time {
		if inJST {
			return time.Now().In(JST)
		} else {
			return time.Now()
		}
	}()
	zipFile, err := zipWriter.CreateHeader(&zip.FileHeader{
		Name:     txtFileName,
		Modified: modified,
	})
	_, err = zipFile.Write([]byte(txtContent))
	if err != nil {
		log.Fatal(err)
	}
	err = zipWriter.Close()
	if err != nil {
		log.Fatal(err)
	}
}

TZ=UTCとして、動作するタイムゾーンをUTCとしています。
そして単純なテキストファイルを含むZIPを2つ生成しています。

Goの場合、zip.FileHeaderModifiedで更新日時を指定できるのですが、それぞれ次のように設定します。

  • default.zipは指定なし: time.Now()
  • in_jst.zipは指定あり: time.Now().In(JST)

これを、タイムゾーン=JSTのWSL2上で日本時間12:59に実行し、タイムゾーン=JSTのWindows11のエクスプローラーで開くと…

default

defaultの場合、更新日時が9時間前の3:59、つまりUTC時間になります。

in_jst_zip

in_jstだと更新日時が12:59、正常にJSTに対応しています。

一方、これらを7-Zipで開いてみると…

default_7zip

in_jst_7zip

なんと、どちらも12:59と正常に日本時間が反映されています。

なぜこのような問題が発生するのか?解説していきます。

ZIPの標準仕様

ZIPの標準仕様は以下で確認できます。

https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT

PKWARE社による資料で著作権表示もありますが、現在はパブリックドメインとして扱えるようです。
より噛み砕いて解説されているWikipediaページがわかりやすいです。

ZIPではローカルファイルヘッダ、セントラルディレクトリヘッダにそれぞれ「ファイルの最終更新時間」「ファイルの最終更新日付」を設定する仕様となっております。
時間、日付ともに2bytesで、値としてはMS-DOS time formatという形式にする必要があります。
Goのarchive/zipを見てみると、以下のような実装となってます。

https://github.com/golang/go/blob/9ddb8ea73724d717a9bbf44be7d585ba5587504f/src/archive/zip/struct.go#L239-L246

見てわかる通り、タイムゾーンが失われていて、かつUTCで保存するといった制約もないフォーマットとなるようです。

ZIPの拡張フィールド

一方、ZIP内のヘッダには拡張フィールド(Extra field)というフィールドが設定可能となっており、各ベンダーらが付加情報を追加してきた経緯があるようです。

以下の資料に拡張フィールドとその仕様がまとめられています。

https://mdfs.net/Docs/Comp/Archiving/Zip/ExtraField

この資料から、以下のような拡張フィールドを使うと更新日時を別定義で付与できるようになっていることがわかります。

  • PKWARE Win95/WinNT Extra Field
  • PKWARE Unix Extra Field
  • Info-ZIP Unix Extra Field (type 1)
  • Extended Timestamp Extra Field (UT Extra field)

それぞれ「タイムゾーン付きで保存」もしくは「UTC時間で保存すること」いずれかが定義されており、タイムゾーンが守られるようになります。

ちなみに、Goのarchive/zipでは読み込み時は上記の4つの拡張フィールドに対応しており書き込み時はExtended Timestamp Extra Fieldを付けるような実装となっております。[1]

zipinfoで確認してみる

これらの拡張フィールドがどう設定されているか?簡単に確認する方法があります。MacやUbuntu(WSL2上で確認)の場合、zipinfoというコマンドがデフォルトでインストールされています。

-vオプション付きで実行すると、更新日時の拡張フィールドについても表示してくれます。

zipinfo -v default.zip

結果(抜粋)はこちら。

Archive:  default.zip
...
Central directory entry #1:
---------------------------

  default.txt

  ...
  extended local header:                          yes
  file last modified on (DOS date/time):          2022 Oct 15 03:59:06
  file last modified on (UT extra field modtime): 2022 Oct 15 12:59:07 local
  file last modified on (UT extra field modtime): 2022 Oct 15 03:59:07 UTC
  ...

このように、標準の更新日時と拡張フィールドの更新日時(localとUTC両方記載)それぞれ設定されていることが確認できます。

ちなみに、Windowsエクスプローラーの右クリック「ZIPファイルに圧縮する」を実行してみたファイルを確認してみると…

Archive:  with_windows.zip
...
Central directory entry #1:
---------------------------

  with_windows.txt

  ...
  extended local header:                          no
  file last modified on (DOS date/time):          2022 Oct 15 15:36:40
  ...

拡張フィールドは設定されませんでした。

結論・まとめ

最初の問題に戻ります。

「WindowsエクスプローラーはExtended Timestamp Extra Fieldの読み込みに対応していない」 という結論となるかと思います。決定的な資料は見つからず、推測にとどまりますが。MS-DOSからの互換性の流れなんでしょうかね?
その他の拡張フィールドについては未検証なので、もしかしたら拡張フィールドの種類によってはタイムゾーンを考慮できるかもしれません。

解決策として、利用されるタイムゾーンがわかるのであればそこに合わせてセットすれば良いですが、不明な場合はサードパーティの解凍ソフトの使用を促すしかないかなと思われます。

また、今回はGoの標準パッケージをもとに話を進めましたが、他の言語などの場合もサポート状況は違いそうでした。[2] 歴史的経緯からして難しい問題だなぁという感想です。

脚注
  1. こちらに経緯が語られています: Big Sky :: Golang の archive/zip でタイムゾーンの問題とファイル名の問題が解決した。 ↩︎

  2. Pythonで試したところ、拡張フィールドの設定はされないようでした。コード: https://github.com/abekoh/zip-extra-field-example/blob/main/main.py ↩︎

Discussion