自作の heika(Symbol の Rust のツールキット。試験的にノードも動く)は AGPL-3.0 にしている。AGPL には、GPL にない 13 条がある。ネットワーク越しに使ってもらうなら、その人にもソースを渡せ、というものだ。
ノードを動かすと REST の口が開いて、知らない人がネットワーク越しに使う。13 条の出番である。リポジトリへのリンクを貼っておけば済みそうに見えるが、手元で少し手を入れてビルドしたノードだと、リンク先のソースと動いているものがずれる。
そこで、ビルドするときにソースを ZIP にしてバイナリに埋め込み、そのバイナリ自身が配るようにした。手を入れてビルドすれば、手を入れたソースが埋め込まれる。ずれようがない。自分のソースを腹に抱えて歩くバイナリ、なんかかわいい✨。
heika legal source --out heika-source.zip # コマンドで取り出す
curl -O https://<ノードのホスト>/heika/sourcecode.zip # 動いているノードから取る
ソースはここにある。
ちょうど 0.10.0 をリリースした。Release に置いたコマンドラインツールのバイナリ(Linux、macOS、Windows の4つ)には、どれもソースが入っている。ダウンロードして、下の手順でぶっこ抜いてみてほしい。
13 条で何が要るか
ざっくり言うと、こうなる。
- 改変したプログラムを、ネットワーク越しに使ってもらう
- そのとき、使う人に「対応するソース」を受け取る機会を与える
13 条がかかるのは、改変した場合だ。改変していないものを動かすだけなら、13 条の義務はそもそもない。ただ、作った側は配った先で改変されるかどうかを知りようがない。改変した人が自分で用意してくれるのが筋だが、手間がかかると忘れられる。私も忘れる。ああそれはいいのか、だれも Pull Request 送ってこないし。
だから、道具の側でやってしまう。何もしなくても、ビルドしたものがそのまま対応するソースを持っている状態にする。最近のトレンドの「しくみで解決」というやつである。仕事ができる人みたいだ。憧れに一歩近づいたかもしれない。
ビルドするときに ZIP を作る
build.rs で git archive を呼んで ZIP にする。ここで手間取ったのは、コミットしていない変更の扱いだ。
git archive HEAD だと、コミットしたものしか入らない。手元でちょっと直してビルドしたものを動かすと、直したところが ZIP に入らない。まさに 13 条で困る場面である。
かといって、作業ツリーをそのまま固めると、置いたままの鍵のファイルまで入る。heika のリポジトリの直下には、手元で試すノードの鍵を置いたフォルダーがある。.gitignore に入れ忘れたら、配る ZIP に鍵が乗る。笑えない。
そこで、こうした。
- 追跡しているファイルだけを入れる。変更はコミットしていなくても入れる
- 追跡していないファイルは、
.gitignoreで無視されていなくても入れない。ビルドのときに警告だけ出す
やり方は、index を写して使う。
// リポジトリの index を OUT_DIR に写す
let temporary_index = out_dir.join("index");
fs::copy(&index, &temporary_index)?;
// 写した index に、追跡しているファイルの今の中身を足して、tree を作る
let with_index = |args: &[&str]| git_with_index(workspace, &temporary_index, args);
with_index(&["add", "--update"])?;
let tree = with_index(&["write-tree"])?;
// その tree を ZIP にする
git(workspace, &["archive", "--format=zip", &prefix, &tree])?;
GIT_INDEX_FILE を写した index に向けて git add --update すると、追跡しているファイルの変更だけが足される。--update は新しいファイルを足さない。write-tree で、その中身の tree ができる。リポジトリの index にも参照にも触らないので、ビルドしただけで git status が変わることもない。
変更がなければ、tree の代わりに HEAD を渡す。git archive にコミットを渡すと、ZIP にコミットのハッシュと時刻が入るからだ。どのコミットから作ったかは、heika legal source も出す。
heika-source.zip に書きました(30e23f7c…)
変更があれば「30e23f7c… に、コミットしていない変更を加えたもの」と出る。
cargo::rerun-if-changed には、追跡しているファイル全部と、HEAD、index、logs/HEAD を並べる。これで、ソースを変えたときかコミットしたときだけ作り直す。
git がない環境や、heika のリポジトリの外でビルドしたとき(ソースだけを切り出してビルドしたときなど)は、空の ZIP を置いて警告する。ビルドは止めない。heika legal source は「ソースが入っていません」と言って失敗する。
決まった名前のセクションに置く
ZIP は include_bytes! でバイナリに入れる。ただ、それだけだと取り出すにはバイナリを動かす必要がある。
動かせないバイナリもある。aarch64 の Linux 向けを x86_64 のマシンで見たいとき。ダウンロードしたものを、まだ動かしたくないとき。ソースを渡すと言いながら「動かせば出ます」は、ちょっと弱い。そもそも素性不詳の野良バイナリを安易に動かすとはけしからん。実にけしからん。ぷんぷん表明である。
そこで、決まった名前のセクションに置いた。
#[used]
#[cfg_attr(
not(any(target_vendor = "apple", windows)),
unsafe(link_section = ".heika_source")
)]
#[cfg_attr(
target_vendor = "apple",
unsafe(link_section = "__TEXT,__heika_source")
)]
#[cfg_attr(windows, unsafe(link_section = ".hksrc"))]
pub(crate) static SOURCE: [u8; SOURCE_LEN] =
*include_bytes!(concat!(env!("OUT_DIR"), "/source.zip"));
const SOURCE_LEN: usize = include_bytes!(concat!(env!("OUT_DIR"), "/source.zip")).len();
ハマりどころは3つあった。
-
&[u8]ではなく配列にする。&'static [u8]にすると、セクションに入るのは ZIP の本体ではなく、指す先と長さだけになる。中身をそのまま置くために、長さをconstで求めて配列にしている。include_bytes!を2回書いているが、同じファイルなので中身は1つしか入らない -
#[used]を付ける。 付けないと、使わない static として消されることがある。普段はheika legal sourceから読むので残るが、念のため -
名前の決まりが OS で違う。 ELF は好きな名前でいい。Mach-O はセグメントとセクションの組で、セクションの名前は16バイトまで。PE はセクションの名前が8バイトまでなので、
.heika_sourceが入らず、.hksrcに縮めた。8バイトて。DOS の 8.3 の亡霊を見た気がする
ZIP は 1.2MB ほどで、バイナリ全体の1割くらいになる。ソースを配るためのものとしては、まあまだ許せるコストだと思う。お客様の費用対効果を最適化というよくわからん宣伝文句を感じる。
実行せずに取り出す
セクションの中身を抜くだけなので、どの OS でも、たまたまそのへんにあるあの道具で取り出せる。
objcopy -O binary --only-section=.heika_source heika heika-source.zip # Linux
segedit heika -extract __TEXT __heika_source heika-source.zip # macOS
llvm-objcopy --dump-section .hksrc=heika-source.zip heika.exe # Windows
heika legal source で書いたものと、objcopy で抜いたものは、1バイトも違わない。当たり前なんだけど、cmp が黙って返ってくると、ちょっとうれしい。
Linux のバイナリを readelf -S で見ると、ちゃんと独立したセクションになっている。
[13] .heika_source PROGBITS 000000000016c2a5 16c2a5 12ffbd 00 Ao 0 0 1
ノードの REST で返す
ノードを動かすと、同じ ZIP を GET /heika/sourcecode.zip で返す。13 条の「ネットワーク越しに使う人」は REST を叩く人なので、同じ口から取れるのがいちばん素直だ。
async fn source_code() -> Response {
match SOURCE.get().filter(|zip| !zip.is_empty()) {
Some(zip) => (
[
(CONTENT_TYPE, "application/zip"),
(
CONTENT_DISPOSITION,
concat!(
"attachment; filename=\"heika-",
env!("CARGO_PKG_VERSION"),
".zip\""
),
),
],
*zip,
)
.into_response(),
None => error(StatusCode::NOT_FOUND, /* … */),
}
}
REST の crate(heika-api)は、ZIP を自分では持たない。バイナリを作る側(heika-cli)が、起動するときに offer_source(&SOURCE) で渡す。ライブラリとして heika-api を使う人は、自分のバイナリのソースを渡せばいい。渡さなければ 404 になる。
curl -O https://<ノードのホスト>/heika/sourcecode.zip
CI で取り出せるかを確かめる
埋め込んだつもりで、取り出せなければ意味がない。リンカーの気分でセクションが消えたり、名前が変わったりしても気づけるように、CI でリリースに載せるバイナリをビルドしたあと、README と同じ手順で取り出して、ZIP として読めるかを確かめている。
- name: 埋め込んだソースを、README の手順で実行せずに取り出せるか確かめる
run: |
case "$RUNNER_OS" in
Linux)
objcopy -O binary --only-section=.heika_source "target/$TARGET/release/heika" source.zip
unzip -tq source.zip ;;
macOS)
segedit "target/$TARGET/release/heika" -extract __TEXT __heika_source source.zip
unzip -tq source.zip ;;
Windows)
llvm-objcopy --dump-section .hksrc=source.zip "target/$TARGET/release/heika.exe"
7z t source.zip ;;
esac
aarch64 の Linux 向けは、arm のランナーでビルドして、そこで抜く。
おわりに
13 条は、守るのが面倒だと思われがちだ。でも、ソースをバイナリに入れてしまえば、配る側は何も考えなくていい。改変した人も、何もしなくても守れている。
build.rs に 100 行ちょっとと、static が1つ、REST の口が1つで済んだ。AGPL で何かを配るなら、試してみてほしい。ライセンスの義務を、未来の自分と知らない誰かに丸投げしなくて済むのは、思ったより気が楽だ。