Bryan

Bryan

twitter
medium

シェルスクリプトにバイナリファイルを埋め込む

前言#

Linux/Unix 系のインストールパッケージを構築する際、さまざまなディストリビューションに適した標準的なパッケージにパッケージ化するだけでなく、プログラムのインストールを行うためのシェルスクリプトを提供し、インストール手順を簡単に 2 ステップに収束させることを望むことが多いです:スクリプトのダウンロード + スクリプトの実行。

通常、このようなインストールスクリプトのほとんどは、再度インターネットから必要なリソースをダウンロードするものであり、これによりスクリプトのサイズを最小限に抑え、常に最新バージョンをインストールできるようにしていますが、これによりダウンロードされた「インストールパッケージ」は本質的に「インストーラー」となり、オフラインインストールができなくなります。

この記事では、すでに生産環境で検証された方法を紹介し、インストールパッケージに動的に URL を埋め込む方法を説明します。

一部の理由により、この記事では主に原理的な説明を行い、完全なコードソリューションを提供することはできませんので、ご了承ください。

また、以下のコードは原理に基づいてこの記事のために作成されたものであり、原理は生産環境で検証されていますが、使用されているコードは厳密な生産検証を受けていませんので、バグがあればお知らせください。

スクリプト構成#

全体のスクリプトは、head + embed-bin の 2 つの部分で構成されています。embed-bin は変更せずにプログラムを埋め込むものであり、head は現在のスクリプトから embed-bin を抽出して実行するための動的に生成されたスクリプトです。

head スクリプトは動的に生成されますが、メンテナンスを簡単にするために、ここではテンプレートの形式を採用しています。

#!/bin/sh
#
# NAME:     {{ .Name }}
# PLATFORM: { .Platform }}
# DIGEST:   {{ .MD5 }}, @LINES@

THIS_DIR=$(DIRNAME=$(dirname "$0"); cd "$DIRNAME"; pwd)
THIS_FILE=$(basename "$0")
THIS_PATH="$THIS_DIR/$THIS_FILE"
EXTRACT={{ if .AutoExtract }}1{{ else }}0{{ end }}
FORCE_EXTRACT=0
PREFIX={{ .DefaultPrefix }}
EXECUTE={{ if .AutoExecute }}1{{ else }}0{{ end }}
{{- end }}

USAGE="{{ .Opts.Usage }}"

while getopts ":h{{ .Opts.FlagNames }}" flag; do
 case "$flag" in
    h)
        printf "%s" "$USAGE"
        exit 2
        ;;
    {{- range .Opts.All }}
    {{ .Name }})
        {{ range .Action.DoIfSet }}{{ . }}
        {{ end }};;{{ end }}
    *)
        printf "ERROR: did not recognize option '%s', please try -h\\n" "$1"
        exit 1
        ;;
 esac
done

# Verify MD5
printf "%s\\n" "Verifying file..."
MD5=$(tail -n +@LINES@ "$THIS_PATH" | md5sum)
if ! echo "$MD5" | grep {{ .MD5 }} >/dev/null; then
    printf "ERROR: md5sum mismatch of tar archive\\n" >&2
    printf "expected: {{ .MD5 }}\\n" >&2
    printf "     got: %s\\n" "$MD5" >&2
    exit 3
fi

{{ if .Archive -}}
if [ -z "$PREFIX" ]; then
    PREFIX=$(mktemp -d -p $(pwd))
fi

if [ "$EXTRACT" = "1" ]; then
    if [ "$FORCE_EXTRACT" = "1" ] || [ ! -f "$PREFIX/.extract-done" ] || [ "$(cat "$PREFIX/.extract-done")" != "{{ .MD5}}" ]; then
        printf "Extracting archive to %s ...\\n" "$PREFIX"

        {
            dd if="$THIS_PATH" bs=1            skip=@ARCHIVE_FIRST_OFFSET@ count=@ARCHIVE_FIRST_BYTES@  2>/dev/null
            dd if="$THIS_PATH" bs=@BLOCK_SIZE@ skip=@ARCHIVE_BLOCK_OFFSET@ count=@ARCHIVE_BLOCKS_COUNT@ 2>/dev/null
            dd if="$THIS_PATH" bs=1            skip=@ARCHIVE_LAST_OFFSET@  count=@ARCHIVE_LAST_BYTES@   2>/dev/null
        } | tar zxf - -C "$PREFIX"

        echo -n {{ .MD5 }} > "$PREFIX/.extract-done"
    else
        printf "Archive has already been extracted to %s\\n" "$PREFIX"
    fi
