Go
template
zip
Go4Day 1

Goの実行ファイルにZIPでリソースを埋め込む

はじめに

ZIPを用いた実行ファイルへのリソースの埋め込み方法があることを紹介し、実際にGoの archive/zipzip コマンドと cat コマンド(と確認のために unzip コマンド)を用いたリソース埋め込みの例を解説する。

Goのリソース埋め込み

Goでアプリケーションを書く際、CSSやJavaScript、画像、テンプレートといったリソースは実行ファイルとは別に配置するか実行ファイルの中に埋め込むかしないといけない。リソースの埋め込みにはコードジェネレータを用いる方法とZIPを使う方法がある。

コードジェネレータ

リソースの埋め込みでよく紹介されるのはコードジェネレータを用いてリソースをGoのソースコードに変換するという方法で、実際 Awesome GoResource Embeddingの項目 にあるのはコードジェネレータを用いてGoのソースコードに変換するアプローチのものしかない。

ZIP

一方、リソースをZIPファイルにまとめて実行ファイルに追記するという埋め込み方もある。既に Zgok というライブラリがあり、作者の @srtkkou さんが解説(Golangで静的ファイルをバイナリに含めるライブラリを書いてみた)を書かれている。

ZIPファイルの仕様ではself-extractingなZIPファイルはターゲットプラットフォームごとの展開コードを含まなければならないとある。

4.1.9 ZIP files MAY be streamed, split into segments (on fixed or on
removable media) or "self-extracting". Self-extracting ZIP
files MUST include extraction code for a target platform within
the ZIP file.

また、zip コマンドのmanページにはself-extractingな実行可能ファイル兼アーカイブは既存のアーカイブにSFXスタブを前置することで作られるとある。

A self-extracting executable archive is created by prepending the SFX stub to an existing archive.

これらのことから、実行ファイルそれ自体をZIPファイルとして取り扱うGoのコマンドを書き、その実行ファイルの末尾にZIPファイルを足せばリソースを埋め込めることがわかる。1

ZIPでのリソース埋め込みの例

ここでは main.goassets/templates/hello.tmpl だけからなるシンプルな例について考える。

$ tree .
.
├── assets
│   └── templates
│       └── hello.tmpl
└── main.go

2 directories, 2 files

asserts/templates.hello.tmpl は以下に示すような単純なテンプレートである。

Hello, {{.}}!

また、 main.go は自身の実行ファイル os.Executable() をZIPファイルとして読み出し、一時ディレクトリに展開する。その後、展開されたリソース中のテンプレートを用いて Hello, World! を出力する。

archive/zip を使うときの注意点として、アーカイブ中のパスに .. が無いことをチェックする必要がある。 .. を許容すると任意のコマンドを実行される脆弱性につながる。

package main

import (
    "archive/zip"
    "fmt"
    "io"
    "io/ioutil"
    "log"
    "os"
    "path/filepath"
    "strings"
    "text/template"
)

func main() {
    exec, err := os.Executable()
    if err != nil {
        log.Fatalf("failed to get executable: %v", err)
    }

    r, err := zip.OpenReader(exec)
    if err != nil {
        log.Fatalf("failed to get zip reader: %v", err)
    }

    dir, err := ioutil.TempDir("", filepath.Base(exec))
    if err != nil {
        log.Fatalf("failed to open template directory: %v", err)
    }
    defer os.RemoveAll(dir)

    for _, f := range r.File {
        if err := extract(f, dir); err != nil {
            log.Fatalf("failed to extract file: %v", err)
        }
    }

    log.Printf("assets: %s", dir)

    t := template.Must(template.ParseGlob(filepath.Join(dir, "templates", "*")))
    if err := t.ExecuteTemplate(os.Stdout, "hello.tmpl", "World"); err != nil {
        log.Fatalf("t.ExecuteTemplate() failed: %v", err)
    }
}

func extract(f *zip.File, dir string) error {
    if strings.Contains(f.Name, "..") {
        // Zip Slip!
        return fmt.Errorf("file path '%s' contains '..'", f.Name)
    }

    path := filepath.Join(dir, filepath.Clean(f.Name))

    if f.Mode().IsDir() {
        if err := os.MkdirAll(path, f.Mode()); err != nil {
            return err
        }
        return nil
    }

    r, err := f.Open()
    if err != nil {
        return err
    }

    tf, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
    if err != nil {
        return err
    }
    defer tf.Close()

    _, err = io.Copy(tf, r)
    return err
}

実行ファイルの作成

まず通常の方法で実行ファイルを作成する。

$ go build -o hello

これにはリソースが埋め込まれていないため、必ず失敗する。

$ ./hello 
2018/11/25 17:15:32 failed to get zip reader: zip: not a valid zip file

ZIPファイルの作成

次にリソースをまとめたZIPファイルを作成する。 zip コマンドは実行時のカレントディレクトリを基準にするため assets ディレクトリの中で実行する必要がある。

$ cd assets
$ zip -r ../assets.zip .
  adding: templates/ (stored 0%)
  adding: templates/hello.tmpl (stored 0%)
$ cd ..

正常に作られたリソースのZIPファイルにはパスに ..assets が含まれない。

$ unzip -l assets.zip
Archive:  assets.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
        0  11-25-2018 16:13   templates/
       14  11-05-2018 21:31   templates/hello.tmpl
---------                     -------
       14                     2 files

実行ファイル兼ZIPファイルの作成

次に、実行ファイルとZIPファイルを連結する。

$ cat hello assets.zip > hello-bundled
$ chmod +x hello-bundled

単純に連結しただけだとZIPファイル中のオフセット値が実行ファイルのサイズ分ずれてしまっているのでまだ失敗する。

$ ./hello-bundled
2018/11/25 17:16:37 failed to get zip reader: zip: not a valid zip file
$ unzip -l hello-bundled 
Archive:  hello-bundled
warning [hello-bundled]:  3309528 extra bytes at beginning or within zipfile
  (attempting to process anyway)
  Length      Date    Time    Name
---------  ---------- -----   ----
        0  11-25-2018 16:13   templates/
       14  11-05-2018 21:31   templates/hello.tmpl
---------                     -------
       14                     2 files

ここで zip -A (--adjust-sfx) するとオフセット値を調整することができる。

$ zip -A hello-bundled
Zip entry offsets appear off by 3309528 bytes - correcting...

調整が済めばself-extractingなZIPファイルになるため成功する。

$ ./hello-bundled 
2018/11/25 17:17:12 assets: /var/folders/xt/6z9sk1dx1d734ltxxst16_h00000gn/T/hello-bundled725779200
Hello, World!

おわりに

ZIPを用いたリソースの埋め込み方法を紹介し、 archive/zip を用いたリソース埋め込みの例を解説した。

実際にこの埋め込み方法を使うには go rungo test がそのままでは動かない。また、リソースを差し替えるにはビルドの手順をやり直す必要がある。

シンプルなインタフェースで go run 時や go test 時のフォールバックと環境変数を用いたリソースの差し替えに対応した assets というライブラリを作ったので試してみてほしい。


  1. SFXスタブとは何か?ということについて書かれた仕様書等は見つけられなかった