𝑫𝒐𝒏'𝒕 𝑹𝒆𝒑𝒆𝒂𝒕 𝒀𝒐𝒖𝒓𝒔𝒆𝒍𝒇
二 度 も 事 故 る な
二度も事故ったのでご報告します
Git for Windows の Git Bash にて。
事故その 1:sed
の不一致行削除 !d
こんな状態で、
$ cat 'lines.txt'
wanted A
wanted B
removed C
wanted D
removed E
$ declare w='wanted'
こうすると 事故る
$ sed "/$w/!d" < 'lines.txt'
sed "/$w/declare w='wanted'" < 'lines.txt'
sed: -e expression #1, char 10: extra characters after command
エスケープも無意味。まぁ文字列の中だし…。
$ sed "/$w/\!d" < 'lines.txt'
sed: -e expression #1, char 9: unknown command: `\'
解決法
!
の部分を一重引用符で囲んで他の文字列と並べることで、文字列として連結できる。
$ sed "/$w/"'!d' < 'lines.txt' # '/wanted/!d' になる
wanted A
wanted B
wanted D
引用符の外でエスケープして並べるという手もある。
$ sed "/$w/"\!d < 'lines.txt'
wanted A
wanted B
wanted D
横並びにするだけでくっつくの、シェルコマンドなんて結局は文字列の塊(トークン)のやり取りなんだな感が凄い。
事故その 2:7z
(7-Zip)のパス再帰絞り込み -ir!
こんな状態で、
$ zipinfo -1 'archive.zip' | sed '/\/$/d' # Zip の中身をファイルだけ表示
archive/some/path/wanted A.txt
archive/some/path/wanted B.txt
archive/xxx/some/ignored C.txt
archive/xxx/some/path/wanted D.txt
archive/xxx/some/zzz/path/ignored E.txt
$ w='/some/path/'
こうすると 事故る
$ 7z x 'archive.zip' -ir!"*$w*"
bash: !": event not found
解決法
こいつは普通にエスケープで済む。
$ 7z x 'archive.zip' -ir\!"*$w*" -bso0 && find './archive/' -type f \
> # -ir!*/some/path/* になる。-bso0 は 7-Zip の標準出力をなくす。
./archive/some/path/wanted A.txt
./archive/some/path/wanted B.txt
./archive/xxx/some/path/wanted D.txt
もちろん一重引用符でも良い。
その場合、-ir
の部分まで含めてしまっても構わない。
$ 7z x 'archive.zip' '-ir!'"*$w*" -bso0 && find './archive/' -type f
./archive/some/path/wanted A.txt
./archive/some/path/wanted B.txt
./archive/xxx/some/path/wanted D.txt
Bash の履歴展開 !何々
とは
Bash で実行した内容は履歴として残っている。
ターミナルで上下キーを押して過去のコマンド履歴を呼び出す、というのはお馴染みの使い方のはず。
それと同様のことをコマンドとして記述できるのが Bash の履歴展開(ヒストリ展開)機能であり、!
から始まる各種書式で提供されている。
!!
とすれば直前のコマンドを展開するし、!-3
とすれば 3 回前のコマンドを展開する(!!
= !-1
)。
そして !何々
は、最も直近に実行した 何々
で始まるコマンドを展開する。
例えば Bash を一旦 exit
で終了し、改めて Bash を起動して echo !e
を実行すると…
$ echo !e
echo exit
exit
!e
には最も直近に実行した e
で始まるコマンドが展開されるため、echo !e
は echo exit
になる。
展開が済んだコマンドは親切にも一旦表示されるらしく、echo exit
が表示される。
そして echo exit
が実行され、文字列 exit
が表示される。
当然 echo
もまた e
で始まるコマンドなので、全く同じ echo !e
をもう一度実行すると…
$ echo !e
echo echo exit
echo exit
今度はこうなる。
そして事故る
この履歴展開機能、使いこなせば便利だろうけど(それは万物がそうだが)、意図せず展開されると事故が起こる。
sed
の方で起きたこと
$ cat 'lines.txt'
wanted A
wanted B
removed C
wanted D
removed E
$ declare w='wanted'
$ sed "/$w/!d" < 'lines.txt'
sed "/$w/declare w='wanted'" < 'lines.txt'
sed: -e expression #1, char 10: extra characters after command
二重引用符は内部で変数展開が行われる。
!d
の部分で、最も直近に実行した d
で始まるコマンド、すなわち declare w='wanted'
が展開されてしまう。
これにより、sed
コマンドのパラメータが "/$w/declare w='wanted'"
という意味わからん内容になり… 事故る
7z
の方で起きた(起きる)こと
$ zipinfo -1 'archive.zip' | sed '/\/$/d' # Zip の中身をファイルだけ表示
archive/some/path/wanted A.txt
archive/some/path/wanted B.txt
archive/xxx/some/ignored C.txt
archive/xxx/some/path/wanted D.txt
archive/xxx/some/zzz/path/ignored E.txt
$ w='/some/path/'
$ 7z x 'archive.zip' -ir!"*$w*"
bash: !": event not found
!"
の部分で、最も直近に実行した "
で始まるコマンドを展開しようとしてしまう。
!"
が具体的にどのように履歴展開されるかは環境の履歴によるので、一意にどうなるとは言えないものの、ほとんどの場合には意図しない結果になり… 事故る
デフォルトでは事故例のように bash: !": event not found
、すなわち「そんなコマンド使ったことねーよ!」とエラーになるが、これはまだマシな方だろう。
不幸にも過去に "
から始まる何かを実行したことがある場合、引用符が閉じていないと見なされて続きの入力を催促されたり…
$ "過去の実行"
bash: 過去の実行: command not found
$ 7z x 'archive.zip' -ir!"*$w*"
7z x 'archive.zip' -ir$w*"
>
文字列の組み合わせによっては、展開が変に成功して処理を続行してしまい、別の事故に誘爆したり…
$ "過去の実行(引用符が閉じていない)
> ^C
$ 7z x 'archive.zip' -ir!" スペース始まりの/パス/*"
7z x 'archive.zip' -ir"過去の実行(引用符が閉じていない) スペース始まりの/パス/*"
7-Zip (a) 19.00 (x86) : Copyright (c) 1999-2018 Igor Pavlov : 2019-02-21
Command Line Error:
Incorrect wildcard type marker
r過去の実行(引用符が閉じていない) スペース始まりの/パス/*
といった感じで、もっとえらいことにもなり得る。
対策
エスケープや一重引用符で対策できることは既に述べた。
一応、他の方法も書いておく。
他の対策(?)その 1:history -c
で履歴を消す
コマンド実行履歴は history
で閲覧できる。
$ history
(略)
486 cat 'lines.txt'
487 declare w='wanted'
488 sed "/$w/declare w='wanted'" < 'lines.txt'
489 sed "/$w/\!d" < 'lines.txt'
490 sed "/$w/"'!d' < 'lines.txt' # '/wanted/!d' になる
491 sed "/$w/"\!d < 'lines.txt'
492 zipinfo -1 'archive.zip' | sed '/\/$/d' # Zip の中身をファイルだけ表示
493 w='/some/path/'
494 7z x 'archive.zip' -ir\!"*$w*" -bso0 && find './archive/' -type f \
495 rm -r 'archive' # 展開実験をする際には必ず前回の展開物を削除してね
496 7z x 'archive.zip' '-ir!'"*$w*" -bso0 && find './archive/' -type f
497 exit
498 echo exit
499 echo echo exit
500 history
この履歴は history -c
で全削除できる。
$ history -c
$ !h
bash: !h: event not found
履歴削除が事故対策として活きるかは微妙だが、展開が変に成功して誘爆するやつぐらいは防げそう。
なお、削除されるのはあくまで現在のセッションが保持している履歴であり、実際に履歴が保存されているファイル $HISTFILE
(~/.bash_history
)の中身はこのコマンドでは別に消えない。
他の対策その 2:set +o histexpand
で履歴展開を切る
set +H
または set +o histexpand
を設定することで、履歴展開そのものを無効化できる。
$ set +o histexpand
$ !s
bash: !s: command not found
履歴を消した場合のエラーは "event not found" だったのに対し、こっちは "command not found" になっている。
履歴展開機能自体が存在しないかのようになっていることがわかる。
最も安全な対策はこれだろう。
もちろん、set -H
または set -o histexpand
で元に戻せる。
ぼやき
この機能、事故った時にそもそも何が起きたのかわかりづらい!!!!
わかったとしても、原因のコマンドが英数字ではなく記号(エクスクラメーションマーク)なので、その後ググりづらい!!!!!!!!
まぁ、知らないのが悪い。
仮に知らないとしても、シェルスクリプトの記号類にはその手の特殊な効果があり得る、ということを覚悟していないのが悪い。
精進します。はい。
参考
-
Bashコマンドラインで!(びっくりマーク)をエスケープ - プログラミングの「YUIPRO」
https://yuis-programming.com/?p=999-
'!'"文字列"
という繋げ方をここで知った。
-
-
quoting - Can't use exclamation mark (!) in bash? - Unix & Linux Stack Exchange
https://unix.stackexchange.com/questions/33339/- 同じようなお悩み。mug896 氏の回答が今回の話に近い。
-
Bash 履歴展開機能についてのノート | 徘徊の書
https://showa-yojyo.github.io/wandering/diary/2019/09/14/bash-history.html- 履歴展開について、日本語でわかりやすくまとまっている。
-
JM Project (Japanese) > Man page of BASH
https://linuxjm.osdn.jp/html/GNU_bash/man1/bash.1.html- 大切なことはみんな Man page が教えてくれた。
-
7-Zip Start Page > Command Line Version User's Guide
https://sevenzip.osdn.jp/chm/cmdline/- 7-Zip のコマンドライン機能のオンラインヘルプ。
正直、インストーラーで 7-Zip 本体と共にインストールされるヘルプファイルの方が見やすい。
- 7-Zip のコマンドライン機能のオンラインヘルプ。
おわり