fi

if [ "$EXECUTE" = "1" ]; then
    echo "Run Command:" {{ .Command }}
    cd "$PREFIX" && {{ .Command }}
fi
{{- end }}

exit 0
## --- DATA --- ##

このスクリプトテンプレートには、{{ XX }}%XX% の 2 種類の変数が存在します。主な違いは、全体のテンプレートレンダリングが 2 ステップに分かれることです:まずすべての {{ XX }} 変数をレンダリングし、その後残りの %XX% 変数をレンダリングします。前者をレンダリングする際には特別な要件はありませんが、後者をレンダリングする際には、変数のレンダリング前後のテキストの長さと行数が変わらないことを保証する必要があります。

このスクリプトは、embed-bin を圧縮パッケージとして解凍します。これは、内部で使用する際に関連データが非常に大きい可能性があるため(数百メガバイトから数ギガバイト)、小さなスクリプトが必要な場合は、圧縮に関するコードを削除できます。

また、このスクリプトは実行前に一度 MD5 チェックを行います。これは、スクリプトが完全にダウンロードされない場合を防ぐためです。しかし、embed-bin 自体が圧縮パッケージであるため、インストール速度を向上させるためにチェックに関するコードを削除することができます(内部で保持している理由は、埋め込む内容が圧縮パッケージだけでなく、複数のファイルであるため、より良いエラーメッセージを提供するためでもあります)。

このスクリプトは、パラメータの渡し能力と一部のデフォルト値の指定も提供しています。これは、特定の状況で関連するステップが異常であり、すべてのステップを実行するのが時間がかかる場合があるためです。実際の使用においては、必要に応じてスクリプトパラメータを削除または変更できます。

スクリプトのパラメータはテンプレートレンダリングエンジンによって提供されます。これは主にメンテナンス性のためです。スクリプト内で関連する内容を記述したい場合は、関連部分を変更できます。

レンダリングスクリプト#

多くを語らず、直接コードを示します。

//go:embed "header.sh.tmpl"
var headerTemplate string

type headerOptions struct {
	Name string
	MD5  string

	Opts *Opts

	*ArchiveOptions
}

type ArchiveOptions struct {
	DefaultPrefix string
	AutoExtract   bool
	AutoExecute   bool
	Command       string // 使用 $PREFIX 引用 prefix

	Filename string // 供 builder 使用,不会打入最终文件
}

func (o *ArchiveOptions) QuotedCommand() string {
	return shells.Quote(o.Command)
}

func renderHeaders(o *headerOptions) ([]byte, error) {
	t := template.New("")

	tt, err := t.Parse(headerTemplate)
	if err != nil {
		return nil, ee.Wrap(err, "invalid template")
	}

	b := bytes.Buffer{}

	err = tt.Execute(&b, o)
	if err != nil {
		return nil, err
	}

	return b.Bytes(), nil
}

func getHeaders(o *headerOptions) ([]byte, error) {
	tmpl, err := renderHeaders(o)
	if err != nil {
		return nil, err
	}

	lines := bytes.Count(tmpl, []byte("\n")) + 1

	tmpl = bytes.ReplaceAll(tmpl, []byte("@LINES@"), []byte(strconv.Itoa(lines)))

	replaceAndFillSpace(tmpl, "@BLOCK_SIZE@", blockSize)

	return tmpl, nil
}

func replaceAndFillSpace(data []byte, old string, new int64) {
	oldBytes := []byte(old)
	newString := strconv.FormatInt(new, 10)

	newWithExtraSpace := append([]byte(newString), bytes.Repeat([]byte{' '}, len(old)-len(newString))...)

	// assert len(old) == len(newWithExtraSpace)

	// Apply replacements to buffer.
	start := 0
	for {
		i := bytes.Index(data[start:], oldBytes)
		if i == -1 {
			return // stop
		}

		start += i
		start += copy(data[start:], newWithExtraSpace)
	}
}

