この記事の狙い
Bash の文字列安全化を“場当たり”ではなく一貫した戦略として運用できるようにします。
ダブルクォートを原則とし、配列・パラメータ展開・コマンド置換・ヒアドキュメント・read 入力・グロブ/IFS/ロケールとの相互作用を事故らない手順でまとめます。
前提と対象
- Bash 4+(macOS 標準の古い Bash 3.x は一部機能が非対応)
set -Eeuo pipefail前提の安全設計に馴染みがある方向け
TL;DR(最小実装・コピペ可)
#!/usr/bin/env bash
set -Eeuo pipefail
# 1) すべての展開は原則ダブルクォート
printf '%s\n' "${var}"
cmd -- "${arr[@]}"
# 2) コマンド置換は $(...) を使い、中で必ずクォート
out="$(some_cmd --opt "${arg}")"
# 3) 文字列を“安全表示”するとき
printf '%q\n' "$var" # Bash 4.4+なら: echo "${var@Q}"
# 4) 入力は IFS と read -r を固定
IFS= read -r line || true # 改行以外は壊さない
# 5) NUL セーフな受け渡し(ファイル名等)
find . -print0 | xargs -0 -I{} printf '<%s>\n' "{}"
設計の要点(原則)
- 常にダブルクォート:
"$var","${arr[@]}","$(cmd)" - 配列は
[@]を基本、結合目的以外で[*]を使わない - stdout=データ/stderr=メッセージ:ログは必ず
>&2 - 入力は
read -r、必要ならIFSを局所化 - NUL セーフ:ファイル名・任意バイナリの受け渡しは
\0区切り - ロケールは明示:比較やソートに影響する処理は
LC_ALL=Cを局所適用 evalを使わない:どうしても必要なら代替(printf -v,declare -n)を選ぶ
レシピ集(安全な書き方)
1) 変数・配列の基本展開
# 変数
printf '%s\n' "${file}"
# 配列(要素単位)
for x in "${files[@]}"; do
printf '[%s]\n' "$x"
done
# コマンド引数へ
some_tool -- "${files[@]}" # -- は以降をオプション解釈から守る
2) 連結・フォーマット
# 連結は printf 一択(echo は不正確)
printf '%s/%s\n' "${dir}" "${base}"
# 事前クォート済み表示(ログ用)
printf 'moving %s -> %s\n' "${src@Q}" "${dst@Q}" # Bash 4.4+
# 4.3以下:
printf -v s '%q' "$src"; printf -v d '%q' "$dst"; printf 'moving %s -> %s\n' "$s" "$d"
3) コマンド置換とパイプ
# 置換結果もクォート
sha="$(sha256sum -- "${file}" | awk '{print $1}')"
# パイプは“片側で”整形し、結果はクォートして扱う
count="$(printf '%s\n' "${items[@]}" | wc -l)"
4) 入力:IFS と read -r
# 1行ずつ安全に読む(バックスラッシュはそのまま)
while IFS= read -r line; do
printf '<%s>\n' "$line"
done <"$path"
# 改行を含むまま配列化
mapfile -t lines <"$path" # 行末の改行は除去、-d '' で NUL 区切り
5) NUL セーフ(ファイル名など)
# 生成: 配列 → NUL 区切り
printf '%s\0' "${files[@]}" | xargs -0 -I{} cp -v -- "{}" "$dst"
# 受信: find → NUL 区切り → 配列
mapfile -d '' -t files < <(find . -type f -print0)
6) ヒアドキュメント(引用の向き)
cat <<'USAGE' # クォートあり: 変数展開しない(テンプレ・ヘルプ向け)
Usage:
mycli [options]
USAGE
cat <<EOF # クォートなし: ${var} を展開する(テンプレ化しない文章向け)
Hello, ${USER}
EOF
7) グロブ(ワイルドカード)と noglob
set -f # 必要時のみ: グロブ無効(関数の中で局所化を推奨)
# …安全化したい短い区間…
set +f
通常はクォート徹底で十分です。
set -fは例外的に。
8) 既定値の安全な適用(未定義/空判定)
: "${TMPDIR:=/tmp}" # 未定義/空なら代入
: "${API_TOKEN:?set API_TOKEN}" # 未定義/空なら即エラー
9) ロケールの影響を局所化
LC_ALL=C sort # バイト順で安定、言語依存の並び替えを避ける
アンチパターン → 安全書き換え
| 悪い例 | 問題 | 良い例 |
|---|---|---|
echo $var | 単語分割/グロブ暴発 | printf '%s\n' "${var}" |
for x in ${arr[@]}; do | 空要素消失 | for x in "${arr[@]}"; do |
out=$(cmd $arg) | 分裂・注入 | out="$(cmd -- "${arg}")" |
echo <<EOF | 変数展開予期せず | cat <<'EOF' |
eval "$user_input" | 任意実行 | 代替: printf -v, declare -n, 連想配列 |
“コピペ可”テストブロック(最小)
#!/usr/bin/env bash
set -Eeuo pipefail
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
# 1) 配列の空要素・空白保持
arr=("a" "" "b c" "*")
out="$(for x in "${arr[@]}"; do printf "<%s>" "$x"; done)"
[[ "$out" == "<a><><b c><*>" ]] || { echo "array-quote"; exit 1; }
# 2) read -r と IFS
printf 'a b\c\nd\n' >"$TMP/in"
mapfile -t lines <"$TMP/in"
[[ "${#lines[@]}" -eq 2 ]] || { echo "mapfile"; exit 1; }
while IFS= read -r l; do last="$l"; done <"$TMP/in"
[[ "$last" == "d" ]] || { echo "read-r"; exit 1; }
# 3) NUL セーフ往復
printf 'x\0y z\0' >"$TMP/nul"
mapfile -d '' -t N <"$TMP/nul"
joined="$(printf '%s|' "${N[@]}")"
[[ "$joined" == "x|y z|" ]] || { echo "nul"; exit 1; }
echo "PASS"
運用設計(実務)
- ライブラリ関数の入口と出口でクォートを徹底(引数受領時/出力直前)。
- ログは stderr に集約し、ユーザー入力は
%q/@Qで安全表示。 - ファイル名リストは最初から最後まで配列で持ち、外部コマンド直前だけ
\0で橋渡し。 - CI に ShellCheck を常時走らせ、
SC2086/SC2046(未クォート)が出たら直す文化に。
互換性と移植性
${var@Q}は Bash 4.4+。それ以前はprintf '%q'を使用。mapfile/readarrayは Bash 4+。Bash 3.x ではwhile IFS= read -rで代替。- BusyBox 等では一部挙動が異なるため、シェバンを Bash 固定する。
セキュリティと安全設計
- コマンド注入対策:
eval/未クォート展開/未検証のIFS変更を避ける。 - ログ漏洩対策:秘密値はマスクし、
@Qなどで“見た目の安全化”のみを過信しない。 - 入力境界:ユーザー入力を変数名やパスの一部に直接組み込まない(検証・許可リスト)。
パフォーマンスの勘所(短く)
printfとパラメータ展開は軽量。外部コマンド(sed,awk)を減らすほど速くなる。- 大量の連結は
printfを使い、配列コピーを避ける。
