👻

jq(gojq) で base64 をデコードする時の落とし穴

2024/12/08に公開

この記事は apstndb Advent Calendar 2024 の7日目記事です。

この記事では jq を使った JSON 内の base64 のデコードとその問題、そして問題の解としての gojq について紹介します。

この記事では jq 1.7, gojq 0.12.17 を使っています。将来的に挙動が変わる可能性はあります。

問題の紹介: JSON に含まれた base64 されたバイト列

Spanner の PROTO BUNDLE に登録した内容を自前でヒューマンリーダブルに出力する の記事では curl と gojq (のラッパーである jqurl), base64, protoc を使って REST API から取得できるスキーマ情報から Protocol Buffers でシリアライズされた型情報をヒューマンリーダブルに出力する方法を説明しました。

$ jqurl -s --auth=google --raw-output \
    "https://spanner.googleapis.com/v1/projects/${SPANNER_PROJECT}/instances/${SPANNER_INSTANCE}/databases/${SPANNER_DATABASE}/ddl" .protoDescriptors |
     base64 -d | protoc --decode=google.protobuf.FileDescriptorSet placeholder_descriptor.proto

この細かい意味は説明しませんが、 curl と jq のラッパーである jqurl を普通の jq コマンドに置き換えた上で、 JSON と base64 の処理だけ抜き出すとこのような処理となります。
--raw-output(-r) は短い -r にしておきます。

$ cat input.json | jq -r .protoDescriptors | base64 -d > output.pb

入力の JSON オブジェクトには protoDescriptors というフィールドがあり、そこには base64 にエンコードされたバイナリデータが入っています。

このような JSON に base64 を含めるパターンはよくあります。それは JSON にはバイナリ型はないことに由来します。有名なものだけでも次のようにあげられるでしょう。

このようなものをどう扱いますか?

$ cat input.json | jq -r .protoDescriptors | base64 -d > output.pb

のように jq コマンドと base64 コマンドをパイプで繋げるのは十分な解です。それぞれの問題をうまく解決できる小さいコマンドをパイプで組み合わせる。まさに UNIX 哲学の美徳ですね。
しかし、 sort -u があるのに sort | uniq とわざわざ書く利点があまりないように、パイプなしでもうまくできるのであればパイプなしでやる利点があるのではないでしょうか。

jq による base64 のデコード

jq でも base64 されたバイト列のデコードはできないのでしょうか。

まず問題を単純にするため JSON に含めない base64 された文字列を扱います。
入力を改行を含まない hoge とするため、 echo -n を使います。単に echo では hoge\nbase64 にわたります。

$ echo -n "hoge" | base64
aG9nZQ==

これを jq に入力しましょう。
JSON 以外の入力を jq で扱おうとしないと使う機会はあまりないかもしれませんが、jq は JSON ではない生の入力を --raw-input(-R) で扱うことができます。

-R, --raw-input           read each line as string instead of JSON;
$ echo -n "hoge" | base64 | jq -R         
"aG9nZQ=="

あとは jq に渡す引数の問題になりました。

base64 のデコードには組み込みの @base64d を使うことができます。

https://jqlang.github.io/jq/manual/#builtin-operators-and-functions

@base64d:
The inverse of @base64, input is decoded as specified by RFC 4648. 
$ echo -n "hoge" | base64 | jq -R '@base64d'
"hoge"

正しくデコードできていますが、まだ JSON 文字列としての出力なので " で囲われています。生の hoge が欲しい場合はどうすれば良いでしょうか。
--raw-output(-r) を使うことで、 JSON 文字列を生の文字列として出力することができます。

-r, --raw-output          output strings without escapes and quotes;
$ echo -n "hoge" | base64 | jq -R -r '@base64d'
hoge

これで完璧に見えますか?いえ、違います。デコード結果のバイト列を見てみましょう。 xxd コマンドは入力を hex で表示することができるため、任意のコマンドの出力を1バイト単位で精査することができます。

$ echo -n "hoge" | base64 | jq -R -r '@base64d' | xxd
00000000: 686f 6765 0a                             hoge.

hoge(0x686f6765) の後に 0x0a があることが見えますか?これは改行コード LF(\n) です。改行を出力しないためにわざわざ echo -n したことを思い出せば、これは混入すべきではありません。

どこで混入しているかというと、 jq の出力は文字列であり文字列の行は必ず改行で終端されます。
改行では都合が悪い場合 --raw-output0 (-0)というオプションがあります。