type Opts struct {
	All []*Opt
}

func (opts *Opts) FlagNames() string {
	b := strings.Builder{}
	for _, opt := range opts.All {
		b.WriteString(opt.Name)
		if len(opt.Arg) != 0 {
			b.WriteString(":")
		}
	}

	return b.String()
}

func (opts *Opts) Usage() string {
	b := strings.Builder{}

	b.WriteString("Usage: $0 [options]\n\n")

	all := make([][2]string, 0, 1+len(opts.All))

	nameLen := 2

	all = append(all, [2]string{"-h", "Print this help message and exit"})

	for _, opt := range opts.All {
		bb := strings.Builder{}
		bb.WriteString("-")
		bb.WriteString(opt.Name)

		if opt.Arg != "" {
			bb.WriteString(" [")
			bb.WriteString(opt.Arg)
			bb.WriteString("]")
		}

		name := bb.String()

		if len(name) > nameLen {
			nameLen = len(name)
		}

		all = append(all, [2]string{name, opt.Help})
	}

	for _, a := range all {
		b.WriteString(a[0])
		b.WriteString(strings.Repeat(" ", nameLen-len(a[0])))
		b.WriteString("\t")
		b.WriteString(a[1])
		b.WriteString("\n")
	}

	return b.String()
}

type Opt struct {
	Name   string
	Arg    string
	Help   string
	Action OptAction
}

type OptAction interface {
	DoIfSet() []string
}

type DoAndExitAction struct {
	Do       []string
	ExitCode int
}

func (a *DoAndExitAction) DoIfSet() []string {
	r := append([]string{}, a.Do...)
	r = append(r, "exit "+strconv.Itoa(a.ExitCode))
	return r
}

type DoAndContinueAction struct {
	Do []string
}

func (a *DoAndContinueAction) DoIfSet() []string {
	return a.Do
}

func SimpleSetEnvAction(envName string, envValue interface{}) *DoAndContinueAction {
	return &DoAndContinueAction{
		Do: []string{fmt.Sprintf("%s=%v", envName, envValue)},
	}
}

type Builder struct {
	Name string

	ArchiveOptions *ArchiveOptions
}

func openAndWrite(filename string, w io.Writer) (int64, error) {
	f, err := os.Open(filename)
	if err != nil {
		return 0, err
	}
	defer f.Close()

	return io.Copy(w, f)
}

func fillAndSetHeader(prefix, filename string, f io.Writer, headers []byte, offset int64) (int64, error) {

	fileLength, err := openAndWrite(filename, f)
	if err != nil {
		return 0, ee.Wrap(err, "cannot append data for "+prefix)
	}

	firstOffset := offset
	firstBytes := blockSize - (firstOffset % blockSize)
	replaceAndFillSpace(headers, fmt.Sprintf("@%s_FIRST_OFFSET@", prefix), firstOffset)
	replaceAndFillSpace(headers, fmt.Sprintf("@%s_FIRST_BYTES@", prefix), firstBytes)

	copy2Start := firstOffset + firstBytes
	copy2Skip := copy2Start / blockSize
	copy2Blocks := (fileLength - copy2Start + firstOffset) / blockSize
	replaceAndFillSpace(headers, fmt.Sprintf("@%s_BLOCK_OFFSET@", prefix), copy2Skip)
	replaceAndFillSpace(headers, fmt.Sprintf("@%s_BLOCKS_COUNT@", prefix), copy2Blocks)

	copy3Start := (copy2Skip + copy2Blocks) * blockSize
	copy3Size := fileLength - firstBytes - (copy2Blocks * blockSize)
	replaceAndFillSpace(headers, fmt.Sprintf("@%s_LAST_OFFSET@", prefix), copy3Start)
	replaceAndFillSpace(headers, fmt.Sprintf("@%s_LAST_BYTES@", prefix), copy3Size)

	return fileLength, nil
}

