Shell/Linux
PR

bash 5.2 の置換でエスケープが壊れる原因は patsub_replacement|バージョンに左右されない直し方

saratogax
記事内に商品プロモーションを含む場合があります

bash の置換 ${var//</&lt;} で < をエスケープしたのに、結果が <lt; になっていませんか?

HTML に埋め込む値や、Slack に送るメッセージのエスケープでよく起こる症状です。

厄介なのは、macOS 標準の bash では正しく動くのに、GitHub Actions などの Linux の CI でだけ壊れることです。

私も GitHub Actions のワークフローで、手動実行のときの入力値を Slack に通知する処理を書いていて、これに遭遇しました。

原因は、bash 5.2 で既定で有効になった patsub_replacement です。

このシェルオプションが有効だと、置き換え後の文字列にある & が「パターンに一致した文字列」に置き換わります。

この記事では、bash 3.2 と 5.2 で実際に試した結果をもとに、どのバージョンでも動く直し方をまとめます。

症状: &lt; に置き換えたのに <lt; になる

Slack のメッセージでは、「&」は「&amp;」、「<」は「&lt;」、「>」は「&gt;」に置き換える必要があります(Slack のドキュメント)。

そこで、次のようなエスケープを書きました。

v='<!channel> a&b'
s="${v//&/&amp;}"
s="${s//</&lt;}"
s="${s//>/&gt;}"
echo "$s"

<!channel> は、Slack でチャンネルの全員に通知する書き方です。

利用者が入力した値にこれが含まれていても通知が飛ばないように、エスケープしておきたいわけです。

このスクリプトを bash 3.2 と bash 5.2 で実行すると、結果が変わります。

bash のバージョン出力
3.2.57(macOS の /bin/bash)&lt;!channel&gt; a&amp;b
5.2.37(Homebrew)<lt;!channel>gt; a&amp;b
※表は横スクロールできます

bash 5.2 では < と > がそのまま残り、後ろに「lt;」と「gt;」が付いています。

エスケープしたつもりが、まったくエスケープされていません。

& のエスケープだけは正しく見える

出力をよく見ると、a&b は bash 5.2 でも正しく a&amp;b になっています。

これは、& を置き換えるときに「一致した文字列」がたまたま & そのものだからです。

&amp; の先頭の & が一致した文字列(&)に置き換わっても、結果は同じ &amp; になります。

& の置き換えだけを試すと正しく動いているように見えるので、注意が必要です。

原因: bash 5.2 で patsub_replacement が既定で有効になった

bash 5.2 のソースに含まれる NEWS(変更点の一覧)には、新しいシェルオプションとして次のように書かれています。

x. New shell option: patsub_replacement. When enabled, a `&' in the replacement
   string of the pattern substitution expansion is replaced by the portion of
   the string that matched the pattern. Backslash will escape the `&' and
   insert a literal `&'.

このオプションは既定で有効なので、何も設定しなくても bash 5.2 以降はこの動きになります。

つまり ${var//pattern/string} の string にある & は、pattern に一致した文字列に置き換わります。

これは、sed のよくある書き方をまねた機能です。

sed の s/o/[&]/g と同じように、一致した文字列を & で参照できます。

w='foo bar'
echo "${w//o/[&]}"
# bash 5.2: f[o][o] bar
# bash 3.2: f[&][&] bar

便利な機能ではありますが、&lt; のように & を文字として書きたい場合には、意図しない置き換えが起こります。

展開全体を二重引用符で囲んでいても防げない

"${v//</&lt;}" のように、展開全体を二重引用符で囲んでいても & は置き換わります。

置き換わるのは、string の中で引用符に囲まれていない & です。

展開全体が引用符で囲まれているかどうかは関係ありません。

置き換え後の文字列を変数に入れても防げません。

rep='&lt;'
v='a<b'
echo "${v//</$rep}"      # bash 5.2: a<lt;b
echo "${v//</"$rep"}"    # bash 5.2: a&lt;b

& があるかどうかは string を展開したあとに調べるので、変数の中身にある & も置き換わります。

変数を "$rep" のように引用符で囲めば、置き換わりません。

どの環境で起こるのか

patsub_replacement は bash 5.2 で追加されたので、5.1 以前の bash には影響しません。

環境bash のバージョン影響
macOS の /bin/bash3.2.57なし
GitHub Actions の ubuntu-22.045.1.16なし
GitHub Actions の ubuntu-24.04(ubuntu-latest)5.2.21あり
Homebrew の bash(私の手元)5.2.37あり
※表は横スクロールできます

GitHub Actions のランナーの bash のバージョンは、actions/runner-images にある各イメージの README で確認しました(2026 年 10 月時点)。

ubuntu-latest は Ubuntu 24.04 を指しているので、ubuntu-latest で動いているワークフローは bash 5.2 の影響を受けます。

ubuntu-22.04 から 24.04 に上げると、同じスクリプトでも結果が変わります。

一方、macOS の /bin/bash は 3.2 なので、#!/bin/bash のスクリプトを手元の Mac で動かしても再現しません。

使っている bash で有効かどうかは、次のように確認できます。

$ echo "$BASH_VERSION"
5.2.37(1)-release
$ shopt patsub_replacement
patsub_replacement	on

bash 5.1 以前では、shopt: patsub_replacement: invalid shell option name というエラーになります(3.2 で確認)。

対処: どのバージョンでも動くのは sed と shopt -u

対処はいくつかありますが、bash のバージョンによって結果が変わるものがあります。

bash 3.2.57 と 5.2.37 で、< を &lt; に置き換えた結果は次のとおりです。

書き方bash 3.2bash 5.2
"${v//</&lt;}"(対処なし)&lt;<lt;
sed で置き換える&lt;&lt;
先に shopt -u patsub_replacement&lt;&lt;
"${v//</"&lt;"}""&lt;"&lt;
"${v//</'&lt;'}"'&lt;'&lt;
"${v//</\&lt;}"\&lt;&lt;
※表は横スクロールできます

bash 3.2 と 5.2 の両方で正しく動いたのは、sed と shopt -u の 2 つだけでした。

おすすめ: sed で置き換える

bash のバージョンに関係なく同じ結果になるのは、sed で置き換える方法です。

escape_slack() {
  printf '%s\n' "$1" | sed -e 's/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g'
}

escape_slack '<!channel> a&b'
# &lt;!channel&gt; a&amp;b

& を最初に置き換えるのがポイントです。

後に回すと、&lt; の & まで &amp; に置き換わってしまいます。

sed でも & は「一致した文字列」の意味なので、\& と書いてエスケープしています。

bash 5.2 の新しい挙動は、まさにこの sed の書き方をまねたものです。

bash のまま直すなら shopt -u

パラメータ展開のまま直したい場合は、置換を使う前に patsub_replacement を無効にします。

shopt -u patsub_replacement 2>/dev/null || true

bash 5.1 以前にはこのオプションがないので、shopt -u はエラーになります。

set -e を使っているスクリプトでは、|| true を付けないとそこで止まります。

私が bash 3.2 で試したところ、|| true を付けない場合は、set -e によって終了コード 1 で終了しました。

GitHub Actions の run も、次のように set -e と同じ状態で実行されます。

  • shell を指定しないとき:bash -e {0}
  • shell: bash のとき:bash --noprofile --norc -eo pipefail {0}

なお、shopt -u はそれ以降のスクリプト全体に効きます。

同じスクリプトの中で & を「一致した文字列」として使っている箇所があれば、そちらも壊れるので注意してください。

引用符で囲む方法は古い bash で壊れる

"${v//</"&lt;"}" のように置き換え後の文字列を引用符で囲む方法は、bash 5.2 のマニュアルに書かれている正式なやり方です。

ただし bash 3.2 では、引用符が取り除かれずに出力に残りました。

bash のマニュアル(互換モード compat42 の説明)によると、二重引用符で囲んだ置換の string から引用符を取り除くようになったのは bash 4.3 からです。

そのため bash 4.3〜5.1 では問題なく動くはずですが、私は確認していません。

バックスラッシュで \&lt; と書く方法も、bash 3.2 ではバックスラッシュが出力に残りました。

macOS の /bin/bash で動かす可能性があるスクリプトでは、sed か shopt -u を選ぶのが無難です。

既存のスクリプトから同じ書き方を探す

置き換え後の文字列に & を含む置換は、次の grep で候補を洗い出せます。

grep -rnE '\$\{[^}]*/[^}]*&' .

.github/workflows の YAML の run に直接書いたスクリプトも、この grep で拾えます。

引用符で囲んで対処済みの行も一致するので、1 件ずつ目で確かめてください。

まとめ

  • bash 5.2 から patsub_replacement が既定で有効になり、${var//pattern/string} の string にある & が「一致した文字列」に置き換わる
  • そのため ${v//</&lt;} は <lt; になる。& → &amp; の置き換えだけはたまたま正しく見える
  • 展開全体を二重引用符で囲んでも、置き換え後の文字列を変数に入れても防げない
  • GitHub Actions の ubuntu-latest(Ubuntu 24.04)は bash 5.2。macOS の /bin/bash は 3.2 なので、手元では再現しない
  • どのバージョンでも動くのは sed か、shopt -u patsub_replacement 2>/dev/null || true。引用符やバックスラッシュで囲む方法は bash 3.2 で壊れる

シェルの組み込みの機能で済ませると、外部コマンドを呼ぶより速くて身軽です。

しかし、bash のバージョンによって意味が変わる書き方は、手元と CI の差になって表に出にくいものです。

エスケープのように結果の正しさが大事な処理は、バージョンに左右されない sed で書いておくのが安心だと感じました。

参考(出典)

ABOUT ME
saratoga
saratoga
フリーランスエンジニア
仕事にも趣味にも IT を駆使するフリーランスエンジニア。技術的な TIPS や日々の生活の中で深堀りしてみたくなったことを備忘録として残していきます。
記事URLをコピーしました