zipファイルの構造をgolangの標準ライブラリから勉強してみました。
zipファイルの構造
zipファイルのデータは以下の3領域に分類されます。- 各ファイルのローカルファイルヘッダとファイルのデータが交互に並ぶ領域
- セントラルディレクトリヘッダがファイル分並ぶ領域
- セントラルディレクトリヘッダの終端領域(ファイル数、データ数などのZIP全体の情報が格納される)
セントラルディレクトリの終端領域にはセントラルディレクトリヘッダの開始オフセットを格納しており、セントラルディレクトリヘッダには各ファイルのローカルファイルヘッダの開始オフセットが入っています。よって解凍する場合もこの順番で辿っていくことになります。
golang標準ライブラリを読む(読み込み)
archive/zipがgoのzipライブラリになります。以下のようにしてzipファイルを読み込み、展開することができます。r, err := zip.NewReader(bytes.NewReader(buf), int64(len(buf)))
for _, f := range r.File {
// f.Name => ファイル名
// f.Open()でio.ReadCloserインターフェースの構造体を返し、それを使ってデータを取得します。
// rc, err := f.Open()
}
zip.NewReaderではzip.Readerのインスタンス化と初期化(init)をしています。
// NewReader returns a new Reader reading from r, which is assumed to
// have the given size in bytes.
func NewReader(r io.ReaderAt, size int64) (*Reader, error) {
zr := new(Reader)
if err := zr.init(r, size); err != nil {
return nil, err
}
return zr, nil
}
init内では以下が行われます
- セントラルディレクトリの終端領域の取得(readDirectoryEnd)
- セントラルディレクトリヘッダの取得
for i, bLen := range []int64{1024, 65 * 1024} {
if bLen > size {
bLen = size
}
buf = make([]byte, int(bLen))
if _, err := r.ReadAt(buf, size-bLen); err != nil && err != io.EOF {
return nil, err
}
if p := findSignatureInBlock(buf); p >= 0 {
buf = buf[p:]
directoryEndOffset = size - bLen + int64(p)
break
}
if i == 1 || bLen == size {
return nil, ErrFormat
}
}
検索している実体はfindSignatureInBlock関数です。シグネチャのオフセットは22バイト以上なので初期値はlen(b)-directoryEndLen(=22)してます。P K 0x05 0x06がセントラルディレクトリ終端領域のシグネチャです。
func findSignatureInBlock(b []byte) int {
for i := len(b) - directoryEndLen; i >= 0; i-- {
// defined from directoryEndSignature in struct.go
if b[i] == 'P' && b[i+1] == 'K' && b[i+2] == 0x05 && b[i+3] == 0x06 {
// n is length of comment
n := int(b[i+directoryEndLen-2]) | int(b[i+directoryEndLen-1])<<8
if n+directoryEndLen+i <= len(b) {
return i
}
}
}
return -1
}
終端領域の開始点から20〜21byteにコメントの長さが入っており、整合性が取れていればその値を返して、整合性が取れていない(シグネチャではなく、たまたまシグネチャに合致した他のデータだった)場合は検査を続けます。
取得できたら、終端領域の情報を構造体に詰め込みます。
// read header into struct
b := readBuf(buf[4:]) // skip signature
d := &directoryEnd{
diskNbr: uint32(b.uint16()),
dirDiskNbr: uint32(b.uint16()),
dirRecordsThisDisk: uint64(b.uint16()),
directoryRecords: uint64(b.uint16()),
directorySize: uint64(b.uint32()),
directoryOffset: uint64(b.uint32()),
commentLen: b.uint16(),
}
次にinit内でセントラルディレクトリヘッダの取得を行います。
rs := io.NewSectionReader(r, 0, size)
if _, err = rs.Seek(int64(end.directoryOffset), io.SeekStart); err != nil {
return err
}
buf := bufio.NewReader(rs)
// The count of files inside a zip is truncated to fit in a uint16.
// Gloss over this by reading headers until we encounter
// a bad one, and then only report a ErrFormat or UnexpectedEOF if
// the file count modulo 65536 is incorrect.
for {
f := &File{zip: z, zipr: r, zipsize: size}
err = readDirectoryHeader(f, buf)
if err == ErrFormat || err == io.ErrUnexpectedEOF {
break
}
if err != nil {
return err
}
z.File = append(z.File, f)
}
SectionReaderはRead、ReadAt、Seekが可能なioの構造体です。これを使ってセントラルディレクトリヘッダのオフセットに移動して、bufio.NewReaerに包んでreadDirectoryHeaderを呼び出します。
※SectionReader使っているのがちょっと謎です。sizeはbufのサイズが入る気がするので全量のReaderでも良さそう。
readDirectoryHeaderではセントラルディレクトリヘッダを読み出して構造体に格納します。
func readDirectoryHeader(f *File, r io.Reader) error {
var buf [directoryHeaderLen]byte
if _, err := io.ReadFull(r, buf[:]); err != nil {
return err
}
b := readBuf(buf[:])
if sig := b.uint32(); sig != directoryHeaderSignature {
return ErrFormat
}
f.CreatorVersion = b.uint16()
f.ReaderVersion = b.uint16()
...
ファイル名、拡張フィールド、コメントは可変長なので、それぞれの長さを足し合わせたバッファを用意してそれにデータを読み出してから切り出してます。
d := make([]byte, filenameLen+extraLen+commentLen)
if _, err := io.ReadFull(r, d); err != nil {
return err
}
f.Name = string(d[:filenameLen])
f.Extra = d[filenameLen : filenameLen+extraLen]
f.Comment = string(d[filenameLen+extraLen:])
上記の処理によって、zip.File構造体が作成されます。zip.File構造体はOpen関数でio.ReadCloserを返します。
// Open returns a ReadCloser that provides access to the File's contents.
// Multiple files may be read concurrently.
func (f *File) Open() (io.ReadCloser, error) {
bodyOffset, err := f.findBodyOffset()
if err != nil {
return nil, err
}
size := int64(f.CompressedSize64)
r := io.NewSectionReader(f.zipr, f.headerOffset+bodyOffset, size)
dcomp := f.zip.decompressor(f.Method)
if dcomp == nil {
return nil, ErrAlgorithm
}
var rc io.ReadCloser = dcomp(r)
var desr io.Reader
if f.hasDataDescriptor() {
desr = io.NewSectionReader(f.zipr, f.headerOffset+bodyOffset+size, dataDescriptorLen)
}
rc = &checksumReader{
rc: rc,
hash: crc32.NewIEEE(),
f: f,
desr: desr,
}
return rc, nil
}
findBodyOffset()はローカルヘッダを辿ってデータ部分のオフセットを返します。NewSectionReaderでは各ファイルのデータ部分のみ読み取るようなレンジ指定になっています。f.ziprはzip.NewReaderの引数のio.ReadAtな構造体が入ります。decompressorには解凍するためのメソッドが格納されており、返り値としてio.ReadCloseを返します。さらにchecksumReaderに包んで返し、これをRead()することでzip内のファイルを読み込むことができます。
golang標準ライブラリを読む(書き込み)
書き込みはこんな感じで書きます↓buf := new(bytes.Buffer)
zw := zip.NewWriter(buf)
zf, err := zw.Create("hoge.txt")
if err != nil {
return nil, err
}
if _, err = zf.Write("hello"); err != nil {
return nil, err
}
zw.Close()
Readerよりシンプルな作りで、zip.Writer#Createでローカルファイルヘッダとローカルファイルデータを順次バッファに書き込んでいき、最後のClose関数でセントラルディレクトリヘッダ+終端領域を書き込みます。
CreateではCreateHeaderを呼び出します。CreateHeader内では色々やってるんですがメインは以下です。
- ローカルファイルヘッダの値を構造体にセット
- セントラルディレクトリを書き出すためにdirメンバ変数(配列)に追加
- ローカルファイルヘッダをio.Writer#Writeする(writerHeader)
- ファイルデータ書き込み用のfileWriter構造体を作成し返却
func writeHeader(w io.Writer, h *FileHeader) error {
var buf [fileHeaderLen]byte
b := writeBuf(buf[:])
b.uint32(uint32(fileHeaderSignature))
b.uint16(h.ReaderVersion)
b.uint16(h.Flags)
b.uint16(h.Method)
b.uint16(h.ModifiedTime)
b.uint16(h.ModifiedDate)
b.uint32(0) // since we are writing a data descriptor crc32,
b.uint32(0) // compressed size,
b.uint32(0) // and uncompressed size should be zero
b.uint16(uint16(len(h.Name)))
b.uint16(uint16(len(h.Extra)))
if _, err := w.Write(buf[:]); err != nil {
return err
}
if _, err := io.WriteString(w, h.Name); err != nil {
return err
}
_, err := w.Write(h.Extra)
return err
}
fileWriter#Writeの処理ではrawCount.Writeでデータを書き込みます。rawCountはcomp関数を噛ませたcountWriterなので、圧縮しつつファイルが書き込まれることになります。また、zipではcrc32による誤り検知を行っており、crc32の計算も行っています。
func (w *fileWriter) Write(p []byte) (int, error) {
if w.closed {
return 0, errors.New("zip: write to closed file")
}
w.crc32.Write(p)
return w.rawCount.Write(p)
}
最後にzip.Writer#Closeでdirメンバ変数を使ってセントラルディレクトリヘッダとセントラルディレクトリ終端領域の書き込みを行います。
CreateHeaderやCloseの関数の最初にはlast変数のCloseが呼び出されています。
if w.last != nil && !w.last.closed {
if err := w.last.close(); err != nil {
return nil, err
}
}
last変数には最後に発行したfileWriter構造体が入っているので、直前までデータを書き込んでいたfileWriterのcloseが呼び出されます。closeではヘッダの値がデータディスクリプタの領域に書き込まれます。データディスクリプタはデータ領域の後ろに書き込まれる領域で、ファイルサイズとCRCを格納します。データディスクリプタを使う場合は、ローカルファイルヘッダのFlagに0x08をセットします。
※データ出力が順方向にしか書き込まれないシステムの場合によく使われる形式らしいです。
b := writeBuf(buf)
b.uint32(dataDescriptorSignature) // de-facto standard, required by OS X
b.uint32(fh.CRC32)
if fh.isZip64() {
b.uint64(fh.CompressedSize64)
b.uint64(fh.UncompressedSize64)
} else {
b.uint32(fh.CompressedSize)
b.uint32(fh.UncompressedSize)
}
_, err := w.zipw.Write(buf)
archive/zipを使っていてハマりそうなところ
次のように書くとセントラルディレクトリが書き込まれません。func hoge() ([]byte, error) {
buf := new(bytes.Buffer)
zw := zip.NewWriter(buf)
defer zw.Close()
zf, err := zw.Create("hoge.txt")
if err != nil {
return nil, err
}
if _, err = zf.Write([]byte("hello")); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
unzipしようとすると以下のエラーになります。
$ unzip hoge.zip
Archive: hoge.zip
End-of-central-directory signature not found. Either this file is not
a zipfile, or it constitutes one disk of a multi-part archive. In the
latter case the central directory and zipfile comment will be found on
the last disk(s) of this archive.
unzip: cannot find zipfile directory in one of hoge.zip or
hoge.zip.zip, and cannot find hoge.zip.ZIP, period.
一見するとdeferが効いてないように見えますが、そうではなくてreturnの値が評価されたあとにdeferのClose()がBufferの操作を行っているために戻り値にはセントラルディレクトリが反映されていないことが原因です。この場合、deferを取り除いてreturnの直前でCloseするか、以下のようにbytes.Bufferを返せばOK。
func hoge() (*bytes.Buffer, error) {
...
return buf, nil
}