func (b *Builder) Build(saveTo string) error {
	header := &headerOptions{
		Name:           b.Name,
		ArchiveOptions: b.ArchiveOptions,
		Opts:           &Opts{},
	}

	fileMD5 := md5.New()

	var dataSize int64

	if header.ArchiveOptions != nil {
		if header.ArchiveOptions.AutoExtract {
			header.Opts.All = append(header.Opts.All, &Opt{
				Name:   "E",
				Help:   "Do not extract archive",
				Action: SimpleSetEnvAction("EXTRACT", 0),
			})
		} else {
			header.Opts.All = append(header.Opts.All, &Opt{
				Name:   "e",
				Help:   "Also extract archive",
				Action: SimpleSetEnvAction("EXTRACT", 1),
			})
		}

		header.Opts.All = append(header.Opts.All, &Opt{
			Name:   "f",
			Help:   "Force extract archive",
			Action: SimpleSetEnvAction("FORCE_EXTRACT", 1),
		})

		prefixOpt := &Opt{
			Name: "d",
			Arg:  "DIR",
			Help: "Extract to directory",
			Action: &DoAndContinueAction{
				Do: []string{`PREFIX="${OPTARG}"`},
			},
		}
		if header.ArchiveOptions.DefaultPrefix != "" {
			prefixOpt.Help += fmt.Sprintf(" (default: %s)", header.ArchiveOptions.DefaultPrefix)
		}

		header.Opts.All = append(header.Opts.All, prefixOpt)

		if header.ArchiveOptions.Command != "" {
			if header.ArchiveOptions.AutoExecute {
				header.Opts.All = append(header.Opts.All, &Opt{
					Name:   "X",
					Help:   "Do not execute command",
					Action: SimpleSetEnvAction("EXECUTE", 0),
				})
			} else {
				header.Opts.All = append(header.Opts.All, &Opt{
					Name:   "x",
					Help:   "Also execute the command",
					Action: SimpleSetEnvAction("EXECUTE", 1),
				})
			}
		}

		n, err := openAndWrite(header.ArchiveOptions.Filename, fileMD5)
		if err != nil {
			return ee.Wrap(err, "failed to read archive file to get md5")
		}
		dataSize += n
	}

	_ = dataSize

	header.MD5 = hex.EncodeToString(fileMD5.Sum(nil))

	headers, err := getHeaders(header)
	if err != nil {
		return ee.Wrap(err, "failed to get headers")
	}

	f, err := os.OpenFile(saveTo, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0755)
	if err != nil {
		return ee.Wrap(err, "failed to write file")
	}
	defer f.Close()

	// write header
	headersLen, err := f.Write(headers)
	if err != nil {
		return ee.Wrap(err, "failed to write headers")
	}

	currentOffset := int64(headersLen)

	//  embed archive
	if header.ArchiveOptions != nil {
		n, err := fillAndSetHeader("ARCHIVE", header.ArchiveOptions.Filename, f, headers, currentOffset)
		if err != nil {
			return ee.Wrap(err, "failed to embed installer")
		}
		currentOffset += n
	}

	_ = currentOffset

	// rewrite headers
	_, err = f.Seek(0, 0)
	if err != nil {
		return ee.Wrap(err, "failed to seek file")
	}
	newHeadersLen, err := f.Write(headers)
	if err != nil {
		return ee.Wrap(err, "failed to rewrite headers")
	}
	if headersLen != newHeadersLen {
		return ee.New("headers unexpected change after rewrite")
	}

	return nil
}

使用法は次の通りです。

b := &Builder{
    Name: name,
    ArchiveOptions: &binbundler.ArchiveOptions{
        DefaultPrefix: "/path/to/extract",
        AutoExtract:   true,
        AutoExecute:   true,
        Command:       "bash $PREFIX/install.sh", # インストールコマンド、シンプルな場合は直接実行、複雑な場合は追加のスクリプトを使用
        Filename:      "/path/to/embed",
    },
}
err = b.Build("/path/to/script-save-to.sh")

全体のスクリプトでは、関連するテンプレート変数が動的に挿入され、関連するオフセットが計算されます。

後記#

この記事は、主に dd を利用して解凍し、動的にオプションを生成して実行プロセスを制御するという考え方を提供するものであり、インターネット上で一般的に使用される grep などの手段を用いてバイナリコンテンツを特定するよりも効率的でメンテナンスが容易です。

この基盤の上に、さらに多くのこと(依存関係の検証、複数ファイルのインストールなど)を実現することも可能ですので、ぜひ試してみてください。

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。