--raw-output0         implies -r and output NUL after each output;

しかしこれを使っても改行コードの代わりにヌル文字が終端文字に使われるだけで、入力を再現することはできていません。

$ echo -n "hoge" | base64 | jq -R --raw-output0 '@base64d' | xxd   
00000000: 686f 6765 00                             hoge.

jq で終端文字列そのものを出力させない方法はないでしょうか?
あります。 --join-output (-j) です。

-j, --join-output         implies -r and output without newline after
                          each output;

これを使うと jq が出力する文字列はいかなる終端文字、区切り文字を使わなくなります。

$ echo -n "hoge" | base64 | jq -R -j '@base64d' | xxd           
00000000: 686f 6765                                hoge

入力と同じ文字列が再現できました。勝ったッ! Advent Calendar 7日目完!

バイナリの魔

ここまで読めば想像できることでしょうがそういうわけにはいきません。
base64 に何故していたかというと、 ASCII や UTF-8 文字列では表現できない任意のバイト列を扱うためです。
ここからバイト列を使って検証します。文字以外は echo では出力できないので printf を使いましょう。

単体の 0x80 は UTF-8 文字列ではないバイト列なので、これを使ってみましょう。

$ printf "%b" "\x80" | xxd
00000000: 80                                       .

これを先ほどと同じパイプラインに渡すと、期待とは異なり入力が再現されません。

$ printf "%b" "\x80" | base64 | jq -R -j '@base64d' | xxd
00000000: efbf bd                                  ...

この efbf bd の並びは何でしょうか。苦しんだことがある人はお分かりでしょうが、 Unicode REPLACEMENT CHARACTER (U+FFFD) を UTF-8 で表現したものです。
せっかくなので Zenn チームのソフトウェアエンジニアの dyoshikawa さんが解説している記事を紹介します。

https://zenn.dev/dyoshikawa/articles/nodejs-charset-introduction-mojibake

結論ですが、 jq の文字列はバイト列ではありません。常に UTF-8 を期待し、 Unicode で表現可能な文字以外は扱うことができません。

@base64d もデコードした結果が UTF-8 文字列ではない場合結果は未定義なのが仕様です。

@base64d:
The inverse of @base64, input is decoded as specified by RFC 4648. Note\: If the decoded string is not UTF-8, the results are undefined.

この仕様はそもそも JSON の仕様上 string が Unicode で定義されていることによります。

https://www.json.org/json-en.html

A string is a sequence of zero or more Unicode characters, wrapped in double quotes, using backslash escapes.

この挙動については困っている人は居るため issue がありますが、仕様であるため修正されることは今のところ期待できなそうです。

https://github.com/jqlang/jq/issues/1931

諦めるしかないのでしょうか?

解: gojq を使う

まだ希望は残されています。
itchny さんによる Go による jq の実装の itchny/gojq は、 jq を単に Go で再実装するのではなく、 jq の問題のある挙動の多くを修正しています。
この挙動もその一つです。

gojq fixes @base64d to allow binary string as the decoded string (jq#1931).

UTF-8 から外れるバイト列を扱うことができずに U+FFFE に置換されて幸せになる人がどこに居るのでしょうか。私は gojq を支持します。

というわけで、先ほど失敗した 0x80 を処理する例は jq を gojq に置き換えるだけで全く問題がなくなります。

$ printf "%b" "\x80" | base64 | gojq -R -j '@base64d' | xxd
00000000: 80                                       .

これができれば、最初にやりたかったことの全てが解決できます。

$ cat input.json | jq -r .protoDescriptors | base64 -d > output.pb

上のコマンドは次のように置き換えられます。

$ cat input.json | gojq -r -j '.protoDescriptors | @base64d' > output.pb

もちろん、 デコードされたバイト列をファイルに落とさなくてもそのままパイプで扱うことができます。
意味は説明しませんが、元の記事 でやりたかったことはまさにこれでした。

$ cat input.json | gojq -r -j '.protoDescriptors | @base64d' |
    protoc --decode=google.protobuf.FileDescriptorSet placeholder_descriptor.proto

まとめ

  • jq で終端文字をなくすには --join-output(-j) が使えます。
  • オリジナルの jq では UTF-8 から外れるバイト列を扱うことはできません。
  • gojq はバイト列も扱えるより良い jq です。 gojq を避けたい理由がないのであれば gojq を使いましょう。

Discussion