Goでファイルをzip圧縮したときにタイムスタンプがずれる問題の回避策


初めに

Goでファイルをzip圧縮する方法と、それに伴う問題と、その回避の記事です。
(更にいい方法がありましたら、ご指摘よろしくお願い致します)

実験環境

go version go1.4.2 darwin/amd64
(つまりMac OS Xです)

zip圧縮コード

ファイルのzip圧縮コード
//ファイルをzip圧縮します。
//zipPathは、zip上で表現されるパスを指定します。
//targetFileは、圧縮するファイルシステム上のファイルのパスを指定します。

func ZipFile(writer *zip.Writer, zipPath string, targetFile string) error {
    f, err := os.Open(targetFile)
    if err != nil {
        return err
    }
    defer f.Close()

    body, err := ioutil.ReadAll(f)

    if err != nil {
        return err
    }

    info , err := os.Stat(targetFile)
    if err != nil {
        return err
    }

    header, _ := zip.FileInfoHeader(info)
    //zip用のパスを設定
    //これを設定しないと、zip内でディレクトリの中に作られない。
    header.Name = zipPath

    zf, err := writer.CreateHeader(header)
    if err != nil {
        return err
    }

    if _, err := zf.Write(body); err != nil {
        return err
    }
    return nil
}
ファイルzip圧縮呼び出し側コード
buf := new(bytes.Buffer)
zw := zip.NewWriter(buf)

if ferr := ZipFile(zw, "zip_path", "file.txt"); ferr != nil {
    panic(ferr)
}

if ferr := zw.Close(); ferr != nil {
    panic(ferr)
}

普通に書くと、多分こんな感じになります。(よね?)
参考:http://golang.org/pkg/archive/zip/

実験

これで圧縮してみます。
見にくいのですが、
左が元のファイル、右がこの処理で圧縮後のファイルのタイムスタンプです。

タイムスタンプが異なっているのがわかります。
(しかもちょうど9時間遅くなります)

つまり、上のように、zip.FileInfoHeaderを利用すると、元のファイルと時間が異なってしまいます。

原因の予想

あくまで予想ですが、探ってみると、

zip.FileInfoHeader->FileHeader.SetModTime->timeToMsDosTime
http://golang.org/src/archive/zip/struct.go#L170

t = t.In(time.UTC)

が、元のファイルのタイムスタンプを標準時間として扱うようにしてしまい、JSTとの差(9時間)遅くなってしまい、timeToMsDosTimeの処理で遅く計算されてしまうからのように思います。

対処コード

このzip.FileInfoHeaderが問題だと思いますし、外からはどうにも出来ないような気がするので

対象コード
http://golang.org/src/archive/zip/struct.go#L110
をそのままコピーしてきて少し修正します。

改造FileInfoHeader
func FileInfoHeader(fi os.FileInfo) (*zip.FileHeader, error) {
    size := fi.Size()
    fh := &zip.FileHeader{
        Name:               fi.Name(),
        UncompressedSize64: uint64(size),
    }

    // 現在のLocalを取得する。 
    local := time.Now().Local()

    //時刻のoffset(秒)を取得する。
    _, offset := local.Zone()

    //ファイルスタンプの時間に時差分を追加する。
    fh.SetModTime(fi.ModTime().Add(time.Duration(offset) * time.Second))
    fh.SetMode(fi.Mode())
    var uintmax = uint32((1 << 32) - 1)
    if fh.UncompressedSize64 > uint64(uintmax) {
        fh.UncompressedSize = uintmax
    } else {
        fh.UncompressedSize = uint32(fh.UncompressedSize64)
    }
    return fh, nil
}

コメントを付けた部分が改造部分です。
ローカル環境の時差オフセットを取得し、ファイルのタイムスタンプに余分に足してるだけです。

これで、zip.FileInfoHeader を上のFileInfoHeaderに置き換えます。

初めのコードの変更点
    header, _ := FileInfoHeader(info)
    //zip用のパスを設定
    //これを設定しないと、zip内でディレクトリの中に作られない。
    header.Name = zipPath

まとめ

zip.FileInfoHeaderを使った時の、タイムスタンプがずれる対処をしました。

zip.FileInfoHeader (timeToMsDosTime)のバグっぽいのですけど、(多分そんなことないと思うので)自分の認識違いなのでしょうか?

おまけ

指定したディレクトリをzip圧縮するコード。

ディレクトリのzip圧縮コード
//zipPathは、zip上で表現されるパスを指定します。
//targetDirは、圧縮するファイルシステム上のディレクトリのパスを指定します。
func ZipDir(writer *zip.Writer, zipPath string, targetDir string) error {
    //圧縮するディレクトリをオープンします。
    dir, err := os.Open(targetDir)
    if err != nil {
        return err
    }
    defer dir.Close()

    //ディレクトリ内のファイル一覧を取得します。
    files, err := dir.Readdirnames(-1)
    if err != nil {
        return err
    }

    for _, fname := range files {
        if err := ZipFile(writer, zipPath+"/"+fname, targetDir+"/"+fname); err != nil {
            return err
        }
    }
    return nil
}
ディレクトリzip圧縮呼び出し側コード
buf := new(bytes.Buffer)
zw := zip.NewWriter(buf)

if ferr := ZipDir(zw, "zip_dir", "dir"); ferr != nil {
    panic(ferr)
}

if ferr := zw.Close(); ferr != nil {
    panic(ferr)
}

参考

qiita:go言語で無圧縮zipが作りたかった