クォートを外すと何が起きるのか
一言でいうと
シェルは変数を展開した後で、単語に分割します。クォートを付けないと、空白を含む値1つが複数の引数に変わり、その瞬間、スクリプトがまったく別のことを行います。
なぜ必要なのか
整理用のスクリプトがあります。
OLD=$(find /data -name '*.tmp' -mtime +7)
rm $OLD
/data/my report.tmpというファイルが1つできた瞬間、rmは/data/myとreport.tmpの2つを削除しようとします。運が悪ければ、/data/myというディレクトリが実際に存在していて、それが消えてしまいます。
ここには問題が2つ重なっています。
- クォートなし: 単語分割が起こります。
- 行単位のパース: ファイル名に改行が含まれることもあります(可能です)。
どう動くのか
ルール1: 変数は常にクォートで囲む
f="my report.txt"
rm $f # rm my report.txt → 인자 2개
rm "$f" # rm "my report.txt" → 인자 1개 ✅
例外はほとんどありません。「これは空白がないのが確実なのに」という判断が外れる日が、スクリプトが壊れる日です。
配列も同じです。
args=(-l -a "my dir")
ls "${args[@]}" # 각 원소가 하나의 인자로 ✅
ls ${args[@]} # 다시 쪼개진다 ✗
"$@"と"$*"の違いも、同じ原理です。"$@"は引数をそれぞれ保存し、"$*"は1つの文字列にまとめます。スクリプトが受け取った引数をそのまま渡すときは、常に"$@"を使います。
ルール2: ファイルの一覧はNULで渡す
ファイル名に入れられない文字は、NULだけです。改行も空白も入れられます。そのため、安全なパイプはNUL区切りです。
# 안전
find /data -name '*.tmp' -mtime +7 -print0 | xargs -0 rm --
# 더 안전 (xargs 도 필요 없다)
find /data -name '*.tmp' -mtime +7 -delete
# 루프가 필요하면
while IFS= read -r -d '' f; do
process "$f"
done < <(find /data -name '*.tmp' -print0)
IFS= read -rの組み合わせも慣用句です。IFS=は前後の空白が削られるのを防ぎ、-rはバックスラッシュをエスケープとして解釈しないようにします。
ルール3: --でオプションとファイルを分ける
-rfという名前のファイルがあると(作れます)、rm $fileはそれをオプションとして解釈します。--は「ここから先はすべて引数」という印です。
rm -- "$f"
grep -- "$pattern" "$file"
ルール4: オプションのパースはgetoptsで
自分で$1をcaseで回すコードは、すぐに破綻します。短いオプションなら、getoptsが定石です。
verbose=0; out=""
while getopts ":vo:" opt; do
case $opt in
v) verbose=1 ;;
o) out=$OPTARG ;;
:) echo "-$OPTARG 에 값이 필요합니다" >&2; exit 2 ;;
\?) echo "알 수 없는 옵션: -$OPTARG" >&2; exit 2 ;;
esac
done
shift $((OPTIND - 1))
# 남은 것이 위치 인자
先頭のコロン(":vo:")は、サイレントエラーモードを有効にします。これを入れて初めて、:と\?の分岐が動作し、自分でメッセージを作れます。
ルール5: 入力を信用しない
ユーザー入力をコマンドにしてはいけません。
# 절대 금지
eval "grep $user_input file.txt"
# 값으로만 쓴다
grep -- "$user_input" file.txt
evalは、文字列をシェルコードとして実行します。入力に; rm -rf /が入ってくると、そのまま実行されます。同様に、ファイルパスも検証します。../../etc/passwdのような値が来ると、意図したディレクトリを抜け出します。
チェックツール
shellcheckは、上の間違いの大半を見つけてくれます。クォートの漏れ(SC2086)、readの-rの漏れ(SC2162)、無駄なcat(SC2002)まで指摘します。CIに入れておけば、レビューでこのような話を繰り返さなくて済みます。
現場での姿
- バッチが何年も問題なく動いたあと、ある日ファイルを削除する → 空白を含むファイルが初めてできた日。
- パスにハングルが入ると失敗する → エンコーディングではなく、たいていクォートの問題。
- オプションの順序を変えると動作が変わる → 手作りのパーサーの典型的な症状。
続くラボですること
敵対的な名前のファイルを自分で作っておいて、ルールを1つずつ試します。採点ツールがみなさんの書いたスクリプトを直接実行します。"$@"の代わりに$*を使うと、引数3つが5つになって届き、getoptsの代わりに手で作ったパーサーは-voで破綻し、evalを使うと、採点ツールが入れたインジェクション文字列が実際に実行されます。書いておくだけの答えでは、合格できません。